@slip-stream-kit/eslint-plugin 0.1.17 → 0.1.20

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 (26) hide show
  1. package/dist/index.js +634 -32
  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/rules/require-jsdoc-example/index.d.ts +1 -0
  22. package/dist/rules/require-jsdoc-example/require-jsdoc-example.d.ts +2 -0
  23. package/dist/utils/cognitive-complexity.d.ts +14 -0
  24. package/dist/utils/component.d.ts +14 -0
  25. package/package.json +3 -3
  26. 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;
488
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
+ });
489
603
  }
490
604
  };
491
605
  }
492
606
  };
493
607
 
494
- // src/rules/props-destructuring-blank-line.ts
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 walk2 = (node, isRoot) => {
652
+ if (!isRoot && node.type === "JSXElement") {
653
+ elements.push(node);
654
+ return;
655
+ }
656
+ for (const child of childNodes(node, visitorKeys)) {
657
+ walk2(child, false);
658
+ }
659
+ };
660
+ walk2(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 } });
765
+ }
766
+ }
767
+ };
768
+ return {
769
+ Program(program) {
770
+ collectComponents2(program.body).forEach(checkComponent);
771
+ }
772
+ };
773
+ }
774
+ };
775
+
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
  {
@@ -1004,6 +1286,282 @@ var requireComponentStories = {
1004
1286
  }
1005
1287
  };
1006
1288
 
