@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 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/rules/component-file-order/component-file-order.ts
331
- var PROPS_SUFFIX = "Props";
332
- var getDirectivePrologueCount = (body) => {
333
- let count = 0;
334
- for (const statement of body) {
335
- const isStringExpression = statement.type === "ExpressionStatement" && statement.expression.type === "Literal" && typeof statement.expression.value === "string";
336
- if (!isStringExpression) {
337
- break;
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
- count += 1;
510
+ current = parent;
340
511
  }
341
- return count;
512
+ return current;
342
513
  };
343
- var getTypeDeclName = (node) => {
344
- if (!node) {
514
+ var findPackageRoot = (filename) => {
515
+ if (!path.posix.isAbsolute(filename)) {
345
516
  return null;
346
517
  }
347
- const named = node;
348
- if (named.type !== "TSInterfaceDeclaration" && named.type !== "TSTypeAliasDeclaration") {
349
- return null;
518
+ const dir = path.posix.dirname(filename);
519
+ const cached = rootCache.get(dir);
520
+ if (cached !== void 0) {
521
+ return cached;
350
522
  }
351
- return named.id?.name ?? null;
523
+ const root = findUp(dir, PACKAGE_MARKERS);
524
+ rootCache.set(dir, root);
525
+ return root;
352
526
  };
353
- var collectTopLevel = (body) => {
354
- const importIndices = [];
355
- const components = [];
356
- const declIndexByName = /* @__PURE__ */ new Map();
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 { importIndices, components, declIndexByName, importedNames };
532
+ return filename.slice(srcPrefix.length).split("/").slice(0, -1);
378
533
  };
379
- var hasStrayBefore = (body, boundary, directiveCount, skipIndex) => {
380
- return body.some((statement, index) => {
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
- var ANONYMOUS_COMPONENT = "component";
385
- var findImportOrderViolations = (importIndices, importBoundary) => {
386
- return importIndices.filter((importIndex) => {
387
- return importIndex > importBoundary;
388
- }).map((importIndex) => {
389
- return { index: importIndex, messageId: "importsFirst" };
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 findAdjacencyViolations = (components, declIndexByName) => {
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/utils/package-root.ts
524
- import fs from "node:fs";
525
- import path from "node:path";
526
- var CONFIG_FILENAME = "infra-kit.config.ts";
527
- var PACKAGE_MARKERS = [CONFIG_FILENAME, "package.json"];
528
- var REPO_MARKERS = ["pnpm-workspace.yaml"];
529
- var hasAnyMarker = (dir, markers) => {
530
- return markers.some((marker) => {
531
- return fs.existsSync(path.posix.join(dir, marker));
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 = path.posix.dirname(filename);
551
- const cached = rootCache.get(dir);
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 dirsUnderSrc = (packageRoot, filename) => {
560
- const srcPrefix = `${packageRoot}/src/`;
561
- if (!filename.startsWith(srcPrefix)) {
562
- return null;
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 findRepoRoot = (packageRoot) => {
567
- return findUp(packageRoot, REPO_MARKERS);
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
- // ../config/dist/package-type.js
571
- import path2 from "node:path";
572
- var PACKAGE_TYPES = ["frontend", "backend", "lib", "e2e", "mobile"];
573
- var APP_DIRECTORY_TYPES = {
574
- ui: "frontend",
575
- api: "backend",
576
- tests: "e2e"
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
- var MOBILE_BASENAME = "mobile-app";
579
- var DEPENDENCY_SIGNALS = [
580
- { type: "e2e", packages: ["@playwright/test"] },
581
- { type: "mobile", packages: ["@capacitor/core", "@capacitor/cli"] },
582
- // `aws-lambda` / `@types/aws-lambda` are deliberately NOT signals: every consumer backend already
583
- // lives at `apps/<app>/api` (row 2), while the only packages those two deps would catch are
584
- // libraries (`packages/lib-core` in both consumers, the infra-kit CLI itself) — a lambda types
585
- // dependency alone never means backend, exactly as `react` alone never means frontend.
586
- { type: "backend", packages: ["serverless"] }
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 fromDirectory = (packageDir, repoRoot) => {
589
- const segments = path2.relative(repoRoot, packageDir).split(path2.sep).filter((segment) => {
590
- return segment !== "" && segment !== ".";
591
- });
592
- const [first, , third] = segments;
593
- if (segments.length === 3 && first === "apps" && third !== void 0) {
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
- return segments.at(-1) === MOBILE_BASENAME ? "mobile" : null;
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
- const isViteReactApp = deps.has("vite") && deps.has("react") && Boolean(pkgJson.scripts?.dev);
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 typeCache = /* @__PURE__ */ new Map();
637
- var configMtimeMs = (configPath) => {
638
- try {
639
- return fs2.statSync(configPath).mtimeMs;
640
- } catch {
641
- return -1;
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 readManifest = (manifestPath) => {
645
- try {
646
- return JSON.parse(fs2.readFileSync(manifestPath, "utf8"));
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 resolvePackageType = (packageRoot) => {
652
- const configPath = path3.posix.join(packageRoot, CONFIG_FILENAME);
653
- const mtimeMs = configMtimeMs(configPath);
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
- const declaredType = mtimeMs >= 0 ? readDeclaredType(fs2.readFileSync(configPath, "utf8")) : void 0;
659
- const type = detectPackageType({
660
- packageDir: packageRoot,
661
- repoRoot: findRepoRoot(packageRoot) ?? packageRoot,
662
- pkgJson: readManifest(path3.posix.join(packageRoot, "package.json")),
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
- typeCache.set(packageRoot, { mtimeMs, type });
666
- return type;
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 calleeNames = (callee) => {
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 = calleeNames(callee.object);
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 = calleeNames(node.callee);
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 = calleeNames(statement.expression.callee);
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 path4 from "node:path";
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 = path4.posix.join(srcDir, specifier.slice(rootAlias.length));
2425
+ absolute = path5.posix.join(srcDir, specifier.slice(rootAlias.length));
1682
2426
  } else if (specifier.startsWith(".")) {
1683
- absolute = path4.posix.resolve(path4.posix.dirname(filename), specifier);
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 path6 from "node:path";
3876
+ import path8 from "node:path";
2933
3877
 
2934
3878
  // src/utils/story-path.ts
2935
- import path5 from "node:path";
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 = path5.posix.extname(basename);
3898
+ const ext = path7.posix.extname(basename);
2955
3899
  const base = ext ? basename.slice(0, -ext.length) : basename;
2956
3900
  return {
2957
- dir: path5.posix.dirname(normalized),
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" ? path5.posix.dirname(parsed.dir) : parsed.dir;
3000
- const storyDir = path5.posix.join(storyBaseDir, options.storiesDir);
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 path5.posix.join(storyDir, `${parsed.base}${options.storySuffix}${extension}`);
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: path6.posix.basename(context.filename.split("\\").join("/")),
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.43.2"
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
  {