@agjs/tsforge 0.1.19 → 0.2.1

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 (122) hide show
  1. package/package.json +6 -2
  2. package/scripts/browser-check.ts +41 -5
  3. package/scripts/build-rules-md.ts +78 -21
  4. package/scripts/cli-metrics.ts +10 -0
  5. package/scripts/sweep.ts +53 -23
  6. package/scripts/web-sweep.ts +292 -0
  7. package/src/browser/index.ts +3 -0
  8. package/src/browser/oracle.ts +215 -8
  9. package/src/cli.ts +22 -4
  10. package/src/config/index.ts +8 -0
  11. package/src/config/profiles.ts +150 -0
  12. package/src/config/tsforge-config.ts +64 -5
  13. package/src/detect-gate.ts +144 -13
  14. package/src/eval/eval.types.ts +9 -0
  15. package/src/eval/failure-class.ts +263 -0
  16. package/src/eval/index.ts +8 -0
  17. package/src/eval/metrics.ts +7 -0
  18. package/src/eval/parse-log.ts +105 -0
  19. package/src/eval/report.ts +19 -0
  20. package/src/eval/score.ts +10 -0
  21. package/src/loop/feedback/meta-rule-docs.ts +48 -0
  22. package/src/loop/feedback/rule-docs.ts +150 -0
  23. package/src/loop/loop.types.ts +4 -0
  24. package/src/loop/rule-docs.generated.json +131 -1
  25. package/src/loop/ttsr-defaults.ts +175 -4
  26. package/src/loop/turn.ts +3 -0
  27. package/src/meta-rules/registry.ts +32 -0
  28. package/src/meta-rules/rules/ci/no-github-context-in-shell.ts +40 -0
  29. package/src/meta-rules/rules/ci/no-pull-request-target-untrusted-checkout.ts +42 -0
  30. package/src/meta-rules/rules/ci/workflow-permissions-explicit.ts +49 -0
  31. package/src/meta-rules/rules/ci/workflow-permissions-least-privilege.ts +44 -0
  32. package/src/meta-rules/rules/config/next-image-remote-patterns-no-wildcards.ts +77 -0
  33. package/src/meta-rules/rules/config/next-instrumentation-present.ts +66 -0
  34. package/src/meta-rules/rules/config/next-proxy-over-middleware.ts +64 -0
  35. package/src/meta-rules/rules/config/tsconfig-recommended-flags.ts +75 -0
  36. package/src/meta-rules/rules/supply-chain/dependency-overrides-require-comment.ts +61 -0
  37. package/src/meta-rules/rules/supply-chain/fastify-security-plugins.ts +54 -0
  38. package/src/meta-rules/rules/supply-chain/lockfile-required.ts +51 -0
  39. package/src/meta-rules/rules/supply-chain/migrations-must-be-checked-in.ts +49 -0
  40. package/src/meta-rules/rules/supply-chain/no-git-or-tarball-dependencies.ts +70 -0
  41. package/src/meta-rules/rules/supply-chain/package-manager-field-required.ts +31 -0
  42. package/src/meta-rules/rules/supply-chain/production-must-not-use-drizzle-push.ts +75 -0
  43. package/src/meta-rules/rules/supply-chain/single-package-manager.ts +30 -0
  44. package/src/meta-rules/utils/lockfiles.ts +105 -0
  45. package/src/meta-rules/utils/workflow-yaml.ts +86 -0
  46. package/src/rule-packs/authorization/index.ts +26 -0
  47. package/src/rule-packs/authorization/rules/id-param-requires-object-authz.ts +87 -0
  48. package/src/rule-packs/authorization/rules/mutating-route-requires-authz.ts +116 -0
  49. package/src/rule-packs/authorization/rules/server-action-requires-authz.ts +101 -0
  50. package/src/rule-packs/authorization/utils.ts +285 -0
  51. package/src/rule-packs/boundary-utils.ts +13 -0
  52. package/src/rule-packs/code-flow/index.ts +4 -1
  53. package/src/rule-packs/code-flow/rules/no-throw-literal.ts +67 -0
  54. package/src/rule-packs/drizzle/index.ts +7 -0
  55. package/src/rule-packs/drizzle/rules/update-delete-account-scoped-must-filter-scope.ts +106 -0
  56. package/src/rule-packs/drizzle/rules/update-delete-must-have-where.ts +73 -0
  57. package/src/rule-packs/drizzle/utils.ts +133 -1
  58. package/src/rule-packs/fastify/index.ts +38 -0
  59. package/src/rule-packs/fastify/rules/error-handler-must-set-status.ts +78 -0
  60. package/src/rule-packs/fastify/rules/prefer-return-over-reply-send.ts +104 -0
  61. package/src/rule-packs/fastify/rules/require-fp-for-shared-plugins.ts +106 -0
  62. package/src/rule-packs/fastify/rules/require-plugin-name.ts +54 -0
  63. package/src/rule-packs/fastify/rules/require-response-schema.ts +62 -0
  64. package/src/rule-packs/fastify/rules/require-route-schema.ts +104 -0
  65. package/src/rule-packs/fastify/rules/test-inject-must-close-app.ts +44 -0
  66. package/src/rule-packs/fastify/utils/fastifyChain.ts +231 -0
  67. package/src/rule-packs/index.ts +10 -0
  68. package/src/rule-packs/jwt-cookies/index.ts +10 -0
  69. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-set-maxage-or-expires.ts +132 -0
  70. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-set-samesite.ts +151 -0
  71. package/src/rule-packs/jwt-cookies/rules/jwt-must-verify-not-decode.ts +124 -0
  72. package/src/rule-packs/module-boundaries/index.ts +3 -0
  73. package/src/rule-packs/module-boundaries/rules/no-react-in-services.ts +111 -0
  74. package/src/rule-packs/nextjs/index.ts +32 -0
  75. package/src/rule-packs/nextjs/rules/await-dynamic-request-apis.ts +65 -0
  76. package/src/rule-packs/nextjs/rules/error-boundary-require-use-client.ts +38 -0
  77. package/src/rule-packs/nextjs/rules/mutation-should-revalidate-cache.ts +152 -0
  78. package/src/rule-packs/nextjs/rules/no-html-img-element.ts +45 -0
  79. package/src/rule-packs/nextjs/rules/no-internal-api-fetch.ts +126 -0
  80. package/src/rule-packs/nextjs/rules/no-secret-props-to-client.ts +118 -0
  81. package/src/rule-packs/nextjs/rules/no-sensitive-next-public-env.ts +72 -0
  82. package/src/rule-packs/nextjs/rules/prefer-lazy-use-state-init.ts +85 -0
  83. package/src/rule-packs/nextjs/rules/server-action-requires-authz-and-validation.ts +178 -0
  84. package/src/rule-packs/nextjs/rules/server-only-modules-import-server-only.ts +87 -0
  85. package/src/rule-packs/nextjs/utils.ts +18 -0
  86. package/src/rule-packs/react-component-architecture/index.ts +18 -0
  87. package/src/rule-packs/react-component-architecture/rules/dangerous-html-requires-sanitize.ts +83 -0
  88. package/src/rule-packs/react-component-architecture/rules/no-anonymous-useEffect.ts +61 -0
  89. package/src/rule-packs/react-component-architecture/rules/no-component-invocation.ts +55 -0
  90. package/src/rule-packs/react-component-architecture/rules/no-derived-state-in-effect.ts +204 -0
  91. package/src/rule-packs/react-component-architecture/rules/no-nested-component.ts +152 -0
  92. package/src/rule-packs/react-component-architecture/rules/no-react-fc.ts +57 -0
  93. package/src/rule-packs/rule-catalog.types.ts +21 -0
  94. package/src/rule-packs/rule-metadata.ts +163 -0
  95. package/src/rule-packs/runtime-boundaries/index.ts +33 -0
  96. package/src/rule-packs/runtime-boundaries/rules/no-prototype-polluting-merge.ts +113 -0
  97. package/src/rule-packs/runtime-boundaries/rules/no-user-controlled-fetch-url.ts +69 -0
  98. package/src/rule-packs/runtime-boundaries/rules/no-user-controlled-redirect.ts +79 -0
  99. package/src/rule-packs/runtime-boundaries/rules/upload-must-set-limits.ts +126 -0
  100. package/src/rule-packs/runtime-boundaries/rules/webhook-must-verify-signature-before-parse.ts +87 -0
  101. package/src/rule-packs/security/index.ts +35 -0
  102. package/src/rule-packs/security/rules/catch-must-handle.ts +126 -0
  103. package/src/rule-packs/security/rules/no-auth-token-in-storage.ts +107 -0
  104. package/src/rule-packs/security/rules/no-child-process-exec.ts +72 -0
  105. package/src/rule-packs/security/rules/no-dynamic-regexp.ts +56 -0
  106. package/src/rule-packs/security/rules/no-inner-html-assignment.ts +42 -0
  107. package/src/rule-packs/security/rules/no-spawn-with-shell.ts +106 -0
  108. package/src/rule-packs/structured-logging/index.ts +6 -0
  109. package/src/rule-packs/structured-logging/rules/caught-error-log-requires-cause.ts +234 -0
  110. package/src/rule-packs/structured-logging/rules/logger-not-console.ts +146 -0
  111. package/src/rule-packs/test-conventions/index.ts +9 -0
  112. package/src/rule-packs/test-conventions/rules/fake-timers-must-be-restored.ts +143 -0
  113. package/src/rule-packs/test-conventions/rules/no-conditional-expect.ts +77 -0
  114. package/src/rule-packs/test-conventions/rules/no-real-network-in-unit-tests.ts +174 -0
  115. package/src/rule-packs/typescript-core/index.ts +30 -0
  116. package/src/rule-packs/typescript-core/rules/exported-functions-require-return-type.ts +74 -0
  117. package/src/rule-packs/typescript-core/rules/fetch-must-check-ok.ts +106 -0
  118. package/src/rule-packs/typescript-core/rules/json-parse-must-validate.ts +97 -0
  119. package/src/rule-packs/typescript-core/rules/no-unsafe-boundary-cast.ts +70 -0
  120. package/src/stack-detection/packs.ts +57 -0
  121. package/strict.type-aware.eslint.config.mjs +33 -0
  122. package/strict.web.eslint.config.mjs +32 -1
