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 +21 -0
- package/README.md +89 -2
- package/dist/index.d.ts +21 -0
- package/dist/index.js +1167 -0
- package/package.json +72 -4
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
|
-
#
|
|
1
|
+
# eslint-plugin-import-structure
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
package/dist/index.d.ts
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|