eslint-plugin-import-structure 0.10.5 → 0.11.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 +8 -5
- package/dist/index.js +332 -101
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -85,9 +85,9 @@ An alias owns its target, and code outside the owned area reaches it only throug
|
|
|
85
85
|
The rules never depend on folder or file names; an alias may point anywhere.
|
|
86
86
|
|
|
87
87
|
- An alias to a folder's `index` file (`index.ts`, or any other extension) owns that folder and everything below it. An alias to any other file owns only that file.
|
|
88
|
-
-
|
|
89
|
-
|
|
90
|
-
- Outside an area, any other route to an owned file is a bypass: a deeper alias specifier such as `#a/one.ts` (`no-barrel-bypass`), or a relative path (`no-relative-bypass`, and `prefer-alias-import`, which rewrites a path to the alias's
|
|
88
|
+
- An alias may sit inside another alias's area. The deeper alias owns its part, carved out of the outer area, and the outer alias owns the rest; the two are ordered like any other pair of aliases.
|
|
89
|
+
- No 2 aliases may have the same area, such as the same target or 2 `index` files in one folder. `enforce-layer-order` reports an overlap once at the top of every file both own, since lint cannot report on `package.json`.
|
|
90
|
+
- Outside an area, any other route to an owned file is a bypass: a deeper alias specifier such as `#a/one.ts` (`no-barrel-bypass`), or a relative path (`no-relative-bypass`, and `prefer-alias-import`, which rewrites a path to the alias's target, in any spelling TypeScript resolves to it).
|
|
91
91
|
- Inside an area, relative imports of siblings and subfolders are free.
|
|
92
92
|
- `../` is banned in every file, owned or not (`no-parent-import`). Anything above a file needs an alias.
|
|
93
93
|
- Files no alias owns need no `index.ts` and no alias, and import `./` siblings and subfolders freely.
|
|
@@ -95,7 +95,8 @@ The rules never depend on folder or file names; an alias may point anywhere.
|
|
|
95
95
|
|
|
96
96
|
```jsonc
|
|
97
97
|
"imports": {
|
|
98
|
-
"#a": "./a/index.ts", // owns a/ and everything below it
|
|
98
|
+
"#a": "./a/index.ts", // owns a/ and everything below it but a/sub/
|
|
99
|
+
"#a-sub": "./a/sub/index.ts", // owns a/sub/, carved out of #a
|
|
99
100
|
"#f": "./b/f.ts", // owns only b/f.ts; b/g.ts stays unowned
|
|
100
101
|
}
|
|
101
102
|
```
|
|
@@ -108,7 +109,9 @@ import { f } from "./b/f.ts"; // ❌ relative path to a file alias; use "#f"
|
|
|
108
109
|
import { g } from "./b/g.ts"; // ✅ unowned
|
|
109
110
|
// in a/one.ts, owned by #a
|
|
110
111
|
import { two } from "./two.ts"; // ✅ sibling
|
|
111
|
-
import { deep } from "./
|
|
112
|
+
import { deep } from "./other/deep.ts"; // ✅ subfolder of the same area
|
|
113
|
+
import { sub } from "./sub/x.ts"; // ❌ relative path into #a-sub
|
|
114
|
+
import { sub } from "#a/sub/x.ts"; // ❌ deeper alias specifier; use "#a-sub"
|
|
112
115
|
```
|
|
113
116
|
|
|
114
117
|
The other rules (import order, duplicate sources, wildcards, absolute paths, self-imports) are not about aliases and apply to every file.
|
package/dist/index.js
CHANGED
|
@@ -161,6 +161,63 @@ function resolveFrom(baseDir, relative) {
|
|
|
161
161
|
function isInsideDir(path, dir) {
|
|
162
162
|
return path.startsWith(dir.endsWith("/") ? dir : `${dir}/`);
|
|
163
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* The `./` specifier from a directory to a path inside it, such as
|
|
166
|
+
* `./sub/x.ts`. Returns `null` for a path outside the directory, which only
|
|
167
|
+
* a banned `../` specifier could reach.
|
|
168
|
+
*/
|
|
169
|
+
function childSpecifier(fromDir, path) {
|
|
170
|
+
return isInsideDir(path, fromDir) ? `./${path.slice(fromDir.replace(/\/$/u, "").length + 1)}` : null;
|
|
171
|
+
}
|
|
172
|
+
/** The extensions a specifier without one may resolve to, in TypeScript's order. */
|
|
173
|
+
const BARE_EXTENSIONS = [
|
|
174
|
+
".ts",
|
|
175
|
+
".tsx",
|
|
176
|
+
".js",
|
|
177
|
+
".jsx"
|
|
178
|
+
];
|
|
179
|
+
/**
|
|
180
|
+
* The source extensions a specifier's extension stands for, in the order
|
|
181
|
+
* TypeScript tries them: `./x.js` names `x.ts`, then `x.tsx`, and so on.
|
|
182
|
+
*/
|
|
183
|
+
const SOURCE_EXTENSIONS = /* @__PURE__ */ new Map([
|
|
184
|
+
[".js", [".ts", ".tsx"]],
|
|
185
|
+
[".jsx", [".tsx"]],
|
|
186
|
+
[".mjs", [".mts"]],
|
|
187
|
+
[".cjs", [".cts"]],
|
|
188
|
+
[".ts", []],
|
|
189
|
+
[".tsx", []],
|
|
190
|
+
[".mts", []],
|
|
191
|
+
[".cts", []]
|
|
192
|
+
]);
|
|
193
|
+
/** The last extension of a path's last segment, or `""` when it has none. */
|
|
194
|
+
function extensionOf(path) {
|
|
195
|
+
const dot = path.lastIndexOf(".");
|
|
196
|
+
return dot > path.lastIndexOf("/") + 1 ? path.slice(dot) : "";
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* The files a resolved specifier path may name, in the order TypeScript
|
|
200
|
+
* tries them: the path itself, then the source files of a script extension
|
|
201
|
+
* (`./x.js` gives `x.ts` and `x.tsx`, `./x.mjs` gives `x.mts`), or, for a
|
|
202
|
+
* path without a script extension, the path with `.ts`, `.tsx`, `.js`, or
|
|
203
|
+
* `.jsx` added.
|
|
204
|
+
*/
|
|
205
|
+
function moduleCandidates(path) {
|
|
206
|
+
const extension = extensionOf(path);
|
|
207
|
+
const sources = SOURCE_EXTENSIONS.get(extension);
|
|
208
|
+
if (sources === void 0) return [path, ...BARE_EXTENSIONS.map((added) => path + added)];
|
|
209
|
+
const stem = path.slice(0, path.length - extension.length);
|
|
210
|
+
return [path, ...sources.map((source) => stem + source)];
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Whether a resolved specifier path names the module file `file`, the way
|
|
214
|
+
* TypeScript resolves it (`moduleCandidates`). One-directional: `./x.mts`
|
|
215
|
+
* never names `x.ts`, and another extension, such as `index.css`, names
|
|
216
|
+
* another file.
|
|
217
|
+
*/
|
|
218
|
+
function namesSameModule(path, file) {
|
|
219
|
+
return moduleCandidates(path).includes(file);
|
|
220
|
+
}
|
|
164
221
|
//#endregion
|
|
165
222
|
//#region src/helpers/imports.ts
|
|
166
223
|
/**
|
|
@@ -180,6 +237,21 @@ const REJECTED_STEMS = ["mod"];
|
|
|
180
237
|
const CONFIG_NAME = "package.json";
|
|
181
238
|
/** A query or fragment suffix, which never names part of a path. */
|
|
182
239
|
const QUERY_PATTERN = /[?#].*$/u;
|
|
240
|
+
/**
|
|
241
|
+
* A specifier without its query or fragment, such as `#a/x.js` for
|
|
242
|
+
* `#a/x.js?raw`. The `#` that starts an alias is kept.
|
|
243
|
+
*/
|
|
244
|
+
function stripQuery(specifier) {
|
|
245
|
+
const alias = specifier.startsWith("#") ? "#" : "";
|
|
246
|
+
return alias + specifier.slice(alias.length).replace(QUERY_PATTERN, "");
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Whether a relative specifier climbs with a `..` segment anywhere, such as
|
|
250
|
+
* `../x.ts` or `./sub/../../x.ts`.
|
|
251
|
+
*/
|
|
252
|
+
function isParentSpecifier(specifier) {
|
|
253
|
+
return specifier.startsWith(".") && stripQuery(specifier).split("/").includes("..");
|
|
254
|
+
}
|
|
183
255
|
/** A path written with a Windows drive after a leading slash, as in `/C:/dir`. */
|
|
184
256
|
const SLASHED_DRIVE_PATTERN = /^\/[a-zA-Z]:/u;
|
|
185
257
|
/**
|
|
@@ -247,7 +319,7 @@ function readImportsField(configPath) {
|
|
|
247
319
|
/**
|
|
248
320
|
* Whether an `imports` entry is a `#` alias with a relative string target.
|
|
249
321
|
* Only those point at local files. A "#" alias for a package, or a condition
|
|
250
|
-
* object,
|
|
322
|
+
* object, names no file here but still takes part in Node.js precedence.
|
|
251
323
|
*/
|
|
252
324
|
function isLocalEntry(specifier, value) {
|
|
253
325
|
return specifier.startsWith("#") && typeof value === "string" && (value.startsWith("./") || value.startsWith("../"));
|
|
@@ -274,37 +346,51 @@ function moduleEntry(configDir, specifier, value, index) {
|
|
|
274
346
|
};
|
|
275
347
|
}
|
|
276
348
|
/**
|
|
277
|
-
*
|
|
278
|
-
*
|
|
349
|
+
* The order Node.js tries subpath patterns in: longest prefix first, then
|
|
350
|
+
* longest key.
|
|
351
|
+
*/
|
|
352
|
+
function comparePatterns(left, right) {
|
|
353
|
+
return right.prefix.length - left.prefix.length || right.specifier.length - left.specifier.length;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Adds one `imports` key to a config being built: a pattern, local or not, a
|
|
357
|
+
* foreign exact key, or a local exact entry.
|
|
358
|
+
*/
|
|
359
|
+
function addImport(config, specifier, value) {
|
|
360
|
+
const local = isLocalEntry(specifier, value) ? value : null;
|
|
361
|
+
const star = specifier.indexOf("*");
|
|
362
|
+
if (star !== -1) {
|
|
363
|
+
config.patterns.push(patternEntry(specifier, star, local));
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
if (local === null) {
|
|
367
|
+
config.foreignSpecifiers.add(specifier);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const entry = moduleEntry(config.configDir, specifier, local, config.entries.length);
|
|
371
|
+
config.entries.push(entry);
|
|
372
|
+
config.bySpecifier.set(specifier, entry);
|
|
373
|
+
if (!config.byTarget.has(entry.target)) config.byTarget.set(entry.target, entry);
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Reads and parses one config file. Returns `null` when the file is
|
|
377
|
+
* unreadable, is not JSON, or has no local `#` entries.
|
|
279
378
|
*/
|
|
280
379
|
function readConfig(configPath, configDir) {
|
|
281
380
|
const imports = readImportsField(configPath);
|
|
282
381
|
if (imports === null) return null;
|
|
283
|
-
const
|
|
284
|
-
const byTarget = /* @__PURE__ */ new Map();
|
|
285
|
-
const bySpecifier = /* @__PURE__ */ new Map();
|
|
286
|
-
const patterns = [];
|
|
287
|
-
for (const [specifier, value] of Object.entries(imports)) {
|
|
288
|
-
if (!isLocalEntry(specifier, value)) continue;
|
|
289
|
-
const star = specifier.indexOf("*");
|
|
290
|
-
if (star !== -1) {
|
|
291
|
-
patterns.push(patternEntry(specifier, star, value));
|
|
292
|
-
continue;
|
|
293
|
-
}
|
|
294
|
-
const entry = moduleEntry(configDir, specifier, value, entries.length);
|
|
295
|
-
entries.push(entry);
|
|
296
|
-
bySpecifier.set(specifier, entry);
|
|
297
|
-
if (!byTarget.has(entry.target)) byTarget.set(entry.target, entry);
|
|
298
|
-
}
|
|
299
|
-
if (entries.length === 0 && patterns.length === 0) return null;
|
|
300
|
-
patterns.sort((left, right) => right.prefix.length - left.prefix.length);
|
|
301
|
-
return {
|
|
382
|
+
const config = {
|
|
302
383
|
configDir,
|
|
303
|
-
entries,
|
|
304
|
-
byTarget,
|
|
305
|
-
bySpecifier,
|
|
306
|
-
|
|
384
|
+
entries: [],
|
|
385
|
+
byTarget: /* @__PURE__ */ new Map(),
|
|
386
|
+
bySpecifier: /* @__PURE__ */ new Map(),
|
|
387
|
+
foreignSpecifiers: /* @__PURE__ */ new Set(),
|
|
388
|
+
patterns: []
|
|
307
389
|
};
|
|
390
|
+
for (const [specifier, value] of Object.entries(imports)) if (specifier.startsWith("#")) addImport(config, specifier, value);
|
|
391
|
+
if (config.entries.length === 0 && config.patterns.every((pattern) => pattern.target === null)) return null;
|
|
392
|
+
config.patterns.sort(comparePatterns);
|
|
393
|
+
return config;
|
|
308
394
|
}
|
|
309
395
|
//#endregion
|
|
310
396
|
//#region src/helpers/modules.ts
|
|
@@ -313,9 +399,13 @@ function readConfig(configPath, configDir) {
|
|
|
313
399
|
* own: which alias owns a file, which one a specifier names, and whether an
|
|
314
400
|
* import touches an owned area at all.
|
|
315
401
|
*
|
|
316
|
-
* An alias owns its target
|
|
317
|
-
*
|
|
318
|
-
*
|
|
402
|
+
* An alias owns its target minus the area of any deeper alias inside it. An
|
|
403
|
+
* alias to a folder's `index.ts` owns that folder and everything below it;
|
|
404
|
+
* an alias to any other file owns only that file. Where one area lies inside
|
|
405
|
+
* another, the deeper alias owns its part and the outer one owns the rest, so
|
|
406
|
+
* every file has a single owner: the alias with the deepest area holding it.
|
|
407
|
+
* Two aliases on the same area tie, which `enforce-layer-order` reports.
|
|
408
|
+
* Nothing here depends on folder or file names.
|
|
319
409
|
*/
|
|
320
410
|
/**
|
|
321
411
|
* Whether a normalized path lies in the area an alias owns. A folder alias's
|
|
@@ -326,46 +416,104 @@ function ownsPath(entry, path) {
|
|
|
326
416
|
return entry.target === path || entry.isBarrel && (path === entry.dir || isInsideDir(path, entry.dir));
|
|
327
417
|
}
|
|
328
418
|
/**
|
|
329
|
-
*
|
|
330
|
-
*
|
|
419
|
+
* The aliases that own a file: those whose area holds it and lies deepest,
|
|
420
|
+
* in declaration order. An outer alias gives way to any alias inside its
|
|
421
|
+
* area, so this is one alias unless two share the same area, which
|
|
422
|
+
* `enforce-layer-order` reports as an overlap.
|
|
331
423
|
*/
|
|
332
|
-
function
|
|
424
|
+
function owningEntries(config, filePath) {
|
|
333
425
|
const path = normalizePath(filePath);
|
|
334
|
-
|
|
426
|
+
let owners = [];
|
|
427
|
+
for (const entry of config.entries) {
|
|
428
|
+
const deepest = owners[0];
|
|
429
|
+
if (!ownsPath(entry, path)) continue;
|
|
430
|
+
if (deepest === void 0 || entry.dir.length > deepest.dir.length) owners = [entry];
|
|
431
|
+
else if (entry.dir.length === deepest.dir.length) owners.push(entry);
|
|
432
|
+
}
|
|
433
|
+
return owners;
|
|
335
434
|
}
|
|
336
435
|
/**
|
|
337
|
-
* Whether `entry`
|
|
338
|
-
*
|
|
339
|
-
*
|
|
436
|
+
* Whether `entry` owns a file. A file two aliases share an area on counts as
|
|
437
|
+
* inside both, so the overlap is reported once by `enforce-layer-order`
|
|
438
|
+
* rather than again as a bypass.
|
|
340
439
|
*/
|
|
341
|
-
function
|
|
342
|
-
return
|
|
440
|
+
function isOwnedBy(config, filePath, entry) {
|
|
441
|
+
return owningEntries(config, filePath).some((owner) => owner.index === entry.index);
|
|
343
442
|
}
|
|
344
443
|
/**
|
|
345
|
-
* Finds the alias that owns a file,
|
|
346
|
-
*
|
|
444
|
+
* Finds the alias that owns a file, the one with the deepest area holding
|
|
445
|
+
* it; the first declared when two share that area. Returns `null` for a file
|
|
446
|
+
* no alias owns.
|
|
347
447
|
*/
|
|
348
448
|
function findOwningEntry(config, filePath) {
|
|
349
|
-
|
|
350
|
-
for (const entry of claimingEntries(config, filePath)) if (best === null || entry.dir.length > best.dir.length) best = entry;
|
|
351
|
-
return best;
|
|
449
|
+
return owningEntries(config, filePath)[0] ?? null;
|
|
352
450
|
}
|
|
353
451
|
/**
|
|
354
|
-
* The
|
|
355
|
-
* `#utils/internal.ts
|
|
356
|
-
*
|
|
452
|
+
* The alias a deeper `#` specifier reaches past, such as `#utils` for
|
|
453
|
+
* `#utils/internal.ts`: the alias whose target the named file is, in any
|
|
454
|
+
* spelling, else the owner of its area. A matching subpath pattern alone
|
|
455
|
+
* names the file, whatever its name; one into unowned files or out of the
|
|
456
|
+
* project gives `null`. With no pattern, the rest is read as a path in the
|
|
457
|
+
* folder of the longest alias prefix (`#a/sub/x.ts` reaches past `#a-sub`
|
|
458
|
+
* when it owns `a/sub/`), falling back to that prefix. A query or fragment
|
|
459
|
+
* is ignored.
|
|
357
460
|
*/
|
|
358
461
|
function deeperAliasOwner(config, specifier) {
|
|
462
|
+
const bare = stripQuery(specifier);
|
|
463
|
+
if (config.foreignSpecifiers.has(bare)) return null;
|
|
464
|
+
const pattern = findPatternEntry(config, bare);
|
|
465
|
+
if (pattern !== null) return ownerOfNamed(config, patternPath(config, pattern, bare));
|
|
466
|
+
const prefix = longestAliasPrefix(config, bare);
|
|
467
|
+
return ownerOfNamed(config, namedPath(config, bare, prefix)) ?? prefix;
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* The file a deeper `#` specifier names, as `deeperAliasOwner` reads it, or
|
|
471
|
+
* `null` when no subpath pattern matches and no folder alias is a prefix.
|
|
472
|
+
*/
|
|
473
|
+
function deeperAliasPath(config, specifier) {
|
|
474
|
+
const bare = stripQuery(specifier);
|
|
475
|
+
if (config.foreignSpecifiers.has(bare)) return null;
|
|
476
|
+
return namedPath(config, bare, longestAliasPrefix(config, bare));
|
|
477
|
+
}
|
|
478
|
+
/** The alias with the longest specifier that is a `/` prefix of `bare`. */
|
|
479
|
+
function longestAliasPrefix(config, bare) {
|
|
359
480
|
let best = null;
|
|
360
|
-
for (const entry of config.entries) if (
|
|
481
|
+
for (const entry of config.entries) if (bare.startsWith(`${entry.specifier}/`) && (best === null || entry.specifier.length > best.specifier.length)) best = entry;
|
|
361
482
|
return best;
|
|
362
483
|
}
|
|
363
484
|
/**
|
|
485
|
+
* The file a deeper `#` specifier, without its query, names: where a
|
|
486
|
+
* subpath pattern sends it, or else, under a folder alias prefix `prefix`,
|
|
487
|
+
* the rest of the specifier read as a path in that folder. Returns `null`
|
|
488
|
+
* when neither applies.
|
|
489
|
+
*/
|
|
490
|
+
function namedPath(config, bare, prefix) {
|
|
491
|
+
const pattern = findPatternEntry(config, bare);
|
|
492
|
+
if (pattern !== null) return patternPath(config, pattern, bare);
|
|
493
|
+
if (prefix === null || !prefix.isBarrel) return null;
|
|
494
|
+
return resolveFrom(prefix.dir, `./${bare.slice(prefix.specifier.length + 1)}`);
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* The file a subpath pattern resolves a matching `#` specifier to, or `null`
|
|
498
|
+
* for a pattern that leads out of the project.
|
|
499
|
+
*/
|
|
500
|
+
function patternPath(config, pattern, specifier) {
|
|
501
|
+
if (pattern.target === null) return null;
|
|
502
|
+
const bare = stripQuery(specifier);
|
|
503
|
+
const matched = bare.slice(pattern.prefix.length, bare.length - pattern.suffix.length);
|
|
504
|
+
return resolveFrom(config.configDir, pattern.target.replaceAll("*", matched));
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
364
507
|
* Finds the subpath pattern a deep `#` specifier reaches through, such as
|
|
365
|
-
* `#utils/*` for `#utils/internal.ts`.
|
|
508
|
+
* `#utils/*` for `#utils/internal.ts`. A query or fragment is ignored, so
|
|
509
|
+
* `#utils/x.js?raw` still matches `#utils/*.js`. Returns `null` when none
|
|
510
|
+
* match or an exact key, which Node.js tries first, names the specifier.
|
|
511
|
+
* The pattern found may lead out of the project, with a `null` target.
|
|
366
512
|
*/
|
|
367
513
|
function findPatternEntry(config, specifier) {
|
|
368
|
-
|
|
514
|
+
const bare = stripQuery(specifier);
|
|
515
|
+
if (config.bySpecifier.has(bare) || config.foreignSpecifiers.has(bare)) return null;
|
|
516
|
+
for (const pattern of config.patterns) if (bare.length >= pattern.prefix.length + pattern.suffix.length && bare.startsWith(pattern.prefix) && bare.endsWith(pattern.suffix)) return pattern;
|
|
369
517
|
return null;
|
|
370
518
|
}
|
|
371
519
|
/**
|
|
@@ -373,7 +521,7 @@ function findPatternEntry(config, specifier) {
|
|
|
373
521
|
* entry such as `#utils` or a deep one such as `#utils/internal.ts`.
|
|
374
522
|
*/
|
|
375
523
|
function targetModule(config, specifier) {
|
|
376
|
-
const exact = config.bySpecifier.get(specifier);
|
|
524
|
+
const exact = config.bySpecifier.get(stripQuery(specifier));
|
|
377
525
|
if (exact !== void 0) return exact;
|
|
378
526
|
return deeperAliasOwner(config, specifier);
|
|
379
527
|
}
|
|
@@ -392,12 +540,10 @@ function resolveLocalSpecifier(filename, specifier) {
|
|
|
392
540
|
if (!specifier.startsWith("#")) return null;
|
|
393
541
|
const config = findImportsConfig(filename);
|
|
394
542
|
if (config === null) return null;
|
|
395
|
-
const exact = config.bySpecifier.get(specifier);
|
|
543
|
+
const exact = config.bySpecifier.get(stripQuery(specifier));
|
|
396
544
|
if (exact !== void 0) return exact.target;
|
|
397
545
|
const pattern = findPatternEntry(config, specifier);
|
|
398
|
-
|
|
399
|
-
const matched = specifier.slice(pattern.prefix.length, specifier.length - pattern.suffix.length).replace(QUERY_PATTERN, "");
|
|
400
|
-
return resolveFrom(config.configDir, pattern.target.replaceAll("*", matched));
|
|
546
|
+
return pattern === null ? null : patternPath(config, pattern, specifier);
|
|
401
547
|
}
|
|
402
548
|
/**
|
|
403
549
|
* The module a specifier points at, whether it was written as a `#` alias or
|
|
@@ -408,13 +554,28 @@ function resolveLocalSpecifier(filename, specifier) {
|
|
|
408
554
|
function resolveTarget(config, filename, specifier) {
|
|
409
555
|
if (specifier.startsWith("#")) return targetModule(config, specifier);
|
|
410
556
|
if (!specifier.startsWith(".")) return null;
|
|
411
|
-
|
|
412
|
-
|
|
557
|
+
return entryAt(config, resolveFrom(parentDir(normalizePath(filename)), stripQuery(specifier)));
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* The alias whose target a resolved path names, trying the files it may name
|
|
561
|
+
* in TypeScript's order (`moduleCandidates`), so `./x.js` names `x.ts` before
|
|
562
|
+
* `x.tsx`; the first declared when two aliases share a target. Returns
|
|
563
|
+
* `null` when the path names no target.
|
|
564
|
+
*/
|
|
565
|
+
function entryAt(config, path) {
|
|
566
|
+
for (const candidate of moduleCandidates(path)) {
|
|
567
|
+
const entry = config.byTarget.get(candidate);
|
|
568
|
+
if (entry !== void 0) return entry;
|
|
569
|
+
}
|
|
570
|
+
return null;
|
|
571
|
+
}
|
|
572
|
+
/** The alias whose target a specifier path names, else its area's owner. */
|
|
573
|
+
function ownerOfNamed(config, path) {
|
|
574
|
+
return path === null ? null : entryAt(config, path) ?? findOwningEntry(config, path);
|
|
413
575
|
}
|
|
414
576
|
/** Whether a path lies in the area some alias owns. */
|
|
415
577
|
function isModulePath(config, path) {
|
|
416
|
-
|
|
417
|
-
return config.entries.some((entry) => ownsPath(entry, normalized));
|
|
578
|
+
return findOwningEntry(config, path) !== null;
|
|
418
579
|
}
|
|
419
580
|
/**
|
|
420
581
|
* Whether an import touches an owned area: the importing file or the file it
|
|
@@ -641,10 +802,24 @@ const enforceImportOrderRule = {
|
|
|
641
802
|
//#endregion
|
|
642
803
|
//#region src/rules/enforce-layer-order.ts
|
|
643
804
|
/**
|
|
805
|
+
* Whether a specifier resolved to its importer's own area names that area's
|
|
806
|
+
* entry point. A deeper `#` specifier to any other file of the area is no
|
|
807
|
+
* cycle; `no-barrel-bypass` reports it and suggests a relative path.
|
|
808
|
+
*/
|
|
809
|
+
function namesOwnEntry(config, specifier, owner) {
|
|
810
|
+
if (!specifier.startsWith("#") || config.bySpecifier.has(stripQuery(specifier))) return true;
|
|
811
|
+
const path = deeperAliasPath(config, specifier);
|
|
812
|
+
return path !== null && namesSameModule(path, owner.target);
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
644
815
|
* `enforce-layer-order` - treats the order of `#` aliases in `package.json`
|
|
645
816
|
* `imports` as the layer order, top layer first: an area may only import
|
|
646
817
|
* aliases declared below its own. Also rejects a file that imports its own
|
|
647
|
-
* area's alias, by alias or by path, and a file that two aliases
|
|
818
|
+
* area's alias, by alias or by path, and a file that two aliases with the
|
|
819
|
+
* same area both own.
|
|
820
|
+
*
|
|
821
|
+
* An alias inside another alias's area owns its part, carved out of the
|
|
822
|
+
* outer area, and the two are ordered like any other pair of aliases.
|
|
648
823
|
*
|
|
649
824
|
* This makes cycles between areas structurally impossible rather than
|
|
650
825
|
* merely detectable. Lint cannot report on `package.json` itself, so an
|
|
@@ -661,7 +836,7 @@ const enforceLayerOrderRule = {
|
|
|
661
836
|
messages: {
|
|
662
837
|
ownEntry: "'{{owner}}' must not import its own entry point through '{{specifier}}'.",
|
|
663
838
|
upward: "'{{owner}}' must not import '{{target}}', which is declared above it in package.json.",
|
|
664
|
-
overlap: "This file is owned by more than one alias in package.json: {{aliases}}."
|
|
839
|
+
overlap: "This file is owned by more than one alias with the same area in package.json: {{aliases}}."
|
|
665
840
|
}
|
|
666
841
|
},
|
|
667
842
|
create(context) {
|
|
@@ -673,6 +848,7 @@ const enforceLayerOrderRule = {
|
|
|
673
848
|
const target = resolveTarget(config, context.filename, specifier);
|
|
674
849
|
if (owner === null || target === null) return;
|
|
675
850
|
if (target.index === owner.index) {
|
|
851
|
+
if (!namesOwnEntry(config, specifier, owner)) return;
|
|
676
852
|
context.report({
|
|
677
853
|
node,
|
|
678
854
|
messageId: "ownEntry",
|
|
@@ -694,8 +870,8 @@ const enforceLayerOrderRule = {
|
|
|
694
870
|
}),
|
|
695
871
|
Program(node) {
|
|
696
872
|
const config = findImportsConfig(context.filename);
|
|
697
|
-
const
|
|
698
|
-
if (
|
|
873
|
+
const owners = config === null ? [] : owningEntries(config, context.filename);
|
|
874
|
+
if (owners.length < 2) return;
|
|
699
875
|
context.report({
|
|
700
876
|
node,
|
|
701
877
|
loc: {
|
|
@@ -703,7 +879,7 @@ const enforceLayerOrderRule = {
|
|
|
703
879
|
column: 0
|
|
704
880
|
},
|
|
705
881
|
messageId: "overlap",
|
|
706
|
-
data: { aliases:
|
|
882
|
+
data: { aliases: owners.map((entry) => entry.specifier).join(", ") }
|
|
707
883
|
});
|
|
708
884
|
}
|
|
709
885
|
};
|
|
@@ -788,6 +964,57 @@ const noAbsoluteImportRule = {
|
|
|
788
964
|
//#endregion
|
|
789
965
|
//#region src/rules/no-barrel-bypass.ts
|
|
790
966
|
/**
|
|
967
|
+
* The `./` specifier a file inside `owner`'s area can use instead of a deeper
|
|
968
|
+
* `#` specifier into that same area, keeping any query. `null` when the named
|
|
969
|
+
* file is not in the importer's folder or below, since `../` is banned, or
|
|
970
|
+
* when it is the area's entry point or the importer itself, which
|
|
971
|
+
* `enforce-layer-order` and `no-self-import` reject by any spelling.
|
|
972
|
+
*/
|
|
973
|
+
function relativeSpecifier(config, filename, specifier, owner) {
|
|
974
|
+
const path = deeperAliasPath(config, specifier);
|
|
975
|
+
const importer = normalizePath(filename);
|
|
976
|
+
if (path === null || namesSameModule(path, owner.target) || namesSameModule(path, importer)) return null;
|
|
977
|
+
const relative = childSpecifier(parentDir(importer), path);
|
|
978
|
+
return relative === null ? null : relative + specifier.slice(stripQuery(specifier).length);
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* Reports a deeper `#` specifier into the importing file's own area. The
|
|
982
|
+
* alias itself is never suggested there, since importing it is a cycle;
|
|
983
|
+
* a relative path is, when one without `../` exists.
|
|
984
|
+
*/
|
|
985
|
+
function reportOwnArea(context, node, data, relative) {
|
|
986
|
+
context.report({
|
|
987
|
+
node,
|
|
988
|
+
messageId: "ownArea",
|
|
989
|
+
data,
|
|
990
|
+
suggest: relative === null ? [] : [{
|
|
991
|
+
messageId: "useRelative",
|
|
992
|
+
data: { path: relative },
|
|
993
|
+
fix: (fixer) => fixer.replaceText(node, quoteLike(node, relative))
|
|
994
|
+
}]
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* Reports a deeper `#` specifier from outside the area of its owner,
|
|
999
|
+
* suggesting the alias unless a query or fragment, which the alias cannot
|
|
1000
|
+
* carry, would be lost.
|
|
1001
|
+
*/
|
|
1002
|
+
function reportBypass(context, node, specifier, owner) {
|
|
1003
|
+
context.report({
|
|
1004
|
+
node,
|
|
1005
|
+
messageId: "bypass",
|
|
1006
|
+
data: {
|
|
1007
|
+
specifier,
|
|
1008
|
+
owner: owner.specifier
|
|
1009
|
+
},
|
|
1010
|
+
suggest: stripQuery(specifier) === specifier ? [{
|
|
1011
|
+
messageId: "useEntry",
|
|
1012
|
+
data: { owner: owner.specifier },
|
|
1013
|
+
fix: (fixer) => fixer.replaceText(node, quoteLike(node, owner.specifier))
|
|
1014
|
+
}] : []
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
791
1018
|
* `no-barrel-bypass` - rejects a `#` specifier deeper than an alias, such as
|
|
792
1019
|
* `#components/internal.ts` beside `#components`, whether or not a subpath
|
|
793
1020
|
* pattern makes it resolve, and any use of a subpath pattern that reaches
|
|
@@ -795,7 +1022,8 @@ const noAbsoluteImportRule = {
|
|
|
795
1022
|
*
|
|
796
1023
|
* Code outside an alias's area reaches it only through the exact alias, so
|
|
797
1024
|
* the area keeps one public surface. Switching to the alias is a suggestion,
|
|
798
|
-
* since its target may not export the name yet.
|
|
1025
|
+
* since its target may not export the name yet. Inside the area, the alias
|
|
1026
|
+
* is a cycle, so a relative path is suggested instead.
|
|
799
1027
|
*/
|
|
800
1028
|
const noBarrelBypassRule = {
|
|
801
1029
|
meta: {
|
|
@@ -808,37 +1036,37 @@ const noBarrelBypassRule = {
|
|
|
808
1036
|
schema: [],
|
|
809
1037
|
messages: {
|
|
810
1038
|
bypass: "'{{specifier}}' reaches past the entry point '{{owner}}'.",
|
|
1039
|
+
ownArea: "'{{specifier}}' reaches into this file's own area '{{owner}}' through an alias.",
|
|
811
1040
|
internals: "'{{specifier}}' reaches into module internals.",
|
|
812
|
-
useEntry: "Import from '{{owner}}' instead."
|
|
1041
|
+
useEntry: "Import from '{{owner}}' instead.",
|
|
1042
|
+
useRelative: "Import from '{{path}}' instead."
|
|
813
1043
|
}
|
|
814
1044
|
},
|
|
815
1045
|
create(context) {
|
|
816
1046
|
return visitSpecifiers((node, specifier) => {
|
|
817
1047
|
if (!specifier.startsWith("#")) return;
|
|
818
1048
|
const config = findImportsConfig(context.filename);
|
|
819
|
-
|
|
1049
|
+
const bare = stripQuery(specifier);
|
|
1050
|
+
if (config === null || config.bySpecifier.has(bare) || config.foreignSpecifiers.has(bare)) return;
|
|
820
1051
|
const owner = deeperAliasOwner(config, specifier);
|
|
821
1052
|
if (owner === null) {
|
|
822
|
-
|
|
1053
|
+
const pattern = findPatternEntry(config, specifier);
|
|
1054
|
+
if (pattern !== null && pattern.target !== null && isInModuleScope(context.filename, specifier)) context.report({
|
|
823
1055
|
node,
|
|
824
1056
|
messageId: "internals",
|
|
825
1057
|
data: { specifier }
|
|
826
1058
|
});
|
|
827
1059
|
return;
|
|
828
1060
|
}
|
|
829
|
-
context.
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
data: {
|
|
1061
|
+
if (isOwnedBy(config, context.filename, owner)) {
|
|
1062
|
+
const relative = relativeSpecifier(config, context.filename, specifier, owner);
|
|
1063
|
+
reportOwnArea(context, node, {
|
|
833
1064
|
specifier,
|
|
834
1065
|
owner: owner.specifier
|
|
835
|
-
},
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
fix: (fixer) => fixer.replaceText(node, quoteLike(node, owner.specifier))
|
|
840
|
-
}]
|
|
841
|
-
});
|
|
1066
|
+
}, relative);
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
reportBypass(context, node, specifier, owner);
|
|
842
1070
|
});
|
|
843
1071
|
}
|
|
844
1072
|
};
|
|
@@ -899,9 +1127,10 @@ const noDuplicateImportSourceRule = {
|
|
|
899
1127
|
//#endregion
|
|
900
1128
|
//#region src/rules/no-parent-import.ts
|
|
901
1129
|
/**
|
|
902
|
-
* `no-parent-import` - rejects `../` specifiers
|
|
903
|
-
*
|
|
904
|
-
* an alias for
|
|
1130
|
+
* `no-parent-import` - rejects `../` specifiers, and any relative specifier
|
|
1131
|
+
* with a `..` segment, in every file, owned by an alias or not, rewriting
|
|
1132
|
+
* one to its `#` alias where `package.json` declares an alias for that file,
|
|
1133
|
+
* in any spelling TypeScript resolves to it.
|
|
905
1134
|
*
|
|
906
1135
|
* Reaching up and back down couples two files through their layout instead
|
|
907
1136
|
* of a name. Anything above a file is reached through an alias.
|
|
@@ -922,11 +1151,11 @@ const noParentImportRule = {
|
|
|
922
1151
|
},
|
|
923
1152
|
create(context) {
|
|
924
1153
|
return visitSpecifiers((node, specifier) => {
|
|
925
|
-
if (!specifier
|
|
1154
|
+
if (!isParentSpecifier(specifier)) return;
|
|
926
1155
|
const config = findImportsConfig(context.filename);
|
|
927
1156
|
const resolved = resolveLocalSpecifier(context.filename, specifier);
|
|
928
|
-
const entry = resolved === null ?
|
|
929
|
-
if (config === null || entry ===
|
|
1157
|
+
const entry = config === null || resolved === null ? null : entryAt(config, resolved);
|
|
1158
|
+
if (config === null || entry === null || isOwnedBy(config, context.filename, entry)) {
|
|
930
1159
|
context.report({
|
|
931
1160
|
node,
|
|
932
1161
|
messageId: "parentImport",
|
|
@@ -941,7 +1170,7 @@ const noParentImportRule = {
|
|
|
941
1170
|
specifier,
|
|
942
1171
|
alias: entry.specifier
|
|
943
1172
|
},
|
|
944
|
-
fix: (fixer) => fixer.replaceText(node, quoteLike(node, entry.specifier))
|
|
1173
|
+
fix: stripQuery(specifier) === specifier ? (fixer) => fixer.replaceText(node, quoteLike(node, entry.specifier)) : null
|
|
945
1174
|
});
|
|
946
1175
|
});
|
|
947
1176
|
}
|
|
@@ -956,8 +1185,8 @@ const noParentImportRule = {
|
|
|
956
1185
|
* folder and everything below it, and an alias to any other file owns only
|
|
957
1186
|
* that file. Code outside an area reaches it only through the alias. Inside
|
|
958
1187
|
* an area, siblings and subfolders are free, and so are files no alias owns.
|
|
959
|
-
* A path to an alias's
|
|
960
|
-
* rewrites it to the alias.
|
|
1188
|
+
* A path to an alias's target, in any spelling TypeScript resolves to it, is
|
|
1189
|
+
* left to `prefer-alias-import`, which rewrites it to the alias.
|
|
961
1190
|
*/
|
|
962
1191
|
const noRelativeBypassRule = {
|
|
963
1192
|
meta: {
|
|
@@ -971,12 +1200,12 @@ const noRelativeBypassRule = {
|
|
|
971
1200
|
},
|
|
972
1201
|
create(context) {
|
|
973
1202
|
return visitSpecifiers((node, specifier) => {
|
|
974
|
-
if (!specifier.startsWith("./")) return;
|
|
1203
|
+
if (!specifier.startsWith("./") || isParentSpecifier(specifier)) return;
|
|
975
1204
|
const config = findImportsConfig(context.filename);
|
|
976
1205
|
const resolved = resolveLocalSpecifier(context.filename, specifier);
|
|
977
|
-
if (config === null || resolved === null || config
|
|
1206
|
+
if (config === null || resolved === null || entryAt(config, resolved) !== null) return;
|
|
978
1207
|
const target = findOwningEntry(config, resolved);
|
|
979
|
-
if (target === null ||
|
|
1208
|
+
if (target === null || isOwnedBy(config, context.filename, target)) return;
|
|
980
1209
|
context.report({
|
|
981
1210
|
node,
|
|
982
1211
|
messageId: "bypass",
|
|
@@ -993,7 +1222,8 @@ const noRelativeBypassRule = {
|
|
|
993
1222
|
/**
|
|
994
1223
|
* `no-self-import` - rejects any statically knowable specifier that resolves
|
|
995
1224
|
* to the importing file: a relative path, an absolute path or `file:` URL, an
|
|
996
|
-
* exact `#` entry, or a `#` subpath pattern
|
|
1225
|
+
* exact `#` entry, or a `#` subpath pattern, in any spelling TypeScript
|
|
1226
|
+
* resolves to the file (such as `.js` for `.ts`, or no extension).
|
|
997
1227
|
*
|
|
998
1228
|
* A self-import is an immediate circular dependency.
|
|
999
1229
|
*/
|
|
@@ -1009,7 +1239,8 @@ const noSelfImportRule = {
|
|
|
1009
1239
|
},
|
|
1010
1240
|
create(context) {
|
|
1011
1241
|
return visitSpecifiers((node, specifier) => {
|
|
1012
|
-
|
|
1242
|
+
const target = resolveLocalSpecifier(context.filename, specifier);
|
|
1243
|
+
if (target === null || !namesSameModule(target, normalizePath(context.filename))) return;
|
|
1013
1244
|
context.report({
|
|
1014
1245
|
node,
|
|
1015
1246
|
messageId: "selfImport",
|
|
@@ -1051,9 +1282,9 @@ const noWildcardImportRule = {
|
|
|
1051
1282
|
//#region src/rules/prefer-alias-import.ts
|
|
1052
1283
|
/**
|
|
1053
1284
|
* `prefer-alias-import` - requires the `#` alias when a `./` specifier names
|
|
1054
|
-
* the
|
|
1055
|
-
*
|
|
1056
|
-
* everywhere outside it.
|
|
1285
|
+
* the target of an alias, in any spelling TypeScript resolves to it, from
|
|
1286
|
+
* outside that alias's area, a same-folder sibling included. An alias owns
|
|
1287
|
+
* its target, so its target has one spelling everywhere outside it.
|
|
1057
1288
|
*
|
|
1058
1289
|
* One spelling per module keeps `package.json` `imports` the single source of
|
|
1059
1290
|
* truth, and keeps the layer rules from being sidestepped by a path.
|
|
@@ -1071,12 +1302,12 @@ const preferAliasImportRule = {
|
|
|
1071
1302
|
},
|
|
1072
1303
|
create(context) {
|
|
1073
1304
|
return visitSpecifiers((node, specifier) => {
|
|
1074
|
-
if (!specifier.startsWith("./")) return;
|
|
1305
|
+
if (!specifier.startsWith("./") || isParentSpecifier(specifier)) return;
|
|
1075
1306
|
const config = findImportsConfig(context.filename);
|
|
1076
1307
|
const resolved = resolveLocalSpecifier(context.filename, specifier);
|
|
1077
|
-
const entry = resolved === null ?
|
|
1078
|
-
if (config === null || entry ===
|
|
1079
|
-
if (
|
|
1308
|
+
const entry = config === null || resolved === null ? null : entryAt(config, resolved);
|
|
1309
|
+
if (config === null || entry === null) return;
|
|
1310
|
+
if (isOwnedBy(config, context.filename, entry)) return;
|
|
1080
1311
|
context.report({
|
|
1081
1312
|
node,
|
|
1082
1313
|
messageId: "useAlias",
|
|
@@ -1084,7 +1315,7 @@ const preferAliasImportRule = {
|
|
|
1084
1315
|
specifier,
|
|
1085
1316
|
alias: entry.specifier
|
|
1086
1317
|
},
|
|
1087
|
-
fix: (fixer) => fixer.replaceText(node, quoteLike(node, entry.specifier))
|
|
1318
|
+
fix: stripQuery(specifier) === specifier ? (fixer) => fixer.replaceText(node, quoteLike(node, entry.specifier)) : null
|
|
1088
1319
|
});
|
|
1089
1320
|
});
|
|
1090
1321
|
}
|
|
@@ -1138,7 +1369,7 @@ const MODULES = [
|
|
|
1138
1369
|
const basePlugin = {
|
|
1139
1370
|
meta: {
|
|
1140
1371
|
name: "eslint-plugin-import-structure",
|
|
1141
|
-
version: "0.
|
|
1372
|
+
version: "0.11.1",
|
|
1142
1373
|
namespace: NAMESPACE
|
|
1143
1374
|
},
|
|
1144
1375
|
rules: Object.fromEntries(rules)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eslint-plugin-import-structure",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"description": "ESLint and Oxlint rules that keep a module graph layered and free of cycles, with package.json \"#\" imports as the layer order.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"eslint",
|