eslint-plugin-import-boundaries 0.2.1 → 0.3.0

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
@@ -116,8 +135,8 @@ Prevent violations of your architecture:
116
135
  {
117
136
  dir: 'application',
118
137
  alias: '@application',
119
- allowImportsFrom: ['@domain'], // Only allow imports from @domain
120
- denyImportsFrom: ['@infrastructure'], // Deny imports from @infrastructure (dependency inversion)
138
+ allowImportsFrom: ['@domain'], // Only allow imports from @domain (deny-all by default)
139
+ // Note: denyImportsFrom is redundant here - anything not in allowImportsFrom is already denied
121
140
  }
122
141
  ```
123
142
 
@@ -132,38 +151,85 @@ 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'], // Can allow parent and specific child, but deny intermediate boundary
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
- - Each boundary must explicitly declare its rules (no inheritance)
163
- - Files in boundaries without rules resolve to their nearest ancestor with rules (or are rejected if no ancestor has rules)
220
+ - Each boundary has rules: explicit (via `allowImportsFrom`/`denyImportsFrom`) or implicit "deny all" (if neither is specified)
221
+ - Each boundary uses its own rules directly (no inheritance from parent boundaries)
164
222
  - Rules work the same regardless of nesting depth (flat rule checking)
165
223
  - You can selectively allow/deny specific nested boundaries
166
- - A boundary is considered to have rules if it defines `allowImportsFrom`, `denyImportsFrom`, or `allowTypeImportsFrom` (even if empty arrays)
224
+ - Files resolve to their most specific boundary (longest matching path), which determines the rules to apply
225
+
226
+ **Rule semantics:**
227
+
228
+ - If both `allowImportsFrom` and `denyImportsFrom` exist: `allowImportsFrom` takes precedence (items in allow list are allowed even if also in deny list)
229
+ - If only `allowImportsFrom`: deny-all by default (only items in allow list are allowed)
230
+ - If only `denyImportsFrom`: allow-all by default (everything except deny list is allowed)
231
+ - If neither: deny-all by default (strictest)
232
+ - **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.
167
233
 
168
234
  ### 4. Type-Only Imports
169
235
 
@@ -201,27 +267,23 @@ import { something } from "@application"; // When inside @application boundary
201
267
 
202
268
  ### Basic Configuration
203
269
 
270
+ Here's a complete configuration example with all boundary rules:
271
+
204
272
  ```javascript
205
273
  {
206
- rootDir: 'src', // Root directory (default: 'src')
207
- crossBoundaryStyle: 'alias', // 'alias' | 'absolute' (default: 'alias')
208
- defaultSeverity: 'error', // 'error' | 'warn' (default: 'error')
209
- allowUnknownBoundaries: false, // Allow imports outside boundaries (default: false)
210
- skipBoundaryRulesForTestFiles: true, // Skip boundary rules for tests (default: true)
211
- barrelFileName: 'index', // Barrel file name without extension (default: 'index')
212
- fileExtensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'], // Extensions to recognize (default: all common JS/TS extensions)
274
+ rootDir: 'src', // Required: Root directory (default: 'src')
213
275
  boundaries: [ // Required: Array of boundary definitions
214
276
  {
215
277
  dir: 'domain', // Required: Relative directory path
216
- alias: '@domain', // Required when crossBoundaryStyle is 'alias', optional when 'absolute'
217
- denyImportsFrom: ['@application', '@infrastructure', '@interface', '@composition'], // Domain is pure
278
+ alias: '@domain', // Required: Import alias (e.g., '@domain')
279
+ denyImportsFrom: ['@application', '@infrastructure', '@interface', '@composition'], // Domain is pure - denies all other boundaries
218
280
  severity: 'error', // Optional: 'error' | 'warn' (overrides defaultSeverity for this boundary)
219
281
  },
220
282
  {
221
283
  dir: 'application',
222
284
  alias: '@application',
223
- allowImportsFrom: ['@domain'], // Application uses domain
224
- denyImportsFrom: ['@infrastructure', '@interface', '@composition'], // Dependency inversion
285
+ allowImportsFrom: ['@domain'], // Application uses domain (deny-all by default)
286
+ // Note: denyImportsFrom is redundant here - anything not in allowImportsFrom is already denied
225
287
  },
226
288
  {
227
289
  dir: 'infrastructure',
@@ -230,34 +292,92 @@ import { something } from "@application"; // When inside @application boundary
230
292
  allowTypeImportsFrom: ['@application'], // Infrastructure implements application ports (types only)
231
293
  },
232
294
  ],
295
+ // Optional configuration options (all have sensible defaults):
296
+ defaultSeverity: 'error', // 'error' | 'warn' (default: 'error')
297
+ enforceBoundaries: true, // Enforce boundary rules (default: true)
298
+ allowUnknownBoundaries: false, // Allow imports outside boundaries (default: false)
299
+ barrelFileName: 'index', // Barrel file name without extension (default: 'index')
300
+ fileExtensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'], // Extensions to recognize (default: all common JS/TS extensions)
233
301
  }
