eslint-plugin-import-structure 0.11.0 → 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 +1 -1
- package/dist/index.js +285 -92
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -87,7 +87,7 @@ The rules never depend on folder or file names; an alias may point anywhere.
|
|
|
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
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
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
|
|
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.
|
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
|
|
@@ -363,46 +449,71 @@ function findOwningEntry(config, filePath) {
|
|
|
363
449
|
return owningEntries(config, filePath)[0] ?? null;
|
|
364
450
|
}
|
|
365
451
|
/**
|
|
366
|
-
* The
|
|
367
|
-
* `#utils/internal.ts
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
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.
|
|
374
460
|
*/
|
|
375
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) {
|
|
376
480
|
let best = null;
|
|
377
|
-
for (const entry of config.entries) if (
|
|
378
|
-
|
|
379
|
-
const path = namedPath(config, specifier, best);
|
|
380
|
-
return (path === null ? null : findOwningEntry(config, path)) ?? best;
|
|
481
|
+
for (const entry of config.entries) if (bare.startsWith(`${entry.specifier}/`) && (best === null || entry.specifier.length > best.specifier.length)) best = entry;
|
|
482
|
+
return best;
|
|
381
483
|
}
|
|
382
484
|
/**
|
|
383
|
-
* The file a deeper `#` specifier
|
|
384
|
-
*
|
|
385
|
-
* the specifier read as a path in that folder. Returns `null`
|
|
386
|
-
* applies.
|
|
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.
|
|
387
489
|
*/
|
|
388
|
-
function namedPath(config,
|
|
389
|
-
const pattern = findPatternEntry(config,
|
|
390
|
-
if (pattern !== null) return patternPath(config, pattern,
|
|
391
|
-
if (!prefix.isBarrel) return null;
|
|
392
|
-
|
|
393
|
-
return resolveFrom(prefix.dir, `./${rest}`);
|
|
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)}`);
|
|
394
495
|
}
|
|
395
|
-
/**
|
|
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
|
+
*/
|
|
396
500
|
function patternPath(config, pattern, specifier) {
|
|
397
|
-
|
|
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);
|
|
398
504
|
return resolveFrom(config.configDir, pattern.target.replaceAll("*", matched));
|
|
399
505
|
}
|
|
400
506
|
/**
|
|
401
507
|
* Finds the subpath pattern a deep `#` specifier reaches through, such as
|
|
402
|
-
* `#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.
|
|
403
512
|
*/
|
|
404
513
|
function findPatternEntry(config, specifier) {
|
|
405
|
-
|
|
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;
|
|
406
517
|
return null;
|
|
407
518
|
}
|
|
408
519
|
/**
|
|
@@ -410,7 +521,7 @@ function findPatternEntry(config, specifier) {
|
|
|
410
521
|
* entry such as `#utils` or a deep one such as `#utils/internal.ts`.
|
|
411
522
|
*/
|
|
412
523
|
function targetModule(config, specifier) {
|
|
413
|
-
const exact = config.bySpecifier.get(specifier);
|
|
524
|
+
const exact = config.bySpecifier.get(stripQuery(specifier));
|
|
414
525
|
if (exact !== void 0) return exact;
|
|
415
526
|
return deeperAliasOwner(config, specifier);
|
|
416
527
|
}
|
|
@@ -429,7 +540,7 @@ function resolveLocalSpecifier(filename, specifier) {
|
|
|
429
540
|
if (!specifier.startsWith("#")) return null;
|
|
430
541
|
const config = findImportsConfig(filename);
|
|
431
542
|
if (config === null) return null;
|
|
432
|
-
const exact = config.bySpecifier.get(specifier);
|
|
543
|
+
const exact = config.bySpecifier.get(stripQuery(specifier));
|
|
433
544
|
if (exact !== void 0) return exact.target;
|
|
434
545
|
const pattern = findPatternEntry(config, specifier);
|
|
435
546
|
return pattern === null ? null : patternPath(config, pattern, specifier);
|
|
@@ -443,8 +554,24 @@ function resolveLocalSpecifier(filename, specifier) {
|
|
|
443
554
|
function resolveTarget(config, filename, specifier) {
|
|
444
555
|
if (specifier.startsWith("#")) return targetModule(config, specifier);
|
|
445
556
|
if (!specifier.startsWith(".")) return null;
|
|
446
|
-
|
|
447
|
-
|
|
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);
|
|
448
575
|
}
|
|
449
576
|
/** Whether a path lies in the area some alias owns. */
|
|
450
577
|
function isModulePath(config, path) {
|
|
@@ -675,6 +802,16 @@ const enforceImportOrderRule = {
|
|
|
675
802
|
//#endregion
|
|
676
803
|
//#region src/rules/enforce-layer-order.ts
|
|
677
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
|
+
/**
|
|
678
815
|
* `enforce-layer-order` - treats the order of `#` aliases in `package.json`
|
|
679
816
|
* `imports` as the layer order, top layer first: an area may only import
|
|
680
817
|
* aliases declared below its own. Also rejects a file that imports its own
|
|
@@ -711,6 +848,7 @@ const enforceLayerOrderRule = {
|
|
|
711
848
|
const target = resolveTarget(config, context.filename, specifier);
|
|
712
849
|
if (owner === null || target === null) return;
|
|
713
850
|
if (target.index === owner.index) {
|
|
851
|
+
if (!namesOwnEntry(config, specifier, owner)) return;
|
|
714
852
|
context.report({
|
|
715
853
|
node,
|
|
716
854
|
messageId: "ownEntry",
|
|
@@ -826,6 +964,57 @@ const noAbsoluteImportRule = {
|
|
|
826
964
|
//#endregion
|
|
827
965
|
//#region src/rules/no-barrel-bypass.ts
|
|
828
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
|
+
/**
|
|
829
1018
|
* `no-barrel-bypass` - rejects a `#` specifier deeper than an alias, such as
|
|
830
1019
|
* `#components/internal.ts` beside `#components`, whether or not a subpath
|
|
831
1020
|
* pattern makes it resolve, and any use of a subpath pattern that reaches
|
|
@@ -833,7 +1022,8 @@ const noAbsoluteImportRule = {
|
|
|
833
1022
|
*
|
|
834
1023
|
* Code outside an alias's area reaches it only through the exact alias, so
|
|
835
1024
|
* the area keeps one public surface. Switching to the alias is a suggestion,
|
|
836
|
-
* 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.
|
|
837
1027
|
*/
|
|
838
1028
|
const noBarrelBypassRule = {
|
|
839
1029
|
meta: {
|
|
@@ -846,37 +1036,37 @@ const noBarrelBypassRule = {
|
|
|
846
1036
|
schema: [],
|
|
847
1037
|
messages: {
|
|
848
1038
|
bypass: "'{{specifier}}' reaches past the entry point '{{owner}}'.",
|
|
1039
|
+
ownArea: "'{{specifier}}' reaches into this file's own area '{{owner}}' through an alias.",
|
|
849
1040
|
internals: "'{{specifier}}' reaches into module internals.",
|
|
850
|
-
useEntry: "Import from '{{owner}}' instead."
|
|
1041
|
+
useEntry: "Import from '{{owner}}' instead.",
|
|
1042
|
+
useRelative: "Import from '{{path}}' instead."
|
|
851
1043
|
}
|
|
852
1044
|
},
|
|
853
1045
|
create(context) {
|
|
854
1046
|
return visitSpecifiers((node, specifier) => {
|
|
855
1047
|
if (!specifier.startsWith("#")) return;
|
|
856
1048
|
const config = findImportsConfig(context.filename);
|
|
857
|
-
|
|
1049
|
+
const bare = stripQuery(specifier);
|
|
1050
|
+
if (config === null || config.bySpecifier.has(bare) || config.foreignSpecifiers.has(bare)) return;
|
|
858
1051
|
const owner = deeperAliasOwner(config, specifier);
|
|
859
1052
|
if (owner === null) {
|
|
860
|
-
|
|
1053
|
+
const pattern = findPatternEntry(config, specifier);
|
|
1054
|
+
if (pattern !== null && pattern.target !== null && isInModuleScope(context.filename, specifier)) context.report({
|
|
861
1055
|
node,
|
|
862
1056
|
messageId: "internals",
|
|
863
1057
|
data: { specifier }
|
|
864
1058
|
});
|
|
865
1059
|
return;
|
|
866
1060
|
}
|
|
867
|
-
context.
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
data: {
|
|
1061
|
+
if (isOwnedBy(config, context.filename, owner)) {
|
|
1062
|
+
const relative = relativeSpecifier(config, context.filename, specifier, owner);
|
|
1063
|
+
reportOwnArea(context, node, {
|
|
871
1064
|
specifier,
|
|
872
1065
|
owner: owner.specifier
|
|
873
|
-
},
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
fix: (fixer) => fixer.replaceText(node, quoteLike(node, owner.specifier))
|
|
878
|
-
}]
|
|
879
|
-
});
|
|
1066
|
+
}, relative);
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
reportBypass(context, node, specifier, owner);
|
|
880
1070
|
});
|
|
881
1071
|
}
|
|
882
1072
|
};
|
|
@@ -937,9 +1127,10 @@ const noDuplicateImportSourceRule = {
|
|
|
937
1127
|
//#endregion
|
|
938
1128
|
//#region src/rules/no-parent-import.ts
|
|
939
1129
|
/**
|
|
940
|
-
* `no-parent-import` - rejects `../` specifiers
|
|
941
|
-
*
|
|
942
|
-
* 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.
|
|
943
1134
|
*
|
|
944
1135
|
* Reaching up and back down couples two files through their layout instead
|
|
945
1136
|
* of a name. Anything above a file is reached through an alias.
|
|
@@ -960,11 +1151,11 @@ const noParentImportRule = {
|
|
|
960
1151
|
},
|
|
961
1152
|
create(context) {
|
|
962
1153
|
return visitSpecifiers((node, specifier) => {
|
|
963
|
-
if (!specifier
|
|
1154
|
+
if (!isParentSpecifier(specifier)) return;
|
|
964
1155
|
const config = findImportsConfig(context.filename);
|
|
965
1156
|
const resolved = resolveLocalSpecifier(context.filename, specifier);
|
|
966
|
-
const entry = resolved === null ?
|
|
967
|
-
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)) {
|
|
968
1159
|
context.report({
|
|
969
1160
|
node,
|
|
970
1161
|
messageId: "parentImport",
|
|
@@ -979,7 +1170,7 @@ const noParentImportRule = {
|
|
|
979
1170
|
specifier,
|
|
980
1171
|
alias: entry.specifier
|
|
981
1172
|
},
|
|
982
|
-
fix: (fixer) => fixer.replaceText(node, quoteLike(node, entry.specifier))
|
|
1173
|
+
fix: stripQuery(specifier) === specifier ? (fixer) => fixer.replaceText(node, quoteLike(node, entry.specifier)) : null
|
|
983
1174
|
});
|
|
984
1175
|
});
|
|
985
1176
|
}
|
|
@@ -994,8 +1185,8 @@ const noParentImportRule = {
|
|
|
994
1185
|
* folder and everything below it, and an alias to any other file owns only
|
|
995
1186
|
* that file. Code outside an area reaches it only through the alias. Inside
|
|
996
1187
|
* an area, siblings and subfolders are free, and so are files no alias owns.
|
|
997
|
-
* A path to an alias's
|
|
998
|
-
* 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.
|
|
999
1190
|
*/
|
|
1000
1191
|
const noRelativeBypassRule = {
|
|
1001
1192
|
meta: {
|
|
@@ -1009,10 +1200,10 @@ const noRelativeBypassRule = {
|
|
|
1009
1200
|
},
|
|
1010
1201
|
create(context) {
|
|
1011
1202
|
return visitSpecifiers((node, specifier) => {
|
|
1012
|
-
if (!specifier.startsWith("./")) return;
|
|
1203
|
+
if (!specifier.startsWith("./") || isParentSpecifier(specifier)) return;
|
|
1013
1204
|
const config = findImportsConfig(context.filename);
|
|
1014
1205
|
const resolved = resolveLocalSpecifier(context.filename, specifier);
|
|
1015
|
-
if (config === null || resolved === null || config
|
|
1206
|
+
if (config === null || resolved === null || entryAt(config, resolved) !== null) return;
|
|
1016
1207
|
const target = findOwningEntry(config, resolved);
|
|
1017
1208
|
if (target === null || isOwnedBy(config, context.filename, target)) return;
|
|
1018
1209
|
context.report({
|
|
@@ -1031,7 +1222,8 @@ const noRelativeBypassRule = {
|
|
|
1031
1222
|
/**
|
|
1032
1223
|
* `no-self-import` - rejects any statically knowable specifier that resolves
|
|
1033
1224
|
* to the importing file: a relative path, an absolute path or `file:` URL, an
|
|
1034
|
-
* 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).
|
|
1035
1227
|
*
|
|
1036
1228
|
* A self-import is an immediate circular dependency.
|
|
1037
1229
|
*/
|
|
@@ -1047,7 +1239,8 @@ const noSelfImportRule = {
|
|
|
1047
1239
|
},
|
|
1048
1240
|
create(context) {
|
|
1049
1241
|
return visitSpecifiers((node, specifier) => {
|
|
1050
|
-
|
|
1242
|
+
const target = resolveLocalSpecifier(context.filename, specifier);
|
|
1243
|
+
if (target === null || !namesSameModule(target, normalizePath(context.filename))) return;
|
|
1051
1244
|
context.report({
|
|
1052
1245
|
node,
|
|
1053
1246
|
messageId: "selfImport",
|
|
@@ -1089,9 +1282,9 @@ const noWildcardImportRule = {
|
|
|
1089
1282
|
//#region src/rules/prefer-alias-import.ts
|
|
1090
1283
|
/**
|
|
1091
1284
|
* `prefer-alias-import` - requires the `#` alias when a `./` specifier names
|
|
1092
|
-
* the
|
|
1093
|
-
*
|
|
1094
|
-
* 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.
|
|
1095
1288
|
*
|
|
1096
1289
|
* One spelling per module keeps `package.json` `imports` the single source of
|
|
1097
1290
|
* truth, and keeps the layer rules from being sidestepped by a path.
|
|
@@ -1109,11 +1302,11 @@ const preferAliasImportRule = {
|
|
|
1109
1302
|
},
|
|
1110
1303
|
create(context) {
|
|
1111
1304
|
return visitSpecifiers((node, specifier) => {
|
|
1112
|
-
if (!specifier.startsWith("./")) return;
|
|
1305
|
+
if (!specifier.startsWith("./") || isParentSpecifier(specifier)) return;
|
|
1113
1306
|
const config = findImportsConfig(context.filename);
|
|
1114
1307
|
const resolved = resolveLocalSpecifier(context.filename, specifier);
|
|
1115
|
-
const entry = resolved === null ?
|
|
1116
|
-
if (config === null || entry ===
|
|
1308
|
+
const entry = config === null || resolved === null ? null : entryAt(config, resolved);
|
|
1309
|
+
if (config === null || entry === null) return;
|
|
1117
1310
|
if (isOwnedBy(config, context.filename, entry)) return;
|
|
1118
1311
|
context.report({
|
|
1119
1312
|
node,
|
|
@@ -1122,7 +1315,7 @@ const preferAliasImportRule = {
|
|
|
1122
1315
|
specifier,
|
|
1123
1316
|
alias: entry.specifier
|
|
1124
1317
|
},
|
|
1125
|
-
fix: (fixer) => fixer.replaceText(node, quoteLike(node, entry.specifier))
|
|
1318
|
+
fix: stripQuery(specifier) === specifier ? (fixer) => fixer.replaceText(node, quoteLike(node, entry.specifier)) : null
|
|
1126
1319
|
});
|
|
1127
1320
|
});
|
|
1128
1321
|
}
|
|
@@ -1176,7 +1369,7 @@ const MODULES = [
|
|
|
1176
1369
|
const basePlugin = {
|
|
1177
1370
|
meta: {
|
|
1178
1371
|
name: "eslint-plugin-import-structure",
|
|
1179
|
-
version: "0.11.
|
|
1372
|
+
version: "0.11.1",
|
|
1180
1373
|
namespace: NAMESPACE
|
|
1181
1374
|
},
|
|
1182
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.11.
|
|
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",
|