@slip-stream-kit/eslint-plugin 0.1.15 → 0.1.19

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.
Files changed (23) hide show
  1. package/dist/index.js +475 -51
  2. package/dist/index.js.map +4 -4
  3. package/dist/rules/{component-arrow-function.d.ts → component-arrow-function/component-arrow-function.d.ts} +0 -1
  4. package/dist/rules/component-arrow-function/index.d.ts +1 -0
  5. package/dist/rules/{component-file-order.d.ts → component-file-order/component-file-order.d.ts} +0 -1
  6. package/dist/rules/component-file-order/index.d.ts +1 -0
  7. package/dist/rules/max-components-per-file/index.d.ts +1 -0
  8. package/dist/rules/max-components-per-file/max-components-per-file.d.ts +2 -0
  9. package/dist/rules/max-jsx-return-size/index.d.ts +1 -0
  10. package/dist/rules/max-jsx-return-size/max-jsx-return-size.d.ts +2 -0
  11. package/dist/rules/props-destructuring-blank-line/index.d.ts +1 -0
  12. package/dist/rules/{props-destructuring-blank-line.d.ts → props-destructuring-blank-line/props-destructuring-blank-line.d.ts} +0 -1
  13. package/dist/rules/props-destructuring-newline/index.d.ts +1 -0
  14. package/dist/rules/{props-destructuring-newline.d.ts → props-destructuring-newline/props-destructuring-newline.d.ts} +0 -1
  15. package/dist/rules/props-type-name/index.d.ts +1 -0
  16. package/dist/rules/props-type-name/props-type-name.d.ts +2 -0
  17. package/dist/rules/props-type-reference/index.d.ts +1 -0
  18. package/dist/rules/{props-type-reference.d.ts → props-type-reference/props-type-reference.d.ts} +0 -1
  19. package/dist/rules/require-component-stories/index.d.ts +1 -0
  20. package/dist/rules/{require-component-stories.d.ts → require-component-stories/require-component-stories.d.ts} +0 -1
  21. package/dist/utils/component.d.ts +30 -0
  22. package/package.json +3 -3
  23. package/readme.md +220 -9
package/dist/index.js CHANGED
@@ -40,18 +40,18 @@ var getComponentName = (node) => {
40
40
  }
41
41
  return null;
42
42
  };
