@agjs/tsforge 0.1.19 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -1
- package/scripts/build-rules-md.ts +78 -21
- package/scripts/sweep.ts +25 -20
- package/scripts/web-sweep.ts +292 -0
- package/src/cli.ts +9 -3
- package/src/config/index.ts +8 -0
- package/src/config/profiles.ts +150 -0
- package/src/config/tsforge-config.ts +64 -5
- package/src/detect-gate.ts +34 -1
- package/src/loop/feedback/meta-rule-docs.ts +48 -0
- package/src/loop/feedback/rule-docs.ts +150 -0
- package/src/loop/rule-docs.generated.json +131 -1
- package/src/loop/ttsr-defaults.ts +175 -4
- package/src/meta-rules/registry.ts +32 -0
- package/src/meta-rules/rules/ci/no-github-context-in-shell.ts +40 -0
- package/src/meta-rules/rules/ci/no-pull-request-target-untrusted-checkout.ts +42 -0
- package/src/meta-rules/rules/ci/workflow-permissions-explicit.ts +49 -0
- package/src/meta-rules/rules/ci/workflow-permissions-least-privilege.ts +44 -0
- package/src/meta-rules/rules/config/next-image-remote-patterns-no-wildcards.ts +77 -0
- package/src/meta-rules/rules/config/next-instrumentation-present.ts +66 -0
- package/src/meta-rules/rules/config/next-proxy-over-middleware.ts +64 -0
- package/src/meta-rules/rules/config/tsconfig-recommended-flags.ts +75 -0
- package/src/meta-rules/rules/supply-chain/dependency-overrides-require-comment.ts +61 -0
- package/src/meta-rules/rules/supply-chain/fastify-security-plugins.ts +54 -0
- package/src/meta-rules/rules/supply-chain/lockfile-required.ts +51 -0
- package/src/meta-rules/rules/supply-chain/migrations-must-be-checked-in.ts +49 -0
- package/src/meta-rules/rules/supply-chain/no-git-or-tarball-dependencies.ts +70 -0
- package/src/meta-rules/rules/supply-chain/package-manager-field-required.ts +31 -0
- package/src/meta-rules/rules/supply-chain/production-must-not-use-drizzle-push.ts +75 -0
- package/src/meta-rules/rules/supply-chain/single-package-manager.ts +30 -0
- package/src/meta-rules/utils/lockfiles.ts +105 -0
- package/src/meta-rules/utils/workflow-yaml.ts +86 -0
- package/src/rule-packs/authorization/index.ts +26 -0
- package/src/rule-packs/authorization/rules/id-param-requires-object-authz.ts +87 -0
- package/src/rule-packs/authorization/rules/mutating-route-requires-authz.ts +116 -0
- package/src/rule-packs/authorization/rules/server-action-requires-authz.ts +101 -0
- package/src/rule-packs/authorization/utils.ts +285 -0
- package/src/rule-packs/boundary-utils.ts +13 -0
- package/src/rule-packs/code-flow/index.ts +4 -1
- package/src/rule-packs/code-flow/rules/no-throw-literal.ts +67 -0
- package/src/rule-packs/drizzle/index.ts +7 -0
- package/src/rule-packs/drizzle/rules/update-delete-account-scoped-must-filter-scope.ts +106 -0
- package/src/rule-packs/drizzle/rules/update-delete-must-have-where.ts +73 -0
- package/src/rule-packs/drizzle/utils.ts +133 -1
- package/src/rule-packs/fastify/index.ts +38 -0
- package/src/rule-packs/fastify/rules/error-handler-must-set-status.ts +78 -0
- package/src/rule-packs/fastify/rules/prefer-return-over-reply-send.ts +104 -0
- package/src/rule-packs/fastify/rules/require-fp-for-shared-plugins.ts +106 -0
- package/src/rule-packs/fastify/rules/require-plugin-name.ts +54 -0
- package/src/rule-packs/fastify/rules/require-response-schema.ts +62 -0
- package/src/rule-packs/fastify/rules/require-route-schema.ts +104 -0
- package/src/rule-packs/fastify/rules/test-inject-must-close-app.ts +44 -0
- package/src/rule-packs/fastify/utils/fastifyChain.ts +231 -0
- package/src/rule-packs/index.ts +10 -0
- package/src/rule-packs/jwt-cookies/index.ts +10 -0
- package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-set-maxage-or-expires.ts +132 -0
- package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-set-samesite.ts +151 -0
- package/src/rule-packs/jwt-cookies/rules/jwt-must-verify-not-decode.ts +124 -0
- package/src/rule-packs/module-boundaries/index.ts +3 -0
- package/src/rule-packs/module-boundaries/rules/no-react-in-services.ts +111 -0
- package/src/rule-packs/nextjs/index.ts +32 -0
- package/src/rule-packs/nextjs/rules/await-dynamic-request-apis.ts +65 -0
- package/src/rule-packs/nextjs/rules/error-boundary-require-use-client.ts +38 -0
- package/src/rule-packs/nextjs/rules/mutation-should-revalidate-cache.ts +152 -0
- package/src/rule-packs/nextjs/rules/no-html-img-element.ts +45 -0
- package/src/rule-packs/nextjs/rules/no-internal-api-fetch.ts +126 -0
- package/src/rule-packs/nextjs/rules/no-secret-props-to-client.ts +118 -0
- package/src/rule-packs/nextjs/rules/no-sensitive-next-public-env.ts +72 -0
- package/src/rule-packs/nextjs/rules/prefer-lazy-use-state-init.ts +85 -0
- package/src/rule-packs/nextjs/rules/server-action-requires-authz-and-validation.ts +178 -0
- package/src/rule-packs/nextjs/rules/server-only-modules-import-server-only.ts +87 -0
- package/src/rule-packs/nextjs/utils.ts +18 -0
- package/src/rule-packs/react-component-architecture/index.ts +18 -0
- package/src/rule-packs/react-component-architecture/rules/dangerous-html-requires-sanitize.ts +83 -0
- package/src/rule-packs/react-component-architecture/rules/no-anonymous-useEffect.ts +61 -0
- package/src/rule-packs/react-component-architecture/rules/no-component-invocation.ts +55 -0
- package/src/rule-packs/react-component-architecture/rules/no-derived-state-in-effect.ts +204 -0
- package/src/rule-packs/react-component-architecture/rules/no-nested-component.ts +152 -0
- package/src/rule-packs/react-component-architecture/rules/no-react-fc.ts +57 -0
- package/src/rule-packs/rule-catalog.types.ts +21 -0
- package/src/rule-packs/rule-metadata.ts +163 -0
- package/src/rule-packs/runtime-boundaries/index.ts +33 -0
- package/src/rule-packs/runtime-boundaries/rules/no-prototype-polluting-merge.ts +113 -0
- package/src/rule-packs/runtime-boundaries/rules/no-user-controlled-fetch-url.ts +69 -0
- package/src/rule-packs/runtime-boundaries/rules/no-user-controlled-redirect.ts +79 -0
- package/src/rule-packs/runtime-boundaries/rules/upload-must-set-limits.ts +126 -0
- package/src/rule-packs/runtime-boundaries/rules/webhook-must-verify-signature-before-parse.ts +87 -0
- package/src/rule-packs/security/index.ts +35 -0
- package/src/rule-packs/security/rules/catch-must-handle.ts +126 -0
- package/src/rule-packs/security/rules/no-auth-token-in-storage.ts +107 -0
- package/src/rule-packs/security/rules/no-child-process-exec.ts +72 -0
- package/src/rule-packs/security/rules/no-dynamic-regexp.ts +56 -0
- package/src/rule-packs/security/rules/no-inner-html-assignment.ts +42 -0
- package/src/rule-packs/security/rules/no-spawn-with-shell.ts +106 -0
- package/src/rule-packs/structured-logging/index.ts +6 -0
- package/src/rule-packs/structured-logging/rules/caught-error-log-requires-cause.ts +234 -0
- package/src/rule-packs/structured-logging/rules/logger-not-console.ts +146 -0
- package/src/rule-packs/test-conventions/index.ts +9 -0
- package/src/rule-packs/test-conventions/rules/fake-timers-must-be-restored.ts +143 -0
- package/src/rule-packs/test-conventions/rules/no-conditional-expect.ts +77 -0
- package/src/rule-packs/test-conventions/rules/no-real-network-in-unit-tests.ts +174 -0
- package/src/rule-packs/typescript-core/index.ts +30 -0
- package/src/rule-packs/typescript-core/rules/exported-functions-require-return-type.ts +74 -0
- package/src/rule-packs/typescript-core/rules/fetch-must-check-ok.ts +106 -0
- package/src/rule-packs/typescript-core/rules/json-parse-must-validate.ts +97 -0
- package/src/rule-packs/typescript-core/rules/no-unsafe-boundary-cast.ts +70 -0
- package/src/stack-detection/packs.ts +57 -0
- 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";
|