@@ -1,23 +1,35 @@
1
1
  import type { TSESLint } from "@typescript-eslint/utils";
2
2
 
3
+ import { dangerousHtmlRequiresSanitizeRule } from "./rules/dangerous-html-requires-sanitize";
3
4
  import { componentFolderStructureRule } from "./rules/component-folder-structure";
4
5
  import { forwardrefDisplayNameRule } from "./rules/forwardref-display-name";
5
6
  import { indexMustReexportDefaultRule } from "./rules/index-must-reexport-default";
6
7
  import { maxHooksPerFileRule } from "./rules/max-hooks-per-file";
8
+ import { noAnonymousUseEffectRule } from "./rules/no-anonymous-useEffect";
9
+ import { noComponentInvocationRule } from "./rules/no-component-invocation";
7
10
  import { noCrossFeatureImportsRule } from "./rules/no-cross-feature-imports";
11
+ import { noDerivedStateInEffectRule } from "./rules/no-derived-state-in-effect";
8
12
  import { noInlineJsxFunctionsRule } from "./rules/no-inline-jsx-functions";
9
13
  import { noJsxComputationRule } from "./rules/no-jsx-computation";
14
+ import { noNestedComponentRule } from "./rules/no-nested-component";
15
+ import { noReactFcRule } from "./rules/no-react-fc";
10
16
  import { noStateInComponentBodyRule } from "./rules/no-state-in-component-body";