43
- var returnsJsx = (node) => {
43
+ var collectOwnReturnArguments = (node) => {
44
44
  if (node.body.type !== "BlockStatement") {
45
- return isJsxNode(node.body);
45
+ return [node.body];
46
46
  }
47
- let found = false;
47
+ const args = [];
48
48
  const visit = (current) => {
49
- if (found || !current || NESTED_SCOPES.has(current.type)) {
49
+ if (!current || NESTED_SCOPES.has(current.type)) {
50
50
  return;
51
51
  }
52
52
  if (current.type === "ReturnStatement") {
53
- if (isJsxNode(current.argument)) {
54
- found = true;
53
+ if (current.argument) {
54
+ args.push(current.argument);
55
55
  }
56
56
  return;
57
57
  }
@@ -81,7 +81,10 @@ var returnsJsx = (node) => {
81
81
  }
82
82
  };
83
83
  node.body.body.forEach(visit);
84
- return found;
84
+ return args;
85
+ };
86
+ var returnsJsx = (node) => {
87
+ return collectOwnReturnArguments(node).some(isJsxNode);
85
88
  };
86
89
  var isComponent = (node) => {
87
90
  const name = getComponentName(node);
@@ -113,6 +116,39 @@ var getComponentFunction = (node) => {
113
116
  }
114
117
  return null;
115
118
  };
119
+ var getAnnotatedParam = (param) => {
120
+ return param.type === "AssignmentPattern" ? param.left : param;
121
+ };
122
+ var getPropsTypeNameFromFunction = (node) => {
123
+ const firstParam = node.params[0];
124
+ if (!firstParam) {
125
+ return null;
126
+ }
127
+ const inner = getAnnotatedParam(firstParam).typeAnnotation?.typeAnnotation;
128
+ if (inner?.type !== "TSTypeReference" || inner.typeName?.type !== "Identifier") {
129
+ return null;
130
+ }
131
+ return inner.typeName.name ?? null;
132
+ };
133
+ var getComponentPropsTypeName = (node) => {
134
+ if (!node) {
135
+ return null;
136
+ }
137
+ if (node.type === "FunctionDeclaration") {
138
+ return isComponent(node) ? getPropsTypeNameFromFunction(node) : null;
139
+ }
140
+ if (node.type === "VariableDeclaration") {
141
+ for (const declaration of node.declarations) {
142
+ const fn2 = getComponentFunction(declaration.init);
143
+ if (fn2 && isComponent(fn2)) {
144
+ return getPropsTypeNameFromFunction(fn2);
145
+ }
146
+ }
147
+ return null;
148
+ }
149
+ const fn = getComponentFunction(node);
150
+ return fn && isComponent(fn) ? getPropsTypeNameFromFunction(fn) : null;
151
+ };
116
152
  var unwrapExport = (statement) => {
117
153
  if (statement.type === "ExportNamedDeclaration" || statement.type === "ExportDefaultDeclaration") {
118
154
  return statement.declaration ?? null;
@@ -193,7 +229,7 @@ var matchesAnyGlob = (filename, patterns) => {
193
229
  });
194
230
  };
195
231
 
196
- // src/rules/component-arrow-function.ts
232
+ // src/rules/component-arrow-function/component-arrow-function.ts
197
233
  var ANONYMOUS_NAME = "component";
198
234
  var reportName = (fn) => {
199
235
  return getComponentName(fn) ?? ANONYMOUS_NAME;
@@ -239,7 +275,7 @@ var componentArrowFunction = {
239
275
  docs: {
240
276
  description: "Enforce that React components are declared as arrow functions, not `function` declarations or function expressions.",
241
277
  recommended: true,
242
- url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin"
278
+ url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#component-arrow-function"
243
279
  },
244
280
  schema: [
245
281
  {
@@ -284,7 +320,7 @@ var componentArrowFunction = {
284
320
  }
285
321
  };
286
322
 
287
- // src/rules/component-file-order.ts
323
+ // src/rules/component-file-order/component-file-order.ts
288
324
  var PROPS_SUFFIX = "Props";
289
325
  var getDirectivePrologueCount = (body) => {
290
326
  let count = 0;
@@ -297,7 +333,7 @@ var getDirectivePrologueCount = (body) => {
297
333
  }
298
334
  return count;
299
335
  };
300
- var getPropsTypeName = (node) => {
336
+ var getTypeDeclName = (node) => {
301
337
  if (!node) {
302
338
  return null;
303
339
  }
@@ -305,13 +341,12 @@ var getPropsTypeName = (node) => {
305
341
  if (named.type !== "TSInterfaceDeclaration" && named.type !== "TSTypeAliasDeclaration") {
306
342
  return null;
307
343
  }
308
- const name = named.id?.name;
309
- return name?.endsWith(PROPS_SUFFIX) ? name : null;
344
+ return named.id?.name ?? null;
310
345
  };
311
346
  var collectTopLevel = (body) => {
312
347
  const importIndices = [];
313
348
  const components = [];
314
- const propsIndexByName = /* @__PURE__ */ new Map();
349
+ const declIndexByName = /* @__PURE__ */ new Map();
315
350
  const importedNames = /* @__PURE__ */ new Set();
316
351
  body.forEach((statement, index) => {
317
352
  if (statement.type === "ImportDeclaration") {
@@ -322,21 +357,24 @@ var collectTopLevel = (body) => {
322
357
  return;
323
358
  }
324
359
  const declaration = unwrapExport(statement);
325
- const propsName = getPropsTypeName(declaration);
326
- if (propsName !== null && !propsIndexByName.has(propsName)) {
327
- propsIndexByName.set(propsName, index);
360
+ const declName = getTypeDeclName(declaration);
361
+ if (declName !== null && !declIndexByName.has(declName)) {
362
+ declIndexByName.set(declName, index);
328
363
  }
329
364
  if (declaresComponent(declaration)) {
330
- components.push({ index, name: getDeclaredComponentName(declaration) });
365
+ const name = getDeclaredComponentName(declaration);
366
+ const propsName = getComponentPropsTypeName(declaration) ?? (name === null ? null : `${name}${PROPS_SUFFIX}`);
367
+ components.push({ index, name, propsName });
331
368
  }
332
369
  });
333
- return { importIndices, components, propsIndexByName, importedNames };
370
+ return { importIndices, components, declIndexByName, importedNames };
334
371
  };
335
372
  var hasStrayBefore = (body, boundary, directiveCount, skipIndex) => {
336
373
  return body.some((statement, index) => {
337
374
  return index < boundary && index >= directiveCount && index !== skipIndex && statement.type !== "ImportDeclaration";
338
375
  });
339
376
  };
377
+ var ANONYMOUS_COMPONENT = "component";
340
378
  var findImportOrderViolations = (importIndices, importBoundary) => {
341
379
  return importIndices.filter((importIndex) => {
342
380
  return importIndex > importBoundary;
@@ -344,15 +382,26 @@ var findImportOrderViolations = (importIndices, importBoundary) => {
344
382
  return { index: importIndex, messageId: "importsFirst" };
345
383
  });
346
384
  };
347
- var findAdjacencyViolations = (components, propsIndexByName) => {
385
+ var findAdjacencyViolations = (components, declIndexByName) => {
348
386
  const violations = [];
387
+ const referenceCount = /* @__PURE__ */ new Map();
388
+ for (const component of components) {
389
+ if (component.propsName !== null) {
390
+ referenceCount.set(component.propsName, (referenceCount.get(component.propsName) ?? 0) + 1);
391
+ }
392
+ }
349
393
  for (const component of components) {
350
- if (component.name === null) {
394
+ const propsName = component.propsName;
395
+ if (propsName === null || (referenceCount.get(propsName) ?? 0) > 1) {
351
396
  continue;
352
397
  }
353
- const propsIndex = propsIndexByName.get(`${component.name}${PROPS_SUFFIX}`);
398
+ const propsIndex = declIndexByName.get(propsName);
354
399
  if (propsIndex !== void 0 && propsIndex !== component.index - 1) {
355
- violations.push({ index: propsIndex, messageId: "interfaceImmediatelyBeforeComponent" });
400
+ violations.push({
401
+ index: propsIndex,
402
+ messageId: "interfaceImmediatelyBeforeComponent",
403
+ data: { interface: propsName, component: component.name ?? ANONYMOUS_COMPONENT }
404
+ });
356
405
  }
357
406
  }
358
407
  return violations;
@@ -363,14 +412,24 @@ var findAnchorViolations = (body, directiveCount, importIndices, first, firstPro
363
412
  return importIndex < firstPropsIndex;
364
413
  });
365
414
  if (importsBeforeInterface && hasStrayBefore(body, firstPropsIndex, directiveCount, first.index)) {
366
- violations.push({ index: firstPropsIndex, messageId: "interfaceImmediatelyAfterImports" });
415
+ violations.push({
416
+ index: firstPropsIndex,
417
+ messageId: "interfaceImmediatelyAfterImports",
418
+ // `firstPropsIndex !== undefined` implies `firstPropsName !== null` (it is derived from it).
419
+ data: { interface: firstPropsName, component: first.name ?? ANONYMOUS_COMPONENT }
420
+ });
367
421
  }
368
422
  const firstPropsImported = firstPropsIndex === void 0 && firstPropsName !== null && importedNames.has(firstPropsName);
369
423
  const importsBeforeComponent = importIndices.every((importIndex) => {
370
424
  return importIndex < first.index;
371
425
  });
372
426
  if (firstPropsImported && importsBeforeComponent && hasStrayBefore(body, first.index, directiveCount)) {
373
- violations.push({ index: first.index, messageId: "componentImmediatelyAfterImports" });
427
+ violations.push({
428
+ index: first.index,
429
+ messageId: "componentImmediatelyAfterImports",
430
+ // `firstPropsImported` requires `firstPropsName !== null`.
431
+ data: { interface: firstPropsName, component: first.name ?? ANONYMOUS_COMPONENT }
432
+ });
374
433
  }
375
434
  return violations;
376
435
  };
@@ -380,7 +439,7 @@ var componentFileOrder = {
380
439
  docs: {
381
440
  description: "Enforce a strict top-level order in React component files: imports first, then \u2014 for each component \u2014 its props interface/type declared immediately before the component.",
382
441
  recommended: true,
383
- url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin"
442
+ url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#component-file-order"
384
443
  },
385
444
  schema: [
386
445
  {
@@ -401,10 +460,14 @@ var componentFileOrder = {
401
460
  }
402
461
  ],
403
462
  messages: {
463
+ // Generic on purpose: fires on a misplaced import, and the constraint is "before *every*
464
+ // component", so naming a single component would mislead in a multi-component file.
404
465
  importsFirst: "Imports must come before the component interface and declaration.",
405
- interfaceImmediatelyBeforeComponent: "The component props interface must be declared immediately before the component.",
406
- interfaceImmediatelyAfterImports: "The component props interface must be declared immediately after the imports, with no other declarations in between.",
407
- componentImmediatelyAfterImports: "When the component props type is imported, the component must be declared immediately after the imports, with no other declarations in between."
466
+ // "props type" (not "interface"): the rule matches both `interface` and `type` alias props
467
+ // declarations, so the neutral term reads correctly for either.
468
+ interfaceImmediatelyBeforeComponent: "Declare the props type `{{interface}}` immediately before component `{{component}}` (no declarations between them).",
469
+ interfaceImmediatelyAfterImports: "Declare the props type `{{interface}}` (for component `{{component}}`) immediately after the imports, with no other declarations in between.",
470
+ componentImmediatelyAfterImports: "Props type `{{interface}}` is imported, so declare component `{{component}}` immediately after the imports, with no other declarations in between."
408
471
  }
409
472
  },
410
473
  create(context) {
@@ -421,17 +484,17 @@ var componentFileOrder = {
421
484
  Program(program) {
422
485
  const body = program.body;
423
486
  const directiveCount = getDirectivePrologueCount(body);
424
- const { importIndices, components, propsIndexByName, importedNames } = collectTopLevel(body);
487
+ const { importIndices, components, declIndexByName, importedNames } = collectTopLevel(body);
425
488
  if (components.length === 0) {
426
489
  return;
427
490
  }
428
491
  const first = components[0];
429
- const firstPropsName = first.name === null ? null : `${first.name}${PROPS_SUFFIX}`;
430
- const firstPropsIndex = firstPropsName === null ? void 0 : propsIndexByName.get(firstPropsName);
492
+ const firstPropsName = first.propsName;
493
+ const firstPropsIndex = firstPropsName === null ? void 0 : declIndexByName.get(firstPropsName);
431
494
  const importBoundary = Math.min(first.index, firstPropsIndex ?? first.index);
432
495
  const violations = [
433
496
  ...findImportOrderViolations(importIndices, importBoundary),
434
- ...findAdjacencyViolations(components, propsIndexByName),
497
+ ...findAdjacencyViolations(components, declIndexByName),
435
498
  ...findAnchorViolations(
436
499
  body,
437
500
  directiveCount,
@@ -443,14 +506,274 @@ var componentFileOrder = {
443
506
  )
444
507
  ];
445
508
  for (const violation of violations) {
446
- context.report({ node: body[violation.index], messageId: violation.messageId });
509
+ context.report({ node: body[violation.index], messageId: violation.messageId, data: violation.data });
510
+ }
511
+ }
512
+ };
513
+ }
514
+ };
515
+
516
+ // src/rules/max-components-per-file/max-components-per-file.ts
517
+ var DEFAULT_MAX_COMPONENTS = 4;
518
+ var ANONYMOUS_NAME2 = "component";
519
+ var componentFunctionsIn = (declaration) => {
520
+ if (declaration.type === "FunctionDeclaration") {
521
+ return isComponent(declaration) ? [declaration] : [];
522
+ }
523
+ if (declaration.type === "VariableDeclaration") {
524
+ return declaration.declarations.map((declarator) => {
525
+ return getComponentFunction(declarator.init);
526
+ }).filter((fn2) => {
527
+ return fn2 !== null && isComponent(fn2);
528
+ });
529
+ }
530
+ const fn = getComponentFunction(declaration);
531
+ return fn && isComponent(fn) ? [fn] : [];
532
+ };
533
+ var collectComponents = (body) => {
534
+ return body.flatMap((statement) => {
535
+ const declaration = unwrapExport(statement);
536
+ return declaration ? componentFunctionsIn(declaration) : [];
537
+ });
538
+ };
539
+ var maxComponentsPerFile = {
540
+ meta: {
541
+ type: "suggestion",
542
+ docs: {
543
+ description: "Limit the number of React components declared in a single file; move extra components into their own files.",
544
+ recommended: true,
545
+ url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#max-components-per-file"
546
+ },
547
+ schema: [
548
+ {
549
+ type: "object",
550
+ properties: {
551
+ maxComponents: {
552
+ type: "integer",
553
+ minimum: 1,
554
+ description: "Maximum number of component declarations a single file may contain before the rule reports."
555
+ },
556
+ paths: {
557
+ type: "array",
558
+ items: { type: "string" },
559
+ description: "Optional glob patterns. When provided, the rule only runs for files whose path matches one of them."
560
+ },
561
+ ignore: {
562
+ type: "array",
563
+ items: { type: "string" },
564
+ description: "Optional glob patterns. The rule is skipped for files whose path matches one of them, even if it also matches `paths`."
565
+ }
566
+ },
567
+ additionalProperties: false
568
+ }
569
+ ],
570
+ messages: {
571
+ // File-scoped problem → reported once, anchored to the first component over
572
+ // the limit so a human or AI fix loop has a concrete node to move out.
573
+ tooManyComponents: "This file declares {{count}} components (max {{max}}); move components such as {{name}} into separate files."
574
+ }
575
+ },
576
+ create(context) {
577
+ const options = context.options[0] ?? {};
578
+ const max = options.maxComponents ?? DEFAULT_MAX_COMPONENTS;
579
+ const paths = options.paths ?? [];
580
+ const ignore = options.ignore ?? [];
581
+ if (ignore.length > 0 && matchesAnyGlob(context.filename, ignore)) {
582
+ return {};
583
+ }
584
+ if (paths.length > 0 && !matchesAnyGlob(context.filename, paths)) {
585
+ return {};
586
+ }
587
+ return {
588
+ Program(program) {
589
+ const components = collectComponents(program.body);
590
+ if (components.length <= max) {
591
+ return;
592
+ }
593
+ const offender = components[max];
594
+ if (!offender) {
595
+ return;
596
+ }
597
+ const name = getComponentName(offender) ?? ANONYMOUS_NAME2;
598
+ context.report({
599
+ node: offender,
600
+ messageId: "tooManyComponents",
601
+ data: { count: components.length, max, name }
602
+ });
603
+ }
604
+ };
605
+ }
606
+ };
607
+
608
+ // src/rules/max-jsx-return-size/max-jsx-return-size.ts
609
+ var DEFAULT_MAX_ELEMENTS = 20;
610
+ var ANONYMOUS_NAME3 = "component";
611
+ var isNode = (value) => {
612
+ return typeof value === "object" && value !== null && typeof value.type === "string";
613
+ };
614
+ var childNodes = (node, visitorKeys) => {
615
+ const children = [];
616
+ const keys = visitorKeys[node.type] ?? Object.keys(node);
617
+ for (const key of keys) {
618
+ if (key === "parent") {
619
+ continue;
620
+ }
621
+ const value = node[key];
622
+ if (Array.isArray(value)) {
623
+ children.push(...value.filter(isNode));
624
+ } else if (isNode(value)) {
625
+ children.push(value);
626
+ }
627
+ }
628
+ return children;
629
+ };
630
+ var countJsxElements = (node, visitorKeys) => {
631
+ const self = node.type === "JSXElement" ? 1 : 0;
632
+ return childNodes(node, visitorKeys).reduce((total, child) => {
633
+ return total + countJsxElements(child, visitorKeys);
634
+ }, self);
635
+ };
636
+ var jsxNameToString = (node) => {
637
+ const name = node;
638
+ switch (name?.type) {
639
+ case "JSXIdentifier":
640
+ return typeof name.name === "string" ? name.name : "element";
641
+ case "JSXMemberExpression":
642
+ return `${jsxNameToString(name.object)}.${jsxNameToString(name.property)}`;
643
+ case "JSXNamespacedName":
644
+ return `${jsxNameToString(name.namespace)}:${jsxNameToString(name.name)}`;
645
+ default:
646
+ return "element";
647
+ }
648
+ };
649
+ var topLevelElements = (root, visitorKeys) => {
650
+ const elements = [];
651
+ const walk = (node, isRoot) => {
652
+ if (!isRoot && node.type === "JSXElement") {
653
+ elements.push(node);
654
+ return;
655
+ }
656
+ for (const child of childNodes(node, visitorKeys)) {
657
+ walk(child, false);
658
+ }
659
+ };
660
+ walk(root, true);
661
+ return elements;
662
+ };
663
+ var largestBlock = (root, visitorKeys) => {
664
+ let best = null;
665
+ for (const element2 of topLevelElements(root, visitorKeys)) {
666
+ const count = countJsxElements(element2, visitorKeys);
667
+ if (!best || count > best.count) {
668
+ best = { node: element2, count };
669
+ }
670
+ }
671
+ if (!best) {
672
+ return null;
673
+ }
674
+ const element = best.node;
675
+ return { name: jsxNameToString(element.openingElement?.name), line: element.loc?.start.line ?? 0, count: best.count };
676
+ };
677
+ var componentFunctionsIn2 = (declaration) => {
678
+ if (declaration.type === "FunctionDeclaration") {
679
+ return isComponent(declaration) ? [declaration] : [];
680
+ }
681
+ if (declaration.type === "VariableDeclaration") {
682
+ return declaration.declarations.map((declarator) => {
683
+ return getComponentFunction(declarator.init);
684
+ }).filter((fn2) => {
685
+ return fn2 !== null && isComponent(fn2);
686
+ });
687
+ }
688
+ const fn = getComponentFunction(declaration);
689
+ return fn && isComponent(fn) ? [fn] : [];
690
+ };
691
+ var collectComponents2 = (body) => {
692
+ return body.flatMap((statement) => {
693
+ const declaration = unwrapExport(statement);
694
+ return declaration ? componentFunctionsIn2(declaration) : [];
695
+ });
696
+ };
697
+ var maxJsxReturnSize = {
698
+ meta: {
699
+ type: "suggestion",
700
+ docs: {
701
+ description: "Warn when a component return renders too many JSX elements; extract parts into variables or sub-components.",
702
+ recommended: true,
703
+ url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#max-jsx-return-size"
704
+ },
705
+ schema: [
706
+ {
707
+ type: "object",
708
+ properties: {
709
+ maxElements: {
710
+ type: "integer",
711
+ minimum: 1,
712
+ description: "Maximum number of JSX elements a single return may render before the rule reports."
713
+ },
714
+ paths: {
715
+ type: "array",
716
+ items: { type: "string" },
717
+ description: "Optional glob patterns. When provided, the rule only runs for files whose path matches one of them."
718
+ },
719
+ ignore: {
720
+ type: "array",
721
+ items: { type: "string" },
722
+ description: "Optional glob patterns. The rule is skipped for files whose path matches one of them, even if it also matches `paths`."
723
+ }
724
+ },
725
+ additionalProperties: false
726
+ }
727
+ ],
728
+ messages: {
729
+ // Points at the single biggest block so a human or AI fix loop knows
730
+ // exactly what to lift out.
731
+ tooManyElements: "{{name}} renders {{count}} JSX elements in one return (max {{max}}). Extract the largest block \u2014 <{{largest}}> at line {{line}} ({{largestCount}} elements) \u2014 into a variable or a sub-component.",
732
+ // Fallback when no single block dominates (e.g. many flat siblings): there is
733
+ // nothing meaningful to point at, so advise splitting.
734
+ tooManyElementsFlat: "{{name}} renders {{count}} JSX elements in one return (max {{max}}). Split it into smaller sub-components or extract groups of elements into variables."
735
+ }
736
+ },
737
+ create(context) {
738
+ const options = context.options[0] ?? {};
739
+ const max = options.maxElements ?? DEFAULT_MAX_ELEMENTS;
740
+ const paths = options.paths ?? [];
741
+ const ignore = options.ignore ?? [];
742
+ if (ignore.length > 0 && matchesAnyGlob(context.filename, ignore)) {
743
+ return {};
744
+ }
745
+ if (paths.length > 0 && !matchesAnyGlob(context.filename, paths)) {
746
+ return {};
747
+ }
748
+ const visitorKeys = context.sourceCode.visitorKeys;
749
+ const checkComponent = (fn) => {
750
+ const name = getComponentName(fn) ?? ANONYMOUS_NAME3;
751
+ for (const argument of collectOwnReturnArguments(fn)) {
752
+ const count = countJsxElements(argument, visitorKeys);
753
+ if (count <= max) {
754
+ continue;
755
+ }
756
+ const largest = largestBlock(argument, visitorKeys);
757
+ if (largest && largest.count >= 2) {
758
+ context.report({
759
+ node: argument,
760
+ messageId: "tooManyElements",
761
+ data: { count, max, name, largest: largest.name, line: largest.line, largestCount: largest.count }
762
+ });
763
+ } else {
764
+ context.report({ node: argument, messageId: "tooManyElementsFlat", data: { count, max, name } });
447
765
  }
448
766
  }
449
767
  };
768
+ return {
769
+ Program(program) {
770
+ collectComponents2(program.body).forEach(checkComponent);
771
+ }
772
+ };
450
773
  }
451
774
  };
452
775
 
453
- // src/rules/props-destructuring-blank-line.ts
776
+ // src/rules/props-destructuring-blank-line/props-destructuring-blank-line.ts
454
777
  var isPropsDestructuring = (statement) => {
455
778
  if (statement.type !== "VariableDeclaration") {
456
779
  return false;
@@ -465,7 +788,7 @@ var propsDestructuringBlankLine = {
465
788
  docs: {
466
789
  description: "Require a blank line after the `const { ... } = props` destructuring statement at the top of a React component body.",
467
790
  recommended: true,
468
- url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin"
791
+ url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#props-destructuring-blank-line"
469
792
  },
470
793
  fixable: "whitespace",
471
794
  schema: [],
@@ -513,7 +836,7 @@ var propsDestructuringBlankLine = {
513
836
  }
514
837
  };
515
838
 
516
- // src/rules/props-destructuring-newline.ts
839
+ // src/rules/props-destructuring-newline/props-destructuring-newline.ts
517
840
  var collectBoundNames = (node, names) => {
518
841
  if (!node) {
519
842
  return;
@@ -547,7 +870,7 @@ var propsDestructuringNewline = {
547
870
  docs: {
548
871
  description: "Require React components to accept a single props parameter and destructure it on its own line in the body, rather than destructuring inline in the parameter list.",
549
872
  recommended: true,
550
- url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin"
873
+ url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#props-destructuring-newline"
551
874
  },
552
875
  fixable: "code",
553
876
  schema: [],
@@ -624,18 +947,87 @@ ${baseIndent}}`
624
947
  }
625
948
  };
626
949
 
627
- // src/rules/props-type-reference.ts
628
- var INLINE_OBJECT_TYPE = "TSTypeLiteral";
629
- var getAnnotatedParam = (param) => {
630
- return param.type === "AssignmentPattern" ? param.left : param;
950
+ // src/rules/props-type-name/props-type-name.ts
951
+ var PROPS_SUFFIX2 = "Props";
952
+ var propsTypeName = {
953
+ meta: {
954
+ type: "suggestion",
955
+ docs: {
956
+ description: "Require a React component's props type to be named `<ComponentName>Props` (e.g. `ButtonProps` for `Button`).",
957
+ recommended: true,
958
+ url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#props-type-name"
959
+ },
960
+ schema: [
961
+ {
962
+ type: "object",
963
+ properties: {
964
+ paths: {
965
+ type: "array",
966
+ items: { type: "string" },
967
+ description: "Optional glob patterns. When provided, the rule only runs for files whose path matches one of them."
968
+ },
969
+ ignore: {
970
+ type: "array",
971
+ items: { type: "string" },
972
+ description: "Optional glob patterns. The rule is skipped for files whose path matches one of them, even if it also matches `paths`."
973
+ }
974
+ },
975
+ additionalProperties: false
976
+ }
977
+ ],
978
+ messages: {
979
+ propsTypeNameMismatch: "A component's props type must be named `{{expected}}`, but it is named `{{actual}}`."
980
+ }
981
+ },
982
+ create(context) {
983
+ const options = context.options[0] ?? {};
984
+ const paths = options.paths ?? [];
985
+ const ignore = options.ignore ?? [];
986
+ if (ignore.length > 0 && matchesAnyGlob(context.filename, ignore)) {
987
+ return {};
988
+ }
989
+ if (paths.length > 0 && !matchesAnyGlob(context.filename, paths)) {
990
+ return {};
991
+ }
992
+ const check = (node) => {
993
+ if (!isComponent(node)) {
994
+ return;
995
+ }
996
+ const componentName = getComponentName(node);
997
+ if (componentName === null) {
998
+ return;
999
+ }
1000
+ const actual = getPropsTypeNameFromFunction(node);
1001
+ if (actual === null) {
1002
+ return;
1003
+ }
1004
+ const expected = `${componentName}${PROPS_SUFFIX2}`;
1005
+ if (actual === expected) {
1006
+ return;
1007
+ }
1008
+ context.report({
1009
+ node: getAnnotatedParam(node.params[0]),
1010
+ messageId: "propsTypeNameMismatch",
1011
+ data: { expected, actual }
1012
+ });
1013
+ };
1014
+ return {
1015
+ ArrowFunctionExpression: check,
1016
+ FunctionDeclaration: check,
1017
+ FunctionExpression: check
1018
+ };
1019
+ }
631
1020
  };
1021
+
1022
+ // src/rules/props-type-reference/props-type-reference.ts
1023
+ var INLINE_OBJECT_TYPE = "TSTypeLiteral";
632
1024
  var propsTypeReference = {
633
1025
  meta: {
634
1026
  type: "suggestion",
635
1027
  docs: {
636
1028
  description: "Require a React component's props parameter to use a named type (e.g. `ButtonProps`) instead of an inline object type literal.",
637
1029
  recommended: true,
638
- url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin"
1030
+ url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#props-type-reference"
639
1031
  },
640
1032
  schema: [
641
1033
  {
@@ -693,7 +1085,7 @@ var propsTypeReference = {
693
1085
  }
694
1086
  };
695
1087
 
696
- // src/rules/require-component-stories.ts
1088
+ // src/rules/require-component-stories/require-component-stories.ts
697
1089
  import { existsSync } from "node:fs";
698
1090
  import path2 from "node:path";
699
1091
 
@@ -769,7 +1161,7 @@ var deriveExpectedStoryPaths = (filePath, opts) => {
769
1161
  });
770
1162
  };
771
1163
 
772
- // src/rules/require-component-stories.ts
1164
+ // src/rules/require-component-stories/require-component-stories.ts
773
1165
  var toStoryPathOptions = (options) => {
774
1166
  const picked = {};
775
1167
  if (options.storiesDir !== void 0) {
@@ -795,7 +1187,7 @@ var requireComponentStories = {
795
1187
  docs: {
796
1188
  description: "Require a co-located Storybook story for every dumb component.",
797
1189
  recommended: true,
798
- url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin"
1190
+ url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#require-component-stories"
799
1191
  },
800
1192
  schema: [
801
1193
  {
@@ -899,8 +1291,11 @@ var rules = {
899
1291
  "props-destructuring-newline": propsDestructuringNewline,
900
1292
  "props-destructuring-blank-line": propsDestructuringBlankLine,
901
1293
  "props-type-reference": propsTypeReference,
1294
+ "props-type-name": propsTypeName,
902
1295
  "component-file-order": componentFileOrder,
903
1296
  "component-arrow-function": componentArrowFunction,
1297
+ "max-components-per-file": maxComponentsPerFile,
1298
+ "max-jsx-return-size": maxJsxReturnSize,
904
1299
  "require-component-stories": requireComponentStories
905
1300
  };
906
1301
 
@@ -924,20 +1319,49 @@ plugin.configs.recommended = [
924
1319
  [`${PLUGIN_NAME}/props-destructuring-newline`]: "error",
925
1320
  [`${PLUGIN_NAME}/props-destructuring-blank-line`]: "error",
926
1321
  [`${PLUGIN_NAME}/props-type-reference`]: "error",
1322
+ [`${PLUGIN_NAME}/props-type-name`]: "error",
927
1323
  [`${PLUGIN_NAME}/component-file-order`]: "error",
928
1324
  // Pages and routes are excluded: route/page modules commonly use `function`
929
1325
  // declarations (and framework conventions like default-exported page functions).
930
1326
  [`${PLUGIN_NAME}/component-arrow-function`]: ["error", { ignore: ["**/pages/**", "**/routes/**"] }],
931
- [`${PLUGIN_NAME}/require-component-stories`]: "error"
1327
+ [`${PLUGIN_NAME}/require-component-stories`]: "error",
1328
+ // Advisory: flags returns that render too many JSX elements; extract into a
1329
+ // variable or sub-component. Warn-class by nature (`type: 'suggestion'`);
1330
+ // severity confirmed against a repo-wide dry run at the default ceiling.
1331
+ [`${PLUGIN_NAME}/max-jsx-return-size`]: "error",
1332
+ // Caps component declarations per file; extra components belong in their own
1333
+ // files. Pages and routes are excluded (framework conventions co-locate
1334
+ // route trees and default-exported page functions). Ceiling confirmed
1335
+ // against a repo-wide dry run at the default.
1336
+ [`${PLUGIN_NAME}/max-components-per-file`]: ["error", { ignore: ["**/pages/**", "**/routes/**"] }]
932
1337
  }
933
1338
  },
934
- // Storybook stories legitimately deviate from the imports *Props → component
935
- // order (meta/args/decorators/render fns), so component-file-order would only
936
- // produce noise there. Every other rule stays enabled for story files.
1339
+ // Storybook stories legitimately deviate from the component conventions: the
1340
+ // imports → *Props → component order (meta/args/decorators/render fns), and
1341
+ // named templates that reference the *component's* props type (e.g.
1342
+ // `const Template = (args: ButtonProps) => ...`) rather than their own
1343
+ // `<TemplateName>Props`. So both the ordering and the props-type-name rules
1344
+ // would only produce noise there. Every other rule stays enabled for stories.
937
1345
  {
938
1346
  files: ["**/*.stories.{ts,tsx}"],
939
1347
  rules: {
940
- [`${PLUGIN_NAME}/component-file-order`]: "off"
1348
+ [`${PLUGIN_NAME}/component-file-order`]: "off",
1349
+ [`${PLUGIN_NAME}/props-type-name`]: "off"
1350
+ }
1351
+ },
1352
+ // Dumb presentational files (`*-component.tsx`) follow the one-component-per-file
1353
+ // convention the props/order/stories rules already assume, so they get a tighter
1354
+ // ceiling of 1. This MUST come AFTER the global `**/*.tsx` block: flat config
1355
+ // REPLACES rule options across matching blocks (it does not merge), and `ignore`
1356
+ // is re-declared here so pages/routes dumb-components keep their exemption.
1357
+ // A repo-wide dry run found zero `*-component.tsx` files declaring >1 component.
1358
+ {
1359
+ files: ["**/*-component.tsx"],
1360
+ rules: {
1361
+ [`${PLUGIN_NAME}/max-components-per-file`]: [
1362
+ "error",
1363
+ { maxComponents: 1, ignore: ["**/pages/**", "**/routes/**"] }
1364
+ ]
941
1365
  }
942
1366
  }
943
1367
  ];