eslint-plugin-import-boundaries 0.1.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/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2025, ClassicalMoser
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,299 @@
1
+ # eslint-plugin-import-boundaries
2
+
3
+ > Enforce architectural boundaries with deterministic import paths.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/eslint-plugin-import-boundaries)](https://www.npmjs.com/package/eslint-plugin-import-boundaries)
6
+ [![License: ISC](https://img.shields.io/badge/License-ISC-blue.svg)](https://opensource.org/licenses/ISC)
7
+
8
+ **Note: This is a beta release developed for a personal project. It is not yet stable and may have breaking changes.**
9
+
10
+ An 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
+
12
+ ## Features
13
+
14
+ - **Deterministic**: One correct path for every import
15
+ - **Explicit Exports**: Ensures every directory is explicit about what it exports (via barrel files)
16
+ - **Readable Paths**: Resolves to logical, readable filepaths (no `../../../../../../` chains)
17
+ - **Architectural Boundaries**: Enforce clean architecture, hexagonal architecture, or any non-nested boundary pattern (nested boundaries planned but not ready)
18
+ - **Auto-fixable**: Most violations are automatically fixable
19
+ - **Zero I/O**: Pure path math and AST analysis - fast even on large codebases
20
+ - **Type-aware**: Different rules for type-only imports vs value imports
21
+ - **Circular Dependency Prevention**: Blocks ancestor barrel imports
22
+ - **Configurable**: Works with any architectural pattern
23
+
24
+ ## Quick Start
25
+
26
+ ```bash
27
+ npm install --save-dev eslint-plugin-import-boundaries
28
+ ```
29
+
30
+ ```javascript
31
+ // eslint.config.js
32
+ import importBoundaries from 'eslint-plugin-import-boundaries';
33
+
34
+ export default {
35
+ plugins: {
36
+ 'import-boundaries': importBoundaries,
37
+ },
38
+ rules: {
39
+ 'import-boundaries/enforce': [
40
+ 'error',
41
+ {
42
+ rootDir: 'src',
43
+ boundaries: [
44
+ { dir: 'domain/entities', alias: '@entities' },
45
+ { dir: 'domain/queries', alias: '@queries' },
46
+ { dir: 'domain/events', alias: '@events' },
47
+ ],
48
+ },
49
+ ],
50
+ },
51
+ };
52
+ ```
53
+
54
+ ## What Problem Does This Solve?
55
+
56
+ Most projects have inconsistent import patterns:
57
+
58
+ - Sometimes `@entities`, sometimes `../entities`, sometimes `../../domain/entities`
59
+ - No clear rules for when to use alias vs relative
60
+ - Import formatting discussions waste time in code reviews
61
+ - Long relative paths like `../../../../../../utils` are hard to read
62
+ - Absolute paths might not fit your architecture (`src/domain/entities/army/unit/weapon/sword`)
63
+ - Directories aren't explicit about their exports (no barrel files)
64
+ - Architectural boundaries are violated without enforcement
65
+ - Circular dependencies sneak in
66
+ - Refactoring is risky because import paths are ambiguous
67
+
68
+ This rule provides deterministic import paths with architectural boundary enforcement - one correct answer for every import, eliminating debates and making refactoring safer.
69
+
70
+ ## Core Rules
71
+
72
+ ### 1. Cross-Boundary Imports → Alias
73
+
74
+ When importing from a different boundary, always use the boundary alias (no subpaths):
75
+
76
+ ```typescript
77
+ // ✅ CORRECT
78
+ import { Entity } from '@entities';
79
+ import { Query } from '@queries';
80
+
81
+ // ❌ WRONG
82
+ import { Entity } from '@entities/army'; // Subpath not allowed
83
+ import { Entity } from '../entities'; // Relative not allowed
84
+ ```
85
+
86
+ ### 2. Same-Boundary Imports → Relative (when close)
87
+
88
+ When importing within the same boundary, use relative paths for close imports:
89
+
90
+ ```typescript
91
+ // Same directory (sibling)
92
+ import { helper } from './helper'; // ✅
93
+
94
+ // Parent's sibling (cousin, max one ../)
95
+ import { utils } from '../utils'; // ✅
96
+
97
+ // Top-level or distant → Use alias
98
+ import { something } from '@queries/topLevel'; // ✅
99
+ ```
100
+
101
+ ### 3. Architectural Boundary Enforcement
102
+
103
+ Prevent violations of your architecture:
104
+
105
+ ```javascript
106
+ {
107
+ dir: 'domain/entities',
108
+ alias: '@entities',
109
+ allowImportsFrom: ['@events'], // Only allow imports from @events
110
+ denyImportsFrom: ['@queries'], // Deny imports from @queries
111
+ }
112
+ ```
113
+
114
+ ```typescript
115
+ // ✅ ALLOWED: @entities can import from @events
116
+ import { Event } from '@events';
117
+
118
+ // ❌ VIOLATION: @entities cannot import from @queries
119
+ import { Query } from '@queries';
120
+ // Error: Cannot import from '@queries' to '@entities': Import not allowed
121
+ ```
122
+
123
+ ### 4. Type-Only Imports
124
+
125
+ Different rules for types vs values (types don't create runtime dependencies):
126
+
127
+ ```javascript
128
+ {
129
+ dir: 'domain/entities',
130
+ alias: '@entities',
131
+ allowImportsFrom: ['@events'], // Value imports
132
+ allowTypeImportsFrom: ['@events', '@queries'], // Type imports (more permissive)
133
+ }
134
+ ```
135
+
136
+ ```typescript
137
+ // ✅ ALLOWED: Type import from @queries
138
+ import type { QueryResult } from '@queries';
139
+
140
+ // ❌ VIOLATION: Value import from @queries
141
+ import { executeQuery } from '@queries';
142
+ ```
143
+
144
+ ### 5. Ancestor Barrel Prevention
145
+
146
+ Prevents circular dependencies by blocking ancestor barrel imports:
147
+
148
+ ```typescript
149
+ // ❌ FORBIDDEN: Would create circular dependency
150
+ import { something } from '@queries'; // When inside @queries boundary
151
+ // Error: Cannot import from ancestor barrel '@queries'.
152
+ // This would create a circular dependency.
153
+ ```
154
+
155
+ ## Configuration
156
+
157
+ ### Basic Configuration
158
+
159
+ ```javascript
160
+ {
161
+ rootDir: 'src', // Root directory (default: 'src')
162
+ crossBoundaryStyle: 'alias', // 'alias' | 'absolute' (default: 'alias')
163
+ defaultSeverity: 'error', // 'error' | 'warn' (default: 'error')
164
+ allowUnknownBoundaries: false, // Allow imports outside boundaries (default: false)
165
+ skipBoundaryRulesForTestFiles: true, // Skip boundary rules for tests (default: true)
166
+ boundaries: [ // Required: Array of boundary definitions
167
+ {
168
+ dir: 'domain/entities', // Required: Relative directory path
169
+ alias: '@entities', // Required: Import alias
170
+ severity: 'error', // Optional: Override default severity
171
+ allowImportsFrom: ['@events'], // Optional: Allowed boundaries (value imports)
172
+ denyImportsFrom: ['@queries'], // Optional: Denied boundaries
173
+ allowTypeImportsFrom: ['@events', '@queries'], // Optional: Allowed for types
174
+ },
175
+ ],
176
+ }
177
+ ```
178
+
179
+ ### Test Files Configuration
180
+
181
+ Use ESLint's file matching for test files:
182
+
183
+ ```javascript
184
+ export default [
185
+ {
186
+ files: ['src/**/*.ts'],
187
+ rules: {
188
+ 'import-boundaries/enforce': [
189
+ 'error',
190
+ {
191
+ /* config */
192
+ },
193
+ ],
194
+ },
195
+ },
196
+ {
197
+ files: ['**/*.test.{ts,js}', '**/*.spec.{ts,js}'],
198
+ rules: {
199
+ 'import-boundaries/enforce': [
200
+ 'error',
201
+ {
202
+ skipBoundaryRulesForTestFiles: true, // Tests can import from any boundary
203
+ // ... rest of config
204
+ },
205
+ ],
206
+ },
207
+ },
208
+ ];
209
+ ```
210
+
211
+ ## How It Works
212
+
213
+ The rule uses pure path math - no file I/O, just deterministic algorithms:
214
+
215
+ 1. **Boundary Detection**: Determines which boundary a file belongs to
216
+ 2. **Path Calculation**: Calculates the correct import path using the "first differing segment" algorithm
217
+ 3. **Boundary Rules**: Checks allow/deny rules for cross-boundary imports
218
+ 4. **Type Detection**: Distinguishes type-only imports from value imports
219
+
220
+ ### Barrel Files as Module Interface
221
+
222
+ The rule assumes barrel files (`index.ts`) are the module interface for each directory. This means:
223
+
224
+ - `./dir` imports from `dir/index.ts` (the barrel)
225
+ - You cannot bypass the barrel: `./dir/file` is not allowed
226
+ - This enforces a clear public API for each module
227
+
228
+ This barrel file assumption enables zero I/O: because we know every directory has a barrel file, we can determine correct paths using pure path math - no file system access needed. This makes the rule fast, reliable, and deterministic.
229
+
230
+ The rule is barrel-agnostic - it enforces the path pattern (must go through the barrel), not what the barrel exports. Whether you use selective exports (`export { A, B } from './module'`) or universal exports (`export * from './module'`) is your choice based on your codebase needs.
231
+
232
+ **Scale considerations**:
233
+
234
+ - **Small projects**: If you have boundaries worth enforcing, you have enough structure for barrel files. For truly tiny projects (single file), this rule may be overkill.
235
+ - **Large projects**: The pattern works at any scale. Performance depends on what you export (use selective exports in large apps), not the pattern itself. The rule's zero I/O approach means it stays fast even in massive codebases.
236
+ - **Monorepos**: Works across packages, but requires manual configuration. You'd configure boundaries that span packages (e.g., `{ dir: 'packages/pkg-a/src/domain', alias: '@pkg-a/domain' }`). Each package maintains its own barrel structure, and the rule enforces boundaries between them based on your configuration.
237
+
238
+ ## What Gets Skipped
239
+
240
+ The rule automatically skips checking for:
241
+
242
+ - External packages (`node_modules`, npm packages)
243
+ - Imports that don't match any boundary alias and aren't relative paths
244
+
245
+ Only internal imports (relative paths, boundary aliases, and absolute paths within `rootDir`) are checked.
246
+
247
+ ## Error Messages
248
+
249
+ Clear, actionable error messages:
250
+
251
+ ```
252
+ Expected '@entities' but got '@entities/army'
253
+ Expected './sibling' but got '@queries/sibling'
254
+ Expected '../cousin' but got '@queries/nested/cousin'
255
+ Cannot import from '@queries' to '@entities': Import not allowed
256
+ Cannot import from ancestor barrel '@queries'. This would create a circular dependency.
257
+ ```
258
+
259
+ ## Comparison with Other Plugins
260
+
261
+ ### Simple Path Enforcers
262
+
263
+ Plugins like `eslint-plugin-no-relative-import-paths` and `eslint-plugin-absolute-imports` only enforce "use absolute paths everywhere" or "use relative paths everywhere." They don't handle:
264
+
265
+ - Deterministic alias vs relative (when to use which)
266
+ - Architectural boundaries
267
+ - Allow/deny rules between boundaries
268
+ - Type-only import handling
269
+ - Circular dependency prevention
270
+ - Barrel file enforcement
271
+
272
+ ### Architectural Boundary Plugins
273
+
274
+ `eslint-plugin-boundaries` does enforce architectural boundaries, but uses a different approach:
275
+
276
+ - Pattern-based element matching (more complex configuration)
277
+ - File I/O for resolution (slower, requires file system access)
278
+ - Different rule structure
279
+
280
+ This plugin uses a different approach:
281
+
282
+ - Simple, deterministic path rules (pure path math, zero I/O)
283
+ - Architectural boundary enforcement
284
+ - Type-aware rules
285
+ - Fast and auto-fixable
286
+
287
+ By assuming barrel files at every directory, this plugin can determine correct paths using pure path math - no file system access needed. This makes it faster and more reliable. The barrel file pattern also enforces clear module interfaces (you must go through the barrel), which is good architecture. Because paths are deterministic, there's no debugging overhead - you always know exactly where a module comes from.
288
+
289
+ ## Roadmap
290
+
291
+ - **Nested Boundaries**: Support for nested boundaries where sub-boundaries can have broader allow patterns than their parents (e.g., `@ports` nested in `@application` can import from `@infrastructure` even though `@application` cannot). This is required for proper hexagonal architecture support. See [Nested Boundaries Design](./NESTED_BOUNDARIES_DESIGN.md) for the design document.
292
+
293
+ ## License
294
+
295
+ ISC
296
+
297
+ ## Contributing
298
+
299
+ Contributions welcome! Please open an issue or PR.
@@ -0,0 +1,462 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+
4
+ //#region eslint-plugin-import-boundaries/pathUtils.ts
5
+ /**
6
+ * Path utility functions for the boundary-alias-vs-relative ESLint rule.
7
+ * Pure path math - no file I/O.
8
+ */
9
+ /**
10
+ * Check if a path is inside a directory.
11
+ * Uses path.relative() which is more reliable than string comparison.
12
+ *
13
+ * @param absDir - Absolute directory path
14
+ * @param absPath - Absolute file or directory path to check
15
+ * @returns true if absPath is inside absDir (or is absDir itself)
16
+ *
17
+ * Examples:
18
+ * - isInsideDir('/a/b', '/a/b/file.ts') => true
19
+ * - isInsideDir('/a/b', '/a/b/c/file.ts') => true
20
+ * - isInsideDir('/a/b', '/a/file.ts') => false (../file.ts)
21
+ * - isInsideDir('/a/b', '/a/b') => true (empty relative path)
22
+ */
23
+ function isInsideDir(absDir, absPath) {
24
+ const rel = path.relative(absDir, absPath);
25
+ if (rel === "") return true;
26
+ return !rel.startsWith("..") && !path.isAbsolute(rel);
27
+ }
28
+
29
+ //#endregion
30
+ //#region eslint-plugin-import-boundaries/boundaryDetection.ts
31
+ /**
32
+ * Check if an import specifier is using an alias subpath (e.g., '@entities/army').
33
+ * Subpaths should be converted to the base alias (e.g., '@entities').
34
+ *
35
+ * @param spec - Import specifier to check
36
+ * @param boundaries - Array of resolved boundaries
37
+ * @returns Object indicating if it's a subpath and which base alias it uses
38
+ *
39
+ * Examples:
40
+ * - checkAliasSubpath('@entities/army', boundaries) => { isSubpath: true, baseAlias: '@entities' }
41
+ * - checkAliasSubpath('@entities', boundaries) => { isSubpath: false }
42
+ */
43
+ function checkAliasSubpath(spec, boundaries) {
44
+ for (const b of boundaries) if (spec.startsWith(`${b.alias}/`)) return {
45
+ isSubpath: true,
46
+ baseAlias: b.alias
47
+ };
48
+ return { isSubpath: false };
49
+ }
50
+ /**
51
+ * Get metadata about the current file being linted.
52
+ * Results are cached per file to avoid recomputation.
53
+ *
54
+ * @param filename - Absolute filename from ESLint context
55
+ * @param boundaries - Array of resolved boundaries
56
+ * @returns FileData with directory and boundary information, or { isValid: false } if file path is invalid
57
+ */
58
+ function getFileData(filename, boundaries) {
59
+ if (!path.isAbsolute(filename)) return { isValid: false };
60
+ const fileDir = path.dirname(filename);
61
+ const matchingBoundaries = boundaries.filter((b) => isInsideDir(b.absDir, filename));
62
+ return {
63
+ isValid: true,
64
+ fileDir,
65
+ fileBoundary: matchingBoundaries.length > 0 ? matchingBoundaries.sort((a, b) => b.absDir.length - a.absDir.length)[0] : null
66
+ };
67
+ }
68
+
69
+ //#endregion
70
+ //#region eslint-plugin-import-boundaries/boundaryRules.ts
71
+ /**
72
+ * Check if an import from fileBoundary to targetBoundary is allowed.
73
+ * Returns violation info if not allowed, null if allowed.
74
+ *
75
+ * Semantics:
76
+ * - If both allowImportsFrom and denyImportsFrom are specified, they work as:
77
+ * - allowImportsFrom: explicit allow list (overrides deny for those items)
78
+ * - denyImportsFrom: explicit deny list (overrides default allow for those items)
79
+ * - If only allowImportsFrom: only those boundaries are allowed (deny-all by default)
80
+ * - If only denyImportsFrom: all boundaries allowed except those (allow-all by default)
81
+ * - If neither: deny-all by default (strictest)
82
+ * - allowTypeImportsFrom: For type-only imports, this overrides allowImportsFrom (allows types from more boundaries)
83
+ */
84
+ function checkBoundaryRules(fileBoundary, targetBoundary, allBoundaries, isTypeOnly = false) {
85
+ if (fileBoundary === targetBoundary) return null;
86
+ if (isTypeOnly && fileBoundary.allowTypeImportsFrom?.includes(targetBoundary.alias)) return null;
87
+ const hasAllowList = fileBoundary.allowImportsFrom && fileBoundary.allowImportsFrom.length > 0;
88
+ const hasDenyList = fileBoundary.denyImportsFrom && fileBoundary.denyImportsFrom.length > 0;
89
+ if (hasAllowList && fileBoundary.allowImportsFrom.includes(targetBoundary.alias)) return null;
90
+ if (hasDenyList && fileBoundary.denyImportsFrom.includes(targetBoundary.alias)) return { reason: `Boundary '${fileBoundary.alias}' explicitly denies imports from '${targetBoundary.alias}'` };
91
+ if (hasAllowList && !hasDenyList) return { reason: `Cross-boundary import from '${targetBoundary.alias}' to '${fileBoundary.alias}' is not allowed. Add '${targetBoundary.alias}' to 'allowImportsFrom' if this import is intentional.` };
92
+ if (hasDenyList && !hasAllowList) return null;
93
+ return { reason: `Cross-boundary import from '${targetBoundary.alias}' to '${fileBoundary.alias}' is not allowed. Add '${targetBoundary.alias}' to 'allowImportsFrom' if this import is intentional.` };
94
+ }
95
+
96
+ //#endregion
97
+ //#region eslint-plugin-import-boundaries/fixer.ts
98
+ /**
99
+ * Create a fixer function to replace an import path.
100
+ * Handles different import node types: ImportDeclaration, ImportExpression, require().
101
+ *
102
+ * @param node - AST node for the import
103
+ * @param newPath - New import path to use
104
+ * @returns Fixer function, or null if node type is unsupported
105
+ */
106
+ function createFixer(node, newPath) {
107
+ return (fixer) => {
108
+ if ("source" in node && node.source) return fixer.replaceText(node.source, `'${newPath}'`);
109
+ if ("arguments" in node && Array.isArray(node.arguments) && node.arguments[0]) return fixer.replaceText(node.arguments[0], `'${newPath}'`);
110
+ return null;
111
+ };
112
+ }
113
+
114
+ //#endregion
115
+ //#region eslint-plugin-import-boundaries/relationshipDetection.ts
116
+ /**
117
+ * Resolve the target path from an import specifier.
118
+ */
119
+ function resolveTargetPath(rawSpec, fileDir, boundaries, rootDir, cwd) {
120
+ let targetAbs;
121
+ let targetDir;
122
+ if (rawSpec.startsWith("@")) {
123
+ const boundary = boundaries.find((b) => rawSpec === b.alias || rawSpec.startsWith(`${b.alias}/`));
124
+ if (boundary) {
125
+ const subpath = rawSpec.slice(boundary.alias.length + 1);
126
+ if (subpath && !subpath.endsWith(".ts")) {
127
+ targetDir = path.resolve(boundary.absDir, subpath);
128
+ targetAbs = path.join(targetDir, "index.ts");
129
+ } else if (subpath) {
130
+ targetAbs = path.resolve(boundary.absDir, subpath);
131
+ targetDir = path.dirname(targetAbs);
132
+ } else {
133
+ targetAbs = path.join(boundary.absDir, "index.ts");
134
+ targetDir = boundary.absDir;
135
+ }
136
+ } else {
137
+ targetAbs = "";
138
+ targetDir = "";
139
+ }
140
+ } else if (rawSpec.startsWith(".")) if (!rawSpec.endsWith(".ts")) {
141
+ targetDir = path.resolve(fileDir, rawSpec);
142
+ targetAbs = path.join(targetDir, "index.ts");
143
+ } else {
144
+ targetAbs = path.resolve(fileDir, rawSpec);
145
+ targetDir = path.dirname(targetAbs);
146
+ }
147
+ else if (rawSpec.startsWith(rootDir)) if (!rawSpec.endsWith(".ts")) {
148
+ targetDir = path.resolve(cwd, rawSpec);
149
+ targetAbs = path.join(targetDir, "index.ts");
150
+ } else {
151
+ targetAbs = path.resolve(cwd, rawSpec);
152
+ targetDir = path.dirname(targetAbs);
153
+ }
154
+ else {
155
+ targetAbs = "";
156
+ targetDir = "";
157
+ }
158
+ return {
159
+ targetAbs,
160
+ targetDir
161
+ };
162
+ }
163
+ /**
164
+ * Calculate the correct import path using the simplified algorithm.
165
+ */
166
+ function calculateCorrectImportPath(rawSpec, fileDir, fileBoundary, boundaries, rootDir, cwd, crossBoundaryStyle = "alias") {
167
+ const { targetAbs, targetDir } = resolveTargetPath(rawSpec, fileDir, boundaries, rootDir, cwd);
168
+ const targetBoundary = boundaries.find((b) => isInsideDir(b.absDir, targetAbs)) ?? null;
169
+ if (!fileBoundary || targetBoundary !== fileBoundary) {
170
+ if (targetBoundary) {
171
+ if (crossBoundaryStyle === "absolute") return path.join(rootDir, targetBoundary.dir).replace(/\\/g, "/");
172
+ return targetBoundary.alias;
173
+ }
174
+ return "UNKNOWN_BOUNDARY";
175
+ }
176
+ if (rawSpec === fileBoundary.alias) return null;
177
+ const targetRelativeToBoundary = path.relative(fileBoundary.absDir, targetDir);
178
+ const fileRelativeToBoundary = path.relative(fileBoundary.absDir, fileDir);
179
+ const targetParts = targetRelativeToBoundary === "" || targetRelativeToBoundary === "." ? [] : targetRelativeToBoundary.split(path.sep).filter((p) => p && p !== ".");
180
+ const fileParts = fileRelativeToBoundary === "" || fileRelativeToBoundary === "." ? [] : fileRelativeToBoundary.split(path.sep).filter((p) => p && p !== ".");
181
+ if (targetParts.length === 0) {
182
+ const targetBasename = path.basename(targetAbs, ".ts");
183
+ if (targetBasename !== "index") return `${fileBoundary.alias}/${targetBasename}`;
184
+ return null;
185
+ }
186
+ let firstDifferingIndex = 0;
187
+ while (firstDifferingIndex < targetParts.length && firstDifferingIndex < fileParts.length && targetParts[firstDifferingIndex] === fileParts[firstDifferingIndex]) firstDifferingIndex++;
188
+ if (firstDifferingIndex >= targetParts.length && firstDifferingIndex >= fileParts.length) {
189
+ const targetBasename = path.basename(targetAbs, ".ts");
190
+ if (targetBasename !== "index") return `./${targetBasename}`;
191
+ return null;
192
+ }
193
+ const firstDifferingSegment = targetParts[firstDifferingIndex];
194
+ if (!firstDifferingSegment) return null;
195
+ if (firstDifferingIndex === fileParts.length) return `./${firstDifferingSegment}`;
196
+ if (firstDifferingIndex === fileParts.length - 1) {
197
+ if (!(firstDifferingIndex === 0)) return `../${firstDifferingSegment}`;
198
+ }
199
+ return `${fileBoundary.alias}/${firstDifferingSegment}`;
200
+ }
201
+
202
+ //#endregion
203
+ //#region eslint-plugin-import-boundaries/importHandler.ts
204
+ /**
205
+ * Main handler for all import statements.
206
+ * Validates import paths against boundary rules and enforces correct path format.
207
+ *
208
+ * @returns true if a violation was reported, false otherwise
209
+ */
210
+ function handleImport(options) {
211
+ const { node, rawSpec, fileDir, fileBoundary, boundaries, rootDir, cwd, context, crossBoundaryStyle = "alias", defaultSeverity, allowUnknownBoundaries = false, isTypeOnly = false, skipBoundaryRules = false } = options;
212
+ const isRelative = rawSpec.startsWith(".");
213
+ const matchesBoundaryAlias = boundaries.some((b) => rawSpec === b.alias || rawSpec.startsWith(`${b.alias}/`));
214
+ const isAbsoluteInRoot = rawSpec.startsWith(rootDir) || rawSpec.startsWith(`/${rootDir}`);
215
+ if (!isRelative && !matchesBoundaryAlias && !isAbsoluteInRoot) return false;
216
+ if (crossBoundaryStyle === "alias") {
217
+ const aliasSubpathCheck = checkAliasSubpath(rawSpec, boundaries);
218
+ if (aliasSubpathCheck.isSubpath) {
219
+ const targetBoundary$1 = boundaries.find((b) => b.alias === aliasSubpathCheck.baseAlias);
220
+ if (targetBoundary$1 && fileBoundary && targetBoundary$1 !== fileBoundary) {
221
+ const expectedPath = targetBoundary$1.alias;
222
+ const severity$1 = fileBoundary.severity || defaultSeverity;
223
+ const reportOptions$1 = {
224
+ node,
225
+ messageId: "incorrectImportPath",
226
+ data: {
227
+ expectedPath,
228
+ actualPath: rawSpec
229
+ },
230
+ fix: createFixer(node, expectedPath),
231
+ ...severity$1 && { severity: severity$1 === "warn" ? 1 : 2 }
232
+ };
233
+ context.report(reportOptions$1);
234
+ return true;
235
+ }
236
+ }
237
+ }
238
+ const { targetAbs } = resolveTargetPath(rawSpec, fileDir, boundaries, rootDir, cwd);
239
+ const targetBoundary = boundaries.find((b) => isInsideDir(b.absDir, targetAbs)) ?? null;
240
+ if (!skipBoundaryRules && fileBoundary && targetBoundary && fileBoundary !== targetBoundary) {
241
+ const violation = checkBoundaryRules(fileBoundary, targetBoundary, boundaries, isTypeOnly);
242
+ if (violation) {
243
+ const severity$1 = fileBoundary.severity || defaultSeverity;
244
+ const reportOptions$1 = {
245
+ node,
246
+ messageId: "boundaryViolation",
247
+ data: {
248
+ from: fileBoundary.alias,
249
+ to: targetBoundary.alias,
250
+ reason: violation.reason
251
+ },
252
+ ...severity$1 && { severity: severity$1 === "warn" ? 1 : 2 }
253
+ };
254
+ context.report(reportOptions$1);
255
+ return true;
256
+ }
257
+ }
258
+ const correctPath = calculateCorrectImportPath(rawSpec, fileDir, fileBoundary, boundaries, rootDir, cwd, crossBoundaryStyle);
259
+ if (!correctPath) {
260
+ if (fileBoundary && rawSpec === fileBoundary.alias) {
261
+ const severity$1 = fileBoundary.severity || defaultSeverity;
262
+ const reportOptions$1 = {
263
+ node,
264
+ messageId: "ancestorBarrelImport",
265
+ data: { alias: fileBoundary.alias },
266
+ ...severity$1 && { severity: severity$1 === "warn" ? 1 : 2 }
267
+ };
268
+ context.report(reportOptions$1);
269
+ return true;
270
+ }
271
+ return false;
272
+ }
273
+ if (correctPath === "UNKNOWN_BOUNDARY") {
274
+ if (!allowUnknownBoundaries) {
275
+ const reportOptions$1 = {
276
+ node,
277
+ messageId: "unknownBoundaryImport",
278
+ data: { path: rawSpec },
279
+ ...defaultSeverity && { severity: defaultSeverity === "warn" ? 1 : 2 }
280
+ };
281
+ context.report(reportOptions$1);
282
+ return true;
283
+ }
284
+ return false;
285
+ }
286
+ if (rawSpec === correctPath) return false;
287
+ const severity = fileBoundary?.severity || defaultSeverity;
288
+ const reportOptions = {
289
+ node,
290
+ messageId: "incorrectImportPath",
291
+ data: {
292
+ expectedPath: correctPath,
293
+ actualPath: rawSpec
294
+ },
295
+ fix: createFixer(node, correctPath),
296
+ ...severity && { severity: severity === "warn" ? 1 : 2 }
297
+ };
298
+ context.report(reportOptions);
299
+ return true;
300
+ }
301
+
302
+ //#endregion
303
+ //#region eslint-plugin-import-boundaries/index.ts
304
+ const rule = {
305
+ meta: {
306
+ type: "problem",
307
+ fixable: "code",
308
+ docs: {
309
+ description: "Enforces architectural boundaries with deterministic import path rules: cross-boundary uses alias without subpath, siblings use relative, boundary-root and top-level paths use alias, cousins use relative (max one ../).",
310
+ recommended: false
311
+ },
312
+ schema: [{
313
+ type: "object",
314
+ properties: {
315
+ rootDir: { type: "string" },
316
+ boundaries: {
317
+ type: "array",
318
+ items: {
319
+ type: "object",
320
+ properties: {
321
+ dir: { type: "string" },
322
+ alias: { type: "string" },
323
+ allowImportsFrom: {
324
+ type: "array",
325
+ items: { type: "string" }
326
+ },
327
+ denyImportsFrom: {
328
+ type: "array",
329
+ items: { type: "string" }
330
+ },
331
+ allowTypeImportsFrom: {
332
+ type: "array",
333
+ items: { type: "string" }
334
+ },
335
+ nestedPathFormat: {
336
+ type: "string",
337
+ enum: [
338
+ "alias",
339
+ "relative",
340
+ "inherit"
341
+ ]
342
+ },
343
+ severity: {
344
+ type: "string",
345
+ enum: ["error", "warn"]
346
+ }
347
+ },
348
+ required: ["dir", "alias"]
349
+ },
350
+ minItems: 1
351
+ },
352
+ crossBoundaryStyle: {
353
+ type: "string",
354
+ enum: ["alias", "absolute"],
355
+ default: "alias"
356
+ },
357
+ defaultSeverity: {
358
+ type: "string",
359
+ enum: ["error", "warn"]
360
+ },
361
+ allowUnknownBoundaries: {
362
+ type: "boolean",
363
+ default: false
364
+ },
365
+ skipBoundaryRulesForTestFiles: {
366
+ type: "boolean",
367
+ default: true
368
+ }
369
+ },
370
+ required: ["boundaries"]
371
+ }],
372
+ messages: {
373
+ incorrectImportPath: "Expected '{{expectedPath}}' but got '{{actualPath}}'.",
374
+ ancestorBarrelImport: "Cannot import from ancestor barrel '{{alias}}'. This would create a circular dependency. Import from the specific file or directory instead.",
375
+ unknownBoundaryImport: "Cannot import from '{{path}}' - path is outside all configured boundaries. Add this path to boundaries configuration or set 'allowUnknownBoundaries: true'.",
376
+ boundaryViolation: "Cannot import from '{{to}}' to '{{from}}': {{reason}}"
377
+ }
378
+ },
379
+ create(context) {
380
+ if (!context.options || context.options.length === 0) throw new Error("boundary-alias-vs-relative requires boundaries configuration");
381
+ const { rootDir = "src", boundaries, crossBoundaryStyle = "alias", defaultSeverity, allowUnknownBoundaries = false, skipBoundaryRulesForTestFiles = true } = context.options[0];
382
+ const cwd = context.getCwd?.() ?? process.cwd();
383
+ const resolvedBoundaries = boundaries.map((b) => ({
384
+ dir: b.dir,
385
+ alias: b.alias,
386
+ absDir: path.resolve(cwd, rootDir, b.dir),
387
+ allowImportsFrom: b.allowImportsFrom,
388
+ denyImportsFrom: b.denyImportsFrom,
389
+ allowTypeImportsFrom: b.allowTypeImportsFrom,
390
+ nestedPathFormat: b.nestedPathFormat,
391
+ severity: b.severity
392
+ }));
393
+ let cachedFileData = null;
394
+ /**
395
+ * Get metadata about the current file being linted.
396
+ * Results are cached per file to avoid recomputation.
397
+ *
398
+ * @returns FileData with directory and boundary information, or { isValid: false } if file path is invalid
399
+ */
400
+ function getFileDataCached() {
401
+ if (cachedFileData) return cachedFileData;
402
+ cachedFileData = getFileData(context.filename ?? context.getFilename?.() ?? "<unknown>", resolvedBoundaries);
403
+ return cachedFileData;
404
+ }
405
+ /**
406
+ * Wrapper function that prepares file data and calls the main import handler.
407
+ *
408
+ * @param node - AST node for the import (ImportDeclaration, ImportExpression, or CallExpression)
409
+ * @param rawSpec - Raw import specifier string (e.g., '@entities', './file', '../parent')
410
+ * @param isTypeOnly - Whether this is a type-only import (TypeScript)
411
+ */
412
+ function handleImportStatement(node, rawSpec, isTypeOnly = false) {
413
+ const fileData = getFileDataCached();
414
+ if (!fileData.isValid) return;
415
+ const { fileDir, fileBoundary } = fileData;
416
+ if (!fileDir) return;
417
+ handleImport({
418
+ node,
419
+ rawSpec,
420
+ fileDir,
421
+ fileBoundary: fileBoundary ?? null,
422
+ boundaries: resolvedBoundaries,
423
+ rootDir,
424
+ cwd,
425
+ context,
426
+ crossBoundaryStyle,
427
+ defaultSeverity,
428
+ allowUnknownBoundaries,
429
+ isTypeOnly,
430
+ skipBoundaryRules: skipBoundaryRulesForTestFiles
431
+ });
432
+ }
433
+ return {
434
+ Program() {
435
+ cachedFileData = null;
436
+ },
437
+ ImportDeclaration(node) {
438
+ const spec = node.source?.value;
439
+ if (typeof spec === "string") handleImportStatement(node, spec, node.importKind === "type");
440
+ },
441
+ ImportExpression(node) {
442
+ const arg = node.source;
443
+ if (arg?.type === "Literal" && typeof arg.value === "string") handleImportStatement(node, arg.value, false);
444
+ },
445
+ CallExpression(node) {
446
+ if (node.callee.type === "Identifier" && node.callee.name === "require" && node.arguments.length === 1 && node.arguments[0]?.type === "Literal" && typeof node.arguments[0].value === "string") handleImportStatement(node, node.arguments[0].value, false);
447
+ },
448
+ ExportNamedDeclaration(node) {
449
+ const spec = node.source?.value;
450
+ if (typeof spec === "string") handleImportStatement(node, spec, node.exportKind === "type");
451
+ },
452
+ ExportAllDeclaration(node) {
453
+ const spec = node.source?.value;
454
+ if (typeof spec === "string") handleImportStatement(node, spec, node.exportKind === "type");
455
+ }
456
+ };
457
+ }
458
+ };
459
+ var eslint_plugin_import_boundaries_default = { rules: { enforce: rule } };
460
+
461
+ //#endregion
462
+ export { eslint_plugin_import_boundaries_default as default };
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "eslint-plugin-import-boundaries",
3
+ "version": "0.1.0",
4
+ "description": "Enforce architectural boundaries with deterministic import paths",
5
+ "type": "module",
6
+ "main": "./eslint-plugin-import-boundaries.js",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./eslint-plugin-import-boundaries.js"
10
+ }
11
+ },
12
+ "files": [
13
+ "eslint-plugin-import-boundaries.js",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "keywords": [
18
+ "eslint",
19
+ "eslint-plugin",
20
+ "eslint-rule",
21
+ "import",
22
+ "boundaries",
23
+ "architecture",
24
+ "clean-architecture",
25
+ "hexagonal-architecture",
26
+ "barrel-files",
27
+ "deterministic",
28
+ "import-paths"
29
+ ],
30
+ "author": "ClassicalMoser",
31
+ "license": "ISC",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/ClassicalMoser/eslint-plugin-import-boundaries.git"
35
+ },
36
+ "bugs": {
37
+ "url": "https://github.com/ClassicalMoser/eslint-plugin-import-boundaries/issues"
38
+ },
39
+ "homepage": "https://github.com/ClassicalMoser/eslint-plugin-import-boundaries#readme",
40
+ "engines": {
41
+ "node": ">=18.0.0"
42
+ },
43
+ "peerDependencies": {
44
+ "eslint": ">=9.0.0"
45
+ },
46
+ "scripts": {
47
+ "build": "tsdown --config tsdown.config.ts",
48
+ "test": "vitest run",
49
+ "test:watch": "vitest watch",
50
+ "test:coverage": "vitest run --coverage",
51
+ "typecheck": "tsc --noEmit",
52
+ "lint": "eslint .",
53
+ "lint:fix": "eslint . --fix",
54
+ "format": "prettier --write .",
55
+ "prepublishOnly": "npm run build && npm run test && npm run typecheck"
56
+ },
57
+ "devDependencies": {
58
+ "@antfu/eslint-config": "^6.4.2",
59
+ "@rolldown/binding-darwin-x64": "1.0.0-beta.53",
60
+ "@types/node": "^24.10.1",
61
+ "@vitest/coverage-v8": "^4.0.15",
62
+ "eslint": "^9.39.1",
63
+ "eslint-config-prettier": "^10.1.8",
64
+ "prettier": "^3.7.4",
65
+ "tsdown": "0.17.0",
66
+ "typescript": "^5.9.3",
67
+ "vitest": "4.0.15"
68
+ }
69
+ }