11
17
  import type { IRulePack } from "../rule-packs.types";
12
18
 
13
19
  const rules: Record<string, TSESLint.RuleModule<string, readonly unknown[]>> = {
14
20
  "component-folder-structure": componentFolderStructureRule,
21
+ "dangerous-html-requires-sanitize": dangerousHtmlRequiresSanitizeRule,
15
22
  "forwardref-display-name": forwardrefDisplayNameRule,
16
23
  "index-must-reexport-default": indexMustReexportDefaultRule,
17
24
  "max-hooks-per-file": maxHooksPerFileRule,
25
+ "no-anonymous-useEffect": noAnonymousUseEffectRule,
26
+ "no-component-invocation": noComponentInvocationRule,
18
27
  "no-cross-feature-imports": noCrossFeatureImportsRule,
28
+ "no-derived-state-in-effect": noDerivedStateInEffectRule,
19
29
  "no-inline-jsx-functions": noInlineJsxFunctionsRule,
20
30
  "no-jsx-computation": noJsxComputationRule,
31
+ "no-nested-component": noNestedComponentRule,
32
+ "no-react-fc": noReactFcRule,
21
33
  "no-state-in-component-body": noStateInComponentBodyRule,
22
34
  };
23
35
 
@@ -28,12 +40,18 @@ export const reactComponentArchitecturePack: IRulePack = {
28
40
  rules,
29
41
  rulesConfig: {
30
42
  "component-folder-structure": "error",
43
+ "dangerous-html-requires-sanitize": "error",
31
44
  "forwardref-display-name": "error",
32
45
  "index-must-reexport-default": "error",
33
46
  "max-hooks-per-file": "warn",
47
+ "no-anonymous-useEffect": "warn",
48
+ "no-component-invocation": "error",
34
49
  "no-cross-feature-imports": "error",
50
+ "no-derived-state-in-effect": "warn",
35
51
  "no-inline-jsx-functions": "warn",
36
52
  "no-jsx-computation": "error",
53
+ "no-nested-component": "error",
54
+ "no-react-fc": "error",
37
55
  "no-state-in-component-body": "error",
38
56
  },
39
57
  };