1289
+ // src/utils/cognitive-complexity.ts
1290
+ var NON_CHILD_KEYS = /* @__PURE__ */ new Set(["parent", "loc", "range", "type"]);
1291
+ var LOOP_TYPES = /* @__PURE__ */ new Set(["ForStatement", "ForInStatement", "ForOfStatement", "WhileStatement", "DoWhileStatement"]);
1292
+ var NESTED_FUNCTION_TYPES = /* @__PURE__ */ new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
1293
+ var isNode2 = (value) => {
1294
+ return typeof value === "object" && value !== null && typeof value.type === "string";
1295
+ };
1296
+ var childrenOf = (node) => {
1297
+ const children = [];
1298
+ for (const key of Object.keys(node)) {
1299
+ if (NON_CHILD_KEYS.has(key)) {
1300
+ continue;
1301
+ }
1302
+ const value = node[key];
1303
+ if (Array.isArray(value)) {
1304
+ children.push(...value.filter(isNode2));
1305
+ } else if (isNode2(value)) {
1306
+ children.push(value);
1307
+ }
1308
+ }
1309
+ return children;
1310
+ };
1311
+ var flattenLogicalOperators = (node, operators) => {
1312
+ if (node.type !== "LogicalExpression") {
1313
+ return [node];
1314
+ }
1315
+ const left = flattenLogicalOperators(node.left, operators);
1316
+ operators.push(node.operator);
1317
+ const right = flattenLogicalOperators(node.right, operators);
1318
+ return [...left, ...right];
1319
+ };
1320
+ var countOperatorRuns = (operators) => {
1321
+ let runs = 0;
1322
+ let previous = null;
1323
+ for (const operator of operators) {
1324
+ if (operator !== previous) {
1325
+ runs += 1;
1326
+ previous = operator;
1327
+ }
1328
+ }
1329
+ return runs;
1330
+ };
1331
+ var recurseChildren = (ctx, node, nesting) => {
1332
+ for (const child of childrenOf(node)) {
1333
+ walk(ctx, child, nesting);
1334
+ }
1335
+ };
1336
+ var handleLogical = (ctx, node, nesting) => {
1337
+ const operators = [];
1338
+ const operands = flattenLogicalOperators(node, operators);
1339
+ ctx.score += countOperatorRuns(operators);
1340
+ for (const operand of operands) {
1341
+ walk(ctx, operand, nesting);
1342
+ }
1343
+ };
1344
+ var handleAlternate = (ctx, alternate, nesting) => {
1345
+ if (!alternate) {
1346
+ return;
1347
+ }
1348
+ if (alternate.type === "IfStatement") {
1349
+ ctx.score += 1;
1350
+ walk(ctx, alternate.test, nesting);
1351
+ walk(ctx, alternate.consequent, nesting + 1);
1352
+ handleAlternate(ctx, alternate.alternate, nesting);
1353
+ return;
1354
+ }
1355
+ ctx.score += 1;
1356
+ walk(ctx, alternate, nesting + 1);
1357
+ };
1358
+ var handleIf = (ctx, node, nesting) => {
1359
+ ctx.score += 1 + nesting;
1360
+ walk(ctx, node.test, nesting);
1361
+ walk(ctx, node.consequent, nesting + 1);
1362
+ handleAlternate(ctx, node.alternate, nesting);
1363
+ };
1364
+ var handleTernary = (ctx, node, nesting) => {
1365
+ ctx.score += 1 + nesting;
1366
+ walk(ctx, node.test, nesting);
1367
+ walk(ctx, node.consequent, nesting + 1);
1368
+ walk(ctx, node.alternate, nesting + 1);
1369
+ };
1370
+ var handleSwitch = (ctx, node, nesting) => {
1371
+ ctx.score += 1 + nesting;
1372
+ walk(ctx, node.discriminant, nesting);
1373
+ for (const switchCase of node.cases) {
1374
+ if (switchCase.test) {
1375
+ walk(ctx, switchCase.test, nesting);
1376
+ }
1377
+ for (const statement of switchCase.consequent) {
1378
+ walk(ctx, statement, nesting + 1);
1379
+ }
1380
+ }
1381
+ };
1382
+ var handleLoop = (ctx, node, nesting) => {
1383
+ ctx.score += 1 + nesting;
1384
+ const body = node.body;
1385
+ for (const child of childrenOf(node)) {
1386
+ walk(ctx, child, child === body ? nesting + 1 : nesting);
1387
+ }
1388
+ };
1389
+ var handleTry = (ctx, node, nesting) => {
1390
+ walk(ctx, node.block, nesting);
1391
+ if (node.handler) {
1392
+ ctx.score += 1 + nesting;
1393
+ walk(ctx, node.handler.body, nesting + 1);
1394
+ }
1395
+ if (node.finalizer) {
1396
+ walk(ctx, node.finalizer, nesting);
1397
+ }
1398
+ };
1399
+ var handleCall = (ctx, node, nesting) => {
1400
+ if (ctx.name && node.callee.type === "Identifier" && node.callee.name === ctx.name) {
1401
+ ctx.score += 1;
1402
+ }
1403
+ recurseChildren(ctx, node, nesting);
1404
+ };
1405
+ function walk(ctx, node, nesting) {
1406
+ const type = node.type;
1407
+ if (type === "LogicalExpression") {
1408
+ handleLogical(ctx, node, nesting);
1409
+ } else if (type === "IfStatement") {
1410
+ handleIf(ctx, node, nesting);
1411
+ } else if (type === "ConditionalExpression") {
1412
+ handleTernary(ctx, node, nesting);
1413
+ } else if (type === "SwitchStatement") {
1414
+ handleSwitch(ctx, node, nesting);
1415
+ } else if (LOOP_TYPES.has(type)) {
1416
+ handleLoop(ctx, node, nesting);
1417
+ } else if (type === "TryStatement") {
1418
+ handleTry(ctx, node, nesting);
1419
+ } else if (NESTED_FUNCTION_TYPES.has(type)) {
1420
+ walk(ctx, node.body, nesting + 1);
1421
+ } else if (type === "CallExpression") {
1422
+ handleCall(ctx, node, nesting);
1423
+ } else {
1424
+ recurseChildren(ctx, node, nesting);
1425
+ }
1426
+ }
1427
+ var cognitiveComplexity = (fn, enclosingName) => {
1428
+ const fallbackName = fn.type === "FunctionDeclaration" ? fn.id?.name ?? null : null;
1429
+ const ctx = { score: 0, name: enclosingName ?? fallbackName };
1430
+ walk(ctx, fn.body, 0);
1431
+ return ctx.score;
1432
+ };
1433
+
1434
+ // src/rules/require-jsdoc-example/require-jsdoc-example.ts
1435
+ var DEFAULT_MIN_COMPLEXITY = 8;
1436
+ var DEFAULT_EXAMPLE_COMPLEXITY = 12;
1437
+ var isTargetFn = (node) => {
1438
+ return node?.type === "ArrowFunctionExpression" || node?.type === "FunctionExpression";
1439
+ };
1440
+ var getExportInfo = (statement) => {
1441
+ if (statement.type === "ExportNamedDeclaration" || statement.type === "ExportDefaultDeclaration") {
1442
+ return { exportStmt: statement, declaration: statement.declaration ?? null };
1443
+ }
1444
+ return { exportStmt: null, declaration: statement };
1445
+ };
1446
+ var targetsFromDeclaration = (declaration, anchors) => {
1447
+ if (declaration.type === "FunctionDeclaration") {
1448
+ return declaration.id ? [{ fn: declaration, name: declaration.id.name, anchors }] : [];
1449
+ }
1450
+ if (declaration.type === "VariableDeclaration") {
1451
+ return declaration.declarations.flatMap((declarator) => {
1452
+ return declarator.id.type === "Identifier" && isTargetFn(declarator.init) ? [{ fn: declarator.init, name: declarator.id.name, anchors }] : [];
1453
+ });
1454
+ }
1455
+ return [];
1456
+ };
1457
+ var collectTargets = (body) => {
1458
+ return body.flatMap((statement) => {
1459
+ const { exportStmt, declaration } = getExportInfo(statement);
1460
+ if (!declaration) {
1461
+ return [];
1462
+ }
1463
+ const anchors = exportStmt ? [exportStmt, declaration] : [declaration];
1464
+ return targetsFromDeclaration(declaration, anchors);
1465
+ });
1466
+ };
1467
+ var hasJsdocBlock = (sourceCode, anchors) => {
1468
+ return anchors.some((anchor) => {
1469
+ return sourceCode.getCommentsBefore(anchor).some((comment) => {
1470
+ return comment.type === "Block" && comment.value.startsWith("*");
1471
+ });
1472
+ });
1473
+ };
1474
+ var hasJsdocExample = (sourceCode, anchors) => {
1475
+ return anchors.some((anchor) => {
1476
+ return sourceCode.getCommentsBefore(anchor).some((comment) => {
1477
+ return comment.type === "Block" && comment.value.startsWith("*") && comment.value.includes("@example");
1478
+ });
1479
+ });
1480
+ };
1481
+ var decideViolation = (target, settings, sourceCode) => {
1482
+ const complexity = cognitiveComplexity(target.fn, target.name);
1483
+ const { minComplexity, exampleComplexity } = settings;
1484
+ if (complexity < minComplexity) {
1485
+ return null;
1486
+ }
1487
+ const { name } = target;
1488
+ if (!hasJsdocBlock(sourceCode, target.anchors)) {
1489
+ return { messageId: "missingJsdoc", data: { name, complexity, minComplexity } };
1490
+ }
1491
+ if (complexity >= exampleComplexity && !hasJsdocExample(sourceCode, target.anchors)) {
1492
+ return { messageId: "missingExample", data: { name, complexity, exampleComplexity } };
1493
+ }
1494
+ return null;
1495
+ };
1496
+ var requireJsdocExample = {
1497
+ meta: {
1498
+ type: "suggestion",
1499
+ docs: {
1500
+ description: "Graduated JSDoc requirement by cognitive complexity: at or above `minComplexity` a function must carry a leading JSDoc block, and at or above `exampleComplexity` that block must also include an `@example` tag.",
1501
+ recommended: true,
1502
+ url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#require-jsdoc-example"
1503
+ },
1504
+ schema: [
1505
+ {
1506
+ type: "object",
1507
+ properties: {
1508
+ minComplexity: {
1509
+ type: "integer",
1510
+ minimum: 1,
1511
+ description: "Cognitive complexity at or above which a function must carry a leading JSDoc block."
1512
+ },
1513
+ exampleComplexity: {
1514
+ type: "integer",
1515
+ minimum: 1,
1516
+ description: "Cognitive complexity at or above which the JSDoc block must also include an `@example` tag."
1517
+ },
1518
+ paths: {
1519
+ type: "array",
1520
+ items: { type: "string" },
1521
+ description: "Optional glob patterns. When provided, the rule only runs for files whose path matches one of them."
1522
+ },
1523
+ ignore: {
1524
+ type: "array",
1525
+ items: { type: "string" },
1526
+ description: "Optional glob patterns. The rule is skipped for files whose path matches one of them, even if it also matches `paths`."
1527
+ }
1528
+ },
1529
+ additionalProperties: false
1530
+ }
1531
+ ],
1532
+ messages: {
1533
+ missingJsdoc: 'function "{{name}}" has cognitive complexity {{complexity}} (>= {{minComplexity}}); add a JSDoc block documenting it.',
1534
+ missingExample: 'function "{{name}}" has cognitive complexity {{complexity}} (>= {{exampleComplexity}}); its JSDoc block needs an `@example` documenting usage.'
1535
+ }
1536
+ },
1537
+ create(context) {
1538
+ const options = context.options[0] ?? {};
1539
+ const paths = options.paths ?? [];
1540
+ const ignore = options.ignore ?? [];
1541
+ if (ignore.length > 0 && matchesAnyGlob(context.filename, ignore)) {
1542
+ return {};
1543
+ }
1544
+ if (paths.length > 0 && !matchesAnyGlob(context.filename, paths)) {
1545
+ return {};
1546
+ }
1547
+ const settings = {
1548
+ minComplexity: options.minComplexity ?? DEFAULT_MIN_COMPLEXITY,
1549
+ exampleComplexity: options.exampleComplexity ?? DEFAULT_EXAMPLE_COMPLEXITY
1550
+ };
1551
+ const { sourceCode } = context;
1552
+ return {
1553
+ Program(program) {
1554
+ for (const target of collectTargets(program.body)) {
1555
+ const violation = decideViolation(target, settings, sourceCode);
1556
+ if (violation) {
1557
+ context.report({ node: target.fn, messageId: violation.messageId, data: violation.data });
1558
+ }
1559
+ }
1560
+ }
1561
+ };
1562
+ }
1563
+ };
1564
+
1007
1565
  // src/rules/index.ts