234
302
  ```
235
303
 
304
+ **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).
305
+
236
306
  ### Test Files Configuration
237
307
 
238
- Use ESLint's file matching for test files:
308
+ **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).
309
+
310
+ **Why skip boundary rules for tests?** Test files often need to:
311
+
312
+ - Import from multiple boundaries to set up test scenarios (e.g., mocking infrastructure while testing application logic)
313
+ - Use test helper libraries or mocks that don't fit clean architectural boundaries
314
+ - Access internal implementation details for thorough testing
315
+ - Create test fixtures that span multiple boundaries
316
+
317
+ 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.
318
+
319
+ **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.
320
+
321
+ **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:
239
322
 
240
323
  ```javascript
324
+ import importBoundaries from "eslint-plugin-import-boundaries";
325
+
326
+ // Define boundaries once - shared between regular files and test files
327
+ const boundaries = [
328
+ {
329
+ dir: "domain",
330
+ alias: "@domain",
331
+ // No imports allowed by default
332
+ },
333
+ {
334
+ dir: "application",
335
+ alias: "@application",
336
+ allowImportsFrom: ["@domain"],
337
+ },
338
+ {
339
+ dir: "infrastructure",
340
+ alias: "@infrastructure",
341
+ allowImportsFrom: ["@domain"],
342
+ },
343
+ ];
344
+
241
345
  export default [
346
+ // Test files - skip boundary rules but keep path format enforcement
347
+ // Put test files first so they take precedence over regular file patterns
242
348
  {
243
- files: ["src/**/*.ts"],
349
+ files: [
350
+ "**/*.test.{ts,js}",
351
+ "**/*.spec.{ts,js}",
352
+ "**/__tests__/**/*.{ts,js}",
353
+ ],
244
354
  rules: {
245
355
  "import-boundaries/enforce": [
246
356
  "error",
247
357
  {
248
- /* config */
358
+ rootDir: "src",
359
+ enforceBoundaries: false, // Tests can import from any boundary
360
+ boundaries, // Same boundaries - needed for path format calculation
249
361
  },
250
362
  ],
251
363
  },
252
364
  },
365
+ // Regular source files - enforce boundary rules
366
+ // Excludes test files via ignores to prevent overlap
253
367
  {
254
- files: ["**/*.test.{ts,js}", "**/*.spec.{ts,js}"],
368
+ files: ["src/**/*.ts", "src/**/*.tsx"],
369
+ ignores: [
370
+ "**/*.test.{ts,js}",
371
+ "**/*.spec.{ts,js}",
372
+ "**/__tests__/**/*.{ts,js}",
373
+ ],
255
374
  rules: {
256
375
  "import-boundaries/enforce": [
257
376
  "error",
258
377
  {
259
- skipBoundaryRulesForTestFiles: true, // Tests can import from any boundary
260
- // ... rest of config
378
+ rootDir: "src",
379
+ enforceBoundaries: true, // Enforce boundary rules
380
+ boundaries,
261
381
  },
262
382
  ],
263
383
  },
@@ -265,6 +385,59 @@ export default [
265
385
  ];
266
386
  ```
267
387
 
388
+ **What gets checked in test files:**
389
+
390
+ - ✅ Path format (alias vs relative) - still enforced
391
+ - ✅ Barrel file imports - still enforced
392
+ - ✅ Ancestor barrel prevention - still enforced
393
+ - ❌ Boundary allow/deny rules - **skipped** (tests can import from any boundary)
394
+
395
+ **What gets checked in regular files:**
396
+
397
+ - ✅ Path format (alias vs relative)
398
+ - ✅ Barrel file imports
399
+ - ✅ Ancestor barrel prevention
400
+ - ✅ Boundary allow/deny rules - **enforced**
401
+
402
+ ### Using Absolute Paths
403
+
404
+ 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'`:
405
+
406
+ ```javascript
407
+ {
408
+ rootDir: 'src',
409
+ crossBoundaryStyle: 'absolute', // Use absolute paths instead of aliases
410
+ boundaries: [
411
+ { dir: 'domain' }, // No alias required when using absolute paths
412
+ { dir: 'application' },
413
+ { dir: 'infrastructure' },
414
+ ],
415
+ }
416
+ ```
417
+
418
+ **Import patterns with absolute paths:**
419
+
420
+ ```typescript
421
+ // Cross-boundary imports → use absolute path
422
+ import { Entity } from "src/domain"; // ✅
423
+ import { UseCase } from "src/application"; // ✅
424
+
425
+ // Same-boundary, close imports → use relative (same as alias style)
426
+ import { helper } from "./helper"; // ✅ Same directory
427
+ import { utils } from "../utils"; // ✅ Parent's sibling
428
+
429
+ // Same-boundary, distant imports → use absolute path
430
+ import { useCase } from "src/application/use-cases"; // ✅ Distant in same boundary
431
+ ```
432
+
433
+ **When to use absolute paths:**
434
+
435
+ - Your build configuration doesn't support path aliases (e.g., some bundlers or older tooling)
436
+ - You prefer explicit paths over aliases for clarity
437
+ - You're working in a codebase that already uses absolute paths
438
+
439
+ **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`.
440
+
268
441
  ## How It Works
269
442
 
270
443
  The rule uses pure path math - no file I/O, just deterministic algorithms:
@@ -100,22 +100,6 @@ function resolveToBoundary(filename, boundaries) {
100
100
  return null;
101
101
  }
102
102
  /**
103
- * Resolve a file to the nearest boundary that has rules specified.
104
- * If no boundaries with rules are found, returns null.
105
- * Used for file boundaries - allows inheritance from ancestors with rules.
106
- *
107
- * @param filename - Absolute filename
108
- * @param boundaries - Array of all boundaries
109
- * @returns The nearest boundary with rules, or null if none found
110
- */
111
- function resolveToSpecifiedBoundary(filename, boundaries) {
112
- const specifiedBoundaries = boundaries.filter((b) => isInsideDir(b.absDir, filename)).filter((b) => b.allowImportsFrom !== void 0 || b.denyImportsFrom !== void 0 || b.allowTypeImportsFrom !== void 0);
113
- if (specifiedBoundaries.length > 0) return specifiedBoundaries.sort((a, b) => b.absDir.length - a.absDir.length)[0];
114
- const ancestors = boundaries.filter((b) => b.allowImportsFrom !== void 0 || b.denyImportsFrom !== void 0 || b.allowTypeImportsFrom !== void 0).filter((b) => isInsideDir(b.absDir, filename));
115
- if (ancestors.length > 0) return ancestors.sort((a, b) => b.absDir.length - a.absDir.length)[0];
116
- return null;
117
- }
118
- /**
119
103
  * Get metadata about the current file being linted.
120
104
  * Results are cached per file to avoid recomputation.
121
105
  *
@@ -128,7 +112,7 @@ function getFileData(filename, boundaries) {
128
112
  return {
129
113
  isValid: true,
130
114
  fileDir: path.dirname(filename),
131
- fileBoundary: resolveToSpecifiedBoundary(filename, boundaries)
115
+ fileBoundary: resolveToBoundary(filename, boundaries)
132
116
  };
133
117
  }
134
118
 
@@ -513,7 +497,7 @@ const rule = {
513
497
  type: "boolean",
514
498
  default: false
515
499
  },
516
- skipBoundaryRulesForTestFiles: {
500
+ enforceBoundaries: {
517
501
  type: "boolean",
518
502
  default: true
519
503
  },
@@ -545,7 +529,7 @@ const rule = {
545
529
  },
546
530
  create(context) {
547
531
  if (!context.options || context.options.length === 0) throw new Error("boundary-alias-vs-relative requires boundaries configuration");
548
- 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 = [
549
533
  ".ts",
550
534
  ".tsx",
551
535
  ".js",
@@ -608,7 +592,7 @@ const rule = {
608
592
  defaultSeverity,
609
593
  allowUnknownBoundaries,
610
594
  isTypeOnly,
611
- skipBoundaryRules: skipBoundaryRulesForTestFiles,
595
+ skipBoundaryRules: !enforceBoundaries,
612
596
  barrelFileName,
613
597
  fileExtensions
614
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.1",
4
+ "version": "0.3.0",
5
5
  "description": "Enforce architectural boundaries with deterministic import paths",
6
6
  "author": "ClassicalMoser",
7
7
  "license": "ISC",