@lanelint/plugin-boundaries 0.0.0-stage → 0.0.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 +56 -2
- package/dist/index.d.mts +754 -0
- package/dist/index.mjs +795 -0
- package/package.json +46 -3
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,795 @@
|
|
|
1
|
+
import { AstUtil, GlobUtil, definePlugin, defineRule } from "@lanelint/core";
|
|
2
|
+
import { toStandardJsonSchema } from "@valibot/to-json-schema";
|
|
3
|
+
import * as v from "valibot";
|
|
4
|
+
//#region src/utils/edgeUtil.ts
|
|
5
|
+
const VALID_PACKAGE = /^(?:@[a-z0-9~][\w.~-]*\/)?[a-z0-9~][\w.~-]*$/i;
|
|
6
|
+
const isBare = (specifier) => !specifier.startsWith(".") && !specifier.startsWith("/") && !specifier.startsWith("#") && !/^[A-Za-z]:[\\/]/.test(specifier);
|
|
7
|
+
/** The npm package name of a bare specifier, or `undefined` for relative paths and aliases. */
|
|
8
|
+
const packageNameOf = (specifier) => {
|
|
9
|
+
if (!isBare(specifier)) return;
|
|
10
|
+
const parts = specifier.split("/");
|
|
11
|
+
const name = specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
|
|
12
|
+
return name && VALID_PACKAGE.test(name) ? name : void 0;
|
|
13
|
+
};
|
|
14
|
+
/** The internal file an edge points to, if any. */
|
|
15
|
+
const internalTarget = (edge) => edge.target.type === "internal" ? edge.target.file : void 0;
|
|
16
|
+
/** Skips edges from test files and type-only edges unless the options include them. */
|
|
17
|
+
const skip = (edge, options) => edge.from.isTest && !options.includeTests || edge.typeOnly && !!options.ignoreTypeImports;
|
|
18
|
+
/** `the feature src/features/a`, or the file path for files outside elements. */
|
|
19
|
+
/** How a file is named in a message: its element, or its path. `start` begins a sentence. */
|
|
20
|
+
const nameOf = (file, start = false) => file.element ? `${start ? "The" : "the"} ${file.element.type} ${file.element.root}` : file.path;
|
|
21
|
+
const EdgeUtil = {
|
|
22
|
+
internalTarget,
|
|
23
|
+
isBare,
|
|
24
|
+
nameOf,
|
|
25
|
+
packageNameOf,
|
|
26
|
+
skip
|
|
27
|
+
};
|
|
28
|
+
//#endregion
|
|
29
|
+
//#region src/models/optionSchemas.ts
|
|
30
|
+
const SelectorSchema = v.pipe(v.union([v.pipe(v.string(), v.minLength(1)), v.pipe(v.strictObject({
|
|
31
|
+
type: v.optional(v.pipe(v.string(), v.description("Element type."))),
|
|
32
|
+
captures: v.optional(v.pipe(v.record(v.string(), v.union([v.string(), v.array(v.string())])), v.description("Allowed values per capture of the element pattern, such as `{ name: \"auth\" }`."))),
|
|
33
|
+
files: v.optional(v.pipe(v.union([v.string(), v.array(v.string())]), v.description("Globs matched against file paths.")))
|
|
34
|
+
}), v.check((selector) => !!(selector.type || selector.captures || selector.files), "A selector needs type, captures, or files."))]), v.title("Selector"), v.description("An element type, or an object that matches elements by type and captures, or files by glob. All given fields must match."));
|
|
35
|
+
/** One selector or a list, normalized to a list. Arrays are tried first, since objects accept them. */
|
|
36
|
+
const SelectorListSchema = v.pipe(v.union([v.array(SelectorSchema), SelectorSchema]), v.transform((value) => Array.isArray(value) ? value : [value]));
|
|
37
|
+
const GlobListSchema = v.pipe(v.union([v.string(), v.array(v.string())]), v.transform((value) => typeof value === "string" ? [value] : value));
|
|
38
|
+
const MessageSchema = v.pipe(v.string(), v.description("Replaces the finding message, for example to explain the rule."));
|
|
39
|
+
const ReasonSchema = v.pipe(v.string(), v.description("Added to the finding message, for example the reason for the restriction."));
|
|
40
|
+
const HintSchema = v.pipe(v.string(), v.description("Shown under the finding as the next step."));
|
|
41
|
+
const IncludeTestsSchema = v.optional(v.pipe(v.boolean(), v.description("Also check imports from test files.")), false);
|
|
42
|
+
const IgnoreTypeImportsSchema = v.optional(v.pipe(v.boolean(), v.description("Skip imports that only import types.")), false);
|
|
43
|
+
//#endregion
|
|
44
|
+
//#region src/utils/selectorUtil.ts
|
|
45
|
+
const matchesOne = (selector, target) => {
|
|
46
|
+
if (typeof selector === "string") return target.element?.type === selector;
|
|
47
|
+
if (selector.type !== void 0 && target.element?.type !== selector.type) return false;
|
|
48
|
+
for (const [name, allowed] of Object.entries(selector.captures ?? {})) {
|
|
49
|
+
const value = target.element?.captures[name];
|
|
50
|
+
if (value === void 0 || !(typeof allowed === "string" ? [allowed] : allowed).includes(value)) return false;
|
|
51
|
+
}
|
|
52
|
+
return !selector.files || GlobUtil.matches(target.path, selector.files);
|
|
53
|
+
};
|
|
54
|
+
const matches$1 = (selectors, target) => selectors.some((selector) => matchesOne(selector, target));
|
|
55
|
+
const describeOne = (selector) => {
|
|
56
|
+
if (typeof selector === "string") return selector;
|
|
57
|
+
const captures = Object.entries(selector.captures ?? {}).map(([name, value]) => `${name}=${typeof value === "string" ? value : value.join("|")}`);
|
|
58
|
+
const files = selector.files ? `files ${GlobUtil.toList(selector.files).join(", ")}` : "";
|
|
59
|
+
return [
|
|
60
|
+
selector.type ?? "",
|
|
61
|
+
captures.length ? `(${captures.join(", ")})` : "",
|
|
62
|
+
files
|
|
63
|
+
].filter(Boolean).join(" ");
|
|
64
|
+
};
|
|
65
|
+
const describe = (selectors) => {
|
|
66
|
+
const names = selectors.map(describeOne);
|
|
67
|
+
return names.length > 1 ? `${names.slice(0, -1).join(", ")} or ${names.at(-1)}` : names[0] ?? "";
|
|
68
|
+
};
|
|
69
|
+
/** Element types referenced by selectors, for config validation. */
|
|
70
|
+
const typesOf = (selectors) => (selectors ?? []).flatMap((selector) => typeof selector === "string" ? [selector] : selector.type ? [selector.type] : []);
|
|
71
|
+
/** True for selectors that match files by glob only, not elements. */
|
|
72
|
+
const isFilesOnly = (selector) => typeof selector !== "string" && !!selector.files && selector.type === void 0 && selector.captures === void 0;
|
|
73
|
+
const SelectorUtil = {
|
|
74
|
+
describe,
|
|
75
|
+
isFilesOnly,
|
|
76
|
+
matches: matches$1,
|
|
77
|
+
typesOf
|
|
78
|
+
};
|
|
79
|
+
//#endregion
|
|
80
|
+
//#region src/rules/allowedImports.ts
|
|
81
|
+
const RuleSchema = v.object({
|
|
82
|
+
from: v.pipe(SelectorListSchema, v.description("Files whose imports this rule checks.")),
|
|
83
|
+
allow: v.optional(v.pipe(SelectorListSchema, v.description("The only targets these files may import, besides their own element."))),
|
|
84
|
+
disallow: v.optional(v.pipe(SelectorListSchema, v.description("Targets these files must not import."))),
|
|
85
|
+
message: v.optional(MessageSchema),
|
|
86
|
+
hint: v.optional(HintSchema)
|
|
87
|
+
});
|
|
88
|
+
const DEFAULT_HINT = "Move the shared code to a place both may import, or invert the dependency.";
|
|
89
|
+
const allowedImports = defineRule({
|
|
90
|
+
meta: {
|
|
91
|
+
description: "Controls which elements may import which.",
|
|
92
|
+
severity: "error",
|
|
93
|
+
docs: [
|
|
94
|
+
"Each rule matches the importing file with `from`. A target that matches `disallow` is",
|
|
95
|
+
"reported. When a matching rule has `allow`, targets that match no `allow` list are reported.",
|
|
96
|
+
"Otherwise `default` decides: `allow` (the default) or `disallow`. Imports inside one element",
|
|
97
|
+
"are allowed, except where a `disallow` selector with only `files` matches. Selectors are",
|
|
98
|
+
"element types, or `{ type, captures, files }`.",
|
|
99
|
+
"",
|
|
100
|
+
"To fix a finding, move the shared code to a place both sides may import, or invert the",
|
|
101
|
+
"dependency so the lower-level code does not know about the higher-level code."
|
|
102
|
+
].join("\n"),
|
|
103
|
+
example: ["error", { rules: [{
|
|
104
|
+
from: "feature",
|
|
105
|
+
allow: ["common", "shared"]
|
|
106
|
+
}, {
|
|
107
|
+
from: "common",
|
|
108
|
+
disallow: "feature",
|
|
109
|
+
message: "Common code must not know features.",
|
|
110
|
+
hint: "Pass what the common code needs in as an argument."
|
|
111
|
+
}] }]
|
|
112
|
+
},
|
|
113
|
+
options: toStandardJsonSchema(v.object({
|
|
114
|
+
rules: v.pipe(v.array(RuleSchema), v.description("Rules, checked in order.")),
|
|
115
|
+
default: v.optional(v.pipe(v.picklist(["allow", "disallow"]), v.description("Whether imports between elements that no rule mentions are allowed.")), "allow"),
|
|
116
|
+
includeTests: IncludeTestsSchema,
|
|
117
|
+
ignoreTypeImports: IgnoreTypeImportsSchema
|
|
118
|
+
})),
|
|
119
|
+
describe: (options) => [...options.rules.flatMap((rule) => {
|
|
120
|
+
const from = SelectorUtil.describe(rule.from);
|
|
121
|
+
return [...rule.allow ? [`${from} may import only ${SelectorUtil.describe(rule.allow)}.`] : [], ...rule.disallow ? [`${from} must not import ${SelectorUtil.describe(rule.disallow)}.`] : []];
|
|
122
|
+
}), ...options.default === "disallow" ? ["Imports between elements that no rule allows are not allowed."] : []],
|
|
123
|
+
elementTypes: (options) => options.rules.flatMap((rule) => [
|
|
124
|
+
...SelectorUtil.typesOf(rule.from),
|
|
125
|
+
...SelectorUtil.typesOf(rule.allow),
|
|
126
|
+
...SelectorUtil.typesOf(rule.disallow)
|
|
127
|
+
]),
|
|
128
|
+
create: (context) => ({ import(edge) {
|
|
129
|
+
const to = EdgeUtil.internalTarget(edge);
|
|
130
|
+
const from = edge.from;
|
|
131
|
+
if (!to || EdgeUtil.skip(edge, context.options)) return;
|
|
132
|
+
const matching = context.options.rules.filter((rule) => SelectorUtil.matches(rule.from, from));
|
|
133
|
+
if (from.element?.id === to.element?.id && (from.element || to.element)) {
|
|
134
|
+
const denied = matching.find((rule) => rule.disallow && SelectorUtil.matches(rule.disallow.filter(SelectorUtil.isFilesOnly), to));
|
|
135
|
+
if (denied) context.report({
|
|
136
|
+
at: edge,
|
|
137
|
+
message: denied.message ?? `${from.path} must not import ${to.path}.`,
|
|
138
|
+
hint: denied.hint ?? DEFAULT_HINT
|
|
139
|
+
});
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const denied = matching.find((rule) => rule.disallow && SelectorUtil.matches(rule.disallow, to));
|
|
143
|
+
const allowLists = matching.filter((rule) => rule.allow);
|
|
144
|
+
const allowed = allowLists.some((rule) => rule.allow && SelectorUtil.matches(rule.allow, to));
|
|
145
|
+
const violation = denied ?? (allowLists.length ? allowed ? void 0 : allowLists[0] : context.options.default === "disallow" && (from.element || to.element) ? {
|
|
146
|
+
message: void 0,
|
|
147
|
+
hint: void 0
|
|
148
|
+
} : void 0);
|
|
149
|
+
if (violation) context.report({
|
|
150
|
+
at: edge,
|
|
151
|
+
message: violation.message ?? `${EdgeUtil.nameOf(from, true)} must not import ${EdgeUtil.nameOf(to)}.`,
|
|
152
|
+
hint: violation.hint ?? DEFAULT_HINT
|
|
153
|
+
});
|
|
154
|
+
} })
|
|
155
|
+
});
|
|
156
|
+
//#endregion
|
|
157
|
+
//#region src/rules/entryOnly.ts
|
|
158
|
+
const entryOnly = defineRule({
|
|
159
|
+
meta: {
|
|
160
|
+
description: "Imports from outside an element must target its entry files.",
|
|
161
|
+
severity: "error",
|
|
162
|
+
docs: [
|
|
163
|
+
"An element exposes its public API through its entry files (`entry` in the element",
|
|
164
|
+
"definition, `index.ts` by default). Code outside the element must not reach into its",
|
|
165
|
+
"other files, so the element can change its internals freely.",
|
|
166
|
+
"",
|
|
167
|
+
"To fix a finding, export what you need from the entry file and import it from there.",
|
|
168
|
+
"Test files are skipped unless `includeTests` is set."
|
|
169
|
+
].join("\n"),
|
|
170
|
+
example: ["error", { elements: ["feature", "common"] }]
|
|
171
|
+
},
|
|
172
|
+
options: toStandardJsonSchema(v.object({
|
|
173
|
+
elements: v.pipe(SelectorListSchema, v.description("Elements that must be imported through their entry files.")),
|
|
174
|
+
includeTests: IncludeTestsSchema,
|
|
175
|
+
ignoreTypeImports: IgnoreTypeImportsSchema
|
|
176
|
+
})),
|
|
177
|
+
describe: (options) => `Code outside a ${SelectorUtil.describe(options.elements)} element imports it only through its entry files.`,
|
|
178
|
+
elementTypes: (options) => SelectorUtil.typesOf(options.elements),
|
|
179
|
+
create: (context) => ({ import(edge) {
|
|
180
|
+
const to = EdgeUtil.internalTarget(edge);
|
|
181
|
+
const element = to?.element;
|
|
182
|
+
if (!to || !element || element.isEntry || edge.from.element?.id === element.id || EdgeUtil.skip(edge, context.options) || !SelectorUtil.matches(context.options.elements, to)) return;
|
|
183
|
+
const entries = context.project.element(element.id)?.entryFiles.map((file) => file.path);
|
|
184
|
+
context.report({
|
|
185
|
+
at: edge,
|
|
186
|
+
message: `Import ${element.root} through its entry file, not ${to.path}.`,
|
|
187
|
+
hint: entries?.length ? `Export what you need from ${entries.join(" or ")} and import it from there.` : `${element.root} has no entry file yet. Create one that exports what you need.`
|
|
188
|
+
});
|
|
189
|
+
} })
|
|
190
|
+
});
|
|
191
|
+
//#endregion
|
|
192
|
+
//#region src/rules/noCycles.ts
|
|
193
|
+
/** The same cycle has the same signature no matter where it starts. */
|
|
194
|
+
const signatureOf = (ids) => ids.map((_, index) => [...ids.slice(index), ...ids.slice(0, index)].join(" -> ")).sort()[0] ?? "";
|
|
195
|
+
const byLocation = (a, b) => a.from.path.localeCompare(b.from.path) || a.line - b.line;
|
|
196
|
+
const noCycles = defineRule({
|
|
197
|
+
meta: {
|
|
198
|
+
description: "Elements must not depend on each other in a cycle.",
|
|
199
|
+
severity: "error",
|
|
200
|
+
docs: [
|
|
201
|
+
"A cycle between elements means none of them can be understood, tested, or moved on its",
|
|
202
|
+
"own. The finding shows the import that closes the cycle and the other imports in it.",
|
|
203
|
+
"",
|
|
204
|
+
"To fix a finding, move the shared code into an element both can depend on, or invert one",
|
|
205
|
+
"of the dependencies, for example by passing a callback instead of importing."
|
|
206
|
+
].join("\n"),
|
|
207
|
+
example: ["error", { ignoreTypeImports: true }]
|
|
208
|
+
},
|
|
209
|
+
options: toStandardJsonSchema(v.optional(v.object({
|
|
210
|
+
elements: v.optional(v.pipe(SelectorListSchema, v.description("Elements to check. Defaults to every element."))),
|
|
211
|
+
includeTests: IncludeTestsSchema,
|
|
212
|
+
ignoreTypeImports: IgnoreTypeImportsSchema
|
|
213
|
+
}), {})),
|
|
214
|
+
describe: (options) => options.elements ? `${SelectorUtil.describe(options.elements)} elements do not depend on each other in a cycle.` : "Elements do not depend on each other in a cycle.",
|
|
215
|
+
elementTypes: (options) => SelectorUtil.typesOf(options.elements),
|
|
216
|
+
create: (context) => ({ async project(project) {
|
|
217
|
+
const { options } = context;
|
|
218
|
+
const graph = await project.graph();
|
|
219
|
+
const pairs = /* @__PURE__ */ new Map();
|
|
220
|
+
for (const edge of [...graph.edges].sort(byLocation)) {
|
|
221
|
+
const to = EdgeUtil.internalTarget(edge);
|
|
222
|
+
const fromId = edge.from.element?.id;
|
|
223
|
+
const toId = to?.element?.id;
|
|
224
|
+
if (!to || !fromId || !toId || fromId === toId || EdgeUtil.skip(edge, options) || options.elements && (!SelectorUtil.matches(options.elements, edge.from) || !SelectorUtil.matches(options.elements, to))) continue;
|
|
225
|
+
const outgoing = pairs.get(fromId) ?? /* @__PURE__ */ new Map();
|
|
226
|
+
const pair = outgoing.get(toId) ?? {
|
|
227
|
+
first: edge,
|
|
228
|
+
scoped: void 0
|
|
229
|
+
};
|
|
230
|
+
if (!pair.scoped && context.isInScope(edge)) pair.scoped = edge;
|
|
231
|
+
outgoing.set(toId, pair);
|
|
232
|
+
pairs.set(fromId, outgoing);
|
|
233
|
+
}
|
|
234
|
+
const pathBack = (start, goal) => {
|
|
235
|
+
const visited = /* @__PURE__ */ new Set([start]);
|
|
236
|
+
const queue = [{
|
|
237
|
+
id: start,
|
|
238
|
+
path: []
|
|
239
|
+
}];
|
|
240
|
+
for (let index = 0; index < queue.length; index++) {
|
|
241
|
+
const current = queue[index];
|
|
242
|
+
if (!current) break;
|
|
243
|
+
for (const [next, pair] of [...pairs.get(current.id) ?? []].sort(([a], [b]) => a.localeCompare(b))) {
|
|
244
|
+
const path = [...current.path, pair.first];
|
|
245
|
+
if (next === goal) return path;
|
|
246
|
+
if (!visited.has(next)) {
|
|
247
|
+
visited.add(next);
|
|
248
|
+
queue.push({
|
|
249
|
+
id: next,
|
|
250
|
+
path
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
const candidates = [...pairs].flatMap(([fromId, outgoing]) => [...outgoing].map(([toId, pair]) => ({
|
|
257
|
+
fromId,
|
|
258
|
+
toId,
|
|
259
|
+
pair
|
|
260
|
+
}))).sort((a, b) => Number(!a.pair.scoped) - Number(!b.pair.scoped) || a.fromId.localeCompare(b.fromId) || a.toId.localeCompare(b.toId));
|
|
261
|
+
const reported = /* @__PURE__ */ new Set();
|
|
262
|
+
for (const { fromId, toId, pair } of candidates) {
|
|
263
|
+
const back = pathBack(toId, fromId);
|
|
264
|
+
if (!back) continue;
|
|
265
|
+
const ids = [
|
|
266
|
+
fromId,
|
|
267
|
+
toId,
|
|
268
|
+
...back.slice(0, -1).map((edge) => EdgeUtil.internalTarget(edge)?.element?.id ?? "")
|
|
269
|
+
];
|
|
270
|
+
const signature = signatureOf(ids);
|
|
271
|
+
if (reported.has(signature)) continue;
|
|
272
|
+
reported.add(signature);
|
|
273
|
+
context.report({
|
|
274
|
+
at: pair.scoped ?? pair.first,
|
|
275
|
+
message: `Dependency cycle: ${[...ids, fromId].join(" -> ")}.`,
|
|
276
|
+
hint: "Move the shared code to an element both can depend on, or invert one dependency.",
|
|
277
|
+
key: signature,
|
|
278
|
+
related: back
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
} })
|
|
282
|
+
});
|
|
283
|
+
//#endregion
|
|
284
|
+
//#region src/rules/noEntryImport.ts
|
|
285
|
+
const noEntryImport = defineRule({
|
|
286
|
+
meta: {
|
|
287
|
+
description: "Imports must target the files they need, not entry files.",
|
|
288
|
+
severity: "error",
|
|
289
|
+
docs: [
|
|
290
|
+
"Entry files often load a whole element, including UI and hooks. Some code must import",
|
|
291
|
+
"the specific files it needs instead: code inside an element importing its own entry",
|
|
292
|
+
"(`to: \"self\"`), or elements that only need a model or helper from another element.",
|
|
293
|
+
"",
|
|
294
|
+
"To fix a finding, import the file that defines what you use."
|
|
295
|
+
].join("\n"),
|
|
296
|
+
example: ["error", { rules: [{
|
|
297
|
+
from: "feature",
|
|
298
|
+
to: "self"
|
|
299
|
+
}, {
|
|
300
|
+
from: "common",
|
|
301
|
+
to: "common",
|
|
302
|
+
message: "Import only the files you need."
|
|
303
|
+
}] }]
|
|
304
|
+
},
|
|
305
|
+
options: toStandardJsonSchema(v.object({
|
|
306
|
+
rules: v.array(v.object({
|
|
307
|
+
from: v.pipe(SelectorListSchema, v.description("Files whose imports this rule checks.")),
|
|
308
|
+
to: v.pipe(v.union([v.literal("self"), SelectorListSchema]), v.description("Elements whose entry files must not be imported, or `self` for the element of the importing file.")),
|
|
309
|
+
message: v.optional(MessageSchema)
|
|
310
|
+
})),
|
|
311
|
+
includeTests: IncludeTestsSchema
|
|
312
|
+
})),
|
|
313
|
+
describe: (options) => options.rules.map((rule) => rule.to === "self" ? `${SelectorUtil.describe(rule.from)} imports its own files directly, not its own entry file.` : `${SelectorUtil.describe(rule.from)} imports the files it needs from ${SelectorUtil.describe(rule.to)}, not their entry files.`),
|
|
314
|
+
elementTypes: (options) => options.rules.flatMap((rule) => [...SelectorUtil.typesOf(rule.from), ...rule.to === "self" ? [] : SelectorUtil.typesOf(rule.to)]),
|
|
315
|
+
create: (context) => ({ import(edge) {
|
|
316
|
+
const to = EdgeUtil.internalTarget(edge);
|
|
317
|
+
const element = to?.element;
|
|
318
|
+
if (!to || !element?.isEntry || EdgeUtil.skip(edge, context.options)) return;
|
|
319
|
+
const self = edge.from.element?.id === element.id;
|
|
320
|
+
const rule = context.options.rules.find((candidate) => SelectorUtil.matches(candidate.from, edge.from) && (candidate.to === "self" ? self : !self && SelectorUtil.matches(candidate.to, to)));
|
|
321
|
+
if (!rule) return;
|
|
322
|
+
context.report({
|
|
323
|
+
at: edge,
|
|
324
|
+
message: rule.message ?? (self ? `Import files of ${element.root} directly, not its own entry file ${to.path}.` : `Import the file you need from ${element.root}, not its entry file ${to.path}.`),
|
|
325
|
+
hint: "Import the file that defines what you use."
|
|
326
|
+
});
|
|
327
|
+
} })
|
|
328
|
+
});
|
|
329
|
+
//#endregion
|
|
330
|
+
//#region src/rules/noOrphans.ts
|
|
331
|
+
const noOrphans = defineRule({
|
|
332
|
+
meta: {
|
|
333
|
+
description: "Source files must be imported by non-test code, or be an entry point.",
|
|
334
|
+
severity: "warn",
|
|
335
|
+
docs: [
|
|
336
|
+
"A file that only tests import is usually left over from a refactor. Entry files of elements,",
|
|
337
|
+
"test files, declaration files, config files, and files matching `roots` are entry points",
|
|
338
|
+
"and are never reported. List other entry points, such as `src/main.ts` or CLI scripts, in",
|
|
339
|
+
"`roots`.",
|
|
340
|
+
"",
|
|
341
|
+
"To fix a finding, delete the file if it is unused, import it where it is needed, or add it",
|
|
342
|
+
"to `roots` if it is an entry point."
|
|
343
|
+
].join("\n"),
|
|
344
|
+
example: ["warn", { roots: ["src/main.ts", "scripts/**"] }]
|
|
345
|
+
},
|
|
346
|
+
options: toStandardJsonSchema(v.optional(v.object({ roots: v.optional(v.pipe(v.array(v.string()), v.description("Globs of entry points, such as `src/main.ts`, that nothing imports.")), []) }), {})),
|
|
347
|
+
create: (context) => ({ async project(project) {
|
|
348
|
+
const roots = [
|
|
349
|
+
"**/*.config.*",
|
|
350
|
+
"**/*.d.ts",
|
|
351
|
+
"**/*.d.mts",
|
|
352
|
+
...context.options.roots
|
|
353
|
+
];
|
|
354
|
+
const candidates = project.files.filter((file) => file.isSource && !file.isTest && !file.element?.isEntry && !GlobUtil.matches(file.path, roots) && context.isInScope(file));
|
|
355
|
+
if (!candidates.length) return;
|
|
356
|
+
const graph = await project.graph();
|
|
357
|
+
for (const file of candidates) if (!graph.importersOf(file).some((edge) => !edge.from.isTest && edge.from.path !== file.path)) context.report({
|
|
358
|
+
at: file,
|
|
359
|
+
message: `Nothing imports ${file.path}.`,
|
|
360
|
+
hint: "Delete it if it is unused, import it where it is needed, or add it to roots if it is an entry point."
|
|
361
|
+
});
|
|
362
|
+
} })
|
|
363
|
+
});
|
|
364
|
+
//#endregion
|
|
365
|
+
//#region src/rules/noUndeclaredDependency.ts
|
|
366
|
+
const typesPackageOf = (name) => `@types/${name.startsWith("@") ? name.slice(1).replace("/", "__") : name}`;
|
|
367
|
+
const declares = (info, name, dev) => name in info.dependencies || name in info.peerDependencies || name in info.optionalDependencies || dev && name in info.devDependencies;
|
|
368
|
+
const noUndeclaredDependency = defineRule({
|
|
369
|
+
meta: {
|
|
370
|
+
description: "Imported packages must be declared in the nearest package.json.",
|
|
371
|
+
severity: "error",
|
|
372
|
+
docs: [
|
|
373
|
+
"A package that is imported but not declared works only by accident, through hoisting or",
|
|
374
|
+
"another package. Imports of workspace packages must be declared too.",
|
|
375
|
+
"",
|
|
376
|
+
"devDependencies count for test files and for files matching `allowDev`. These files may",
|
|
377
|
+
"also use packages declared in the root package.json, where monorepos often keep shared",
|
|
378
|
+
"dev tools such as a test runner. Type-only imports also accept the matching `@types`",
|
|
379
|
+
"package.",
|
|
380
|
+
"",
|
|
381
|
+
"To fix a finding, add the package to the nearest package.json."
|
|
382
|
+
].join("\n"),
|
|
383
|
+
example: ["error", {
|
|
384
|
+
allowDev: ["**/*.config.*", "scripts/**"],
|
|
385
|
+
ignore: ["virtual:*"]
|
|
386
|
+
}]
|
|
387
|
+
},
|
|
388
|
+
options: toStandardJsonSchema(v.optional(v.object({
|
|
389
|
+
allowDev: v.optional(v.pipe(v.array(v.string()), v.description("Globs of files that may import devDependencies, besides test files.")), ["**/*.config.*", "**/scripts/**"]),
|
|
390
|
+
ignore: v.optional(v.pipe(v.array(v.string()), v.description("Package names or globs that are never reported.")), []),
|
|
391
|
+
allowRootDev: v.optional(v.pipe(v.boolean(), v.description("Let the files that may use devDependencies also use packages declared in the root package.json.")), true)
|
|
392
|
+
}), {})),
|
|
393
|
+
create: (context) => ({ import(edge) {
|
|
394
|
+
const { target } = edge;
|
|
395
|
+
if (target.type === "builtin" || target.type === "excluded") return;
|
|
396
|
+
const name = target.type === "external" ? target.package : EdgeUtil.packageNameOf(edge.specifier);
|
|
397
|
+
if (!name || GlobUtil.matches(name, context.options.ignore)) return;
|
|
398
|
+
const info = context.project.packageOf(edge.from);
|
|
399
|
+
if (!info || info.name === name) return;
|
|
400
|
+
if (target.type === "internal") {
|
|
401
|
+
if (!context.project.workspacePackage(name) || context.project.packageOf(target.file)?.directory === info.directory) return;
|
|
402
|
+
}
|
|
403
|
+
const dev = edge.from.isTest || GlobUtil.matches(edge.from.path, context.options.allowDev);
|
|
404
|
+
const root = dev && context.options.allowRootDev && info.directory ? context.project.packageOf("package.json") : void 0;
|
|
405
|
+
const declared = (manifest) => declares(manifest, name, dev) || edge.typeOnly && declares(manifest, typesPackageOf(name), true);
|
|
406
|
+
if (declared(info) || root?.directory === "" && declared(root)) return;
|
|
407
|
+
const manifest = info.directory ? `${info.directory}/package.json` : "package.json";
|
|
408
|
+
context.report({
|
|
409
|
+
at: edge,
|
|
410
|
+
message: `${name} is not declared in ${manifest}.`,
|
|
411
|
+
hint: `Add ${name} to ${dev ? "dependencies or devDependencies" : "dependencies"} in ${manifest}.`,
|
|
412
|
+
key: name
|
|
413
|
+
});
|
|
414
|
+
} })
|
|
415
|
+
});
|
|
416
|
+
//#endregion
|
|
417
|
+
//#region src/rules/noUnresolved.ts
|
|
418
|
+
const noUnresolved = defineRule({
|
|
419
|
+
meta: {
|
|
420
|
+
description: "Imports must resolve to a file or an installed package.",
|
|
421
|
+
severity: "error",
|
|
422
|
+
docs: [
|
|
423
|
+
"An import that cannot be resolved usually points at a moved or deleted file, a typo, or a",
|
|
424
|
+
"package that is not installed. Resolution follows the nearest tsconfig.json, including",
|
|
425
|
+
"`paths`. Imports of files excluded from the project are not reported.",
|
|
426
|
+
"",
|
|
427
|
+
"To fix a finding, correct the path, or install the missing package."
|
|
428
|
+
].join("\n"),
|
|
429
|
+
example: ["error", { ignore: ["virtual:*", "*.svg?react"] }]
|
|
430
|
+
},
|
|
431
|
+
options: toStandardJsonSchema(v.optional(v.object({ ignore: v.optional(v.pipe(v.array(v.string()), v.description("Globs of import specifiers that are never reported, such as `virtual:*`.")), []) }), {})),
|
|
432
|
+
create: (context) => ({ import(edge) {
|
|
433
|
+
if (edge.target.type !== "unresolved" || GlobUtil.matches(edge.specifier, context.options.ignore)) return;
|
|
434
|
+
context.report({
|
|
435
|
+
at: edge,
|
|
436
|
+
message: `Cannot resolve "${edge.specifier}".`,
|
|
437
|
+
hint: "Fix the import path, or install the package if it is missing."
|
|
438
|
+
});
|
|
439
|
+
} })
|
|
440
|
+
});
|
|
441
|
+
//#endregion
|
|
442
|
+
//#region src/rules/publicApi.ts
|
|
443
|
+
/** Files reachable from an element's entry files through re-exports. */
|
|
444
|
+
const publicFilesOf = async (project, entries) => {
|
|
445
|
+
const reachable = /* @__PURE__ */ new Set();
|
|
446
|
+
const queue = [...entries];
|
|
447
|
+
while (queue.length) {
|
|
448
|
+
const file = queue.pop();
|
|
449
|
+
if (!file || reachable.has(file.path)) continue;
|
|
450
|
+
reachable.add(file.path);
|
|
451
|
+
for (const edge of await project.importsOf(file)) if (edge.reexport && edge.target.type === "internal") queue.push(edge.target.file);
|
|
452
|
+
}
|
|
453
|
+
return reachable;
|
|
454
|
+
};
|
|
455
|
+
const publicApi = defineRule({
|
|
456
|
+
meta: {
|
|
457
|
+
description: "Imported files of an element must be reachable from its entry files.",
|
|
458
|
+
severity: "error",
|
|
459
|
+
docs: [
|
|
460
|
+
"Some elements allow imports of individual files, as long as those files are part of the",
|
|
461
|
+
"public API: reachable from an entry file through re-exports, directly or through other",
|
|
462
|
+
"barrels. The rule checks files, not individual exported names.",
|
|
463
|
+
"",
|
|
464
|
+
"To fix a finding, export the file from the element entry (or a barrel it re-exports), or",
|
|
465
|
+
"import something that is already public."
|
|
466
|
+
].join("\n"),
|
|
467
|
+
example: ["error", {
|
|
468
|
+
elements: "common",
|
|
469
|
+
exceptions: [{
|
|
470
|
+
from: "feature",
|
|
471
|
+
to: "src/common/*/models/**"
|
|
472
|
+
}]
|
|
473
|
+
}]
|
|
474
|
+
},
|
|
475
|
+
options: toStandardJsonSchema(v.object({
|
|
476
|
+
elements: v.pipe(SelectorListSchema, v.description("Elements whose imported files must be part of their public API.")),
|
|
477
|
+
from: v.optional(v.pipe(SelectorListSchema, v.description("Files whose imports are checked. Defaults to files outside the element."))),
|
|
478
|
+
exceptions: v.optional(v.pipe(v.array(v.object({
|
|
479
|
+
from: v.optional(v.pipe(SelectorListSchema, v.description("Importing files. Defaults to any file."))),
|
|
480
|
+
to: v.pipe(GlobListSchema, v.description("Globs of files that may be imported anyway."))
|
|
481
|
+
})), v.description("Imports that are allowed even though the file is not public.")), []),
|
|
482
|
+
includeTests: IncludeTestsSchema
|
|
483
|
+
})),
|
|
484
|
+
describe: (options) => `${options.from ? `${SelectorUtil.describe(options.from)} imports files` : "Files"} in a ${SelectorUtil.describe(options.elements)} element ${options.from ? "" : "are imported from outside "}only when its entry files export them.`,
|
|
485
|
+
elementTypes: (options) => [
|
|
486
|
+
...SelectorUtil.typesOf(options.elements),
|
|
487
|
+
...SelectorUtil.typesOf(options.from),
|
|
488
|
+
...options.exceptions.flatMap((exception) => SelectorUtil.typesOf(exception.from))
|
|
489
|
+
],
|
|
490
|
+
create: (context) => {
|
|
491
|
+
const candidates = [];
|
|
492
|
+
return {
|
|
493
|
+
import(edge) {
|
|
494
|
+
const to = EdgeUtil.internalTarget(edge);
|
|
495
|
+
const element = to?.element;
|
|
496
|
+
const { options } = context;
|
|
497
|
+
if (!to || !element || element.isEntry || edge.from.element?.id === element.id || EdgeUtil.skip(edge, options) || !SelectorUtil.matches(options.elements, to) || options.from && !SelectorUtil.matches(options.from, edge.from) || options.exceptions.some((exception) => (!exception.from || SelectorUtil.matches(exception.from, edge.from)) && GlobUtil.matches(to.path, exception.to))) return;
|
|
498
|
+
candidates.push(edge);
|
|
499
|
+
},
|
|
500
|
+
async project(project) {
|
|
501
|
+
const publicFiles = /* @__PURE__ */ new Map();
|
|
502
|
+
for (const edge of candidates) {
|
|
503
|
+
const to = EdgeUtil.internalTarget(edge);
|
|
504
|
+
const element = to?.element && project.element(to.element.id);
|
|
505
|
+
if (!to || !element) continue;
|
|
506
|
+
let reachable = publicFiles.get(element.id);
|
|
507
|
+
if (!reachable) {
|
|
508
|
+
reachable = await publicFilesOf(project, element.entryFiles);
|
|
509
|
+
publicFiles.set(element.id, reachable);
|
|
510
|
+
}
|
|
511
|
+
if (!reachable.has(to.path)) context.report({
|
|
512
|
+
at: edge,
|
|
513
|
+
message: `${to.path} is not exported from the entry files of ${element.root}.`,
|
|
514
|
+
hint: `Export it from ${element.entryFiles.map((file) => file.path).join(" or ") || "an entry file"}, or import something that is.`
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
//#endregion
|
|
522
|
+
//#region src/rules/restrictedGlobals.ts
|
|
523
|
+
/** Objects whose properties are globals, as in `globalThis.fetch`. */
|
|
524
|
+
const GLOBAL_OBJECTS = /* @__PURE__ */ new Set([
|
|
525
|
+
"globalThis",
|
|
526
|
+
"window",
|
|
527
|
+
"self",
|
|
528
|
+
"global"
|
|
529
|
+
]);
|
|
530
|
+
/** Subtrees that only describe types and never run. */
|
|
531
|
+
const TYPE_ONLY = /* @__PURE__ */ new Set([
|
|
532
|
+
"TSTypeAnnotation",
|
|
533
|
+
"TSTypeAliasDeclaration",
|
|
534
|
+
"TSInterfaceDeclaration",
|
|
535
|
+
"TSTypeParameterInstantiation",
|
|
536
|
+
"TSTypeParameterDeclaration",
|
|
537
|
+
"TSTypeQuery",
|
|
538
|
+
"TSDeclareFunction",
|
|
539
|
+
"TSModuleDeclaration"
|
|
540
|
+
]);
|
|
541
|
+
const RestrictionSchema$1 = v.object({
|
|
542
|
+
name: v.pipe(v.string(), v.regex(/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)?$/, "Use a name such as fetch or Date.now."), v.description("A global such as `fetch` or `process`, or a member such as `Date.now`.")),
|
|
543
|
+
from: v.optional(v.pipe(SelectorListSchema, v.description("Files the restriction applies to. Defaults to every file."))),
|
|
544
|
+
message: v.optional(ReasonSchema),
|
|
545
|
+
hint: v.optional(HintSchema)
|
|
546
|
+
});
|
|
547
|
+
/** Whether an identifier is read as a value, rather than used as a property name or label. */
|
|
548
|
+
const isReference = (parent, key) => {
|
|
549
|
+
if (!parent) return true;
|
|
550
|
+
switch (parent.type) {
|
|
551
|
+
case "MemberExpression": return key !== "property" || parent.computed;
|
|
552
|
+
case "Property":
|
|
553
|
+
case "MethodDefinition":
|
|
554
|
+
case "PropertyDefinition":
|
|
555
|
+
case "AccessorProperty": return key !== "key" || parent.computed;
|
|
556
|
+
case "LabeledStatement":
|
|
557
|
+
case "BreakStatement":
|
|
558
|
+
case "ContinueStatement": return false;
|
|
559
|
+
case "ImportSpecifier":
|
|
560
|
+
case "ExportSpecifier": return false;
|
|
561
|
+
default: return true;
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
const restrictedGlobals = defineRule({
|
|
565
|
+
meta: {
|
|
566
|
+
description: "Globals, or members of globals, that must not be used.",
|
|
567
|
+
severity: "error",
|
|
568
|
+
docs: [
|
|
569
|
+
"Import rules cannot see globals such as `fetch`, `process`, `Date.now`, or `Math.random`.",
|
|
570
|
+
"This rule finds them in the code, including `globalThis.fetch`. A name declared in the",
|
|
571
|
+
"file, for example a parameter called `fetch`, is not the global and is not reported.",
|
|
572
|
+
"Restrictions can apply to some elements or files only, for example to keep utils pure.",
|
|
573
|
+
"",
|
|
574
|
+
"To fix a finding, pass the value in, or move the code to a place where it is allowed."
|
|
575
|
+
].join("\n"),
|
|
576
|
+
example: ["error", { globals: [{
|
|
577
|
+
name: "fetch",
|
|
578
|
+
from: { files: "**/utils/**" },
|
|
579
|
+
message: "Utils are pure functions."
|
|
580
|
+
}, {
|
|
581
|
+
name: "Date.now",
|
|
582
|
+
from: { files: "**/utils/**" },
|
|
583
|
+
hint: "Pass the time in."
|
|
584
|
+
}] }]
|
|
585
|
+
},
|
|
586
|
+
options: toStandardJsonSchema(v.object({ globals: v.pipe(v.array(RestrictionSchema$1), v.description("The restricted globals.")) })),
|
|
587
|
+
describe: (options) => options.globals.map((restriction) => {
|
|
588
|
+
const where = restriction.from ? ` in ${SelectorUtil.describe(restriction.from)}` : "";
|
|
589
|
+
return `Do not use ${restriction.name}${where}.${restriction.message ? ` ${restriction.message}` : ""}`;
|
|
590
|
+
}),
|
|
591
|
+
elementTypes: (options) => options.globals.flatMap((restriction) => SelectorUtil.typesOf(restriction.from)),
|
|
592
|
+
create: (context) => ({ ast(file, ast) {
|
|
593
|
+
const restrictions = context.options.globals.filter((restriction) => (!restriction.from || SelectorUtil.matches(restriction.from, file)) && ast.text.includes(restriction.name.split(".").at(-1) ?? restriction.name));
|
|
594
|
+
if (!restrictions.length) return;
|
|
595
|
+
const byName = new Map(restrictions.map((restriction) => [restriction.name, restriction]));
|
|
596
|
+
const declared = AstUtil.declaredNames(ast.program);
|
|
597
|
+
const seen = /* @__PURE__ */ new Map();
|
|
598
|
+
const report = (name, start) => {
|
|
599
|
+
const restriction = byName.get(name);
|
|
600
|
+
if (!restriction || declared.has(name.split(".")[0] ?? name)) return;
|
|
601
|
+
const occurrence = (seen.get(name) ?? 0) + 1;
|
|
602
|
+
seen.set(name, occurrence);
|
|
603
|
+
context.report({
|
|
604
|
+
at: {
|
|
605
|
+
path: file.path,
|
|
606
|
+
line: ast.lineOf(start)
|
|
607
|
+
},
|
|
608
|
+
message: `Do not use ${name}.${restriction.message ? ` ${restriction.message}` : ""}`,
|
|
609
|
+
hint: restriction.hint,
|
|
610
|
+
key: `${name}#${occurrence}`
|
|
611
|
+
});
|
|
612
|
+
};
|
|
613
|
+
AstUtil.walk(ast.program, (node, parent, key) => {
|
|
614
|
+
if (TYPE_ONLY.has(node.type)) return false;
|
|
615
|
+
if (node.type === "Identifier" && isReference(parent, key)) report(node.name, node.start);
|
|
616
|
+
if (node.type === "MemberExpression" && !node.computed && node.object.type === "Identifier" && node.property.type === "Identifier") {
|
|
617
|
+
const object = node.object.name;
|
|
618
|
+
const property = node.property.name;
|
|
619
|
+
report(GLOBAL_OBJECTS.has(object) ? property : `${object}.${property}`, node.start);
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
} })
|
|
623
|
+
});
|
|
624
|
+
//#endregion
|
|
625
|
+
//#region src/rules/restrictedPackages.ts
|
|
626
|
+
const RestrictionSchema = v.object({
|
|
627
|
+
name: v.pipe(v.string(), v.description("Package name or glob, such as `@mui/material`, `lodash*`, or `node:fs`.")),
|
|
628
|
+
importNames: v.optional(v.pipe(v.array(v.string()), v.description("Only these imported names are restricted. Defaults to the whole package."))),
|
|
629
|
+
allowTypeImports: v.optional(v.pipe(v.boolean(), v.description("Allow imports that only import types.")), false),
|
|
630
|
+
from: v.optional(v.pipe(SelectorListSchema, v.description("Files the restriction applies to. Defaults to every file."))),
|
|
631
|
+
message: v.optional(ReasonSchema),
|
|
632
|
+
hint: v.optional(HintSchema)
|
|
633
|
+
});
|
|
634
|
+
const restrictedPackages = defineRule({
|
|
635
|
+
meta: {
|
|
636
|
+
description: "Packages, or names from packages, that must not be imported.",
|
|
637
|
+
severity: "error",
|
|
638
|
+
docs: [
|
|
639
|
+
"Restrictions keep code on the libraries the project has chosen, for example the design",
|
|
640
|
+
"system instead of a component library it wraps, or away from a library being phased out.",
|
|
641
|
+
"A restriction can cover a whole package, some names from it, or only some elements.",
|
|
642
|
+
"Node builtins are matched as `node:<name>`, for example to keep `node:fs` out of pure code.",
|
|
643
|
+
"",
|
|
644
|
+
"To fix a finding, use the alternative named in the message."
|
|
645
|
+
].join("\n"),
|
|
646
|
+
example: ["error", { packages: [{
|
|
647
|
+
name: "@mui/material",
|
|
648
|
+
importNames: ["Button"],
|
|
649
|
+
message: "Use the design system instead.",
|
|
650
|
+
hint: "Import Button from @acme/design-system."
|
|
651
|
+
}, {
|
|
652
|
+
name: "node:{fs,child_process}",
|
|
653
|
+
from: { files: "**/utils/**" }
|
|
654
|
+
}] }]
|
|
655
|
+
},
|
|
656
|
+
options: toStandardJsonSchema(v.object({ packages: v.pipe(v.array(RestrictionSchema), v.description("The restricted packages.")) })),
|
|
657
|
+
describe: (options) => options.packages.map((restriction) => {
|
|
658
|
+
return `Do not import ${restriction.importNames ? `${restriction.importNames.join(", ")} from ${restriction.name}` : restriction.name}${restriction.from ? ` in ${SelectorUtil.describe(restriction.from)}` : ""}.${restriction.message ? ` ${restriction.message}` : ""}`;
|
|
659
|
+
}),
|
|
660
|
+
elementTypes: (options) => options.packages.flatMap((restriction) => SelectorUtil.typesOf(restriction.from)),
|
|
661
|
+
create: (context) => ({ import(edge) {
|
|
662
|
+
const name = edge.target.type === "builtin" ? `node:${edge.target.module}` : edge.target.type === "external" ? edge.target.package : EdgeUtil.packageNameOf(edge.specifier);
|
|
663
|
+
if (!name) return;
|
|
664
|
+
for (const restriction of context.options.packages) {
|
|
665
|
+
if (restriction.name !== name && !GlobUtil.matches(name, restriction.name)) continue;
|
|
666
|
+
if (edge.typeOnly && restriction.allowTypeImports || restriction.from && !SelectorUtil.matches(restriction.from, edge.from)) continue;
|
|
667
|
+
const names = restriction.importNames ? edge.names.filter((imported) => restriction.importNames?.includes(imported)) : [];
|
|
668
|
+
if (restriction.importNames && !names.length) continue;
|
|
669
|
+
const what = names.length ? `${names.join(", ")} from ${name}` : name;
|
|
670
|
+
context.report({
|
|
671
|
+
at: edge,
|
|
672
|
+
message: `Do not import ${what}.${restriction.message ? ` ${restriction.message}` : ""}`,
|
|
673
|
+
hint: restriction.hint,
|
|
674
|
+
key: `${restriction.name}:${what}`
|
|
675
|
+
});
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
} })
|
|
679
|
+
});
|
|
680
|
+
//#endregion
|
|
681
|
+
//#region src/utils/exportsUtil.ts
|
|
682
|
+
/** Exports keys with their targets, or `undefined` when the package has no `exports` field. */
|
|
683
|
+
const entriesOf = (exports) => {
|
|
684
|
+
if (exports === void 0) return;
|
|
685
|
+
if (exports === null || typeof exports !== "object" || Array.isArray(exports)) return [[".", exports]];
|
|
686
|
+
const entries = Object.entries(exports);
|
|
687
|
+
return entries.some(([key]) => key.startsWith(".")) ? entries : [[".", exports]];
|
|
688
|
+
};
|
|
689
|
+
const matches = (key, subpath) => {
|
|
690
|
+
const star = key.indexOf("*");
|
|
691
|
+
if (star === -1) return key.endsWith("/") ? subpath.startsWith(key) : key === subpath;
|
|
692
|
+
const prefix = key.slice(0, star);
|
|
693
|
+
const suffix = key.slice(star + 1);
|
|
694
|
+
return subpath.length >= prefix.length + suffix.length && subpath.startsWith(prefix) && subpath.endsWith(suffix);
|
|
695
|
+
};
|
|
696
|
+
/**
|
|
697
|
+
* Whether a package exports a subpath, such as `.` or `./icons`, following Node: exact keys win
|
|
698
|
+
* over patterns, the longest pattern wins, and a `null` target blocks the subpath.
|
|
699
|
+
*/
|
|
700
|
+
const isExported = (exports, subpath) => {
|
|
701
|
+
const entries = entriesOf(exports);
|
|
702
|
+
if (!entries) return true;
|
|
703
|
+
const match = entries.find(([key]) => key === subpath) ?? entries.filter(([key]) => key !== subpath && matches(key, subpath)).sort(([a], [b]) => b.length - a.length)[0];
|
|
704
|
+
return !!match && match[1] !== null;
|
|
705
|
+
};
|
|
706
|
+
/** Exported subpaths, for messages. */
|
|
707
|
+
const subpathsOf = (exports) => (entriesOf(exports) ?? []).filter(([, target]) => target !== null).map(([key]) => key);
|
|
708
|
+
const ExportsUtil = {
|
|
709
|
+
isExported,
|
|
710
|
+
subpathsOf
|
|
711
|
+
};
|
|
712
|
+
//#endregion
|
|
713
|
+
//#region src/rules/workspaceImports.ts
|
|
714
|
+
const MAX_SUBPATHS = 5;
|
|
715
|
+
/** The same package, or one package inside the other. The root package contains nothing. */
|
|
716
|
+
const nested = (a, b) => a === b || !!a && !!b && (a.startsWith(`${b}/`) || b.startsWith(`${a}/`));
|
|
717
|
+
const workspaceImports = defineRule({
|
|
718
|
+
meta: {
|
|
719
|
+
description: "Imports of other workspace packages must use the package name and a subpath it exports.",
|
|
720
|
+
severity: "error",
|
|
721
|
+
docs: [
|
|
722
|
+
"A workspace package exposes its public API through its name and the `exports` field of",
|
|
723
|
+
"its package.json. Code in another package must not reach past it, with a relative path",
|
|
724
|
+
"into the package or with a subpath that `exports` does not list. A package without",
|
|
725
|
+
"`exports` exports every subpath, as in Node. A package inside another package may also be",
|
|
726
|
+
"imported through the outer package. Imports inside one package, between a package and the",
|
|
727
|
+
"packages nested in it, and of the root package are not checked.",
|
|
728
|
+
"",
|
|
729
|
+
"To fix a finding, import the package by its name and an exported subpath, or add the",
|
|
730
|
+
"subpath to `exports` in the package.json of the imported package.",
|
|
731
|
+
"Test files are skipped unless `includeTests` is set."
|
|
732
|
+
].join("\n"),
|
|
733
|
+
example: ["error", { ignore: ["@acme/legacy-*"] }]
|
|
734
|
+
},
|
|
735
|
+
options: toStandardJsonSchema(v.optional(v.object({
|
|
736
|
+
ignore: v.optional(v.pipe(v.array(v.string()), v.description("Names or globs of packages whose imports are never reported.")), []),
|
|
737
|
+
includeTests: IncludeTestsSchema
|
|
738
|
+
}), {})),
|
|
739
|
+
describe: () => "Other workspace packages are imported by name, through a subpath they export.",
|
|
740
|
+
create: (context) => ({ import(edge) {
|
|
741
|
+
const to = EdgeUtil.internalTarget(edge);
|
|
742
|
+
if (!to || EdgeUtil.skip(edge, context.options)) return;
|
|
743
|
+
const target = context.project.packageOf(to);
|
|
744
|
+
if (!target?.name || !target.directory) return;
|
|
745
|
+
const from = context.project.packageOf(edge.from)?.directory ?? "";
|
|
746
|
+
if (nested(from, target.directory) || GlobUtil.matches(target.name, context.options.ignore)) return;
|
|
747
|
+
const name = target.name;
|
|
748
|
+
const manifest = `${target.directory}/package.json`;
|
|
749
|
+
const via = context.project.workspacePackage(EdgeUtil.packageNameOf(edge.specifier) ?? "");
|
|
750
|
+
if (!via?.name || !to.path.startsWith(`${via.directory}/`)) {
|
|
751
|
+
context.report({
|
|
752
|
+
at: edge,
|
|
753
|
+
message: `Import ${name} by its package name, not by the path ${edge.specifier}.`,
|
|
754
|
+
hint: `Import from "${name}", or a subpath that ${manifest} exports.`
|
|
755
|
+
});
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
const subpath = `.${edge.specifier.slice(via.name.length)}`;
|
|
759
|
+
if (ExportsUtil.isExported(via.exports, subpath)) return;
|
|
760
|
+
const exported = ExportsUtil.subpathsOf(via.exports);
|
|
761
|
+
const shown = exported.slice(0, MAX_SUBPATHS).join(", ");
|
|
762
|
+
const viaManifest = `${via.directory}/package.json`;
|
|
763
|
+
context.report({
|
|
764
|
+
at: edge,
|
|
765
|
+
message: `${via.name} does not export ${subpath}.`,
|
|
766
|
+
hint: exported.length ? `Import from a subpath that ${viaManifest} exports (${shown}${exported.length > MAX_SUBPATHS ? ", …" : ""}), or add ${subpath} to its exports.` : `Add ${subpath} to exports in ${viaManifest}.`
|
|
767
|
+
});
|
|
768
|
+
} })
|
|
769
|
+
});
|
|
770
|
+
//#endregion
|
|
771
|
+
//#region src/index.ts
|
|
772
|
+
const boundaries = definePlugin({
|
|
773
|
+
name: "boundaries",
|
|
774
|
+
description: "Rules about dependencies between elements and on packages and globals.",
|
|
775
|
+
rules: {
|
|
776
|
+
"allowed-imports": allowedImports,
|
|
777
|
+
"entry-only": entryOnly,
|
|
778
|
+
"no-cycles": noCycles,
|
|
779
|
+
"no-entry-import": noEntryImport,
|
|
780
|
+
"no-orphans": noOrphans,
|
|
781
|
+
"no-undeclared-dependency": noUndeclaredDependency,
|
|
782
|
+
"no-unresolved": noUnresolved,
|
|
783
|
+
"public-api": publicApi,
|
|
784
|
+
"restricted-globals": restrictedGlobals,
|
|
785
|
+
"restricted-packages": restrictedPackages,
|
|
786
|
+
"workspace-imports": workspaceImports
|
|
787
|
+
},
|
|
788
|
+
configs: { recommended: { rules: {
|
|
789
|
+
"boundaries/no-cycles": "error",
|
|
790
|
+
"boundaries/no-unresolved": "error",
|
|
791
|
+
"boundaries/no-undeclared-dependency": "error"
|
|
792
|
+
} } }
|
|
793
|
+
});
|
|
794
|
+
//#endregion
|
|
795
|
+
export { allowedImports, boundaries as default, entryOnly, noCycles, noEntryImport, noOrphans, noUndeclaredDependency, noUnresolved, publicApi, restrictedGlobals, restrictedPackages, workspaceImports };
|