@slip-stream-kit/eslint-plugin 0.1.17 → 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 +338 -30
  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.d.ts → props-type-name/props-type-name.d.ts} +0 -1
  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 +14 -0
  22. package/package.json +3 -3
  23. package/readme.md +159 -0
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);
@@ -226,7 +229,7 @@ var matchesAnyGlob = (filename, patterns) => {
226
229
  });
227
230
  };
228
231
 
229
- // src/rules/component-arrow-function.ts
232
+ // src/rules/component-arrow-function/component-arrow-function.ts
230
233
  var ANONYMOUS_NAME = "component";
231
234
  var reportName = (fn) => {
232
235
  return getComponentName(fn) ?? ANONYMOUS_NAME;
@@ -272,7 +275,7 @@ var componentArrowFunction = {
272
275
  docs: {
273
276
  description: "Enforce that React components are declared as arrow functions, not `function` declarations or function expressions.",
274
277
  recommended: true,
275
- 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"
276
279
  },
277
280
  schema: [
278
281
  {
@@ -317,7 +320,7 @@ var componentArrowFunction = {
317
320
  }
318
321
  };
319
322
 
320
- // src/rules/component-file-order.ts
323
+ // src/rules/component-file-order/component-file-order.ts
321
324
  var PROPS_SUFFIX = "Props";
322
325
  var getDirectivePrologueCount = (body) => {
323
326
  let count = 0;
@@ -371,6 +374,7 @@ var hasStrayBefore = (body, boundary, directiveCount, skipIndex) => {
371
374
  return index < boundary && index >= directiveCount && index !== skipIndex && statement.type !== "ImportDeclaration";
372
375
  });
373
376
  };
377
+ var ANONYMOUS_COMPONENT = "component";
374
378
  var findImportOrderViolations = (importIndices, importBoundary) => {
375
379
  return importIndices.filter((importIndex) => {
376
380
  return importIndex > importBoundary;
@@ -393,7 +397,11 @@ var findAdjacencyViolations = (components, declIndexByName) => {
393
397
  }
394
398
  const propsIndex = declIndexByName.get(propsName);
395
399
  if (propsIndex !== void 0 && propsIndex !== component.index - 1) {
396
- 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
+ });
397
405
  }
398
406
  }
399
407
  return violations;
@@ -404,14 +412,24 @@ var findAnchorViolations = (body, directiveCount, importIndices, first, firstPro
404
412
  return importIndex < firstPropsIndex;
405
413
  });
406
414
  if (importsBeforeInterface && hasStrayBefore(body, firstPropsIndex, directiveCount, first.index)) {
407
- 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
+ });
408
421
  }
409
422
  const firstPropsImported = firstPropsIndex === void 0 && firstPropsName !== null && importedNames.has(firstPropsName);
410
423
  const importsBeforeComponent = importIndices.every((importIndex) => {
411
424
  return importIndex < first.index;
412
425
  });
413
426
  if (firstPropsImported && importsBeforeComponent && hasStrayBefore(body, first.index, directiveCount)) {
414
- 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
+ });
415
433
  }
416
434
  return violations;
417
435
  };
@@ -421,7 +439,7 @@ var componentFileOrder = {
421
439
  docs: {
422
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.",
423
441
  recommended: true,
424
- 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"
425
443
  },
426
444
  schema: [
427
445
  {
@@ -442,10 +460,14 @@ var componentFileOrder = {
442
460
  }
443
461
  ],
444
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.
445
465
  importsFirst: "Imports must come before the component interface and declaration.",
446
- interfaceImmediatelyBeforeComponent: "The component props interface must be declared immediately before the component.",
447
- interfaceImmediatelyAfterImports: "The component props interface must be declared immediately after the imports, with no other declarations in between.",
448
- 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."
449
471
  }
450
472
  },
451
473
  create(context) {
@@ -484,14 +506,274 @@ var componentFileOrder = {
484
506
  )
485
507
  ];
486
508
  for (const violation of violations) {
487
- 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;
488
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 } });
765
+ }
766
+ }
767
+ };
768
+ return {
769
+ Program(program) {
770
+ collectComponents2(program.body).forEach(checkComponent);
489
771
  }
490
772
  };
491
773
  }
492
774
  };
493
775
 
