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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +285 -92
  3. 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 exact target).
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, is legitimate but irrelevant here.
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
- * Reads and parses one config file. Returns `null` when the file
278
- * is unreadable, is not JSON, or has no local `#` entries.
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 entries = [];
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
- patterns
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 exact alias a deeper `#` specifier reaches past, such as `#utils` for
367
- * `#utils/internal.ts`, whether or not a subpath pattern makes it resolve.
368
- * It is the owner of the file the specifier names: the file a matching
369
- * subpath pattern resolves to, or else, under a folder alias prefix, the rest
370
- * of the specifier read as a path in that folder. So `#a/sub/x.ts` reaches
371
- * past `#a-sub` when that alias owns `a/sub/`. A named file no alias owns
372
- * falls back to the longest alias prefix. Returns `null` when no alias is a
373
- * prefix.
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 (specifier.startsWith(`${entry.specifier}/`) && (best === null || entry.specifier.length > best.specifier.length)) best = entry;
378
- if (best === null) return null;
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 names under the alias prefix `prefix`:
384
- * where a subpath pattern sends it, or else, for a folder alias, the rest of
385
- * the specifier read as a path in that folder. Returns `null` when neither
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, specifier, prefix) {
389
- const pattern = findPatternEntry(config, specifier);
390
- if (pattern !== null) return patternPath(config, pattern, specifier);
391
- if (!prefix.isBarrel) return null;
392
- const rest = specifier.slice(prefix.specifier.length + 1).replace(QUERY_PATTERN, "");
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
- /** The file a subpath pattern resolves a `#` specifier matching it to. */
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
- const matched = specifier.slice(pattern.prefix.length, specifier.length - pattern.suffix.length).replace(QUERY_PATTERN, "");
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`. Returns `null` when none match.
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
- for (const pattern of config.patterns) if (specifier.length >= pattern.prefix.length + pattern.suffix.length && specifier.startsWith(pattern.prefix) && specifier.endsWith(pattern.suffix)) return pattern;
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
- const resolved = resolveFrom(parentDir(normalizePath(filename)), specifier);
447
- return config.byTarget.get(resolved) ?? null;
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
- if (config === null || config.bySpecifier.has(specifier)) return;
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
- if (findPatternEntry(config, specifier) !== null && isInModuleScope(context.filename, specifier)) context.report({
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.report({
868
- node,
869
- messageId: "bypass",
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
- suggest: [{
875
- messageId: "useEntry",
876
- data: { owner: owner.specifier },
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 in every file, owned by an
941
- * alias or not, rewriting one to its `#` alias where `package.json` declares
942
- * an alias for exactly that file.
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.startsWith("../")) return;
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 ? void 0 : config?.byTarget.get(resolved);
967
- if (config === null || entry === void 0 || isOwnedBy(config, context.filename, 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 exact target is left to `prefer-alias-import`, which
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.byTarget.has(resolved)) return;
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
- if (resolveLocalSpecifier(context.filename, specifier) !== normalizePath(context.filename)) return;
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 exact target of an alias from outside that alias's area, a same-folder
1093
- * sibling included. An alias owns its target, so its target has one spelling
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 ? void 0 : config?.byTarget.get(resolved);
1116
- if (config === null || entry === void 0) return;
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.0",
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.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",