1008
1566
  var rules = {
1009
1567
  "props-destructuring-newline": propsDestructuringNewline,
@@ -1012,7 +1570,10 @@ var rules = {
1012
1570
  "props-type-name": propsTypeName,
1013
1571
  "component-file-order": componentFileOrder,
1014
1572
  "component-arrow-function": componentArrowFunction,
1015
- "require-component-stories": requireComponentStories
1573
+ "max-components-per-file": maxComponentsPerFile,
1574
+ "max-jsx-return-size": maxJsxReturnSize,
1575
+ "require-component-stories": requireComponentStories,
1576
+ "require-jsdoc-example": requireJsdocExample
1016
1577
  };
1017
1578
 
1018
1579
  // src/index.ts
@@ -1020,7 +1581,7 @@ var PLUGIN_NAME = "@wl";
1020
1581
  var plugin = {
1021
1582
  meta: {
1022
1583
  name: "@wl/eslint-plugin",
1023
- version: "0.1.14"
1584
+ version: "0.1.20"
1024
1585
  },
1025
1586
  rules,
1026
1587
  configs: {}
@@ -1040,7 +1601,16 @@ plugin.configs.recommended = [
1040
1601
  // Pages and routes are excluded: route/page modules commonly use `function`
1041
1602
  // declarations (and framework conventions like default-exported page functions).
1042
1603
  [`${PLUGIN_NAME}/component-arrow-function`]: ["error", { ignore: ["**/pages/**", "**/routes/**"] }],
1043
- [`${PLUGIN_NAME}/require-component-stories`]: "error"
1604
+ [`${PLUGIN_NAME}/require-component-stories`]: "error",
1605
+ // Advisory: flags returns that render too many JSX elements; extract into a
1606
+ // variable or sub-component. Warn-class by nature (`type: 'suggestion'`);
1607
+ // severity confirmed against a repo-wide dry run at the default ceiling.
1608
+ [`${PLUGIN_NAME}/max-jsx-return-size`]: "error",
1609
+ // Caps component declarations per file; extra components belong in their own
1610
+ // files. Pages and routes are excluded (framework conventions co-locate
1611
+ // route trees and default-exported page functions). Ceiling confirmed
1612
+ // against a repo-wide dry run at the default.
1613
+ [`${PLUGIN_NAME}/max-components-per-file`]: ["error", { ignore: ["**/pages/**", "**/routes/**"] }]
1044
1614
  }
1045
1615
  },
1046
1616
  // Storybook stories legitimately deviate from the component conventions: the
@@ -1055,6 +1625,38 @@ plugin.configs.recommended = [
1055
1625
  [`${PLUGIN_NAME}/component-file-order`]: "off",
1056
1626
  [`${PLUGIN_NAME}/props-type-name`]: "off"
1057
1627
  }
1628
+ },
1629
+ // Dumb presentational files (`*-component.tsx`) follow the one-component-per-file
1630
+ // convention the props/order/stories rules already assume, so they get a tighter
1631
+ // ceiling of 1. This MUST come AFTER the global `**/*.tsx` block: flat config
1632
+ // REPLACES rule options across matching blocks (it does not merge), and `ignore`
1633
+ // is re-declared here so pages/routes dumb-components keep their exemption.
1634
+ // A repo-wide dry run found zero `*-component.tsx` files declaring >1 component.
1635
+ {
1636
+ files: ["**/*-component.tsx"],
1637
+ rules: {
1638
+ [`${PLUGIN_NAME}/max-components-per-file`]: [
1639
+ "error",
1640
+ { maxComponents: 1, ignore: ["**/pages/**", "**/routes/**"] }
1641
+ ]
1642
+ }
1643
+ },
1644
+ // `require-jsdoc-example` targets named functions, which overwhelmingly live in
1645
+ // plain `.ts` lib/util modules (not just `.tsx`), so it gets its OWN block scoped
1646
+ // to both extensions — the tsx-only blocks above would never reach where it
1647
+ // matters. Severity is `warn` (not `error`) so first adoption does not break
1648
+ // consumers' CI; the graduated defaults (minComplexity 8 → require a JSDoc block,
1649
+ // exampleComplexity 12 → also require `@example`) are left implicit. Flat config
1650
+ // REPLACES rule options across matching blocks, so this rule lives only here and
1651
+ // relies on no option merging.
1652
+ {
1653
+ files: ["**/*.ts", "**/*.tsx"],
1654
+ plugins: {
1655
+ [PLUGIN_NAME]: plugin
1656
+ },
1657
+ rules: {
1658
+ [`${PLUGIN_NAME}/require-jsdoc-example`]: "warn"
1659
+ }
1058
1660
  }
1059
1661
  ];
1060
1662
  var meta = plugin.meta;