eslint-plugin-import-structure 0.0.0-stage → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cunarist
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,90 @@
1
- # Temporary Holding Version
1
+ # eslint-plugin-import-structure
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Import-structure rules for both [ESLint](https://eslint.org) and [Oxlint](https://oxc.rs/docs/guide/usage/linter): a module graph that stays layered and free of cycles.\
4
+ Install with `npm install --save-dev eslint-plugin-import-structure`. Requires Node.js 22.18+.
5
+
6
+ ```ts
7
+ // Oxlint, through Vite+: vite.config.ts
8
+ import plugin from "eslint-plugin-import-structure";
9
+ import { defineConfig } from "vite-plus";
10
+ const rules = { ...plugin.configs.modules.rules };
11
+ export default defineConfig({
12
+ lint: { jsPlugins: ["eslint-plugin-import-structure"], rules },
13
+ });
14
+ ```
15
+
16
+ ```ts
17
+ // ESLint 9: eslint.config.ts (loading a .ts config needs jiti)
18
+ import plugin from "eslint-plugin-import-structure";
19
+ import { parser } from "typescript-eslint";
20
+ const ts = { files: ["**/*.ts"], languageOptions: { parser } };
21
+ export default [ts, plugin.configs.modules];
22
+ ```
23
+
24
+ ## Configs and rules
25
+
26
+ - `modules`: areas reached only through their `package.json` `imports` aliases, layered by their order.
27
+ - `all`: every rule, including ones added later. Rules take no options.
28
+
29
+ The order of the `#` aliases in `package.json` is the layer order, top first; an area may only import aliases declared below its own.
30
+
31
+ ### Modules
32
+
33
+ An alias owns its target, and code outside the owned area reaches it only through the alias.
34
+ The rule never depends on folder or file names; an alias may point anywhere.
35
+
36
+ - An alias to a folder's `index.ts` owns that folder and everything below it. An alias to any other file owns only that file.
37
+ - No file may be owned by 2 aliases: no alias inside another alias's area, and no 2 aliases with the same target.
38
+ `enforce-layer-order` reports an overlap once at the top of every file 2 aliases claim, since lint cannot report on `package.json`.
39
+ - 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).
40
+ - Inside an area, relative imports of siblings and subfolders are free.
41
+ - `../` is banned in every file, owned or not (`no-parent-import`). Anything above a file needs an alias.
42
+ - Files no alias owns need no `index.ts` and no alias, and import `./` siblings and subfolders freely.
43
+ - Don't declare `/*` subpath patterns: any use of one that reaches an owned area is a bypass (`no-barrel-bypass`). Entry points are named `index.ts` only (`enforce-mod-file`).
44
+
45
+ ```jsonc
46
+ "imports": {
47
+ "#a": "./a/index.ts", // owns a/ and everything below it
48
+ "#f": "./b/f.ts", // owns only b/f.ts; b/g.ts stays unowned
49
+ }
50
+ ```
51
+
52
+ ```ts
53
+ // in main.ts, which no alias owns
54
+ import { a } from "#a"; // ✅
55
+ import { one } from "#a/one.ts"; // ❌ deeper alias specifier
56
+ import { two } from "./a/two.ts"; // ❌ relative path into #a
57
+ import { f } from "./b/f.ts"; // ❌ relative path to a file alias; use "#f"
58
+ import { g } from "./b/g.ts"; // ✅ unowned
59
+ // in a/one.ts, owned by #a
60
+ import { two } from "./two.ts"; // ✅ sibling
61
+ import { deep } from "./sub/deep.ts"; // ✅ subfolder of the same area
62
+ import { x } from "../x.ts"; // ❌ no ../ anywhere
63
+ ```
64
+
65
+ The other rules (import order, duplicate sources, wildcards, absolute paths, self-imports) are not about aliases and apply to every file.
66
+
67
+ <!-- prettier-ignore-start -->
68
+ <!-- begin auto-generated rules list -->
69
+
70
+ 💼 [Configurations](https://github.com/cunarist/eslint-plugin-import-structure#configs-and-rules) enabled in.\
71
+ 🧭 Set in the `modules` [configuration](https://github.com/cunarist/eslint-plugin-import-structure#configs-and-rules).
72
+
73
+ | Name                       | Description | 💼 |
74
+ | :--------------------------------------------------------------------- | :--------------------------------------------------------------------------- | :- |
75
+ | [enforce-import-order](docs/rules/enforce-import-order.md) | Enforce grouped and sorted import declarations and import members | 🧭 |
76
+ | [enforce-layer-order](docs/rules/enforce-layer-order.md) | Enforce the order of `package.json` `imports` as the layer order | 🧭 |
77
+ | [enforce-mod-file](docs/rules/enforce-mod-file.md) | Enforce `index.ts` as the name of module entry points | 🧭 |
78
+ | [no-absolute-import](docs/rules/no-absolute-import.md) | Disallow absolute paths and `file:` URLs as module specifiers | 🧭 |
79
+ | [no-barrel-bypass](docs/rules/no-barrel-bypass.md) | Disallow `#` specifiers deeper than an alias | 🧭 |
80
+ | [no-duplicate-import-source](docs/rules/no-duplicate-import-source.md) | Disallow separate import declarations from the same source | 🧭 |
81
+ | [no-parent-import](docs/rules/no-parent-import.md) | Disallow parent-relative module specifiers | 🧭 |
82
+ | [no-relative-bypass](docs/rules/no-relative-bypass.md) | Disallow relative specifiers that reach into an alias's area from outside it | 🧭 |
83
+ | [no-self-import](docs/rules/no-self-import.md) | Disallow a module from importing itself | 🧭 |
84
+ | [no-wildcard-import](docs/rules/no-wildcard-import.md) | Disallow namespace imports | 🧭 |
85
+ | [prefer-alias-import](docs/rules/prefer-alias-import.md) | Require the `#` alias for a module that `package.json` declares | 🧭 |
86
+
87
+ <!-- end auto-generated rules list -->
88
+ <!-- prettier-ignore-end -->
89
+
90
+ License: MIT.
@@ -0,0 +1,21 @@
1
+ import { ConfigObject, Plugin } from "@eslint/core";
2
+ //#region src/plugin/index.d.ts
3
+ /** The flat configs at `plugin.configs`: `modules` and `all`. */
4
+ interface ModulesConfigs {
5
+ modules: ConfigObject;
6
+ all: ConfigObject;
7
+ }
8
+ /** The plugin, with its configs known by name. */
9
+ interface ModulesPlugin {
10
+ meta?: Plugin["meta"];
11
+ rules?: Plugin["rules"];
12
+ configs: ModulesConfigs;
13
+ }
14
+ /**
15
+ * The plugin, for ESLint `plugins` or Oxlint `jsPlugins`, and the package's
16
+ * only export. Configs: `modules` holds the ported rules, and
17
+ * `all` turns on every rule, so a new rule joins it by itself.
18
+ */
19
+ declare const plugin: ModulesPlugin;
20
+ //#endregion
21
+ export { plugin as default };
package/dist/index.js ADDED
@@ -0,0 +1,1167 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ //#region src/helpers/docs.ts
3
+ /** Where a rule's `.md` lives, so `meta.docs.url` links to the source of truth. */
4
+ const DOCS_BASE = "https://github.com/cunarist/eslint-plugin-import-structure/blob/main/docs/rules";
5
+ /** The documentation URL of a rule, e.g. `no-self-import`. */
6
+ function docsUrl(rule) {
7
+ return `${DOCS_BASE}/${rule}.md`;
8
+ }
9
+ //#endregion
10
+ //#region src/helpers/import-order.ts
11
+ /** The three import groups, in the order they must appear. */
12
+ const IMPORT_GROUPS = [
13
+ "package",
14
+ "`#` alias",
15
+ "relative"
16
+ ];
17
+ /**
18
+ * Classifies a specifier into its `IMPORT_GROUPS` index. Anything that is
19
+ * neither a `#` alias nor a path is a package, which covers bare names,
20
+ * `node:` builtins, and URLs alike.
21
+ */
22
+ function importGroup(specifier) {
23
+ if (specifier.startsWith("#")) return 1;
24
+ if (specifier.startsWith(".") || specifier.startsWith("/")) return 2;
25
+ return 0;
26
+ }
27
+ /** The name of a group, for a message. */
28
+ function groupName(group) {
29
+ return IMPORT_GROUPS[group] ?? "";
30
+ }
31
+ /**
32
+ * Orders specifiers by code point rather than locale, so the result does not
33
+ * depend on collation rules that quietly ignore punctuation.
34
+ */
35
+ function compareSpecifiers(a, b) {
36
+ if (a < b) return -1;
37
+ return a > b ? 1 : 0;
38
+ }
39
+ /**
40
+ * Whether an import only runs a module for its side effects, as in
41
+ * `import "./polyfill.ts";`. Such imports can depend on running before or
42
+ * after their neighbours, so the fix never moves them.
43
+ */
44
+ function isSideEffect(node) {
45
+ return node.specifiers.length === 0;
46
+ }
47
+ /** Sorts imports by group, then by specifier. */
48
+ function compareEntries(a, b) {
49
+ return a.group - b.group || compareSpecifiers(a.specifier, b.specifier);
50
+ }
51
+ /**
52
+ * The imports in their fixed order: side-effect imports keep their places,
53
+ * and the imports in each run between them are sorted within that run, so no
54
+ * import crosses a side-effect import.
55
+ */
56
+ function fixedOrder(entries) {
57
+ const ordered = [];
58
+ let run = [];
59
+ for (const entry of entries) if (isSideEffect(entry.node)) {
60
+ ordered.push(...run.toSorted(compareEntries), entry);
61
+ run = [];
62
+ } else run.push(entry);
63
+ ordered.push(...run.toSorted(compareEntries));
64
+ return ordered;
65
+ }
66
+ /**
67
+ * Whether imports are in group order and sorted within each group, which is
68
+ * what the rewrite needs to clear every order problem.
69
+ */
70
+ function isSorted(entries) {
71
+ return entries.every((entry, index) => {
72
+ const previous = entries[index - 1];
73
+ return previous === void 0 || compareEntries(previous, entry) <= 0;
74
+ });
75
+ }
76
+ /** What is wrong between two neighbouring imports, or `null` if nothing. */
77
+ function orderProblem(previous, current, blankLine) {
78
+ const names = /* @__PURE__ */ new Map([["group", groupName(current.group)], ["previous", groupName(previous.group)]]);
79
+ if (current.group < previous.group) return {
80
+ messageId: "groupOrder",
81
+ data: names
82
+ };
83
+ if (current.group > previous.group && !blankLine) return {
84
+ messageId: "missingBlankLine",
85
+ data: names
86
+ };
87
+ if (current.group === previous.group && blankLine) return {
88
+ messageId: "extraBlankLine",
89
+ data: names
90
+ };
91
+ if (current.group === previous.group && compareSpecifiers(current.specifier, previous.specifier) < 0) return {
92
+ messageId: "sourceOrder",
93
+ data: /* @__PURE__ */ new Map([["specifier", current.specifier], ["previous", previous.specifier]])
94
+ };
95
+ return null;
96
+ }
97
+ //#endregion
98
+ //#region src/helpers/paths.ts
99
+ /**
100
+ * Path helpers that work on forward-slash paths, whatever the platform, so
101
+ * `C:\dir\a.ts`, `/dir/a.ts`, and `file:///dir/a.ts` compare alike.
102
+ */
103
+ /** A Windows drive path after a leading slash, as in `/C:/dir`. */
104
+ const SLASHED_DRIVE_PATTERN$1 = /^\/[a-zA-Z]:/u;
105
+ /** Every backslash, which Windows uses as a separator. */
106
+ const BACKSLASH_PATTERN = /\\/gu;
107
+ /**
108
+ * Normalizes a path by removing the `file://` prefix and converting
109
+ * backslashes to forward slashes on Windows.
110
+ */
111
+ function normalizePath(path) {
112
+ if (path === "") return path;
113
+ let normalized = path;
114
+ if (normalized.startsWith("file://")) {
115
+ normalized = decodeURIComponent(normalized.slice(7));
116
+ if (SLASHED_DRIVE_PATTERN$1.test(normalized)) normalized = normalized.slice(1);
117
+ }
118
+ return normalized.replace(BACKSLASH_PATTERN, "/");
119
+ }
120
+ /** The parent directory of a path, without a trailing slash. */
121
+ function parentDir(path) {
122
+ const normalized = normalizePath(path);
123
+ const index = normalized.lastIndexOf("/");
124
+ return index <= 0 ? normalized : normalized.slice(0, index);
125
+ }
126
+ /** The last segment of a path. */
127
+ function baseName(path) {
128
+ const normalized = normalizePath(path);
129
+ return normalized.slice(normalized.lastIndexOf("/") + 1);
130
+ }
131
+ /**
132
+ * The last segment without its extension, so `index.ts`, `index.tsx` and
133
+ * `index.js` all yield `index`. A leading dot is kept, so `.gitignore` is its
134
+ * own stem rather than an empty string.
135
+ */
136
+ function stemName(path) {
137
+ const base = baseName(path);
138
+ const dot = base.lastIndexOf(".");
139
+ return dot <= 0 ? base : base.slice(0, dot);
140
+ }
141
+ /**
142
+ * Resolves a relative path such as `../utils/index.ts` against a directory,
143
+ * returning a normalized absolute path.
144
+ */
145
+ function resolveFrom(baseDir, relative) {
146
+ const segments = normalizePath(baseDir).split("/");
147
+ for (const segment of normalizePath(relative).split("/")) {
148
+ if (segment === "" || segment === ".") continue;
149
+ if (segment === "..") {
150
+ if (segments.length > 1) segments.pop();
151
+ continue;
152
+ }
153
+ segments.push(segment);
154
+ }
155
+ return segments.join("/");
156
+ }
157
+ /**
158
+ * Whether a path lies inside a directory. Segment aware, so `/src/utils` does
159
+ * not contain `/src/utils-extra/index.ts`.
160
+ */
161
+ function isInsideDir(path, dir) {
162
+ return path.startsWith(dir.endsWith("/") ? dir : `${dir}/`);
163
+ }
164
+ //#endregion
165
+ //#region src/helpers/imports.ts
166
+ /**
167
+ * Reads the `#` entries of the nearest `package.json` `imports` field, the
168
+ * Node.js subpath imports that name a project's modules and, by their order,
169
+ * its layers.
170
+ */
171
+ /**
172
+ * File name stems that mark a directory's public entry point. Compared
173
+ * without the extension so `.ts`, `.tsx`, `.js` and friends all count. Only
174
+ * `index` qualifies; a `mod` file is a plain module.
175
+ */
176
+ const BARREL_STEMS = ["index"];
177
+ /** Entry-point stems the `enforce-mod-file` rule rejects. */
178
+ const REJECTED_STEMS = ["mod"];
179
+ /** The config file searched for in every directory up from a file. */
180
+ const CONFIG_NAME = "package.json";
181
+ /** A query or fragment suffix, which never names part of a path. */
182
+ const QUERY_PATTERN = /[?#].*$/u;
183
+ /** A path written with a Windows drive after a leading slash, as in `/C:/dir`. */
184
+ const SLASHED_DRIVE_PATTERN = /^\/[a-zA-Z]:/u;
185
+ /**
186
+ * Parsed configs keyed by file path. An entry is reused only while the file's
187
+ * modification time is unchanged, so an editor session sees edits.
188
+ */
189
+ const configCache = /* @__PURE__ */ new Map();
190
+ /** The modification time of a file, or `null` when it does not exist. */
191
+ function modifiedAt(path) {
192
+ try {
193
+ const stats = statSync(path, { throwIfNoEntry: false });
194
+ return stats?.isFile() === true ? stats.mtimeMs : null;
195
+ } catch {
196
+ return null;
197
+ }
198
+ }
199
+ /**
200
+ * Walks up from a file to the nearest `package.json` and reads its
201
+ * `#`-prefixed imports. Like Node.js, the search stops at that file even when
202
+ * it declares no `#` entries, so a nested package never borrows the imports
203
+ * of an enclosing one. Returns `null` when no `package.json` exists up the
204
+ * tree, or when the nearest one cannot be read or declares no local `#` entry.
205
+ */
206
+ function findImportsConfig(fromFile) {
207
+ let dir = parentDir(normalizePath(fromFile));
208
+ for (;;) {
209
+ const configPath = `${dir}/${CONFIG_NAME}`;
210
+ const mtimeMs = modifiedAt(configPath);
211
+ if (mtimeMs !== null) return cachedConfig(configPath, dir, mtimeMs);
212
+ const parent = parentDir(dir);
213
+ if (parent === dir) return null;
214
+ dir = parent;
215
+ }
216
+ }
217
+ /** One config file, read through the cache. */
218
+ function cachedConfig(configPath, configDir, mtimeMs) {
219
+ const cached = configCache.get(configPath);
220
+ if (cached?.mtimeMs === mtimeMs) return cached.config;
221
+ const config = readConfig(configPath, configDir);
222
+ configCache.set(configPath, {
223
+ mtimeMs,
224
+ config
225
+ });
226
+ return config;
227
+ }
228
+ /** The `imports` object of a parsed `package.json`, or `null` if it has none. */
229
+ function importsField(parsed) {
230
+ if (typeof parsed !== "object" || parsed === null) return null;
231
+ const imports = Reflect.get(parsed, "imports");
232
+ return typeof imports === "object" && imports !== null ? imports : null;
233
+ }
234
+ /**
235
+ * Parses one config file and returns its `imports` object. `null` when the
236
+ * file is unreadable, is not JSON, or has no `imports` object.
237
+ */
238
+ function readImportsField(configPath) {
239
+ let parsed = null;
240
+ try {
241
+ parsed = JSON.parse(readFileSync(configPath, "utf8"));
242
+ } catch {
243
+ return null;
244
+ }
245
+ return importsField(parsed);
246
+ }
247
+ /**
248
+ * Whether an `imports` entry is a `#` alias with a relative string target.
249
+ * Only those point at local files. A "#" alias for a package, or a condition
250
+ * object, is legitimate but irrelevant here.
251
+ */
252
+ function isLocalEntry(specifier, value) {
253
+ return specifier.startsWith("#") && typeof value === "string" && (value.startsWith("./") || value.startsWith("../"));
254
+ }
255
+ /** A subpath pattern entry, split around the `*` at index `star`. */
256
+ function patternEntry(specifier, star, target) {
257
+ return {
258
+ specifier,
259
+ prefix: specifier.slice(0, star),
260
+ suffix: specifier.slice(star + 1),
261
+ target
262
+ };
263
+ }
264
+ /** An exact entry, resolved from the config directory, declared at `index`. */
265
+ function moduleEntry(configDir, specifier, value, index) {
266
+ const target = resolveFrom(configDir, value);
267
+ const isBarrel = BARREL_STEMS.includes(stemName(target));
268
+ return {
269
+ specifier,
270
+ target,
271
+ dir: isBarrel ? parentDir(target) : target,
272
+ isBarrel,
273
+ index
274
+ };
275
+ }
276
+ /**
277
+ * Reads and parses one config file. Returns `null` when the file
278
+ * is unreadable, is not JSON, or has no local `#` entries.
279
+ */
280
+ function readConfig(configPath, configDir) {
281
+ const imports = readImportsField(configPath);
282
+ 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((a, b) => b.prefix.length - a.prefix.length);
301
+ return {
302
+ configDir,
303
+ entries,
304
+ byTarget,
305
+ bySpecifier,
306
+ patterns
307
+ };
308
+ }
309
+ //#endregion
310
+ //#region src/helpers/modules.ts
311
+ /**
312
+ * Looks up the areas the `#` aliases of a `package.json` `imports` config
313
+ * own: which alias owns a file, which one a specifier names, and whether an
314
+ * import touches an owned area at all.
315
+ *
316
+ * An alias owns its target. An alias to a folder's `index.ts` owns that
317
+ * folder and everything below it; an alias to any other file owns only that
318
+ * file. Nothing here depends on folder or file names.
319
+ */
320
+ /**
321
+ * Whether a normalized path lies in the area an alias owns. A folder alias's
322
+ * area includes the folder path itself, so a directory specifier such as
323
+ * `./utils` cannot reach the area by path.
324
+ */
325
+ function ownsPath(entry, path) {
326
+ return entry.target === path || entry.isBarrel && (path === entry.dir || isInsideDir(path, entry.dir));
327
+ }
328
+ /**
329
+ * Every alias whose area holds a file, in declaration order. More than one
330
+ * means two aliases overlap, which `enforce-layer-order` reports.
331
+ */
332
+ function claimingEntries(config, filePath) {
333
+ const path = normalizePath(filePath);
334
+ return config.entries.filter((entry) => ownsPath(entry, path));
335
+ }
336
+ /**
337
+ * Whether `entry` is among the aliases that claim a file. A file two aliases
338
+ * overlap on counts as inside both areas, so the overlap is reported once by
339
+ * `enforce-layer-order` rather than again as a bypass.
340
+ */
341
+ function isClaimedBy(config, filePath, entry) {
342
+ return claimingEntries(config, filePath).some((claimant) => claimant.index === entry.index);
343
+ }
344
+ /**
345
+ * Finds the alias that owns a file, which is the one with the most specific
346
+ * area containing it. Returns `null` for a file no alias owns.
347
+ */
348
+ function findOwningEntry(config, filePath) {
349
+ let best = null;
350
+ for (const entry of claimingEntries(config, filePath)) if (best === null || entry.dir.length > best.dir.length) best = entry;
351
+ return best;
352
+ }
353
+ /**
354
+ * The exact alias a deeper `#` specifier reaches past, such as `#utils` for
355
+ * `#utils/internal.ts`, whether or not a subpath pattern makes it resolve.
356
+ * The longest alias wins. Returns `null` when no alias is a prefix.
357
+ */
358
+ function deeperAliasOwner(config, specifier) {
359
+ let best = null;
360
+ for (const entry of config.entries) if (specifier.startsWith(`${entry.specifier}/`) && (best === null || entry.specifier.length > best.specifier.length)) best = entry;
361
+ return best;
362
+ }
363
+ /**
364
+ * Finds the subpath pattern a deep `#` specifier reaches through, such as
365
+ * `#utils/*` for `#utils/internal.ts`. Returns `null` when none match.
366
+ */
367
+ function findPatternEntry(config, specifier) {
368
+ 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;
369
+ return null;
370
+ }
371
+ /**
372
+ * Resolves the module a `#` specifier belongs to, whether it is an exact
373
+ * entry such as `#utils` or a deep one such as `#utils/internal.ts`.
374
+ */
375
+ function targetModule(config, specifier) {
376
+ const exact = config.bySpecifier.get(specifier);
377
+ if (exact !== void 0) return exact;
378
+ return deeperAliasOwner(config, specifier);
379
+ }
380
+ /**
381
+ * Resolves a local module specifier far enough to tell which file it names.
382
+ * Package specifiers have no local target, so they give `null`.
383
+ */
384
+ function resolveLocalSpecifier(filename, specifier) {
385
+ const path = specifier.replace(QUERY_PATTERN, "");
386
+ if (specifier.startsWith(".")) return resolveFrom(parentDir(normalizePath(filename)), path);
387
+ if (specifier.startsWith("file:")) return normalizePath(path);
388
+ if (specifier.startsWith("/")) {
389
+ const absolute = normalizePath(path);
390
+ return SLASHED_DRIVE_PATTERN.test(absolute) ? absolute.slice(1) : absolute;
391
+ }
392
+ if (!specifier.startsWith("#")) return null;
393
+ const config = findImportsConfig(filename);
394
+ if (config === null) return null;
395
+ const exact = config.bySpecifier.get(specifier);
396
+ if (exact !== void 0) return exact.target;
397
+ const pattern = findPatternEntry(config, specifier);
398
+ if (pattern === null) return null;
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));
401
+ }
402
+ /**
403
+ * The module a specifier points at, whether it was written as a `#` alias or
404
+ * as a path. Resolving both means the layer rules cannot be sidestepped by
405
+ * spelling the same target a different way. Returns `null` for specifiers
406
+ * that leave the project, such as package names.
407
+ */
408
+ function resolveTarget(config, filename, specifier) {
409
+ if (specifier.startsWith("#")) return targetModule(config, specifier);
410
+ if (!specifier.startsWith(".")) return null;
411
+ const resolved = resolveFrom(parentDir(normalizePath(filename)), specifier);
412
+ return config.byTarget.get(resolved) ?? null;
413
+ }
414
+ /** Whether a path lies in the area some alias owns. */
415
+ function isModulePath(config, path) {
416
+ const normalized = normalizePath(path);
417
+ return config.entries.some((entry) => ownsPath(entry, normalized));
418
+ }
419
+ /**
420
+ * Whether an import touches an owned area: the importing file or the file it
421
+ * names lies in one. Imports between files no alias owns, such as
422
+ * `vite.config.ts` reaching `./scripts/build.ts`, give `false`, and so does
423
+ * everything in a project whose `package.json` declares no `#` alias.
424
+ */
425
+ function isInModuleScope(filename, specifier) {
426
+ const config = findImportsConfig(filename);
427
+ if (config === null) return false;
428
+ if (isModulePath(config, filename)) return true;
429
+ const target = resolveLocalSpecifier(filename, specifier);
430
+ return target !== null && isModulePath(config, target);
431
+ }
432
+ //#endregion
433
+ //#region src/helpers/specifiers.ts
434
+ /**
435
+ * Builds a visitor that runs `check` for every module specifier in a file,
436
+ * so each rule only describes what to do with one specifier.
437
+ */
438
+ function visitSpecifiers(check) {
439
+ function handle(node) {
440
+ const source = node.source;
441
+ if (source === null) return;
442
+ if (source.type === "TemplateLiteral") {
443
+ const [quasi] = source.quasis;
444
+ if (source.expressions.length === 0 && source.quasis.length === 1 && quasi !== void 0) check(source, quasi.value.raw);
445
+ return;
446
+ }
447
+ if (source.type === "Literal" && typeof source.value === "string") check(source, source.value);
448
+ }
449
+ return {
450
+ ImportDeclaration: handle,
451
+ ExportNamedDeclaration: handle,
452
+ ExportAllDeclaration: handle,
453
+ ImportExpression: handle,
454
+ TSImportType(node) {
455
+ check(node.source, node.source.value);
456
+ }
457
+ };
458
+ }
459
+ /**
460
+ * Rewrites a specifier while keeping the original quote character. A template
461
+ * literal has no quote of its own, and rewriting it as a plain string is the
462
+ * point, so it falls through to double quotes.
463
+ */
464
+ function quoteLike(original, specifier) {
465
+ const quote = original.type === "Literal" && original.raw.startsWith("'") ? "'" : "\"";
466
+ return quote + specifier + quote;
467
+ }
468
+ //#endregion
469
+ //#region src/rules/enforce-import-order.ts
470
+ /** Every line break, for counting the lines between two imports. */
471
+ const NEWLINE_PATTERN = /\n/gu;
472
+ /**
473
+ * Orders imported names case insensitively, unlike `compareSpecifiers`.
474
+ * Specifiers are lowercase paths where only punctuation is ambiguous, but a
475
+ * member list mixes `PascalCase` types, `camelCase` functions and
476
+ * `SCREAMING_CASE` constants. Sorting those by code point would clump them by
477
+ * case, which hides the alphabetical order a reader is scanning for.
478
+ */
479
+ function compareMembers(a, b) {
480
+ const lowered = compareSpecifiers(a.toLowerCase(), b.toLowerCase());
481
+ return lowered === 0 ? compareSpecifiers(a, b) : lowered;
482
+ }
483
+ /**
484
+ * The names inside the braces of an import, in source order. Default and
485
+ * namespace bindings sit outside the braces and are left alone.
486
+ */
487
+ function namedSpecifiers(node) {
488
+ return node.specifiers.filter((specifier) => specifier.type === "ImportSpecifier");
489
+ }
490
+ /** The name a member is sorted by, which is the one written first. */
491
+ function memberName(specifier) {
492
+ const imported = specifier.imported;
493
+ return imported.type === "Identifier" ? imported.name : specifier.local.name;
494
+ }
495
+ /** Whether a comment lies inside a range of the source. */
496
+ function hasCommentWithin(source, start, end) {
497
+ return source.getAllComments().some((comment) => comment.range[0] >= start && comment.range[1] <= end);
498
+ }
499
+ /**
500
+ * Rewrites the braces of one import in sorted order, preserving whether the
501
+ * list was written on one line or spread across several. `null` when a
502
+ * comment anywhere in the import could end up beside a different member, or
503
+ * when a multiline list does not start its first member on a line of its
504
+ * own, since there is no indent to reuse.
505
+ */
506
+ function fixMemberOrder(source, node, members) {
507
+ const first = members.at(0);
508
+ const last = members.at(-1);
509
+ if (first === void 0 || last === void 0) return null;
510
+ const start = first.range[0];
511
+ const end = last.range[1];
512
+ if (hasCommentWithin(source, node.range[0], node.range[1])) return null;
513
+ const text = source.text;
514
+ const sorted = members.toSorted((a, b) => compareMembers(memberName(a), memberName(b)));
515
+ const multiline = text.slice(start, end).includes("\n");
516
+ const lead = text.slice(text.lastIndexOf("\n", start) + 1, start);
517
+ if (multiline && lead.trim() !== "") return null;
518
+ const separator = multiline ? `,\n${multiline ? lead : ""}` : ", ";
519
+ const replacement = sorted.map((member) => source.getText(member)).join(separator);
520
+ return (fixer) => fixer.replaceTextRange([start, end], replacement);
521
+ }
522
+ /**
523
+ * Builds the whole-block rewrite, but only when nothing would be lost and the
524
+ * rewrite changes something. Comments inside the block have no home in the
525
+ * rewritten text, and statements interleaved between imports would be erased
526
+ * by a single range replacement, so both cases give `null` and report
527
+ * without a fix. So does a block that cannot be put fully in order without
528
+ * moving a side-effect import, rather than fixing it halfway.
529
+ */
530
+ function fixImportBlock(source, body, entries) {
531
+ const first = entries.at(0);
532
+ const last = entries.at(-1);
533
+ if (first === void 0 || last === void 0) return null;
534
+ const start = first.node.range[0];
535
+ const end = last.node.range[1];
536
+ const contiguous = body.indexOf(last.node) - body.indexOf(first.node) + 1 === entries.length;
537
+ const lineEnd = source.text.indexOf("\n", end);
538
+ const commentEnd = lineEnd === -1 ? source.text.length : lineEnd;
539
+ if (!contiguous || hasCommentWithin(source, start, commentEnd)) return null;
540
+ const ordered = fixedOrder(entries);
541
+ if (!isSorted(ordered)) return null;
542
+ let replacement = "";
543
+ let previousGroup = null;
544
+ for (const entry of ordered) {
545
+ if (previousGroup !== null) replacement += entry.group === previousGroup ? "\n" : "\n\n";
546
+ replacement += source.getText(entry.node);
547
+ previousGroup = entry.group;
548
+ }
549
+ return (fixer) => fixer.replaceTextRange([start, end], replacement);
550
+ }
551
+ /**
552
+ * Reports the first import member that sorts before the one ahead of it, with
553
+ * a fix that sorts the whole member list when it is safe. Returns whether a
554
+ * report was made.
555
+ */
556
+ function reportMemberOrder(context, source, imports) {
557
+ for (const node of imports) {
558
+ const members = namedSpecifiers(node);
559
+ for (const [index, member] of members.entries()) {
560
+ const before = members[index - 1];
561
+ if (before === void 0) continue;
562
+ const name = memberName(member);
563
+ const previous = memberName(before);
564
+ if (compareMembers(name, previous) >= 0) continue;
565
+ const fix = fixMemberOrder(source, node, members);
566
+ context.report({
567
+ node: member,
568
+ messageId: "memberOrder",
569
+ data: {
570
+ name,
571
+ previous
572
+ },
573
+ ...fix === null ? {} : { fix }
574
+ });
575
+ return true;
576
+ }
577
+ }
578
+ return false;
579
+ }
580
+ /**
581
+ * Reports the first import out of group order, sort order, or blank-line
582
+ * spacing, with a fix that rewrites the whole block when it is safe.
583
+ */
584
+ function reportImportOrder(context, source, body, imports) {
585
+ const entries = imports.map((node) => ({
586
+ node,
587
+ specifier: node.source.value,
588
+ group: importGroup(node.source.value)
589
+ }));
590
+ for (const [index, current] of entries.entries()) {
591
+ const previous = entries[index - 1];
592
+ if (previous === void 0) continue;
593
+ const problem = orderProblem(previous, current, (source.text.slice(previous.node.range[1], current.node.range[0]).match(NEWLINE_PATTERN) ?? []).length >= 2);
594
+ if (problem === null) continue;
595
+ const fix = fixImportBlock(source, body, entries);
596
+ context.report({
597
+ node: current.node,
598
+ messageId: problem.messageId,
599
+ data: Object.fromEntries(problem.data),
600
+ ...fix === null ? {} : { fix }
601
+ });
602
+ return;
603
+ }
604
+ }
605
+ /**
606
+ * `enforce-import-order` - groups imports as packages, then `#` aliases, then
607
+ * relative paths, separated by a blank line and each sorted by code point.
608
+ * Names inside the braces are sorted too, ignoring case and any `type`
609
+ * modifier.
610
+ *
611
+ * The fix rewrites the import block, unless a comment or another statement
612
+ * sits between the imports, so nothing gets discarded. Side-effect imports
613
+ * stay where they are and no import is moved across one.
614
+ */
615
+ const enforceImportOrderRule = {
616
+ meta: {
617
+ type: "layout",
618
+ docs: {
619
+ description: "Enforce grouped and sorted import declarations and import members",
620
+ url: docsUrl("enforce-import-order")
621
+ },
622
+ fixable: "code",
623
+ schema: [],
624
+ messages: {
625
+ memberOrder: "Expected '{{name}}' before '{{previous}}'.",
626
+ groupOrder: "Expected {{group}} imports before {{previous}} imports.",
627
+ missingBlankLine: "Expected a blank line between {{previous}} imports and {{group}} imports.",
628
+ extraBlankLine: "Unexpected blank line between {{group}} imports.",
629
+ sourceOrder: "Expected '{{specifier}}' before '{{previous}}'."
630
+ }
631
+ },
632
+ create(context) {
633
+ const source = context.sourceCode;
634
+ return { "Program:exit"(program) {
635
+ const body = program.body;
636
+ const imports = body.filter((statement) => statement.type === "ImportDeclaration");
637
+ if (!reportMemberOrder(context, source, imports)) reportImportOrder(context, source, body, imports);
638
+ } };
639
+ }
640
+ };
641
+ //#endregion
642
+ //#region src/rules/enforce-layer-order.ts
643
+ /**
644
+ * `enforce-layer-order` - treats the order of `#` aliases in `package.json`
645
+ * `imports` as the layer order, top layer first: an area may only import
646
+ * 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 claim.
648
+ *
649
+ * This makes cycles between areas structurally impossible rather than
650
+ * merely detectable. Lint cannot report on `package.json` itself, so an
651
+ * overlap fires once at the top of every file it affects.
652
+ */
653
+ const enforceLayerOrderRule = {
654
+ meta: {
655
+ type: "problem",
656
+ docs: {
657
+ description: "Enforce the order of `package.json` `imports` as the layer order",
658
+ url: docsUrl("enforce-layer-order")
659
+ },
660
+ schema: [],
661
+ messages: {
662
+ ownEntry: "'{{owner}}' must not import its own entry point through '{{specifier}}'.",
663
+ 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}}."
665
+ }
666
+ },
667
+ create(context) {
668
+ return {
669
+ ...visitSpecifiers((node, specifier) => {
670
+ const config = findImportsConfig(context.filename);
671
+ if (config === null) return;
672
+ const owner = findOwningEntry(config, context.filename);
673
+ const target = resolveTarget(config, context.filename, specifier);
674
+ if (owner === null || target === null) return;
675
+ if (target.index === owner.index) {
676
+ context.report({
677
+ node,
678
+ messageId: "ownEntry",
679
+ data: {
680
+ owner: owner.specifier,
681
+ specifier
682
+ }
683
+ });
684
+ return;
685
+ }
686
+ if (target.index < owner.index) context.report({
687
+ node,
688
+ messageId: "upward",
689
+ data: {
690
+ owner: owner.specifier,
691
+ target: target.specifier
692
+ }
693
+ });
694
+ }),
695
+ Program(node) {
696
+ const config = findImportsConfig(context.filename);
697
+ const claiming = config === null ? [] : claimingEntries(config, context.filename);
698
+ if (claiming.length < 2) return;
699
+ context.report({
700
+ node,
701
+ loc: {
702
+ line: 1,
703
+ column: 0
704
+ },
705
+ messageId: "overlap",
706
+ data: { aliases: claiming.map((entry) => entry.specifier).join(", ") }
707
+ });
708
+ }
709
+ };
710
+ }
711
+ };
712
+ //#endregion
713
+ //#region src/rules/enforce-mod-file.ts
714
+ /**
715
+ * `enforce-mod-file` - requires module entry points to be named `index.ts`,
716
+ * the Node.js convention. Reports both a file named `mod` and any project
717
+ * specifier pointing at one.
718
+ *
719
+ * The rule keeps its original name from when it required `mod.ts`; on
720
+ * Node.js the entry point is `index.ts`. One name everywhere means a
721
+ * module's entry point is never ambiguous. Only the folders `#` entries own
722
+ * are modules, so a file outside them, and an import between such files, is
723
+ * left alone.
724
+ */
725
+ const enforceModFileRule = {
726
+ meta: {
727
+ type: "suggestion",
728
+ docs: {
729
+ description: "Enforce `index.ts` as the name of module entry points",
730
+ url: docsUrl("enforce-mod-file")
731
+ },
732
+ schema: [],
733
+ messages: {
734
+ fileName: "Expected the entry point to be named 'index.ts' but found '{{name}}'.",
735
+ specifier: "'{{specifier}}' points at a 'mod' entry point."
736
+ }
737
+ },
738
+ create(context) {
739
+ return {
740
+ ...visitSpecifiers((node, specifier) => {
741
+ if (!specifier.startsWith(".") && !specifier.startsWith("#")) return;
742
+ if (REJECTED_STEMS.includes(stemName(specifier)) && isInModuleScope(context.filename, specifier)) context.report({
743
+ node,
744
+ messageId: "specifier",
745
+ data: { specifier }
746
+ });
747
+ }),
748
+ Program(node) {
749
+ const filename = normalizePath(context.filename);
750
+ const config = findImportsConfig(filename);
751
+ if (REJECTED_STEMS.includes(stemName(filename)) && config !== null && isModulePath(config, filename)) context.report({
752
+ node,
753
+ messageId: "fileName",
754
+ data: { name: baseName(filename) }
755
+ });
756
+ }
757
+ };
758
+ }
759
+ };
760
+ //#endregion
761
+ //#region src/rules/no-absolute-import.ts
762
+ /**
763
+ * `no-absolute-import` - rejects specifiers starting with `/` or `file:`.
764
+ *
765
+ * A leading slash resolves against the file system root rather than the
766
+ * project root, and a `file:` URL hard codes one machine's layout.
767
+ */
768
+ const noAbsoluteImportRule = {
769
+ meta: {
770
+ type: "problem",
771
+ docs: {
772
+ description: "Disallow absolute paths and `file:` URLs as module specifiers",
773
+ url: docsUrl("no-absolute-import")
774
+ },
775
+ schema: [],
776
+ messages: { absolute: "'{{specifier}}' is an absolute path." }
777
+ },
778
+ create(context) {
779
+ return visitSpecifiers((node, specifier) => {
780
+ if (specifier.startsWith("/") || specifier.startsWith("file:")) context.report({
781
+ node,
782
+ messageId: "absolute",
783
+ data: { specifier }
784
+ });
785
+ });
786
+ }
787
+ };
788
+ //#endregion
789
+ //#region src/rules/no-barrel-bypass.ts
790
+ /**
791
+ * `no-barrel-bypass` - rejects a `#` specifier deeper than an alias, such as
792
+ * `#components/internal.ts` beside `#components`, whether or not a subpath
793
+ * pattern makes it resolve, and any use of a subpath pattern that reaches
794
+ * into an owned area.
795
+ *
796
+ * Code outside an alias's area reaches it only through the exact alias, so
797
+ * the area keeps one public surface. Switching to the alias is a suggestion,
798
+ * since its target may not export the name yet.
799
+ */
800
+ const noBarrelBypassRule = {
801
+ meta: {
802
+ type: "suggestion",
803
+ docs: {
804
+ description: "Disallow `#` specifiers deeper than an alias",
805
+ url: docsUrl("no-barrel-bypass")
806
+ },
807
+ hasSuggestions: true,
808
+ schema: [],
809
+ messages: {
810
+ bypass: "'{{specifier}}' reaches past the entry point '{{owner}}'.",
811
+ internals: "'{{specifier}}' reaches into module internals.",
812
+ useEntry: "Import from '{{owner}}' instead."
813
+ }
814
+ },
815
+ create(context) {
816
+ return visitSpecifiers((node, specifier) => {
817
+ if (!specifier.startsWith("#")) return;
818
+ const config = findImportsConfig(context.filename);
819
+ if (config === null || config.bySpecifier.has(specifier)) return;
820
+ const owner = deeperAliasOwner(config, specifier);
821
+ if (owner === null) {
822
+ if (findPatternEntry(config, specifier) !== null && isInModuleScope(context.filename, specifier)) context.report({
823
+ node,
824
+ messageId: "internals",
825
+ data: { specifier }
826
+ });
827
+ return;
828
+ }
829
+ context.report({
830
+ node,
831
+ messageId: "bypass",
832
+ data: {
833
+ specifier,
834
+ owner: owner.specifier
835
+ },
836
+ suggest: [{
837
+ messageId: "useEntry",
838
+ data: { owner: owner.specifier },
839
+ fix: (fixer) => fixer.replaceText(node, quoteLike(node, owner.specifier))
840
+ }]
841
+ });
842
+ });
843
+ }
844
+ };
845
+ //#endregion
846
+ //#region src/rules/no-duplicate-import-source.ts
847
+ /**
848
+ * The declarations in a same-source group that another one in the group can
849
+ * absorb. A value import absorbs anything, so the first one keeps its place
850
+ * and the rest are reported. With no value import, the bare imports collapse
851
+ * into one and the type-only imports into another, and those two survive.
852
+ */
853
+ function mergeableImports(group) {
854
+ const typeOnly = group.filter((node) => node.importKind === "type");
855
+ const runtime = group.filter((node) => node.importKind !== "type");
856
+ const firstValue = runtime.find((node) => node.specifiers.length > 0);
857
+ if (firstValue !== void 0) return group.filter((node) => node !== firstValue);
858
+ return [...runtime.slice(1), ...typeOnly.slice(1)];
859
+ }
860
+ /**
861
+ * `no-duplicate-import-source` - rejects separate static import declarations
862
+ * with the same module specifier. Re-exports and dynamic imports are
863
+ * different operations and are left alone.
864
+ *
865
+ * A type-only import beside a bare one is the exception. The type-only
866
+ * declaration disappears from the emitted module, so folding the bare import
867
+ * into it would drop the side effect that registers a custom element or
868
+ * installs a polyfill. Those two say different things and both have to stay.
869
+ */
870
+ const noDuplicateImportSourceRule = {
871
+ meta: {
872
+ type: "suggestion",
873
+ docs: {
874
+ description: "Disallow separate import declarations from the same source",
875
+ url: docsUrl("no-duplicate-import-source")
876
+ },
877
+ schema: [],
878
+ messages: { duplicate: "Import source '{{specifier}}' is duplicated." }
879
+ },
880
+ create(context) {
881
+ const groups = /* @__PURE__ */ new Map();
882
+ return {
883
+ ImportDeclaration(node) {
884
+ const specifier = node.source.value;
885
+ const group = groups.get(specifier) ?? [];
886
+ group.push(node);
887
+ groups.set(specifier, group);
888
+ },
889
+ "Program:exit"(_node) {
890
+ for (const [specifier, group] of groups) for (const node of mergeableImports(group)) context.report({
891
+ node: node.source,
892
+ messageId: "duplicate",
893
+ data: { specifier }
894
+ });
895
+ }
896
+ };
897
+ }
898
+ };
899
+ //#endregion
900
+ //#region src/rules/no-parent-import.ts
901
+ /**
902
+ * `no-parent-import` - rejects `../` specifiers in every file, owned by an
903
+ * alias or not, rewriting one to its `#` alias where `package.json` declares
904
+ * an alias for exactly that file.
905
+ *
906
+ * Reaching up and back down couples two files through their layout instead
907
+ * of a name. Anything above a file is reached through an alias.
908
+ */
909
+ const noParentImportRule = {
910
+ meta: {
911
+ type: "suggestion",
912
+ docs: {
913
+ description: "Disallow parent-relative module specifiers",
914
+ url: docsUrl("no-parent-import")
915
+ },
916
+ fixable: "code",
917
+ schema: [],
918
+ messages: {
919
+ parentImport: "Unexpected parent-relative import '{{specifier}}'.",
920
+ useAlias: "Parent-relative import '{{specifier}}' should be '{{alias}}'."
921
+ }
922
+ },
923
+ create(context) {
924
+ return visitSpecifiers((node, specifier) => {
925
+ if (!specifier.startsWith("../")) return;
926
+ const config = findImportsConfig(context.filename);
927
+ const resolved = resolveLocalSpecifier(context.filename, specifier);
928
+ const entry = resolved === null ? void 0 : config?.byTarget.get(resolved);
929
+ if (config === null || entry === void 0 || isClaimedBy(config, context.filename, entry)) {
930
+ context.report({
931
+ node,
932
+ messageId: "parentImport",
933
+ data: { specifier }
934
+ });
935
+ return;
936
+ }
937
+ context.report({
938
+ node,
939
+ messageId: "useAlias",
940
+ data: {
941
+ specifier,
942
+ alias: entry.specifier
943
+ },
944
+ fix: (fixer) => fixer.replaceText(node, quoteLike(node, entry.specifier))
945
+ });
946
+ });
947
+ }
948
+ };
949
+ //#endregion
950
+ //#region src/rules/no-relative-bypass.ts
951
+ /**
952
+ * `no-relative-bypass` - rejects a `./` specifier that reaches into an area
953
+ * an alias owns from outside that area.
954
+ *
955
+ * An alias owns its target: an alias to a folder's `index.ts` owns that
956
+ * folder and everything below it, and an alias to any other file owns only
957
+ * that file. Code outside an area reaches it only through the alias. Inside
958
+ * an area, siblings and subfolders are free, and so are files no alias owns.
959
+ * A path to an alias's exact target is left to `prefer-alias-import`, which
960
+ * rewrites it to the alias.
961
+ */
962
+ const noRelativeBypassRule = {
963
+ meta: {
964
+ type: "suggestion",
965
+ docs: {
966
+ description: "Disallow relative specifiers that reach into an alias's area from outside it",
967
+ url: docsUrl("no-relative-bypass")
968
+ },
969
+ schema: [],
970
+ messages: { bypass: "'{{specifier}}' reaches into the area of '{{alias}}' from outside it." }
971
+ },
972
+ create(context) {
973
+ return visitSpecifiers((node, specifier) => {
974
+ if (!specifier.startsWith("./")) return;
975
+ const config = findImportsConfig(context.filename);
976
+ const resolved = resolveLocalSpecifier(context.filename, specifier);
977
+ if (config === null || resolved === null || config.byTarget.has(resolved)) return;
978
+ const target = findOwningEntry(config, resolved);
979
+ if (target === null || isClaimedBy(config, context.filename, target)) return;
980
+ context.report({
981
+ node,
982
+ messageId: "bypass",
983
+ data: {
984
+ specifier,
985
+ alias: target.specifier
986
+ }
987
+ });
988
+ });
989
+ }
990
+ };
991
+ //#endregion
992
+ //#region src/rules/no-self-import.ts
993
+ /**
994
+ * `no-self-import` - rejects any statically knowable specifier that resolves
995
+ * to the importing file: a relative path, an absolute path or `file:` URL, an
996
+ * exact `#` entry, or a `#` subpath pattern.
997
+ *
998
+ * A self-import is an immediate circular dependency.
999
+ */
1000
+ const noSelfImportRule = {
1001
+ meta: {
1002
+ type: "problem",
1003
+ docs: {
1004
+ description: "Disallow a module from importing itself",
1005
+ url: docsUrl("no-self-import")
1006
+ },
1007
+ schema: [],
1008
+ messages: { selfImport: "'{{specifier}}' imports the current file." }
1009
+ },
1010
+ create(context) {
1011
+ return visitSpecifiers((node, specifier) => {
1012
+ if (resolveLocalSpecifier(context.filename, specifier) !== normalizePath(context.filename)) return;
1013
+ context.report({
1014
+ node,
1015
+ messageId: "selfImport",
1016
+ data: { specifier }
1017
+ });
1018
+ });
1019
+ }
1020
+ };
1021
+ //#endregion
1022
+ //#region src/rules/no-wildcard-import.ts
1023
+ /**
1024
+ * `no-wildcard-import` - requires imports to name the bindings they depend
1025
+ * on, so `import * as name` is rejected, `type` or not.
1026
+ *
1027
+ * An explicit list keeps each file's dependency surface narrow and visible at
1028
+ * the import site. Side-effect imports and re-exports are left alone.
1029
+ */
1030
+ const noWildcardImportRule = {
1031
+ meta: {
1032
+ type: "suggestion",
1033
+ docs: {
1034
+ description: "Disallow namespace imports",
1035
+ url: docsUrl("no-wildcard-import")
1036
+ },
1037
+ schema: [],
1038
+ messages: { wildcard: "Unexpected wildcard import." }
1039
+ },
1040
+ create(context) {
1041
+ return { ImportDeclaration(node) {
1042
+ const namespace = node.specifiers.find((specifier) => specifier.type === "ImportNamespaceSpecifier");
1043
+ if (namespace !== void 0) context.report({
1044
+ node: namespace,
1045
+ messageId: "wildcard"
1046
+ });
1047
+ } };
1048
+ }
1049
+ };
1050
+ //#endregion
1051
+ //#region src/rules/prefer-alias-import.ts
1052
+ /**
1053
+ * `prefer-alias-import` - requires the `#` alias when a `./` specifier names
1054
+ * the exact target of an alias from outside that alias's area, a same-folder
1055
+ * sibling included. An alias owns its target, so its target has one spelling
1056
+ * everywhere outside it.
1057
+ *
1058
+ * One spelling per module keeps `package.json` `imports` the single source of
1059
+ * truth, and keeps the layer rules from being sidestepped by a path.
1060
+ */
1061
+ const preferAliasImportRule = {
1062
+ meta: {
1063
+ type: "suggestion",
1064
+ docs: {
1065
+ description: "Require the `#` alias for a module that `package.json` declares",
1066
+ url: docsUrl("prefer-alias-import")
1067
+ },
1068
+ fixable: "code",
1069
+ schema: [],
1070
+ messages: { useAlias: "'{{specifier}}' is declared as '{{alias}}' in package.json." }
1071
+ },
1072
+ create(context) {
1073
+ return visitSpecifiers((node, specifier) => {
1074
+ if (!specifier.startsWith("./")) return;
1075
+ const config = findImportsConfig(context.filename);
1076
+ const resolved = resolveLocalSpecifier(context.filename, specifier);
1077
+ const entry = resolved === null ? void 0 : config?.byTarget.get(resolved);
1078
+ if (config === null || entry === void 0) return;
1079
+ if (isClaimedBy(config, context.filename, entry)) return;
1080
+ context.report({
1081
+ node,
1082
+ messageId: "useAlias",
1083
+ data: {
1084
+ specifier,
1085
+ alias: entry.specifier
1086
+ },
1087
+ fix: (fixer) => fixer.replaceText(node, quoteLike(node, entry.specifier))
1088
+ });
1089
+ });
1090
+ }
1091
+ };
1092
+ //#endregion
1093
+ //#region src/rules/index.ts
1094
+ /**
1095
+ * Every rule, keyed by rule id, in alphabetical order.
1096
+ *
1097
+ * A `Map` rather than an object literal: a rule record is a lookup keyed by
1098
+ * rule id, so the ids belong in value position.
1099
+ */
1100
+ const rules = /* @__PURE__ */ new Map([
1101
+ ["enforce-import-order", enforceImportOrderRule],
1102
+ ["enforce-layer-order", enforceLayerOrderRule],
1103
+ ["enforce-mod-file", enforceModFileRule],
1104
+ ["no-absolute-import", noAbsoluteImportRule],
1105
+ ["no-barrel-bypass", noBarrelBypassRule],
1106
+ ["no-duplicate-import-source", noDuplicateImportSourceRule],
1107
+ ["no-parent-import", noParentImportRule],
1108
+ ["no-relative-bypass", noRelativeBypassRule],
1109
+ ["no-self-import", noSelfImportRule],
1110
+ ["no-wildcard-import", noWildcardImportRule],
1111
+ ["prefer-alias-import", preferAliasImportRule]
1112
+ ]);
1113
+ //#endregion
1114
+ //#region src/plugin/index.ts
1115
+ /** The prefix of every rule id, as ESLint derives it from the package name. */
1116
+ const NAMESPACE = "import-structure";
1117
+ /**
1118
+ * The `modules` preset - every rule the plugin was ported with.
1119
+ *
1120
+ * Bans the import patterns that create cycles, keeps every module behind one
1121
+ * entry point, layers modules by their `package.json` `imports` order, and
1122
+ * keeps import blocks grouped and sorted.
1123
+ */
1124
+ const MODULES = [
1125
+ "no-self-import",
1126
+ "no-parent-import",
1127
+ "no-absolute-import",
1128
+ "prefer-alias-import",
1129
+ "no-barrel-bypass",
1130
+ "no-relative-bypass",
1131
+ "no-wildcard-import",
1132
+ "enforce-mod-file",
1133
+ "no-duplicate-import-source",
1134
+ "enforce-import-order",
1135
+ "enforce-layer-order"
1136
+ ];
1137
+ /** The plugin before its configs exist, which each config refers back to. */
1138
+ const basePlugin = {
1139
+ meta: {
1140
+ name: "eslint-plugin-import-structure",
1141
+ version: "0.10.2",
1142
+ namespace: NAMESPACE
1143
+ },
1144
+ rules: Object.fromEntries(rules)
1145
+ };
1146
+ /**
1147
+ * A flat config that loads the plugin and turns every given rule on. A config
1148
+ * is only a list of rule ids, so one rule can sit in several configs.
1149
+ */
1150
+ function presetConfig(preset, ruleIds) {
1151
+ return {
1152
+ name: `${NAMESPACE}/${preset}`,
1153
+ plugins: Object.fromEntries([[NAMESPACE, basePlugin]]),
1154
+ rules: Object.fromEntries(ruleIds.map((id) => [`${NAMESPACE}/${id}`, "error"]))
1155
+ };
1156
+ }
1157
+ /**
1158
+ * The plugin, for ESLint `plugins` or Oxlint `jsPlugins`, and the package's
1159
+ * only export. Configs: `modules` holds the ported rules, and
1160
+ * `all` turns on every rule, so a new rule joins it by itself.
1161
+ */
1162
+ const plugin = Object.assign(basePlugin, { configs: {
1163
+ modules: presetConfig("modules", MODULES),
1164
+ all: presetConfig("all", [...rules.keys()])
1165
+ } });
1166
+ //#endregion
1167
+ export { plugin as default };
package/package.json CHANGED
@@ -1,6 +1,74 @@
1
1
  {
2
2
  "name": "eslint-plugin-import-structure",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.10.2",
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
+ "keywords": [
6
+ "eslint",
7
+ "eslint-plugin",
8
+ "eslintplugin",
9
+ "import",
10
+ "import-structure",
11
+ "imports",
12
+ "oxlint"
13
+ ],
14
+ "homepage": "https://github.com/cunarist/eslint-plugin-import-structure#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/cunarist/eslint-plugin-import-structure/issues"
17
+ },
18
+ "license": "MIT",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/cunarist/eslint-plugin-import-structure.git"
22
+ },
23
+ "files": [
24
+ "dist"
25
+ ],
26
+ "type": "module",
27
+ "imports": {
28
+ "#plugin": "./src/plugin/index.ts",
29
+ "#rules": "./src/rules/index.ts",
30
+ "#testing": "./src/testing/index.ts",
31
+ "#helpers": "./src/helpers/index.ts"
32
+ },
33
+ "exports": {
34
+ ".": "./dist/index.js"
35
+ },
36
+ "scripts": {
37
+ "docs": "eslint-doc-generator"
38
+ },
39
+ "dependencies": {
40
+ "@eslint/core": "^0.17.0"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^22.20.5",
44
+ "@typescript-eslint/parser": "^8.71.1",
45
+ "@typescript-eslint/types": "^8.71.1",
46
+ "eslint": "^9.39.5",
47
+ "eslint-doc-generator": "^3.7.1",
48
+ "vite-plus": "^1.1.0"
49
+ },
50
+ "engines": {
51
+ "node": ">=22.18"
52
+ },
53
+ "eslint-doc-generator": {
54
+ "configEmoji": [
55
+ [
56
+ "modules",
57
+ "🧭"
58
+ ]
59
+ ],
60
+ "ignoreConfig": [
61
+ "all"
62
+ ],
63
+ "ruleListColumns": [
64
+ "name",
65
+ "description",
66
+ "configsError"
67
+ ],
68
+ "ruleDocSectionInclude": [
69
+ "Rule Details",
70
+ "When Not To Use It"
71
+ ],
72
+ "urlConfigs": "https://github.com/cunarist/eslint-plugin-import-structure#configs-and-rules"
73
+ }
74
+ }