@slip-stream-kit/eslint-plugin 0.43.2 → 0.44.0
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/dist/index.js +1194 -228
- package/dist/index.js.map +4 -4
- package/dist/rules/atom-naming/atom-naming.d.ts +2 -0
- package/dist/rules/atom-naming/index.d.ts +1 -0
- package/dist/rules/component-classname/component-classname.d.ts +2 -0
- package/dist/rules/component-classname/index.d.ts +1 -0
- package/dist/rules/component-name-matches-file/component-name-matches-file.d.ts +4 -0
- package/dist/rules/component-name-matches-file/index.d.ts +1 -0
- package/dist/rules/dumb-component-purity/dumb-component-purity.d.ts +3 -0
- package/dist/rules/dumb-component-purity/index.d.ts +1 -0
- package/dist/rules/feature-files/feature-files.d.ts +4 -0
- package/dist/rules/feature-files/index.d.ts +1 -0
- package/dist/rules/no-hooks-in-services/index.d.ts +1 -0
- package/dist/rules/no-hooks-in-services/no-hooks-in-services.d.ts +3 -0
- package/dist/utils/component-file-scope.d.ts +8 -0
- package/dist/utils/exported-components.d.ts +16 -0
- package/package.json +2 -2
- package/readme.md +144 -12
package/dist/index.js
CHANGED
|
@@ -1,3 +1,197 @@
|
|
|
1
|
+
// src/utils/path-match.ts
|
|
2
|
+
var REGEX_METACHARS = /* @__PURE__ */ new Set(["\\", "^", "$", ".", "|", "+", "(", ")", "[", "]", "{", "}"]);
|
|
3
|
+
var globToRegExp = (glob) => {
|
|
4
|
+
let source = "";
|
|
5
|
+
for (let index = 0; index < glob.length; index++) {
|
|
6
|
+
const char = glob[index];
|
|
7
|
+
if (char === "*") {
|
|
8
|
+
if (glob[index + 1] === "*") {
|
|
9
|
+
source += ".*";
|
|
10
|
+
index++;
|
|
11
|
+
if (glob[index + 1] === "/") {
|
|
12
|
+
index++;
|
|
13
|
+
}
|
|
14
|
+
} else {
|
|
15
|
+
source += "[^/]*";
|
|
16
|
+
}
|
|
17
|
+
} else if (char === "?") {
|
|
18
|
+
source += "[^/]";
|
|
19
|
+
} else if (REGEX_METACHARS.has(char)) {
|
|
20
|
+
source += `\\${char}`;
|
|
21
|
+
} else {
|
|
22
|
+
source += char;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return new RegExp(source);
|
|
26
|
+
};
|
|
27
|
+
var matchesAnyGlob = (filename, patterns) => {
|
|
28
|
+
const normalized = filename.split("\\").join("/");
|
|
29
|
+
return patterns.some((pattern) => {
|
|
30
|
+
return globToRegExp(pattern).test(normalized);
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// src/rules/atom-naming/atom-naming.ts
|
|
35
|
+
var ATOM_FACTORIES = /* @__PURE__ */ new Set([
|
|
36
|
+
"atom",
|
|
37
|
+
"atomWithStorage",
|
|
38
|
+
"atomWithReset",
|
|
39
|
+
"atomWithDefault",
|
|
40
|
+
"atomFamily",
|
|
41
|
+
"loadable",
|
|
42
|
+
"selectAtom",
|
|
43
|
+
"splitAtom",
|
|
44
|
+
"focusAtom"
|
|
45
|
+
]);
|
|
46
|
+
var isWriteOnly = (call) => {
|
|
47
|
+
const [read, write] = call.arguments;
|
|
48
|
+
return call.callee.type === "Identifier" && call.callee.name === "atom" && read?.type === "Literal" && read.value === null && write !== void 0;
|
|
49
|
+
};
|
|
50
|
+
var asFunction = (node) => {
|
|
51
|
+
return node?.type === "ArrowFunctionExpression" || node?.type === "FunctionExpression" ? node : null;
|
|
52
|
+
};
|
|
53
|
+
var isNamedArgsType = (param, argsSuffix) => {
|
|
54
|
+
const target = param.type === "AssignmentPattern" ? param.left : param;
|
|
55
|
+
if (target.type === "RestElement") {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
const annotation = target.typeAnnotation?.typeAnnotation;
|
|
59
|
+
if (annotation === void 0) {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
return annotation.type === "TSTypeReference" && annotation.typeName?.type === "Identifier" && annotation.typeName.name.endsWith(argsSuffix);
|
|
63
|
+
};
|
|
64
|
+
var exampleArgsType = (name, suffixes, argsSuffix) => {
|
|
65
|
+
const suffix = suffixes.find((candidate) => {
|
|
66
|
+
return name.endsWith(candidate) && name.length > candidate.length;
|
|
67
|
+
});
|
|
68
|
+
const base = (suffix === void 0 ? name : name.slice(0, -suffix.length)).replace(/^\$+/, "");
|
|
69
|
+
return `${base.charAt(0).toUpperCase()}${base.slice(1)}${argsSuffix}`;
|
|
70
|
+
};
|
|
71
|
+
var atomNaming = {
|
|
72
|
+
meta: {
|
|
73
|
+
type: "suggestion",
|
|
74
|
+
docs: {
|
|
75
|
+
description: "Require readable Jotai atoms to start with `$`, write-only atoms to end with `Fx` (async) or `Atom` (sync), and write-only atoms to take a single `*Args` object.",
|
|
76
|
+
recommended: true,
|
|
77
|
+
url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#atom-naming"
|
|
78
|
+
},
|
|
79
|
+
schema: [
|
|
80
|
+
{
|
|
81
|
+
type: "object",
|
|
82
|
+
properties: {
|
|
83
|
+
asyncSuffix: {
|
|
84
|
+
type: "string",
|
|
85
|
+
minLength: 1,
|
|
86
|
+
description: "Suffix of an async write-only atom (default 'Fx')."
|
|
87
|
+
},
|
|
88
|
+
syncSuffix: {
|
|
89
|
+
type: "string",
|
|
90
|
+
minLength: 1,
|
|
91
|
+
description: "Suffix of a sync write-only atom (default 'Atom')."
|
|
92
|
+
},
|
|
93
|
+
argsSuffix: {
|
|
94
|
+
type: "string",
|
|
95
|
+
minLength: 1,
|
|
96
|
+
description: "Suffix of the type naming a write-only atom's argument (default 'Args')."
|
|
97
|
+
},
|
|
98
|
+
readablePrefix: {
|
|
99
|
+
type: "string",
|
|
100
|
+
minLength: 1,
|
|
101
|
+
description: "Prefix of a readable atom (default '$')."
|
|
102
|
+
},
|
|
103
|
+
paths: {
|
|
104
|
+
type: "array",
|
|
105
|
+
items: { type: "string" },
|
|
106
|
+
description: "Optional glob patterns. When provided, the rule only runs for files whose path matches one of them."
|
|
107
|
+
},
|
|
108
|
+
ignore: {
|
|
109
|
+
type: "array",
|
|
110
|
+
items: { type: "string" },
|
|
111
|
+
description: "Optional glob patterns. The rule is skipped for files whose path matches one of them, even if it also matches `paths`."
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
additionalProperties: false
|
|
115
|
+
}
|
|
116
|
+
],
|
|
117
|
+
messages: {
|
|
118
|
+
readablePrefix: 'Readable atom "{{name}}" must start with "{{prefix}}" (e.g. "{{expected}}").',
|
|
119
|
+
writeOnlyPrefix: 'Write-only atom "{{name}}" must not start with "{{prefix}}": that prefix marks readable atoms.',
|
|
120
|
+
writeOnlySuffix: 'Write-only atom "{{name}}" must end with {{expected}}.',
|
|
121
|
+
argsNotNamed: 'Write-only atom "{{name}}" must take a single argument typed with a named `*{{suffix}}` type (e.g. `args: {{example}}`).'
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
create(context) {
|
|
125
|
+
const options = context.options[0] ?? {};
|
|
126
|
+
const paths = options.paths ?? [];
|
|
127
|
+
const ignore = options.ignore ?? [];
|
|
128
|
+
if (ignore.length > 0 && matchesAnyGlob(context.filename, ignore)) {
|
|
129
|
+
return {};
|
|
130
|
+
}
|
|
131
|
+
if (paths.length > 0 && !matchesAnyGlob(context.filename, paths)) {
|
|
132
|
+
return {};
|
|
133
|
+
}
|
|
134
|
+
const asyncSuffix = options.asyncSuffix ?? "Fx";
|
|
135
|
+
const syncSuffix = options.syncSuffix ?? "Atom";
|
|
136
|
+
const argsSuffix = options.argsSuffix ?? "Args";
|
|
137
|
+
const prefix = options.readablePrefix ?? "$";
|
|
138
|
+
const checkWriteOnly = (id, call) => {
|
|
139
|
+
const { name } = id;
|
|
140
|
+
const write = asFunction(call.arguments[1]);
|
|
141
|
+
if (name.startsWith(prefix)) {
|
|
142
|
+
context.report({ node: id, messageId: "writeOnlyPrefix", data: { name, prefix } });
|
|
143
|
+
}
|
|
144
|
+
const asyncForm = { suffix: asyncSuffix, kind: "async" };
|
|
145
|
+
const syncForm = { suffix: syncSuffix, kind: "sync" };
|
|
146
|
+
let accepted = [asyncForm, syncForm];
|
|
147
|
+
if (write !== null) {
|
|
148
|
+
accepted = [write.async ? asyncForm : syncForm];
|
|
149
|
+
}
|
|
150
|
+
const hasSuffix = accepted.some((form) => {
|
|
151
|
+
return name.endsWith(form.suffix);
|
|
152
|
+
});
|
|
153
|
+
if (!hasSuffix) {
|
|
154
|
+
const expected = accepted.map((form) => {
|
|
155
|
+
return `"${form.suffix}" (${form.kind})`;
|
|
156
|
+
}).join(" or ");
|
|
157
|
+
context.report({ node: id, messageId: "writeOnlySuffix", data: { name, expected } });
|
|
158
|
+
}
|
|
159
|
+
const inputs = write?.params.slice(2) ?? [];
|
|
160
|
+
if (inputs.length === 0) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (inputs.length > 1 || !isNamedArgsType(inputs[0], argsSuffix)) {
|
|
164
|
+
context.report({
|
|
165
|
+
node: inputs[0],
|
|
166
|
+
messageId: "argsNotNamed",
|
|
167
|
+
data: { name, suffix: argsSuffix, example: exampleArgsType(name, [asyncSuffix, syncSuffix], argsSuffix) }
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
const check = (node) => {
|
|
172
|
+
const { id, init } = node;
|
|
173
|
+
if (id.type !== "Identifier" || init?.type !== "CallExpression" || init.callee.type !== "Identifier" || !ATOM_FACTORIES.has(init.callee.name)) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (isWriteOnly(init)) {
|
|
177
|
+
checkWriteOnly(id, init);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (!id.name.startsWith(prefix)) {
|
|
181
|
+
context.report({
|
|
182
|
+
node: id,
|
|
183
|
+
messageId: "readablePrefix",
|
|
184
|
+
data: { name: id.name, prefix, expected: `${prefix}${id.name}` }
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
return {
|
|
189
|
+
"Program > VariableDeclaration > VariableDeclarator": check,
|
|
190
|
+
"Program > ExportNamedDeclaration > VariableDeclaration > VariableDeclarator": check
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
1
195
|
// src/utils/component.ts
|
|
2
196
|
var COMPONENT_WRAPPER_CALLEES = /* @__PURE__ */ new Set(["memo", "forwardRef", "observer", "React.memo", "React.forwardRef"]);
|
|
3
197
|
var NESTED_SCOPES = /* @__PURE__ */ new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
|
|
@@ -203,39 +397,6 @@ var bodyDeclaresComponent = (body) => {
|
|
|
203
397
|
});
|
|
204
398
|
};
|
|
205
399
|
|
|
206
|
-
// src/utils/path-match.ts
|
|
207
|
-
var REGEX_METACHARS = /* @__PURE__ */ new Set(["\\", "^", "$", ".", "|", "+", "(", ")", "[", "]", "{", "}"]);
|
|
208
|
-
var globToRegExp = (glob) => {
|
|
209
|
-
let source = "";
|
|
210
|
-
for (let index = 0; index < glob.length; index++) {
|
|
211
|
-
const char = glob[index];
|
|
212
|
-
if (char === "*") {
|
|
213
|
-
if (glob[index + 1] === "*") {
|
|
214
|
-
source += ".*";
|
|
215
|
-
index++;
|
|
216
|
-
if (glob[index + 1] === "/") {
|
|
217
|
-
index++;
|
|
218
|
-
}
|
|
219
|
-
} else {
|
|
220
|
-
source += "[^/]*";
|
|
221
|
-
}
|
|
222
|
-
} else if (char === "?") {
|
|
223
|
-
source += "[^/]";
|
|
224
|
-
} else if (REGEX_METACHARS.has(char)) {
|
|
225
|
-
source += `\\${char}`;
|
|
226
|
-
} else {
|
|
227
|
-
source += char;
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
return new RegExp(source);
|
|
231
|
-
};
|
|
232
|
-
var matchesAnyGlob = (filename, patterns) => {
|
|
233
|
-
const normalized = filename.split("\\").join("/");
|
|
234
|
-
return patterns.some((pattern) => {
|
|
235
|
-
return globToRegExp(pattern).test(normalized);
|
|
236
|
-
});
|
|
237
|
-
};
|
|
238
|
-
|
|
239
400
|
// src/rules/component-arrow-function/component-arrow-function.ts
|
|
240
401
|
var ANONYMOUS_NAME = "component";
|
|
241
402
|
var reportName = (fn) => {
|
|
@@ -327,69 +488,551 @@ var componentArrowFunction = {
|
|
|
327
488
|
}
|
|
328
489
|
};
|
|
329
490
|
|
|
330
|
-
// src/
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
491
|
+
// src/utils/package-root.ts
|
|
492
|
+
import fs from "node:fs";
|
|
493
|
+
import path from "node:path";
|
|
494
|
+
var CONFIG_FILENAME = "infra-kit.config.ts";
|
|
495
|
+
var PACKAGE_MARKERS = [CONFIG_FILENAME, "package.json"];
|
|
496
|
+
var REPO_MARKERS = ["pnpm-workspace.yaml"];
|
|
497
|
+
var hasAnyMarker = (dir, markers) => {
|
|
498
|
+
return markers.some((marker) => {
|
|
499
|
+
return fs.existsSync(path.posix.join(dir, marker));
|
|
500
|
+
});
|
|
501
|
+
};
|
|
502
|
+
var rootCache = /* @__PURE__ */ new Map();
|
|
503
|
+
var findUp = (startDir, markers) => {
|
|
504
|
+
let current = startDir;
|
|
505
|
+
while (!hasAnyMarker(current, markers)) {
|
|
506
|
+
const parent = path.posix.dirname(current);
|
|
507
|
+
if (parent === current) {
|
|
508
|
+
return null;
|
|
338
509
|
}
|
|
339
|
-
|
|
510
|
+
current = parent;
|
|
340
511
|
}
|
|
341
|
-
return
|
|
512
|
+
return current;
|
|
342
513
|
};
|
|
343
|
-
var
|
|
344
|
-
if (!
|
|
514
|
+
var findPackageRoot = (filename) => {
|
|
515
|
+
if (!path.posix.isAbsolute(filename)) {
|
|
345
516
|
return null;
|
|
346
517
|
}
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
518
|
+
const dir = path.posix.dirname(filename);
|
|
519
|
+
const cached = rootCache.get(dir);
|
|
520
|
+
if (cached !== void 0) {
|
|
521
|
+
return cached;
|
|
350
522
|
}
|
|
351
|
-
|
|
523
|
+
const root = findUp(dir, PACKAGE_MARKERS);
|
|
524
|
+
rootCache.set(dir, root);
|
|
525
|
+
return root;
|
|
352
526
|
};
|
|
353
|
-
var
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
const importedNames = /* @__PURE__ */ new Set();
|
|
358
|
-
for (const [index, statement] of body.entries()) {
|
|
359
|
-
if (statement.type === "ImportDeclaration") {
|
|
360
|
-
importIndices.push(index);
|
|
361
|
-
for (const specifier of statement.specifiers) {
|
|
362
|
-
importedNames.add(specifier.local.name);
|
|
363
|
-
}
|
|
364
|
-
continue;
|
|
365
|
-
}
|
|
366
|
-
const declaration = unwrapExport(statement);
|
|
367
|
-
const declName = getTypeDeclName(declaration);
|
|
368
|
-
if (declName !== null && !declIndexByName.has(declName)) {
|
|
369
|
-
declIndexByName.set(declName, index);
|
|
370
|
-
}
|
|
371
|
-
if (declaresComponent(declaration)) {
|
|
372
|
-
const name = getDeclaredComponentName(declaration);
|
|
373
|
-
const propsName = getComponentPropsTypeName(declaration) ?? (name === null ? null : `${name}${PROPS_SUFFIX}`);
|
|
374
|
-
components.push({ index, name, propsName });
|
|
375
|
-
}
|
|
527
|
+
var dirsUnderSrc = (packageRoot, filename) => {
|
|
528
|
+
const srcPrefix = `${packageRoot}/src/`;
|
|
529
|
+
if (!filename.startsWith(srcPrefix)) {
|
|
530
|
+
return null;
|
|
376
531
|
}
|
|
377
|
-
return
|
|
532
|
+
return filename.slice(srcPrefix.length).split("/").slice(0, -1);
|
|
378
533
|
};
|
|
379
|
-
var
|
|
380
|
-
return
|
|
381
|
-
return index < boundary && index >= directiveCount && index !== skipIndex && statement.type !== "ImportDeclaration";
|
|
382
|
-
});
|
|
534
|
+
var findRepoRoot = (packageRoot) => {
|
|
535
|
+
return findUp(packageRoot, REPO_MARKERS);
|
|
383
536
|
};
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
537
|
+
|
|
538
|
+
// ../config/dist/package-type.js
|
|
539
|
+
import path2 from "node:path";
|
|
540
|
+
var PACKAGE_TYPES = ["frontend", "backend", "lib", "e2e", "mobile"];
|
|
541
|
+
var APP_DIRECTORY_TYPES = {
|
|
542
|
+
ui: "frontend",
|
|
543
|
+
api: "backend",
|
|
544
|
+
tests: "e2e"
|
|
545
|
+
};
|
|
546
|
+
var MOBILE_BASENAME = "mobile-app";
|
|
547
|
+
var DEPENDENCY_SIGNALS = [
|
|
548
|
+
{ type: "e2e", packages: ["@playwright/test"] },
|
|
549
|
+
{ type: "mobile", packages: ["@capacitor/core", "@capacitor/cli"] },
|
|
550
|
+
// `aws-lambda` / `@types/aws-lambda` are deliberately NOT signals: every consumer backend already
|
|
551
|
+
// lives at `apps/<app>/api` (row 2), while the only packages those two deps would catch are
|
|
552
|
+
// libraries (`packages/lib-core` in both consumers, the infra-kit CLI itself) — a lambda types
|
|
553
|
+
// dependency alone never means backend, exactly as `react` alone never means frontend.
|
|
554
|
+
{ type: "backend", packages: ["serverless"] }
|
|
555
|
+
];
|
|
556
|
+
var fromDirectory = (packageDir, repoRoot) => {
|
|
557
|
+
const segments = path2.relative(repoRoot, packageDir).split(path2.sep).filter((segment) => {
|
|
558
|
+
return segment !== "" && segment !== ".";
|
|
390
559
|
});
|
|
560
|
+
const [first, , third] = segments;
|
|
561
|
+
if (segments.length === 3 && first === "apps" && third !== void 0) {
|
|
562
|
+
const appType = APP_DIRECTORY_TYPES[third];
|
|
563
|
+
if (appType) return appType;
|
|
564
|
+
}
|
|
565
|
+
return segments.at(-1) === MOBILE_BASENAME ? "mobile" : null;
|
|
391
566
|
};
|
|
392
|
-
var
|
|
567
|
+
var fromDependencies = (pkgJson) => {
|
|
568
|
+
const deps = /* @__PURE__ */ new Set([
|
|
569
|
+
...Object.keys(pkgJson.dependencies ?? {}),
|
|
570
|
+
...Object.keys(pkgJson.devDependencies ?? {}),
|
|
571
|
+
...Object.keys(pkgJson.peerDependencies ?? {})
|
|
572
|
+
]);
|
|
573
|
+
for (const signal of DEPENDENCY_SIGNALS) {
|
|
574
|
+
if (signal.packages.some((name) => {
|
|
575
|
+
return deps.has(name);
|
|
576
|
+
}))
|
|
577
|
+
return signal.type;
|
|
578
|
+
}
|
|
579
|
+
const isViteReactApp = deps.has("vite") && deps.has("react") && Boolean(pkgJson.scripts?.dev);
|
|
580
|
+
return isViteReactApp ? "frontend" : null;
|
|
581
|
+
};
|
|
582
|
+
var detectPackageType = ({
|
|
583
|
+
packageDir,
|
|
584
|
+
repoRoot,
|
|
585
|
+
pkgJson,
|
|
586
|
+
declaredType
|
|
587
|
+
}) => {
|
|
588
|
+
return declaredType ?? fromDirectory(packageDir, repoRoot) ?? fromDependencies(pkgJson) ?? "lib";
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
// src/utils/package-type-reader.ts
|
|
592
|
+
import fs2 from "node:fs";
|
|
593
|
+
import path3 from "node:path";
|
|
594
|
+
var BLOCK_COMMENT = /\/\*[\s\S]*?\*\//g;
|
|
595
|
+
var FULL_LINE_COMMENT = /^[ \t]*\/\/.*$/gm;
|
|
596
|
+
var DECLARED_TYPE = new RegExp(`(?:^|[\\s{,])type:\\s*['"](${PACKAGE_TYPES.join("|")})['"]`, "m");
|
|
597
|
+
var readDeclaredType = (text) => {
|
|
598
|
+
const stripped = text.replace(BLOCK_COMMENT, "").replace(FULL_LINE_COMMENT, "");
|
|
599
|
+
const literal = DECLARED_TYPE.exec(stripped)?.[1];
|
|
600
|
+
return PACKAGE_TYPES.find((candidate) => {
|
|
601
|
+
return candidate === literal;
|
|
602
|
+
});
|
|
603
|
+
};
|
|
604
|
+
var typeCache = /* @__PURE__ */ new Map();
|
|
605
|
+
var configMtimeMs = (configPath) => {
|
|
606
|
+
try {
|
|
607
|
+
return fs2.statSync(configPath).mtimeMs;
|
|
608
|
+
} catch {
|
|
609
|
+
return -1;
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
var readManifest = (manifestPath) => {
|
|
613
|
+
try {
|
|
614
|
+
return JSON.parse(fs2.readFileSync(manifestPath, "utf8"));
|
|
615
|
+
} catch {
|
|
616
|
+
return {};
|
|
617
|
+
}
|
|
618
|
+
};
|
|
619
|
+
var resolvePackageType = (packageRoot) => {
|
|
620
|
+
const configPath = path3.posix.join(packageRoot, CONFIG_FILENAME);
|
|
621
|
+
const mtimeMs = configMtimeMs(configPath);
|
|
622
|
+
const hit = typeCache.get(packageRoot);
|
|
623
|
+
if (hit !== void 0 && hit.mtimeMs === mtimeMs) {
|
|
624
|
+
return hit.type;
|
|
625
|
+
}
|
|
626
|
+
const declaredType = mtimeMs >= 0 ? readDeclaredType(fs2.readFileSync(configPath, "utf8")) : void 0;
|
|
627
|
+
const type = detectPackageType({
|
|
628
|
+
packageDir: packageRoot,
|
|
629
|
+
repoRoot: findRepoRoot(packageRoot) ?? packageRoot,
|
|
630
|
+
pkgJson: readManifest(path3.posix.join(packageRoot, "package.json")),
|
|
631
|
+
declaredType
|
|
632
|
+
});
|
|
633
|
+
typeCache.set(packageRoot, { mtimeMs, type });
|
|
634
|
+
return type;
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
// src/utils/component-file-scope.ts
|
|
638
|
+
var DUMB_COMPONENT_PATHS = ["**/features/*/components/**/*.tsx", "**/*-component.tsx"];
|
|
639
|
+
var NON_UI_TYPES = /* @__PURE__ */ new Set(["backend", "e2e"]);
|
|
640
|
+
var STORY_OR_TEST_DIR = /(?:^|\/)(?:__tests__|__stories__)\//;
|
|
641
|
+
var STORY_OR_TEST_EXT = /\.(?:stories|test|spec)\.[cm]?[jt]sx?$/;
|
|
642
|
+
var isStoryOrTestFile = (filename) => {
|
|
643
|
+
return STORY_OR_TEST_DIR.test(filename) || STORY_OR_TEST_EXT.test(filename);
|
|
644
|
+
};
|
|
645
|
+
var isUiPackageFile = (filename) => {
|
|
646
|
+
const packageRoot = findPackageRoot(filename);
|
|
647
|
+
return packageRoot === null || !NON_UI_TYPES.has(resolvePackageType(packageRoot));
|
|
648
|
+
};
|
|
649
|
+
var isInScope = (filename, paths, ignore) => {
|
|
650
|
+
if (ignore.length > 0 && matchesAnyGlob(filename, ignore)) {
|
|
651
|
+
return false;
|
|
652
|
+
}
|
|
653
|
+
return paths.length === 0 || matchesAnyGlob(filename, paths);
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
// src/utils/exported-components.ts
|
|
657
|
+
var fromFunctionDeclaration = (node) => {
|
|
658
|
+
if (!isComponent(node)) {
|
|
659
|
+
return null;
|
|
660
|
+
}
|
|
661
|
+
return { name: node.id?.name ?? null, id: node.id ?? null, declaration: node.id ? node : null, fn: node };
|
|
662
|
+
};
|
|
663
|
+
var fromVariableDeclaration = (node) => {
|
|
664
|
+
return node.declarations.flatMap((declarator) => {
|
|
665
|
+
const fn = getComponentFunction(declarator.init);
|
|
666
|
+
if (!fn || declarator.id.type !== "Identifier" || !isComponent(fn)) {
|
|
667
|
+
return [];
|
|
668
|
+
}
|
|
669
|
+
return [{ name: declarator.id.name, id: declarator.id, declaration: declarator, fn }];
|
|
670
|
+
});
|
|
671
|
+
};
|
|
672
|
+
var fromDeclaration = (node) => {
|
|
673
|
+
if (node?.type === "FunctionDeclaration") {
|
|
674
|
+
const component = fromFunctionDeclaration(node);
|
|
675
|
+
return component ? [component] : [];
|
|
676
|
+
}
|
|
677
|
+
if (node?.type === "VariableDeclaration") {
|
|
678
|
+
return fromVariableDeclaration(node);
|
|
679
|
+
}
|
|
680
|
+
return [];
|
|
681
|
+
};
|
|
682
|
+
var collectLocalComponents = (body) => {
|
|
683
|
+
const local = /* @__PURE__ */ new Map();
|
|
684
|
+
for (const statement of body) {
|
|
685
|
+
for (const component of fromDeclaration(statement)) {
|
|
686
|
+
if (component.name !== null) {
|
|
687
|
+
local.set(component.name, component);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
return local;
|
|
692
|
+
};
|
|
693
|
+
var fromDefaultExport = (declaration, local) => {
|
|
694
|
+
if (declaration.type === "Identifier") {
|
|
695
|
+
const component = local.get(declaration.name);
|
|
696
|
+
return component ? [component] : [];
|
|
697
|
+
}
|
|
698
|
+
const node = declaration;
|
|
699
|
+
if (node.type === "FunctionDeclaration" && node.id) {
|
|
700
|
+
return fromDeclaration(node);
|
|
701
|
+
}
|
|
702
|
+
const fn = getComponentFunction(node);
|
|
703
|
+
return fn && isComponent(fn) ? [{ name: null, id: null, declaration: null, fn }] : [];
|
|
704
|
+
};
|
|
705
|
+
var fromStatement = (statement, local) => {
|
|
706
|
+
if (statement.type === "ExportDefaultDeclaration") {
|
|
707
|
+
return fromDefaultExport(statement.declaration, local);
|
|
708
|
+
}
|
|
709
|
+
if (statement.type !== "ExportNamedDeclaration") {
|
|
710
|
+
return [];
|
|
711
|
+
}
|
|
712
|
+
if (statement.declaration) {
|
|
713
|
+
return fromDeclaration(statement.declaration);
|
|
714
|
+
}
|
|
715
|
+
if (statement.source) {
|
|
716
|
+
return [];
|
|
717
|
+
}
|
|
718
|
+
return statement.specifiers.flatMap((specifier) => {
|
|
719
|
+
const component = specifier.local.type === "Identifier" ? local.get(specifier.local.name) : void 0;
|
|
720
|
+
return component ? [component] : [];
|
|
721
|
+
});
|
|
722
|
+
};
|
|
723
|
+
var collectExportedComponents = (program) => {
|
|
724
|
+
const local = collectLocalComponents(program.body);
|
|
725
|
+
const found = program.body.flatMap((statement) => {
|
|
726
|
+
return fromStatement(statement, local);
|
|
727
|
+
});
|
|
728
|
+
return [...new Set(found)];
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
// src/rules/component-classname/component-classname.ts
|
|
732
|
+
var MAX_ALIAS_DEPTH = 5;
|
|
733
|
+
var isClassNameMember = (member) => {
|
|
734
|
+
if (member.type !== "TSPropertySignature" || member.computed) {
|
|
735
|
+
return false;
|
|
736
|
+
}
|
|
737
|
+
return member.key?.name === "className" || member.key?.value === "className";
|
|
738
|
+
};
|
|
739
|
+
var findTypeDeclaration = (program, name) => {
|
|
740
|
+
for (const statement of program.body) {
|
|
741
|
+
const node = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement;
|
|
742
|
+
if ((node?.type === "TSInterfaceDeclaration" || node?.type === "TSTypeAliasDeclaration") && node.id?.name === name) {
|
|
743
|
+
return node;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return null;
|
|
747
|
+
};
|
|
748
|
+
var fromMembers = (members, body) => {
|
|
749
|
+
return { hasClassName: members.some(isClassNameMember), container: { members, body } };
|
|
750
|
+
};
|
|
751
|
+
var fromIntersection = (shapes) => {
|
|
752
|
+
if (shapes.some((shape) => {
|
|
753
|
+
return shape?.hasClassName;
|
|
754
|
+
})) {
|
|
755
|
+
return { hasClassName: true, container: null };
|
|
756
|
+
}
|
|
757
|
+
if (shapes.includes(null)) {
|
|
758
|
+
return null;
|
|
759
|
+
}
|
|
760
|
+
return {
|
|
761
|
+
hasClassName: false,
|
|
762
|
+
container: shapes.findLast((shape) => {
|
|
763
|
+
return shape?.container;
|
|
764
|
+
})?.container ?? null
|
|
765
|
+
};
|
|
766
|
+
};
|
|
767
|
+
var resolveShape = (program, type, depth) => {
|
|
768
|
+
if (!type || depth > MAX_ALIAS_DEPTH) {
|
|
769
|
+
return null;
|
|
770
|
+
}
|
|
771
|
+
if (type.type === "TSTypeLiteral") {
|
|
772
|
+
return fromMembers(type.members ?? [], type);
|
|
773
|
+
}
|
|
774
|
+
if (type.type === "TSIntersectionType") {
|
|
775
|
+
return fromIntersection(
|
|
776
|
+
(type.types ?? []).map((part) => {
|
|
777
|
+
return resolveShape(program, part, depth + 1);
|
|
778
|
+
})
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
if (type.type !== "TSTypeReference" || type.typeName?.type !== "Identifier" || type.typeArguments) {
|
|
782
|
+
return null;
|
|
783
|
+
}
|
|
784
|
+
const declaration = findTypeDeclaration(program, type.typeName.name);
|
|
785
|
+
if (declaration?.type === "TSTypeAliasDeclaration") {
|
|
786
|
+
return resolveShape(program, declaration.typeAnnotation, depth + 1);
|
|
787
|
+
}
|
|
788
|
+
if (!declaration?.body) {
|
|
789
|
+
return null;
|
|
790
|
+
}
|
|
791
|
+
const shape = fromMembers(declaration.body.body, declaration.body);
|
|
792
|
+
return shape?.hasClassName || !declaration.extends?.length ? shape : null;
|
|
793
|
+
};
|
|
794
|
+
var bindingFromObjectPattern = (pattern) => {
|
|
795
|
+
const destructured = pattern.properties.find((property) => {
|
|
796
|
+
return property.type === "Property" && !property.computed && property.key.type === "Identifier" && property.key.name === "className";
|
|
797
|
+
});
|
|
798
|
+
if (destructured) {
|
|
799
|
+
const value = destructured.value.type === "AssignmentPattern" ? destructured.value.left : destructured.value;
|
|
800
|
+
return value.type === "Identifier" ? { kind: "identifier", name: value.name } : null;
|
|
801
|
+
}
|
|
802
|
+
const rest = pattern.properties.find((property) => {
|
|
803
|
+
return property.type === "RestElement";
|
|
804
|
+
});
|
|
805
|
+
return rest?.argument.type === "Identifier" ? { kind: "member", object: rest.argument.name } : null;
|
|
806
|
+
};
|
|
807
|
+
var bindingsFromBody = (fn, propsName) => {
|
|
808
|
+
const statements = fn.body.type === "BlockStatement" ? fn.body.body : [];
|
|
809
|
+
return statements.flatMap((statement) => {
|
|
810
|
+
if (statement.type !== "VariableDeclaration") {
|
|
811
|
+
return [];
|
|
812
|
+
}
|
|
813
|
+
return statement.declarations.flatMap((declarator) => {
|
|
814
|
+
const isPropsDestructure = declarator.id.type === "ObjectPattern" && declarator.init?.type === "Identifier" && declarator.init.name === propsName;
|
|
815
|
+
const binding = isPropsDestructure ? bindingFromObjectPattern(declarator.id) : null;
|
|
816
|
+
return binding ? [binding] : [];
|
|
817
|
+
});
|
|
818
|
+
});
|
|
819
|
+
};
|
|
820
|
+
var getClassNameBindings = (fn) => {
|
|
821
|
+
const param = fn.params[0];
|
|
822
|
+
const pattern = param ? getAnnotatedParam(param) : null;
|
|
823
|
+
if (pattern?.type === "Identifier") {
|
|
824
|
+
return [{ kind: "member", object: pattern.name }, ...bindingsFromBody(fn, pattern.name)];
|
|
825
|
+
}
|
|
826
|
+
const binding = pattern?.type === "ObjectPattern" ? bindingFromObjectPattern(pattern) : null;
|
|
827
|
+
return binding ? [binding] : [];
|
|
828
|
+
};
|
|
829
|
+
var matchesBinding = (node, binding) => {
|
|
830
|
+
if (binding.kind === "identifier") {
|
|
831
|
+
return node.type === "Identifier" && node.name === binding.name;
|
|
832
|
+
}
|
|
833
|
+
return node.type === "MemberExpression" && !node.computed && node.object.type === "Identifier" && node.object.name === binding.object && node.property.type === "Identifier" && node.property.name === "className";
|
|
834
|
+
};
|
|
835
|
+
var referencesBinding = (node, binding, visitorKeys) => {
|
|
836
|
+
if (matchesBinding(node, binding)) {
|
|
837
|
+
return true;
|
|
838
|
+
}
|
|
839
|
+
const label = node.type === "MemberExpression" ? "property" : "key";
|
|
840
|
+
const keys = (visitorKeys[node.type] ?? []).filter((key) => {
|
|
841
|
+
return !(key === label && "computed" in node && !node.computed);
|
|
842
|
+
});
|
|
843
|
+
return keys.some((key) => {
|
|
844
|
+
const child = node[key];
|
|
845
|
+
const children = Array.isArray(child) ? child : [child];
|
|
846
|
+
return children.some((item) => {
|
|
847
|
+
return item !== null && typeof item?.type === "string" && referencesBinding(item, binding, visitorKeys);
|
|
848
|
+
});
|
|
849
|
+
});
|
|
850
|
+
};
|
|
851
|
+
var isFragment = (element) => {
|
|
852
|
+
const name = element.openingElement?.name;
|
|
853
|
+
if (name?.type === "JSXIdentifier") {
|
|
854
|
+
return name.name === "Fragment";
|
|
855
|
+
}
|
|
856
|
+
return name?.type === "JSXMemberExpression" && name.property?.name === "Fragment";
|
|
857
|
+
};
|
|
858
|
+
var getRootElement = (fn) => {
|
|
859
|
+
const returns = collectOwnReturnArguments(fn);
|
|
860
|
+
const root = returns.length === 1 ? returns[0] : null;
|
|
861
|
+
return root?.type === "JSXElement" && !isFragment(root) ? root : null;
|
|
862
|
+
};
|
|
863
|
+
var buildMemberFix = (sourceCode, container) => {
|
|
864
|
+
return (fixer) => {
|
|
865
|
+
const last = container.members.at(-1);
|
|
866
|
+
if (!last) {
|
|
867
|
+
return fixer.replaceTextRange(container.body.range, "{ className?: string }");
|
|
868
|
+
}
|
|
869
|
+
const separator = /[;,]$/.exec(sourceCode.text.slice(...last.range))?.[0] ?? "";
|
|
870
|
+
if (container.body.loc.start.line === container.body.loc.end.line) {
|
|
871
|
+
return fixer.insertTextAfterRange(
|
|
872
|
+
last.range,
|
|
873
|
+
separator ? ` className?: string${separator}` : "; className?: string"
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
const indent = /^\s*/.exec(sourceCode.lines[last.loc.start.line - 1])[0];
|
|
877
|
+
return fixer.insertTextAfterRange(last.range, `
|
|
878
|
+
${indent}className?: string${separator}`);
|
|
879
|
+
};
|
|
880
|
+
};
|
|
881
|
+
var componentClassname = {
|
|
882
|
+
meta: {
|
|
883
|
+
type: "suggestion",
|
|
884
|
+
fixable: "code",
|
|
885
|
+
docs: {
|
|
886
|
+
description: "Require a dumb component to declare a `className` prop and merge it into its root element (`className={cn(..., className)}`).",
|
|
887
|
+
recommended: true,
|
|
888
|
+
url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#component-classname"
|
|
889
|
+
},
|
|
890
|
+
schema: [
|
|
891
|
+
{
|
|
892
|
+
type: "object",
|
|
893
|
+
properties: {
|
|
894
|
+
paths: {
|
|
895
|
+
type: "array",
|
|
896
|
+
items: { type: "string" },
|
|
897
|
+
description: `Glob patterns of the files to check. Replaces the default (${DUMB_COMPONENT_PATHS.join(", ")}).`
|
|
898
|
+
},
|
|
899
|
+
ignore: {
|
|
900
|
+
type: "array",
|
|
901
|
+
items: { type: "string" },
|
|
902
|
+
description: "Glob patterns of files to skip (e.g. svg sprites, icons), even if they also match `paths`. Stories and tests are always skipped."
|
|
903
|
+
}
|
|
904
|
+
},
|
|
905
|
+
additionalProperties: false
|
|
906
|
+
}
|
|
907
|
+
],
|
|
908
|
+
messages: {
|
|
909
|
+
missingClassNameProp: "`{{name}}` must accept a `className` prop: declare `className?: string` on its props type so callers can position it.",
|
|
910
|
+
rootMissingClassName: "The root element of `{{name}}` must merge the `className` prop, e.g. `className={cn('\u2026', className)}`."
|
|
911
|
+
}
|
|
912
|
+
},
|
|
913
|
+
create(context) {
|
|
914
|
+
const options = context.options[0] ?? {};
|
|
915
|
+
const filename = context.filename.split("\\").join("/");
|
|
916
|
+
if (isStoryOrTestFile(filename) || !isInScope(filename, options.paths ?? DUMB_COMPONENT_PATHS, options.ignore ?? []) || !isUiPackageFile(filename)) {
|
|
917
|
+
return {};
|
|
918
|
+
}
|
|
919
|
+
const { sourceCode } = context;
|
|
920
|
+
const checkProps = (program, component, name) => {
|
|
921
|
+
const param = component.fn.params[0];
|
|
922
|
+
if (!param) {
|
|
923
|
+
context.report({ node: component.id ?? component.fn, messageId: "missingClassNameProp", data: { name } });
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
const annotation = getAnnotatedParam(param).typeAnnotation?.typeAnnotation;
|
|
927
|
+
const shape = annotation ? resolveShape(program, annotation, 0) : null;
|
|
928
|
+
if (shape && !shape.hasClassName) {
|
|
929
|
+
context.report({
|
|
930
|
+
node: param,
|
|
931
|
+
messageId: "missingClassNameProp",
|
|
932
|
+
data: { name },
|
|
933
|
+
fix: shape.container ? buildMemberFix(sourceCode, shape.container) : null
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
};
|
|
937
|
+
const checkRoot = (component, name) => {
|
|
938
|
+
const root = getRootElement(component.fn);
|
|
939
|
+
const attributes = root?.openingElement?.attributes ?? [];
|
|
940
|
+
if (!root || attributes.some((attribute2) => {
|
|
941
|
+
return attribute2.type === "JSXSpreadAttribute";
|
|
942
|
+
})) {
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
const attribute = attributes.find((candidate) => {
|
|
946
|
+
return candidate.type === "JSXAttribute" && candidate.name?.name === "className";
|
|
947
|
+
});
|
|
948
|
+
const bindings = getClassNameBindings(component.fn);
|
|
949
|
+
const expression = attribute?.value?.type === "JSXExpressionContainer" ? attribute.value.expression ?? null : null;
|
|
950
|
+
const merged = expression !== null && bindings.some((binding) => {
|
|
951
|
+
return referencesBinding(expression, binding, sourceCode.visitorKeys);
|
|
952
|
+
});
|
|
953
|
+
if (!merged) {
|
|
954
|
+
context.report({
|
|
955
|
+
node: attribute ?? root.openingElement.name,
|
|
956
|
+
messageId: "rootMissingClassName",
|
|
957
|
+
data: { name }
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
};
|
|
961
|
+
return {
|
|
962
|
+
Program(program) {
|
|
963
|
+
for (const component of collectExportedComponents(program)) {
|
|
964
|
+
const name = component.name ?? "this component";
|
|
965
|
+
checkProps(program, component, name);
|
|
966
|
+
checkRoot(component, name);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
};
|
|
972
|
+
|
|
973
|
+
// src/rules/component-file-order/component-file-order.ts
|
|
974
|
+
var PROPS_SUFFIX = "Props";
|
|
975
|
+
var getDirectivePrologueCount = (body) => {
|
|
976
|
+
let count = 0;
|
|
977
|
+
for (const statement of body) {
|
|
978
|
+
const isStringExpression = statement.type === "ExpressionStatement" && statement.expression.type === "Literal" && typeof statement.expression.value === "string";
|
|
979
|
+
if (!isStringExpression) {
|
|
980
|
+
break;
|
|
981
|
+
}
|
|
982
|
+
count += 1;
|
|
983
|
+
}
|
|
984
|
+
return count;
|
|
985
|
+
};
|
|
986
|
+
var getTypeDeclName = (node) => {
|
|
987
|
+
if (!node) {
|
|
988
|
+
return null;
|
|
989
|
+
}
|
|
990
|
+
const named = node;
|
|
991
|
+
if (named.type !== "TSInterfaceDeclaration" && named.type !== "TSTypeAliasDeclaration") {
|
|
992
|
+
return null;
|
|
993
|
+
}
|
|
994
|
+
return named.id?.name ?? null;
|
|
995
|
+
};
|
|
996
|
+
var collectTopLevel = (body) => {
|
|
997
|
+
const importIndices = [];
|
|
998
|
+
const components = [];
|
|
999
|
+
const declIndexByName = /* @__PURE__ */ new Map();
|
|
1000
|
+
const importedNames = /* @__PURE__ */ new Set();
|
|
1001
|
+
for (const [index, statement] of body.entries()) {
|
|
1002
|
+
if (statement.type === "ImportDeclaration") {
|
|
1003
|
+
importIndices.push(index);
|
|
1004
|
+
for (const specifier of statement.specifiers) {
|
|
1005
|
+
importedNames.add(specifier.local.name);
|
|
1006
|
+
}
|
|
1007
|
+
continue;
|
|
1008
|
+
}
|
|
1009
|
+
const declaration = unwrapExport(statement);
|
|
1010
|
+
const declName = getTypeDeclName(declaration);
|
|
1011
|
+
if (declName !== null && !declIndexByName.has(declName)) {
|
|
1012
|
+
declIndexByName.set(declName, index);
|
|
1013
|
+
}
|
|
1014
|
+
if (declaresComponent(declaration)) {
|
|
1015
|
+
const name = getDeclaredComponentName(declaration);
|
|
1016
|
+
const propsName = getComponentPropsTypeName(declaration) ?? (name === null ? null : `${name}${PROPS_SUFFIX}`);
|
|
1017
|
+
components.push({ index, name, propsName });
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
return { importIndices, components, declIndexByName, importedNames };
|
|
1021
|
+
};
|
|
1022
|
+
var hasStrayBefore = (body, boundary, directiveCount, skipIndex) => {
|
|
1023
|
+
return body.some((statement, index) => {
|
|
1024
|
+
return index < boundary && index >= directiveCount && index !== skipIndex && statement.type !== "ImportDeclaration";
|
|
1025
|
+
});
|
|
1026
|
+
};
|
|
1027
|
+
var ANONYMOUS_COMPONENT = "component";
|
|
1028
|
+
var findImportOrderViolations = (importIndices, importBoundary) => {
|
|
1029
|
+
return importIndices.filter((importIndex) => {
|
|
1030
|
+
return importIndex > importBoundary;
|
|
1031
|
+
}).map((importIndex) => {
|
|
1032
|
+
return { index: importIndex, messageId: "importsFirst" };
|
|
1033
|
+
});
|
|
1034
|
+
};
|
|
1035
|
+
var findAdjacencyViolations = (components, declIndexByName) => {
|
|
393
1036
|
const violations = [];
|
|
394
1037
|
const referenceCount = /* @__PURE__ */ new Map();
|
|
395
1038
|
for (const component of components) {
|
|
@@ -520,167 +1163,264 @@ var componentFileOrder = {
|
|
|
520
1163
|
}
|
|
521
1164
|
};
|
|
522
1165
|
|
|
523
|
-
// src/
|
|
524
|
-
import
|
|
525
|
-
|
|
526
|
-
var
|
|
527
|
-
var
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
1166
|
+
// src/rules/component-name-matches-file/component-name-matches-file.ts
|
|
1167
|
+
import path4 from "node:path";
|
|
1168
|
+
var DEFAULT_PATHS = ["**/features/**", "**/*-component.tsx", "**/*-container.tsx"];
|
|
1169
|
+
var DEFAULT_IGNORE = ["**/pages/**", "**/routes/**", "**/renderer/**", "**/primitives/**"];
|
|
1170
|
+
var SUFFIX_BY_DIR = {
|
|
1171
|
+
components: "-component",
|
|
1172
|
+
containers: "-container"
|
|
1173
|
+
};
|
|
1174
|
+
var toPascalCase = (basename) => {
|
|
1175
|
+
return basename.split(/[-_.]/).filter(Boolean).map((part) => {
|
|
1176
|
+
return part[0].toUpperCase() + part.slice(1);
|
|
1177
|
+
}).join("");
|
|
1178
|
+
};
|
|
1179
|
+
var requiredSuffix = (dirs) => {
|
|
1180
|
+
const featureRoot2 = dirs.indexOf("features") + 2;
|
|
1181
|
+
const index = dirs.findLastIndex((name, position) => {
|
|
1182
|
+
return name in SUFFIX_BY_DIR && featureRoot2 > 1 && position >= featureRoot2;
|
|
532
1183
|
});
|
|
533
|
-
|
|
534
|
-
var rootCache = /* @__PURE__ */ new Map();
|
|
535
|
-
var findUp = (startDir, markers) => {
|
|
536
|
-
let current = startDir;
|
|
537
|
-
while (!hasAnyMarker(current, markers)) {
|
|
538
|
-
const parent = path.posix.dirname(current);
|
|
539
|
-
if (parent === current) {
|
|
540
|
-
return null;
|
|
541
|
-
}
|
|
542
|
-
current = parent;
|
|
543
|
-
}
|
|
544
|
-
return current;
|
|
545
|
-
};
|
|
546
|
-
var findPackageRoot = (filename) => {
|
|
547
|
-
if (!path.posix.isAbsolute(filename)) {
|
|
1184
|
+
if (index === -1) {
|
|
548
1185
|
return null;
|
|
549
1186
|
}
|
|
550
|
-
const dir =
|
|
551
|
-
|
|
552
|
-
if (cached !== void 0) {
|
|
553
|
-
return cached;
|
|
554
|
-
}
|
|
555
|
-
const root = findUp(dir, PACKAGE_MARKERS);
|
|
556
|
-
rootCache.set(dir, root);
|
|
557
|
-
return root;
|
|
1187
|
+
const dir = dirs[index];
|
|
1188
|
+
return { dir, suffix: SUFFIX_BY_DIR[dir] };
|
|
558
1189
|
};
|
|
559
|
-
var
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
}
|
|
564
|
-
return filename.slice(srcPrefix.length).split("/").slice(0, -1);
|
|
1190
|
+
var findVariable = (sourceCode, component) => {
|
|
1191
|
+
return sourceCode.getDeclaredVariables(component.declaration).find((variable) => {
|
|
1192
|
+
return variable.name === component.name;
|
|
1193
|
+
});
|
|
565
1194
|
};
|
|
566
|
-
var
|
|
567
|
-
return
|
|
1195
|
+
var renameFix = (variable, expected) => {
|
|
1196
|
+
return (fixer) => {
|
|
1197
|
+
const nodes = [
|
|
1198
|
+
...variable.identifiers,
|
|
1199
|
+
...variable.references.map((reference) => {
|
|
1200
|
+
return reference.identifier;
|
|
1201
|
+
})
|
|
1202
|
+
];
|
|
1203
|
+
const ranges = new Map(
|
|
1204
|
+
nodes.map((node) => {
|
|
1205
|
+
return [node.range.join(":"), node.range];
|
|
1206
|
+
})
|
|
1207
|
+
);
|
|
1208
|
+
return [...ranges.values()].map((range) => {
|
|
1209
|
+
return fixer.replaceTextRange(range, expected);
|
|
1210
|
+
});
|
|
1211
|
+
};
|
|
568
1212
|
};
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
1213
|
+
var componentNameMatchesFile = {
|
|
1214
|
+
meta: {
|
|
1215
|
+
type: "suggestion",
|
|
1216
|
+
hasSuggestions: true,
|
|
1217
|
+
docs: {
|
|
1218
|
+
description: "Require a `.tsx` file's exported component to be named after the file (`abroad-slider-component.tsx` \u2192 `AbroadSliderComponent`), and files under `components/` / `containers/` to end with `-component` / `-container`.",
|
|
1219
|
+
recommended: true,
|
|
1220
|
+
url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#component-name-matches-file"
|
|
1221
|
+
},
|
|
1222
|
+
schema: [
|
|
1223
|
+
{
|
|
1224
|
+
type: "object",
|
|
1225
|
+
properties: {
|
|
1226
|
+
paths: {
|
|
1227
|
+
type: "array",
|
|
1228
|
+
items: { type: "string" },
|
|
1229
|
+
description: `Glob patterns of files to check. Replaces the default (${DEFAULT_PATHS.join(", ")}).`
|
|
1230
|
+
},
|
|
1231
|
+
ignore: {
|
|
1232
|
+
type: "array",
|
|
1233
|
+
items: { type: "string" },
|
|
1234
|
+
description: `Glob patterns of files to skip, even if they also match \`paths\`. Replaces the default (${DEFAULT_IGNORE.join(", ")}).`
|
|
1235
|
+
}
|
|
1236
|
+
},
|
|
1237
|
+
additionalProperties: false
|
|
1238
|
+
}
|
|
1239
|
+
],
|
|
1240
|
+
messages: {
|
|
1241
|
+
nameMismatch: "The exported component in `{{file}}` must be named `{{expected}}`, but it is named `{{actual}}`.",
|
|
1242
|
+
wrongSuffix: "A file under `{{dir}}/` must be named `*{{suffix}}.tsx`, but it is `{{file}}`.",
|
|
1243
|
+
renameComponent: "Rename `{{actual}}` to `{{expected}}`."
|
|
1244
|
+
}
|
|
1245
|
+
},
|
|
1246
|
+
create(context) {
|
|
1247
|
+
const options = context.options[0] ?? {};
|
|
1248
|
+
const filename = context.filename.split("\\").join("/");
|
|
1249
|
+
const file = path4.posix.basename(filename);
|
|
1250
|
+
if (!file.endsWith(".tsx") || file === "index.tsx" || isStoryOrTestFile(filename) || !isInScope(filename, options.paths ?? DEFAULT_PATHS, options.ignore ?? DEFAULT_IGNORE) || !isUiPackageFile(filename)) {
|
|
1251
|
+
return {};
|
|
1252
|
+
}
|
|
1253
|
+
const basename = file.slice(0, -".tsx".length);
|
|
1254
|
+
const expected = toPascalCase(basename);
|
|
1255
|
+
return {
|
|
1256
|
+
Program(program) {
|
|
1257
|
+
const suffixRule = requiredSuffix(path4.posix.dirname(filename).split("/"));
|
|
1258
|
+
if (suffixRule && !basename.endsWith(suffixRule.suffix)) {
|
|
1259
|
+
context.report({
|
|
1260
|
+
node: program,
|
|
1261
|
+
loc: { line: 1, column: 0 },
|
|
1262
|
+
messageId: "wrongSuffix",
|
|
1263
|
+
data: { ...suffixRule, file }
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
const named = collectExportedComponents(program).filter((component2) => {
|
|
1267
|
+
return component2.name !== null;
|
|
1268
|
+
});
|
|
1269
|
+
if (named.length !== 1 || named[0].name === expected) {
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
const component = named[0];
|
|
1273
|
+
const actual = component.name;
|
|
1274
|
+
const variable = findVariable(context.sourceCode, component);
|
|
1275
|
+
const moduleScope = context.sourceCode.getScope(program).childScopes[0] ?? context.sourceCode.getScope(program);
|
|
1276
|
+
const nameTaken = moduleScope.set.has(expected);
|
|
1277
|
+
context.report({
|
|
1278
|
+
node: component.id,
|
|
1279
|
+
messageId: "nameMismatch",
|
|
1280
|
+
data: { file, expected, actual },
|
|
1281
|
+
suggest: variable && !nameTaken ? [{ messageId: "renameComponent", data: { actual, expected }, fix: renameFix(variable, expected) }] : []
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
577
1286
|
};
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
1287
|
+
|
|
1288
|
+
// src/rules/dumb-component-purity/dumb-component-purity.ts
|
|
1289
|
+
var DEFAULT_ALLOWED_HOOKS = [
|
|
1290
|
+
"useState",
|
|
1291
|
+
"useRef",
|
|
1292
|
+
"useMemo",
|
|
1293
|
+
"useCallback",
|
|
1294
|
+
"useId",
|
|
1295
|
+
"useEffect",
|
|
1296
|
+
"useLayoutEffect",
|
|
1297
|
+
"useImperativeHandle"
|
|
587
1298
|
];
|
|
588
|
-
var
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
const appType = APP_DIRECTORY_TYPES[third];
|
|
595
|
-
if (appType) return appType;
|
|
1299
|
+
var EFFECT_HOOKS = /* @__PURE__ */ new Set(["useEffect", "useLayoutEffect"]);
|
|
1300
|
+
var HOOK_NAME = /^use[A-Z]/;
|
|
1301
|
+
var ANALYTICS = /analytics/i;
|
|
1302
|
+
var getHookName = (callee) => {
|
|
1303
|
+
if (callee.type === "Identifier") {
|
|
1304
|
+
return HOOK_NAME.test(callee.name) ? callee.name : null;
|
|
596
1305
|
}
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
var fromDependencies = (pkgJson) => {
|
|
600
|
-
const deps = /* @__PURE__ */ new Set([
|
|
601
|
-
...Object.keys(pkgJson.dependencies ?? {}),
|
|
602
|
-
...Object.keys(pkgJson.devDependencies ?? {}),
|
|
603
|
-
...Object.keys(pkgJson.peerDependencies ?? {})
|
|
604
|
-
]);
|
|
605
|
-
for (const signal of DEPENDENCY_SIGNALS) {
|
|
606
|
-
if (signal.packages.some((name) => {
|
|
607
|
-
return deps.has(name);
|
|
608
|
-
}))
|
|
609
|
-
return signal.type;
|
|
1306
|
+
if (callee.type === "MemberExpression" && callee.object.type === "Identifier" && callee.object.name === "React" && callee.property.type === "Identifier" && HOOK_NAME.test(callee.property.name)) {
|
|
1307
|
+
return callee.property.name;
|
|
610
1308
|
}
|
|
611
|
-
|
|
612
|
-
return isViteReactApp ? "frontend" : null;
|
|
613
|
-
};
|
|
614
|
-
var detectPackageType = ({
|
|
615
|
-
packageDir,
|
|
616
|
-
repoRoot,
|
|
617
|
-
pkgJson,
|
|
618
|
-
declaredType
|
|
619
|
-
}) => {
|
|
620
|
-
return declaredType ?? fromDirectory(packageDir, repoRoot) ?? fromDependencies(pkgJson) ?? "lib";
|
|
621
|
-
};
|
|
622
|
-
|
|
623
|
-
// src/utils/package-type-reader.ts
|
|
624
|
-
import fs2 from "node:fs";
|
|
625
|
-
import path3 from "node:path";
|
|
626
|
-
var BLOCK_COMMENT = /\/\*[\s\S]*?\*\//g;
|
|
627
|
-
var FULL_LINE_COMMENT = /^[ \t]*\/\/.*$/gm;
|
|
628
|
-
var DECLARED_TYPE = new RegExp(`(?:^|[\\s{,])type:\\s*['"](${PACKAGE_TYPES.join("|")})['"]`, "m");
|
|
629
|
-
var readDeclaredType = (text) => {
|
|
630
|
-
const stripped = text.replace(BLOCK_COMMENT, "").replace(FULL_LINE_COMMENT, "");
|
|
631
|
-
const literal = DECLARED_TYPE.exec(stripped)?.[1];
|
|
632
|
-
return PACKAGE_TYPES.find((candidate) => {
|
|
633
|
-
return candidate === literal;
|
|
634
|
-
});
|
|
1309
|
+
return null;
|
|
635
1310
|
};
|
|
636
|
-
var
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
1311
|
+
var calleeNames = (node) => {
|
|
1312
|
+
if (node.type === "Identifier") {
|
|
1313
|
+
return [node.name];
|
|
1314
|
+
}
|
|
1315
|
+
if (node.type === "MemberExpression") {
|
|
1316
|
+
const property = !node.computed && node.property.type === "Identifier" ? [node.property.name] : [];
|
|
1317
|
+
return [...calleeNames(node.object), ...property];
|
|
642
1318
|
}
|
|
1319
|
+
return [];
|
|
643
1320
|
};
|
|
644
|
-
var
|
|
645
|
-
|
|
646
|
-
return
|
|
647
|
-
} catch {
|
|
648
|
-
return {};
|
|
1321
|
+
var rootObjectName = (node) => {
|
|
1322
|
+
if (node.type === "Identifier") {
|
|
1323
|
+
return node.name;
|
|
649
1324
|
}
|
|
1325
|
+
return node.type === "MemberExpression" ? rootObjectName(node.object) : null;
|
|
650
1326
|
};
|
|
651
|
-
var
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
const hit = typeCache.get(packageRoot);
|
|
655
|
-
if (hit !== void 0 && hit.mtimeMs === mtimeMs) {
|
|
656
|
-
return hit.type;
|
|
1327
|
+
var isSideEffectCall = (callee) => {
|
|
1328
|
+
if (callee.type === "Identifier" && callee.name === "fetch") {
|
|
1329
|
+
return true;
|
|
657
1330
|
}
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
declaredType
|
|
1331
|
+
if (callee.type === "MemberExpression" && rootObjectName(callee) === "httpClient") {
|
|
1332
|
+
return true;
|
|
1333
|
+
}
|
|
1334
|
+
return calleeNames(callee).some((name) => {
|
|
1335
|
+
return ANALYTICS.test(name);
|
|
664
1336
|
});
|
|
665
|
-
|
|
666
|
-
|
|
1337
|
+
};
|
|
1338
|
+
var dumbComponentPurity = {
|
|
1339
|
+
meta: {
|
|
1340
|
+
type: "problem",
|
|
1341
|
+
docs: {
|
|
1342
|
+
description: "Keep dumb components pure: only React's local-state hooks are allowed, and effects may not fetch, call `httpClient` or send analytics.",
|
|
1343
|
+
recommended: true,
|
|
1344
|
+
url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#dumb-component-purity"
|
|
1345
|
+
},
|
|
1346
|
+
schema: [
|
|
1347
|
+
{
|
|
1348
|
+
type: "object",
|
|
1349
|
+
properties: {
|
|
1350
|
+
paths: {
|
|
1351
|
+
type: "array",
|
|
1352
|
+
items: { type: "string" },
|
|
1353
|
+
description: `Glob patterns of the files to check. Replaces the default (${DUMB_COMPONENT_PATHS.join(", ")}).`
|
|
1354
|
+
},
|
|
1355
|
+
ignore: {
|
|
1356
|
+
type: "array",
|
|
1357
|
+
items: { type: "string" },
|
|
1358
|
+
description: "Glob patterns of files to skip, even if they also match `paths`. Stories and tests are always skipped."
|
|
1359
|
+
},
|
|
1360
|
+
allowHooks: {
|
|
1361
|
+
type: "array",
|
|
1362
|
+
items: { type: "string" },
|
|
1363
|
+
description: `Hooks allowed on top of the defaults (${DEFAULT_ALLOWED_HOOKS.join(", ")}), e.g. UI-library hooks like \`useMediaQuery\`.`
|
|
1364
|
+
}
|
|
1365
|
+
},
|
|
1366
|
+
additionalProperties: false
|
|
1367
|
+
}
|
|
1368
|
+
],
|
|
1369
|
+
messages: {
|
|
1370
|
+
forbiddenHook: '"{{hook}}" is not in the allowed hook list for dumb components. Call it in the container and pass the value as a prop.',
|
|
1371
|
+
sideEffectInEffect: "`{{call}}` is a side effect a dumb component must not run. Move it to the container and pass a callback or the result as a prop."
|
|
1372
|
+
}
|
|
1373
|
+
},
|
|
1374
|
+
create(context) {
|
|
1375
|
+
const options = context.options[0] ?? {};
|
|
1376
|
+
const filename = context.filename.split("\\").join("/");
|
|
1377
|
+
if (isStoryOrTestFile(filename) || !isInScope(filename, options.paths ?? DUMB_COMPONENT_PATHS, options.ignore ?? []) || !isUiPackageFile(filename)) {
|
|
1378
|
+
return {};
|
|
1379
|
+
}
|
|
1380
|
+
const allowed = /* @__PURE__ */ new Set([...DEFAULT_ALLOWED_HOOKS, ...options.allowHooks ?? []]);
|
|
1381
|
+
const effectCallbacks = /* @__PURE__ */ new Set();
|
|
1382
|
+
return {
|
|
1383
|
+
CallExpression(node) {
|
|
1384
|
+
const hook = getHookName(node.callee);
|
|
1385
|
+
if (hook !== null && !allowed.has(hook)) {
|
|
1386
|
+
context.report({ node: node.callee, messageId: "forbiddenHook", data: { hook } });
|
|
1387
|
+
}
|
|
1388
|
+
if (hook !== null && EFFECT_HOOKS.has(hook) && node.arguments[0]) {
|
|
1389
|
+
effectCallbacks.add(node.arguments[0]);
|
|
1390
|
+
}
|
|
1391
|
+
if (!isSideEffectCall(node.callee)) {
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
const inEffect = context.sourceCode.getAncestors(node).some((ancestor) => {
|
|
1395
|
+
return effectCallbacks.has(ancestor);
|
|
1396
|
+
});
|
|
1397
|
+
if (inEffect) {
|
|
1398
|
+
context.report({
|
|
1399
|
+
node: node.callee,
|
|
1400
|
+
messageId: "sideEffectInEffect",
|
|
1401
|
+
data: { call: context.sourceCode.getText(node.callee) }
|
|
1402
|
+
});
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
};
|
|
1406
|
+
}
|
|
667
1407
|
};
|
|
668
1408
|
|
|
669
1409
|
// src/utils/playwright-call.ts
|
|
670
1410
|
var TEST_MODIFIERS = /* @__PURE__ */ new Set(["only", "skip", "fixme", "fail"]);
|
|
671
1411
|
var DESCRIBE_MODIFIERS = /* @__PURE__ */ new Set(["only", "skip", "fixme", "serial", "parallel"]);
|
|
672
|
-
var
|
|
1412
|
+
var calleeNames2 = (callee) => {
|
|
673
1413
|
if (callee.type === "Identifier") {
|
|
674
1414
|
return [callee.name];
|
|
675
1415
|
}
|
|
676
1416
|
if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier") {
|
|
677
1417
|
return null;
|
|
678
1418
|
}
|
|
679
|
-
const head =
|
|
1419
|
+
const head = calleeNames2(callee.object);
|
|
680
1420
|
return head === null ? null : [...head, callee.property.name];
|
|
681
1421
|
};
|
|
682
1422
|
var playwrightCallKind = (node) => {
|
|
683
|
-
const names =
|
|
1423
|
+
const names = calleeNames2(node.callee);
|
|
684
1424
|
if (names === null || names[0] !== "test") {
|
|
685
1425
|
return null;
|
|
686
1426
|
}
|
|
@@ -714,7 +1454,7 @@ var testStatementNames = (statement) => {
|
|
|
714
1454
|
if (statement.type !== "ExpressionStatement" || statement.expression.type !== "CallExpression") {
|
|
715
1455
|
return null;
|
|
716
1456
|
}
|
|
717
|
-
const names =
|
|
1457
|
+
const names = calleeNames2(statement.expression.callee);
|
|
718
1458
|
return names?.[0] === "test" ? names : null;
|
|
719
1459
|
};
|
|
720
1460
|
var isInE2ePackage = (context) => {
|
|
@@ -1641,8 +2381,12 @@ var e2eTopLevelDescribe = {
|
|
|
1641
2381
|
}
|
|
1642
2382
|
};
|
|
1643
2383
|
|
|
2384
|
+
// src/rules/feature-files/feature-files.ts
|
|
2385
|
+
import fs3 from "node:fs";
|
|
2386
|
+
import path6 from "node:path";
|
|
2387
|
+
|
|
1644
2388
|
// src/utils/import-target.ts
|
|
1645
|
-
import
|
|
2389
|
+
import path5 from "node:path";
|
|
1646
2390
|
var DEFAULT_ROOT_ALIAS = "#root";
|
|
1647
2391
|
var INDEX_FILE = /^index(?:\.[cm]?[jt]sx?)?$/;
|
|
1648
2392
|
var literalValue = (source) => {
|
|
@@ -1678,9 +2422,9 @@ var srcDirOf = (filename) => {
|
|
|
1678
2422
|
var resolveUnderSrc = (filename, srcDir, specifier, rootAlias) => {
|
|
1679
2423
|
let absolute;
|
|
1680
2424
|
if (specifier === rootAlias || specifier.startsWith(`${rootAlias}/`)) {
|
|
1681
|
-
absolute =
|
|
2425
|
+
absolute = path5.posix.join(srcDir, specifier.slice(rootAlias.length));
|
|
1682
2426
|
} else if (specifier.startsWith(".")) {
|
|
1683
|
-
absolute =
|
|
2427
|
+
absolute = path5.posix.resolve(path5.posix.dirname(filename), specifier);
|
|
1684
2428
|
} else {
|
|
1685
2429
|
return null;
|
|
1686
2430
|
}
|
|
@@ -1714,6 +2458,132 @@ var isLayeredFile = (filename, ignore) => {
|
|
|
1714
2458
|
return packageRoot === null || !UNLAYERED_TYPES.has(resolvePackageType(packageRoot));
|
|
1715
2459
|
};
|
|
1716
2460
|
|
|
2461
|
+
// src/rules/feature-files/feature-files.ts
|
|
2462
|
+
var ROOT_FILES = ["index", "types", "services", "analytics", "constants", "translates"];
|
|
2463
|
+
var SERVICE_FILES = ["main", "api", "libs", "index", "types", "constants"];
|
|
2464
|
+
var SERVICES = "services";
|
|
2465
|
+
var TEST_OR_STORY_DIR = /\/__(?:tests|stories)__\//;
|
|
2466
|
+
var EXEMPT_FILE = /\.(?:(?:test|spec|stories)\.[cm]?[jt]sx?|d\.[cm]?ts)$/;
|
|
2467
|
+
var stemOf = (basename) => {
|
|
2468
|
+
return basename.slice(0, basename.length - path6.posix.extname(basename).length);
|
|
2469
|
+
};
|
|
2470
|
+
var describeAllowed = (stems) => {
|
|
2471
|
+
return stems.map((stem) => {
|
|
2472
|
+
return `${stem}.ts`;
|
|
2473
|
+
}).join(", ");
|
|
2474
|
+
};
|
|
2475
|
+
var isServiceFile = (filename, dirs) => {
|
|
2476
|
+
return dirs[2] === SERVICES || dirs.length === 2 && stemOf(path6.posix.basename(filename)) === SERVICES;
|
|
2477
|
+
};
|
|
2478
|
+
var findNameViolation = (filename, dirs, options) => {
|
|
2479
|
+
const basename = path6.posix.basename(filename);
|
|
2480
|
+
const stem = stemOf(basename);
|
|
2481
|
+
if (dirs.length === 2) {
|
|
2482
|
+
const allowed = [...ROOT_FILES, ...options.extraRootFiles ?? []];
|
|
2483
|
+
if (!allowed.includes(stem)) {
|
|
2484
|
+
return { messageId: "unknownRootFile", data: { file: basename, allowed: describeAllowed(allowed) } };
|
|
2485
|
+
}
|
|
2486
|
+
if (stem === SERVICES && fs3.existsSync(path6.posix.join(path6.posix.dirname(filename), SERVICES))) {
|
|
2487
|
+
return { messageId: "servicesFileAndFolder", data: { feature: dirs[1] } };
|
|
2488
|
+
}
|
|
2489
|
+
return null;
|
|
2490
|
+
}
|
|
2491
|
+
if (dirs.length === 3 && dirs[2] === SERVICES) {
|
|
2492
|
+
const allowed = [...SERVICE_FILES, ...options.extraServiceFiles ?? []];
|
|
2493
|
+
if (!allowed.includes(stem)) {
|
|
2494
|
+
return { messageId: "unknownServiceFile", data: { file: basename, allowed: describeAllowed(allowed) } };
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
return null;
|
|
2498
|
+
};
|
|
2499
|
+
var featureFiles = {
|
|
2500
|
+
meta: {
|
|
2501
|
+
type: "problem",
|
|
2502
|
+
docs: {
|
|
2503
|
+
description: "Allow only the conventional files in a feature root and its `services/`, one of `services.ts` or `services/`, and no JSX in services.",
|
|
2504
|
+
recommended: true,
|
|
2505
|
+
url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#feature-files"
|
|
2506
|
+
},
|
|
2507
|
+
schema: [
|
|
2508
|
+
{
|
|
2509
|
+
type: "object",
|
|
2510
|
+
properties: {
|
|
2511
|
+
extraRootFiles: {
|
|
2512
|
+
type: "array",
|
|
2513
|
+
items: { type: "string", minLength: 1 },
|
|
2514
|
+
uniqueItems: true,
|
|
2515
|
+
description: `File names without extension allowed in a feature root besides ${ROOT_FILES.join(", ")}.`
|
|
2516
|
+
},
|
|
2517
|
+
extraServiceFiles: {
|
|
2518
|
+
type: "array",
|
|
2519
|
+
items: { type: "string", minLength: 1 },
|
|
2520
|
+
uniqueItems: true,
|
|
2521
|
+
description: `File names without extension allowed in \`services/\` besides ${SERVICE_FILES.join(", ")}.`
|
|
2522
|
+
},
|
|
2523
|
+
paths: {
|
|
2524
|
+
type: "array",
|
|
2525
|
+
items: { type: "string" },
|
|
2526
|
+
description: "Optional glob patterns. When provided, the rule only runs for files whose path matches one of them."
|
|
2527
|
+
},
|
|
2528
|
+
ignore: {
|
|
2529
|
+
type: "array",
|
|
2530
|
+
items: { type: "string" },
|
|
2531
|
+
description: "Optional glob patterns. The rule is skipped for files whose path matches one of them, even if it also matches `paths`."
|
|
2532
|
+
}
|
|
2533
|
+
},
|
|
2534
|
+
additionalProperties: false
|
|
2535
|
+
}
|
|
2536
|
+
],
|
|
2537
|
+
messages: {
|
|
2538
|
+
unknownRootFile: '"{{file}}" is not an allowed feature-root file. Allowed: {{allowed}}.',
|
|
2539
|
+
unknownServiceFile: '"{{file}}" is not an allowed file in `services/`. Allowed: {{allowed}}.',
|
|
2540
|
+
servicesFileAndFolder: 'Feature "{{feature}}" has both `services.ts` and a `services/` folder. Keep one: move this file into `services/main.ts`, or fold the folder into this file.',
|
|
2541
|
+
jsxInService: "Services hold atoms and pure functions; JSX belongs in a component or container."
|
|
2542
|
+
}
|
|
2543
|
+
},
|
|
2544
|
+
create(context) {
|
|
2545
|
+
const options = context.options[0] ?? {};
|
|
2546
|
+
const filename = context.filename.split("\\").join("/");
|
|
2547
|
+
const paths = options.paths ?? [];
|
|
2548
|
+
const ignore = options.ignore ?? [];
|
|
2549
|
+
if (ignore.length > 0 && matchesAnyGlob(filename, ignore)) {
|
|
2550
|
+
return {};
|
|
2551
|
+
}
|
|
2552
|
+
if (paths.length > 0 && !matchesAnyGlob(filename, paths)) {
|
|
2553
|
+
return {};
|
|
2554
|
+
}
|
|
2555
|
+
if (TEST_OR_STORY_DIR.test(filename) || EXEMPT_FILE.test(filename)) {
|
|
2556
|
+
return {};
|
|
2557
|
+
}
|
|
2558
|
+
const srcDir = srcDirOf(filename);
|
|
2559
|
+
if (srcDir === null) {
|
|
2560
|
+
return {};
|
|
2561
|
+
}
|
|
2562
|
+
const dirs = fileDirsUnderSrc(filename, srcDir);
|
|
2563
|
+
if (dirs[0] !== "features" || dirs.length < 2 || !isUiPackageFile(filename)) {
|
|
2564
|
+
return {};
|
|
2565
|
+
}
|
|
2566
|
+
const violation = findNameViolation(filename, dirs, options);
|
|
2567
|
+
const listeners = {};
|
|
2568
|
+
if (violation !== null) {
|
|
2569
|
+
listeners.Program = (program) => {
|
|
2570
|
+
context.report({ ...violation, node: program });
|
|
2571
|
+
};
|
|
2572
|
+
}
|
|
2573
|
+
if (isServiceFile(filename, dirs)) {
|
|
2574
|
+
let reported = false;
|
|
2575
|
+
const reportJsx = (node) => {
|
|
2576
|
+
if (!reported) {
|
|
2577
|
+
reported = true;
|
|
2578
|
+
context.report({ node, messageId: "jsxInService" });
|
|
2579
|
+
}
|
|
2580
|
+
};
|
|
2581
|
+
Object.assign(listeners, { JSXElement: reportJsx, JSXFragment: reportJsx });
|
|
2582
|
+
}
|
|
2583
|
+
return listeners;
|
|
2584
|
+
}
|
|
2585
|
+
};
|
|
2586
|
+
|
|
1717
2587
|
// src/rules/feature-public-api/feature-public-api.ts
|
|
1718
2588
|
var featurePublicApi = {
|
|
1719
2589
|
meta: {
|
|
@@ -2444,6 +3314,80 @@ var noFeatureSelfImport = {
|
|
|
2444
3314
|
}
|
|
2445
3315
|
};
|
|
2446
3316
|
|
|
3317
|
+
// src/rules/no-hooks-in-services/no-hooks-in-services.ts
|
|
3318
|
+
var DEFAULT_PATHS2 = [
|
|
3319
|
+
"/services/",
|
|
3320
|
+
"/services.ts",
|
|
3321
|
+
"/services.tsx",
|
|
3322
|
+
"/service.ts",
|
|
3323
|
+
"/service.tsx",
|
|
3324
|
+
"/entities/**/model.ts",
|
|
3325
|
+
"/entities/**/model.tsx"
|
|
3326
|
+
];
|
|
3327
|
+
var HOOK_NAME2 = /^use[A-Z0-9]/;
|
|
3328
|
+
var TEST_OR_STORY_DIR2 = /\/__(?:tests|stories)__\//;
|
|
3329
|
+
var TEST_OR_STORY_FILE = /\.(?:test|spec|stories)\.[cm]?[jt]sx?$/;
|
|
3330
|
+
var noHooksInServices = {
|
|
3331
|
+
meta: {
|
|
3332
|
+
type: "problem",
|
|
3333
|
+
docs: {
|
|
3334
|
+
description: "Disallow declaring React hooks in services and entity models: they hold atoms and pure functions, the hook belongs in a container or `lib/`.",
|
|
3335
|
+
recommended: true,
|
|
3336
|
+
url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#no-hooks-in-services"
|
|
3337
|
+
},
|
|
3338
|
+
schema: [
|
|
3339
|
+
{
|
|
3340
|
+
type: "object",
|
|
3341
|
+
properties: {
|
|
3342
|
+
paths: {
|
|
3343
|
+
type: "array",
|
|
3344
|
+
items: { type: "string" },
|
|
3345
|
+
description: `Glob patterns of service files; replaces the default (${DEFAULT_PATHS2.join(", ")}).`
|
|
3346
|
+
},
|
|
3347
|
+
ignore: {
|
|
3348
|
+
type: "array",
|
|
3349
|
+
items: { type: "string" },
|
|
3350
|
+
description: "Optional glob patterns. The rule is skipped for files whose path matches one of them, even if it also matches `paths`."
|
|
3351
|
+
}
|
|
3352
|
+
},
|
|
3353
|
+
additionalProperties: false
|
|
3354
|
+
}
|
|
3355
|
+
],
|
|
3356
|
+
messages: {
|
|
3357
|
+
hookInService: '"{{name}}" is a hook, and services hold only atoms and pure functions. Move it to a container or to `lib/`.'
|
|
3358
|
+
}
|
|
3359
|
+
},
|
|
3360
|
+
create(context) {
|
|
3361
|
+
const options = context.options[0] ?? {};
|
|
3362
|
+
const filename = context.filename.split("\\").join("/");
|
|
3363
|
+
const ignore = options.ignore ?? [];
|
|
3364
|
+
if (ignore.length > 0 && matchesAnyGlob(filename, ignore)) {
|
|
3365
|
+
return {};
|
|
3366
|
+
}
|
|
3367
|
+
if (!matchesAnyGlob(filename, options.paths ?? DEFAULT_PATHS2) || TEST_OR_STORY_DIR2.test(filename) || TEST_OR_STORY_FILE.test(filename) || // Backend packages have `src/services/` too, where `use*` means something else.
|
|
3368
|
+
!isUiPackageFile(filename)) {
|
|
3369
|
+
return {};
|
|
3370
|
+
}
|
|
3371
|
+
const report = (id) => {
|
|
3372
|
+
if (HOOK_NAME2.test(id.name)) {
|
|
3373
|
+
context.report({ node: id, messageId: "hookInService", data: { name: id.name } });
|
|
3374
|
+
}
|
|
3375
|
+
};
|
|
3376
|
+
return {
|
|
3377
|
+
FunctionDeclaration(node) {
|
|
3378
|
+
if (node.id) {
|
|
3379
|
+
report(node.id);
|
|
3380
|
+
}
|
|
3381
|
+
},
|
|
3382
|
+
VariableDeclarator(node) {
|
|
3383
|
+
if (node.id.type === "Identifier" && (node.init?.type === "ArrowFunctionExpression" || node.init?.type === "FunctionExpression")) {
|
|
3384
|
+
report(node.id);
|
|
3385
|
+
}
|
|
3386
|
+
}
|
|
3387
|
+
};
|
|
3388
|
+
}
|
|
3389
|
+
};
|
|
3390
|
+
|
|
2447
3391
|
// src/rules/package-structure/package-structure.ts
|
|
2448
3392
|
var DEFAULT_ENTRIES = {
|
|
2449
3393
|
frontend: {
|
|
@@ -2929,10 +3873,10 @@ var propsTypeReference = {
|
|
|
2929
3873
|
|
|
2930
3874
|
// src/rules/require-component-stories/require-component-stories.ts
|
|
2931
3875
|
import { existsSync } from "node:fs";
|
|
2932
|
-
import
|
|
3876
|
+
import path8 from "node:path";
|
|
2933
3877
|
|
|
2934
3878
|
// src/utils/story-path.ts
|
|
2935
|
-
import
|
|
3879
|
+
import path7 from "node:path";
|
|
2936
3880
|
var DEFAULT_STORY_PATH_OPTIONS = {
|
|
2937
3881
|
storiesDir: "__stories__",
|
|
2938
3882
|
storySuffix: ".stories",
|
|
@@ -2951,10 +3895,10 @@ var parse = (filePath) => {
|
|
|
2951
3895
|
const normalized = toPosix(filePath);
|
|
2952
3896
|
const segments = normalized.split("/");
|
|
2953
3897
|
const basename = segments[segments.length - 1] ?? "";
|
|
2954
|
-
const ext =
|
|
3898
|
+
const ext = path7.posix.extname(basename);
|
|
2955
3899
|
const base = ext ? basename.slice(0, -ext.length) : basename;
|
|
2956
3900
|
return {
|
|
2957
|
-
dir:
|
|
3901
|
+
dir: path7.posix.dirname(normalized),
|
|
2958
3902
|
base,
|
|
2959
3903
|
ext,
|
|
2960
3904
|
parent: segments[segments.length - 2] ?? "",
|
|
@@ -2996,10 +3940,10 @@ var deriveExpectedStoryPaths = (filePath, opts) => {
|
|
|
2996
3940
|
return [];
|
|
2997
3941
|
}
|
|
2998
3942
|
const parsed = parse(filePath);
|
|
2999
|
-
const storyBaseDir = classification.mode === "feature-root" ?
|
|
3000
|
-
const storyDir =
|
|
3943
|
+
const storyBaseDir = classification.mode === "feature-root" ? path7.posix.dirname(parsed.dir) : parsed.dir;
|
|
3944
|
+
const storyDir = path7.posix.join(storyBaseDir, options.storiesDir);
|
|
3001
3945
|
return options.storyExtensions.map((extension) => {
|
|
3002
|
-
return
|
|
3946
|
+
return path7.posix.join(storyDir, `${parsed.base}${options.storySuffix}${extension}`);
|
|
3003
3947
|
});
|
|
3004
3948
|
};
|
|
3005
3949
|
|
|
@@ -3119,7 +4063,7 @@ var requireComponentStories = {
|
|
|
3119
4063
|
node: program,
|
|
3120
4064
|
messageId: "missingStory",
|
|
3121
4065
|
data: {
|
|
3122
|
-
component:
|
|
4066
|
+
component: path8.posix.basename(context.filename.split("\\").join("/")),
|
|
3123
4067
|
expected: candidates[0] ?? ""
|
|
3124
4068
|
}
|
|
3125
4069
|
});
|
|
@@ -3522,7 +4466,13 @@ var rules = {
|
|
|
3522
4466
|
"e2e-describe-order": e2eDescribeOrder,
|
|
3523
4467
|
"e2e-top-level-describe": e2eTopLevelDescribe,
|
|
3524
4468
|
"e2e-test-tags": e2eTestTags,
|
|
3525
|
-
"e2e-test-title": e2eTestTitle
|
|
4469
|
+
"e2e-test-title": e2eTestTitle,
|
|
4470
|
+
"atom-naming": atomNaming,
|
|
4471
|
+
"no-hooks-in-services": noHooksInServices,
|
|
4472
|
+
"feature-files": featureFiles,
|
|
4473
|
+
"component-name-matches-file": componentNameMatchesFile,
|
|
4474
|
+
"component-classname": componentClassname,
|
|
4475
|
+
"dumb-component-purity": dumbComponentPurity
|
|
3526
4476
|
};
|
|
3527
4477
|
|
|
3528
4478
|
// src/index.ts
|
|
@@ -3530,7 +4480,7 @@ var PLUGIN_NAME = "@wl";
|
|
|
3530
4480
|
var plugin = {
|
|
3531
4481
|
meta: {
|
|
3532
4482
|
name: "@wl/eslint-plugin",
|
|
3533
|
-
version: "0.
|
|
4483
|
+
version: "0.44.0"
|
|
3534
4484
|
},
|
|
3535
4485
|
rules,
|
|
3536
4486
|
configs: {}
|
|
@@ -3644,6 +4594,22 @@ plugin.configs.recommended = [
|
|
|
3644
4594
|
[`${PLUGIN_NAME}/no-feature-self-import`]: "error"
|
|
3645
4595
|
}
|
|
3646
4596
|
},
|
|
4597
|
+
// fe-architect naming and purity rules. Each gates itself on path and package type (backend and e2e
|
|
4598
|
+
// packages are skipped), so one repo-wide block is enough.
|
|
4599
|
+
{
|
|
4600
|
+
files: ["**/*.{ts,tsx}"],
|
|
4601
|
+
plugins: {
|
|
4602
|
+
[PLUGIN_NAME]: plugin
|
|
4603
|
+
},
|
|
4604
|
+
rules: {
|
|
4605
|
+
[`${PLUGIN_NAME}/atom-naming`]: "error",
|
|
4606
|
+
[`${PLUGIN_NAME}/no-hooks-in-services`]: "error",
|
|
4607
|
+
[`${PLUGIN_NAME}/feature-files`]: "error",
|
|
4608
|
+
[`${PLUGIN_NAME}/component-name-matches-file`]: "error",
|
|
4609
|
+
[`${PLUGIN_NAME}/component-classname`]: "error",
|
|
4610
|
+
[`${PLUGIN_NAME}/dumb-component-purity`]: "error"
|
|
4611
|
+
}
|
|
4612
|
+
},
|
|
3647
4613
|
// The e2e rules gate themselves on the package type, so a repo-wide glob costs one cached type
|
|
3648
4614
|
// lookup per file elsewhere.
|
|
3649
4615
|
{
|