@@ -0,0 +1,83 @@
1
+ import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
2
+
3
+ import { createRule } from "../../create-rule";
4
+
5
+ export const RULE_NAME = "dangerous-html-requires-sanitize";
6
+
7
+ type MessageIds = "missingSanitize";
8
+
9
+ const SANITIZE_IMPORTS = new Set([
10
+ "dompurify",
11
+ "isomorphic-dompurify",
12
+ "sanitize-html",
13
+ ]);
14
+
15
+ function fileImportsSanitizer(program: TSESTree.Program): boolean {
16
+ for (const statement of program.body) {
17
+ if (statement.type !== AST_NODE_TYPES.ImportDeclaration) {
18
+ continue;
19
+ }
20
+
21
+ const source = statement.source.value;
22
+
23
+ if (typeof source !== "string") {
24
+ continue;
25
+ }
26
+
27
+ const base = source.split("/")[0];
28
+
29
+ if (base !== undefined && SANITIZE_IMPORTS.has(base)) {
30
+ return true;
31
+ }
32
+
33
+ for (const spec of statement.specifiers) {
34
+ if (
35
+ spec.type === AST_NODE_TYPES.ImportSpecifier &&
36
+ spec.imported.type === AST_NODE_TYPES.Identifier &&
37
+ spec.imported.name.toLowerCase().includes("sanitize")
38
+ ) {
39
+ return true;
40
+ }
41
+ }
42
+ }
43
+
44
+ return false;
45
+ }
46
+
47
+ export const dangerousHtmlRequiresSanitizeRule = createRule<[], MessageIds>({
48
+ name: RULE_NAME,
49
+ meta: {
50
+ type: "problem",
51
+ docs: {
52
+ description:
53
+ "dangerouslySetInnerHTML requires a sanitization library (DOMPurify or equivalent) imported in the same file.",
54
+ },
55
+ schema: [],
56
+ messages: {
57
+ missingSanitize:
58
+ "`dangerouslySetInnerHTML` requires sanitizing HTML first — import DOMPurify (or isomorphic-dompurify) and pass sanitized markup.",
59
+ },
60
+ },
61
+ defaultOptions: [],
62
+ create(context) {
63
+ let hasSanitizerImport = false;
64
+
65
+ return {
66
+ Program(program: TSESTree.Program) {
67
+ hasSanitizerImport = fileImportsSanitizer(program);
68
+ },
69
+ JSXAttribute(node: TSESTree.JSXAttribute) {
70
+ if (
71
+ node.name.type !== AST_NODE_TYPES.JSXIdentifier ||
72
+ node.name.name !== "dangerouslySetInnerHTML"
73
+ ) {
74
+ return;
75
+ }
76
+
77
+ if (!hasSanitizerImport) {
78
+ context.report({ node, messageId: "missingSanitize" });
79
+ }
80
+ },
81
+ };
82
+ },
83
+ });
@@ -0,0 +1,61 @@
1
+ import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
2
+
3
+ import { createRule } from "../../create-rule";
4
+
5
+ export const RULE_NAME = "no-anonymous-useEffect";
6
+
7
+ type MessageIds = "anonymousEffect";
8
+
9
+ function isUseEffectCall(node: TSESTree.CallExpression): boolean {
10
+ const callee = node.callee;
11
+
12
+ if (
13
+ callee.type === AST_NODE_TYPES.Identifier &&
14
+ callee.name === "useEffect"
15
+ ) {
16
+ return true;
17
+ }
18
+
19
+ if (
20
+ callee.type === AST_NODE_TYPES.MemberExpression &&
21
+ !callee.computed &&
22
+ callee.property.type === AST_NODE_TYPES.Identifier &&
23
+ callee.property.name === "useEffect"
24
+ ) {
25
+ return true;
26
+ }
27
+
28
+ return false;
29
+ }
30
+
31
+ export const noAnonymousUseEffectRule = createRule<[], MessageIds>({
32
+ name: RULE_NAME,
33
+ meta: {
34
+ type: "suggestion",
35
+ docs: {
36
+ description:
37
+ "Disallow anonymous arrow functions passed to useEffect — use a named function for debuggable stack traces.",
38
+ },
39
+ schema: [],
40
+ messages: {
41
+ anonymousEffect:
42
+ "Pass a named function to `useEffect` (e.g. `useEffect(function syncSession() { ... }, deps)`) instead of an anonymous arrow.",
43
+ },
44
+ },
45
+ defaultOptions: [],
46
+ create(context) {
47
+ return {
48
+ CallExpression(node: TSESTree.CallExpression) {
49
+ if (!isUseEffectCall(node)) {
50
+ return;
51
+ }
52
+
53
+ const effectFn = node.arguments[0];
54
+
55
+ if (effectFn?.type === AST_NODE_TYPES.ArrowFunctionExpression) {
56
+ context.report({ node: effectFn, messageId: "anonymousEffect" });
57
+ }
58
+ },
59
+ };
60
+ },
61
+ });
@@ -0,0 +1,55 @@
1
+ import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
2
+
3
+ import { createRule } from "../../create-rule";
4
+
5
+ export const RULE_NAME = "no-component-invocation";
6
+
7
+ type MessageIds = "componentInvocation";
8
+
9
+ function isComponentName(name: string): boolean {
10
+ return /^[A-Z]/.test(name);
11
+ }
12
+
13
+ export const noComponentInvocationRule = createRule<[], MessageIds>({
14
+ name: RULE_NAME,
15
+ meta: {
16
+ type: "problem",
17
+ docs: {
18
+ description:
19
+ "Disallow invoking React components as plain functions — use JSX (`<Header />`) instead of `{Header()}`.",
20
+ },
21
+ schema: [],
22
+ messages: {
23
+ componentInvocation:
24
+ "Do not call `{{name}}()` as a function — render it as JSX: `<{{name}} />`.",
25
+ },
26
+ },
27
+ defaultOptions: [],
28
+ create(context) {
29
+ return {
30
+ JSXExpressionContainer(node: TSESTree.JSXExpressionContainer) {
31
+ const expression = node.expression;
32
+
33
+ if (expression.type !== AST_NODE_TYPES.CallExpression) {
34
+ return;
35
+ }
36
+
37
+ const callee = expression.callee;
38
+
39
+ if (callee.type !== AST_NODE_TYPES.Identifier) {
40
+ return;
41
+ }
42
+
43
+ if (!isComponentName(callee.name)) {
44
+ return;
45
+ }
46
+
47
+ context.report({
48
+ node: expression,
49
+ messageId: "componentInvocation",
50
+ data: { name: callee.name },
51
+ });
52
+ },
53
+ };
54
+ },
55
+ });
@@ -0,0 +1,204 @@
1
+ import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
2
+
3
+ import { createRule } from "../../create-rule";
4
+ import { walkSome } from "../../utils";
5
+
6
+ export const RULE_NAME = "no-derived-state-in-effect";
7
+
8
+ type MessageIds = "derivedStateInEffect";
9
+
10
+ function isUseStateSetterName(name: string): boolean {
11
+ return /^set[A-Z]/.test(name);
12
+ }
13
+
14
+ function collectUseStateSetters(
15
+ node: TSESTree.FunctionDeclaration | TSESTree.ArrowFunctionExpression
16
+ ): Set<string> {
17
+ const setters = new Set<string>();
18
+ const body =
19
+ node.type === AST_NODE_TYPES.ArrowFunctionExpression
20
+ ? node.body.type === AST_NODE_TYPES.BlockStatement
21
+ ? node.body
22
+ : null
23
+ : node.body;
24
+
25
+ if (body === null) {
26
+ return setters;
27
+ }
28
+
29
+ walkSome(body, (current) => {
30
+ if (current.type !== AST_NODE_TYPES.VariableDeclarator) {
31
+ return false;
32
+ }
33
+
34
+ if (
35
+ current.id.type !== AST_NODE_TYPES.ArrayPattern ||
36
+ current.init?.type !== AST_NODE_TYPES.CallExpression
37
+ ) {
38
+ return false;
39
+ }
40
+
41
+ const initCallee = current.init.callee;
42
+
43
+ if (
44
+ initCallee.type !== AST_NODE_TYPES.Identifier ||
45
+ initCallee.name !== "useState"
46
+ ) {
47
+ return false;
48
+ }
49
+
50
+ const setter = current.id.elements[1];
51
+
52
+ if (setter?.type === AST_NODE_TYPES.Identifier) {
53
+ setters.add(setter.name);
54
+ }
55
+
56
+ return false;
57
+ });
58
+
59
+ return setters;
60
+ }
61
+
62
+ function isInsideNode(node: TSESTree.Node, ancestor: TSESTree.Node): boolean {
63
+ let current: TSESTree.Node | null | undefined = node;
64
+
65
+ while (current !== undefined && current !== null) {
66
+ if (current === ancestor) {
67
+ return true;
68
+ }
69
+
70
+ current = current.parent;
71
+ }
72
+
73
+ return false;
74
+ }
75
+
76
+ function setterCalledOutsideEffect(
77
+ root: TSESTree.Node,
78
+ setterName: string,
79
+ effectNode: TSESTree.CallExpression
80
+ ): boolean {
81
+ return walkSome(root, (current) => {
82
+ if (current.type !== AST_NODE_TYPES.CallExpression) {
83
+ return false;
84
+ }
85
+
86
+ if (isInsideNode(current, effectNode)) {
87
+ return false;
88
+ }
89
+
90
+ const callee = current.callee;
91
+
92
+ return (
93
+ callee.type === AST_NODE_TYPES.Identifier && callee.name === setterName
94
+ );
95
+ });
96
+ }
97
+
98
+ function effectCallsSetter(
99
+ effectFn: TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression,
100
+ setterName: string
101
+ ): boolean {
102
+ return walkSome(effectFn.body, (current) => {
103
+ if (current.type !== AST_NODE_TYPES.CallExpression) {
104
+ return false;
105
+ }
106
+
107
+ const callee = current.callee;
108
+
109
+ return (
110
+ callee.type === AST_NODE_TYPES.Identifier && callee.name === setterName
111
+ );
112
+ });
113
+ }
114
+
115
+ export const noDerivedStateInEffectRule = createRule<[], MessageIds>({
116
+ name: RULE_NAME,
117
+ meta: {
118
+ type: "suggestion",
119
+ docs: {
120
+ description:
121
+ "Disallow setting local state inside useEffect when the value can be derived during render (or memoized with useMemo).",
122
+ },
123
+ schema: [],
124
+ messages: {
125
+ derivedStateInEffect:
126
+ "Do not call `{{setter}}` only inside `useEffect` — compute derived values during render or wrap expensive work in `useMemo`.",
127
+ },
128
+ },
129
+ defaultOptions: [],
130
+ create(context) {
131
+ function checkFunction(
132
+ node: TSESTree.FunctionDeclaration | TSESTree.ArrowFunctionExpression
133
+ ): void {
134
+ const setters = collectUseStateSetters(node);
135
+
136
+ if (setters.size === 0) {
137
+ return;
138
+ }
139
+
140
+ const body =
141
+ node.type === AST_NODE_TYPES.ArrowFunctionExpression
142
+ ? node.body.type === AST_NODE_TYPES.BlockStatement
143
+ ? node.body
144
+ : null
145
+ : node.body;
146
+
147
+ if (body === null) {
148
+ return;
149
+ }
150
+
151
+ walkSome(body, (current) => {
152
+ if (current.type !== AST_NODE_TYPES.CallExpression) {
153
+ return false;
154
+ }
155
+
156
+ const callee = current.callee;
157
+
158
+ if (
159
+ callee.type !== AST_NODE_TYPES.Identifier ||
160
+ callee.name !== "useEffect"
161
+ ) {
162
+ return false;
163
+ }
164
+
165
+ const effectFn = current.arguments[0];
166
+
167
+ if (
168
+ effectFn?.type !== AST_NODE_TYPES.ArrowFunctionExpression &&
169
+ effectFn?.type !== AST_NODE_TYPES.FunctionExpression
170
+ ) {
171
+ return false;
172
+ }
173
+
174
+ for (const setterName of setters) {
175
+ if (!isUseStateSetterName(setterName)) {
176
+ continue;
177
+ }
178
+
179
+ if (
180
+ effectCallsSetter(effectFn, setterName) &&
181
+ !setterCalledOutsideEffect(body, setterName, current)
182
+ ) {
183
+ context.report({
184
+ node: current,
185
+ messageId: "derivedStateInEffect",
186
+ data: { setter: setterName },
187
+ });
188
+ }
189
+ }
190
+
191
+ return false;
192
+ });
193
+ }
194
+
195
+ return {
196
+ FunctionDeclaration: checkFunction,
197
+ ArrowFunctionExpression(node: TSESTree.ArrowFunctionExpression) {
198
+ if (node.parent?.type === AST_NODE_TYPES.VariableDeclarator) {
199
+ checkFunction(node);
200
+ }
201
+ },
202
+ };
203
+ },
204
+ });
@@ -0,0 +1,152 @@
1
+ import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
2
+
3
+ import { createRule } from "../../create-rule";
4
+ import { walkAll } from "../../utils";
5
+ import {
6
+ isComponentFile,
7
+ isJsxReturningFunction,
8
+ isStoryFile,
9
+ isTestFile,
10
+ } from "../utils";
11
+
12
+ export const RULE_NAME = "no-nested-component";
13
+
14
+ type MessageIds = "nestedComponent";
15
+
16
+ function isComponentName(name: string): boolean {
17
+ return /^[A-Z]/.test(name);
18
+ }
19
+
20
+ function returnsJsx(
21
+ node:
22
+ | TSESTree.FunctionDeclaration
23
+ | TSESTree.ArrowFunctionExpression
24
+ | TSESTree.FunctionExpression
25
+ ): boolean {
26
+ if (node.type === AST_NODE_TYPES.ArrowFunctionExpression) {
27
+ return isJsxReturningFunction(node);
28
+ }
29
+
30
+ const body = node.body;
31
+
32
+ if (body.type !== AST_NODE_TYPES.BlockStatement) {
33
+ return false;
34
+ }
35
+
36
+ for (const stmt of body.body) {
37
+ if (stmt.type !== AST_NODE_TYPES.ReturnStatement) {
38
+ continue;
39
+ }
40
+
41
+ const arg = stmt.argument;
42
+
43
+ if (
44
+ arg &&
45
+ (arg.type === AST_NODE_TYPES.JSXElement ||
46
+ arg.type === AST_NODE_TYPES.JSXFragment)
47
+ ) {
48
+ return true;
49
+ }
50
+ }
51
+
52
+ return false;
53
+ }
54
+
55
+ function isNestedComponentDeclaration(node: TSESTree.Node): boolean {
56
+ if (
57
+ node.type === AST_NODE_TYPES.FunctionDeclaration &&
58
+ node.id !== null &&
59
+ isComponentName(node.id.name) &&
60
+ returnsJsx(node)
61
+ ) {
62
+ return true;
63
+ }
64
+
65
+ if (node.type === AST_NODE_TYPES.VariableDeclaration) {
66
+ for (const decl of node.declarations) {
67
+ if (decl.id.type !== AST_NODE_TYPES.Identifier) {
68
+ continue;
69
+ }
70
+
71
+ if (!isComponentName(decl.id.name) || decl.init === null) {
72
+ continue;
73
+ }
74
+
75
+ if (
76
+ (decl.init.type === AST_NODE_TYPES.ArrowFunctionExpression ||
77
+ decl.init.type === AST_NODE_TYPES.FunctionExpression) &&
78
+ returnsJsx(decl.init)
79
+ ) {
80
+ return true;
81
+ }
82
+ }
83
+ }
84
+
85
+ return false;
86
+ }
87
+
88
+ export const noNestedComponentRule = createRule<[], MessageIds>({
89
+ name: RULE_NAME,
90
+ meta: {
91
+ type: "problem",
92
+ docs: {
93
+ description:
94
+ "Disallow declaring React components inside another component body — nested components reset state on every parent render.",
95
+ },
96
+ schema: [],
97
+ messages: {
98
+ nestedComponent:
99
+ "Do not declare a component inside another component — move it to its own file or to module scope.",
100
+ },
101
+ },
102
+ defaultOptions: [],
103
+ create(context) {
104
+ const filename = context.filename;
105
+
106
+ if (
107
+ !isComponentFile(filename) ||
108
+ isStoryFile(filename) ||
109
+ isTestFile(filename)
110
+ ) {
111
+ return {};
112
+ }
113
+
114
+ function checkOuterComponent(
115
+ node:
116
+ | TSESTree.FunctionDeclaration
117
+ | TSESTree.ArrowFunctionExpression
118
+ | TSESTree.FunctionExpression
119
+ ): void {
120
+ if (!returnsJsx(node)) {
121
+ return;
122
+ }
123
+
124
+ const body =
125
+ node.type === AST_NODE_TYPES.ArrowFunctionExpression
126
+ ? node.body.type === AST_NODE_TYPES.BlockStatement
127
+ ? node.body
128
+ : null
129
+ : node.body;
130
+
131
+ if (body?.type !== AST_NODE_TYPES.BlockStatement) {
132
+ return;
133
+ }
134
+
135
+ walkAll(body, (nested) => {
136
+ if (nested === body) {
137
+ return;
138
+ }
139
+
140
+ if (isNestedComponentDeclaration(nested)) {
141
+ context.report({ node: nested, messageId: "nestedComponent" });
142
+ }
143
+ });
144
+ }
145
+
146
+ return {
147
+ FunctionDeclaration: checkOuterComponent,
148
+ FunctionExpression: checkOuterComponent,
149
+ ArrowFunctionExpression: checkOuterComponent,
150
+ };
151
+ },
152
+ });
@@ -0,0 +1,57 @@
1
+ import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
2
+
3
+ import { createRule } from "../../create-rule";
4
+
5
+ export const RULE_NAME = "no-react-fc";
6
+
7
+ type MessageIds = "noReactFc";
8
+
9
+ function isReactFcType(node: TSESTree.TSTypeReference): boolean {
10
+ const typeName = node.typeName;
11
+
12
+ if (typeName.type === AST_NODE_TYPES.TSQualifiedName) {
13
+ const left = typeName.left;
14
+ const right = typeName.right.name;
15
+
16
+ if (left.type === AST_NODE_TYPES.Identifier && left.name === "React") {
17
+ return right === "FC" || right === "FunctionComponent";
18
+ }
19
+ }
20
+
21
+ if (typeName.type === AST_NODE_TYPES.Identifier) {
22
+ return (
23
+ typeName.name === "FC" ||
24
+ typeName.name === "FunctionComponent" ||
25
+ typeName.name === "VFC" ||
26
+ typeName.name === "VoidFunctionComponent"
27
+ );
28
+ }
29
+
30
+ return false;
31
+ }
32
+
33
+ export const noReactFcRule = createRule<[], MessageIds>({
34
+ name: RULE_NAME,
35
+ meta: {
36
+ type: "problem",
37
+ docs: {
38
+ description:
39
+ "Disallow React.FC / FunctionComponent — type props explicitly on the function parameter instead.",
40
+ },
41
+ schema: [],
42
+ messages: {
43
+ noReactFc:
44
+ "Do not use `React.FC` or `FunctionComponent` — declare props explicitly on the function (e.g. `function Button({ onClick }: IButtonProps)`).",
45
+ },
46
+ },
47
+ defaultOptions: [],
48
+ create(context) {
49
+ return {
50
+ TSTypeReference(node: TSESTree.TSTypeReference) {
51
+ if (isReactFcType(node)) {
52
+ context.report({ node, messageId: "noReactFc" });
53
+ }
54
+ },
55
+ };
56
+ },
57
+ });
@@ -0,0 +1,21 @@
1
+ /** Adoption tier: safety rules default on; architecture rules opt-in via profile. */
2
+ export type RuleTier = "safety" | "framework" | "architecture" | "experimental";
3
+
4
+ export type FalsePositiveRisk = "low" | "medium" | "high";
5
+
6
+ /** Catalog metadata for a single ESLint pack rule or meta-rule. */
7
+ export interface IRuleCatalogEntry {
8
+ readonly tier: RuleTier;
9
+ readonly tags?: readonly string[];
10
+ readonly falsePositiveRisk?: FalsePositiveRisk;
11
+ readonly requiresTypeInfo?: boolean;
12
+ readonly profiles?: readonly ProfileId[];
13
+ }
14
+
15
+ export type ProfileId =
16
+ | "recommended"
17
+ | "strict"
18
+ | "security"
19
+ | "frontend"
20
+ | "backend"
21
+ | "opinionated";