494
- // src/rules/props-destructuring-blank-line.ts
776
+ // src/rules/props-destructuring-blank-line/props-destructuring-blank-line.ts
495
777
  var isPropsDestructuring = (statement) => {
496
778
  if (statement.type !== "VariableDeclaration") {
497
779
  return false;
@@ -506,7 +788,7 @@ var propsDestructuringBlankLine = {
506
788
  docs: {
507
789
  description: "Require a blank line after the `const { ... } = props` destructuring statement at the top of a React component body.",
508
790
  recommended: true,
509
- 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"
510
792
  },
511
793
  fixable: "whitespace",
512
794
  schema: [],
@@ -554,7 +836,7 @@ var propsDestructuringBlankLine = {
554
836
  }
555
837
  };
556
838
 
557
- // src/rules/props-destructuring-newline.ts
839
+ // src/rules/props-destructuring-newline/props-destructuring-newline.ts
558
840
  var collectBoundNames = (node, names) => {
559
841
  if (!node) {
560
842
  return;
@@ -588,7 +870,7 @@ var propsDestructuringNewline = {
588
870
  docs: {
589
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.",
590
872
  recommended: true,
591
- 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"
592
874
  },
593
875
  fixable: "code",
594
876
  schema: [],
@@ -665,7 +947,7 @@ ${baseIndent}}`
665
947
  }
666
948
  };
667
949
 
668
- // src/rules/props-type-name.ts
950
+ // src/rules/props-type-name/props-type-name.ts
669
951
  var PROPS_SUFFIX2 = "Props";
670
952
  var propsTypeName = {
671
953
  meta: {
@@ -673,7 +955,7 @@ var propsTypeName = {
673
955
  docs: {
674
956
  description: "Require a React component's props type to be named `<ComponentName>Props` (e.g. `ButtonProps` for `Button`).",
675
957
  recommended: true,
676
- url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin"
958
+ url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#props-type-name"
677
959
  },
678
960
  schema: [
679
961
  {
@@ -737,7 +1019,7 @@ var propsTypeName = {
737
1019
  }
738
1020
  };
739
1021
 
740
- // src/rules/props-type-reference.ts
1022
+ // src/rules/props-type-reference/props-type-reference.ts
741
1023
  var INLINE_OBJECT_TYPE = "TSTypeLiteral";
742
1024
  var propsTypeReference = {
743
1025
  meta: {
@@ -745,7 +1027,7 @@ var propsTypeReference = {
745
1027
  docs: {
746
1028
  description: "Require a React component's props parameter to use a named type (e.g. `ButtonProps`) instead of an inline object type literal.",
747
1029
  recommended: true,
748
- 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"
749
1031
  },
750
1032
  schema: [
751
1033
  {
@@ -803,7 +1085,7 @@ var propsTypeReference = {
803
1085
  }
804
1086
  };
805
1087
 
806
- // src/rules/require-component-stories.ts
1088
+ // src/rules/require-component-stories/require-component-stories.ts
807
1089
  import { existsSync } from "node:fs";
808
1090
  import path2 from "node:path";
809
1091
 
@@ -879,7 +1161,7 @@ var deriveExpectedStoryPaths = (filePath, opts) => {
879
1161
  });
880
1162
  };
881
1163
 
882
- // src/rules/require-component-stories.ts
1164
+ // src/rules/require-component-stories/require-component-stories.ts
883
1165
  var toStoryPathOptions = (options) => {
884
1166
  const picked = {};
885
1167
  if (options.storiesDir !== void 0) {
@@ -905,7 +1187,7 @@ var requireComponentStories = {
905
1187
  docs: {
906
1188
  description: "Require a co-located Storybook story for every dumb component.",
907
1189
  recommended: true,
908
- 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"
909
1191
  },
910
1192
  schema: [
911
1193
  {
@@ -1012,6 +1294,8 @@ var rules = {
1012
1294
  "props-type-name": propsTypeName,
1013
1295
  "component-file-order": componentFileOrder,
1014
1296
  "component-arrow-function": componentArrowFunction,
1297
+ "max-components-per-file": maxComponentsPerFile,
1298
+ "max-jsx-return-size": maxJsxReturnSize,
1015
1299
  "require-component-stories": requireComponentStories
1016
1300
  };
1017
1301
 
@@ -1040,7 +1324,16 @@ plugin.configs.recommended = [
1040
1324
  // Pages and routes are excluded: route/page modules commonly use `function`
1041
1325
  // declarations (and framework conventions like default-exported page functions).
1042
1326
  [`${PLUGIN_NAME}/component-arrow-function`]: ["error", { ignore: ["**/pages/**", "**/routes/**"] }],
1043
- [`${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/**"] }]
1044
1337
  }
1045
1338
  },
1046
1339
  // Storybook stories legitimately deviate from the component conventions: the
@@ -1055,6 +1348,21 @@ plugin.configs.recommended = [
1055
1348
  [`${PLUGIN_NAME}/component-file-order`]: "off",
1056
1349
  [`${PLUGIN_NAME}/props-type-name`]: "off"
1057
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
+ ]
1365
+ }
1058
1366
  }
1059
1367
  ];
1060
1368
  var meta = plugin.meta;