@agjs/tsforge 0.1.18 → 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/browser/oracle.ts +29 -1
- 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/inference/inference.types.ts +8 -0
- package/src/inference/request.ts +5 -1
- package/src/inference/stream.ts +21 -2
- package/src/inference/wire.ts +0 -0
- 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/run.ts +3 -0
- package/src/loop/session.ts +12 -5
- 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
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
import { createRule } from "../../create-rule";
|
|
4
|
+
import { calleeName, isServerAppFile } from "../utils";
|
|
5
|
+
|
|
6
|
+
export const RULE_NAME = "no-internal-api-fetch";
|
|
7
|
+
|
|
8
|
+
type MessageIds = "internalApiFetch";
|
|
9
|
+
|
|
10
|
+
function urlLooksLikeInternalApi(value: string): boolean {
|
|
11
|
+
const trimmed = value.trim();
|
|
12
|
+
|
|
13
|
+
if (trimmed.startsWith("/api/") || trimmed === "/api") {
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return /localhost(?::\d+)?\/api\b/.test(trimmed);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function literalApiUrl(node: TSESTree.Expression): boolean {
|
|
21
|
+
if (node.type === AST_NODE_TYPES.Literal && typeof node.value === "string") {
|
|
22
|
+
return urlLooksLikeInternalApi(node.value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function templateApiUrl(node: TSESTree.TemplateLiteral): boolean {
|
|
29
|
+
const cooked = node.quasis.map((q) => q.value.cooked ?? "").join("");
|
|
30
|
+
|
|
31
|
+
return urlLooksLikeInternalApi(cooked);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function firstArgLooksLikeInternalApi(
|
|
35
|
+
args: readonly TSESTree.CallExpressionArgument[]
|
|
36
|
+
): boolean {
|
|
37
|
+
const first = args[0];
|
|
38
|
+
|
|
39
|
+
if (first === undefined || first.type === AST_NODE_TYPES.SpreadElement) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (literalApiUrl(first)) {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (first.type === AST_NODE_TYPES.TemplateLiteral) {
|
|
48
|
+
return templateApiUrl(first);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isFetchCall(node: TSESTree.CallExpression): boolean {
|
|
55
|
+
const name = calleeName(node.callee);
|
|
56
|
+
|
|
57
|
+
return name === "fetch";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isAxiosApiCall(node: TSESTree.CallExpression): boolean {
|
|
61
|
+
const callee = node.callee;
|
|
62
|
+
|
|
63
|
+
if (callee.type !== AST_NODE_TYPES.MemberExpression || callee.computed) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (
|
|
68
|
+
callee.object.type !== AST_NODE_TYPES.Identifier ||
|
|
69
|
+
callee.object.name !== "axios"
|
|
70
|
+
) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (callee.property.type !== AST_NODE_TYPES.Identifier) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const method = callee.property.name;
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
method === "get" ||
|
|
82
|
+
method === "post" ||
|
|
83
|
+
method === "put" ||
|
|
84
|
+
method === "patch" ||
|
|
85
|
+
method === "delete" ||
|
|
86
|
+
method === "request"
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const noInternalApiFetchRule = createRule<[], MessageIds>({
|
|
91
|
+
name: RULE_NAME,
|
|
92
|
+
meta: {
|
|
93
|
+
type: "problem",
|
|
94
|
+
docs: {
|
|
95
|
+
description:
|
|
96
|
+
"Disallow Server Components from fetching the app's own /api routes — import services or ORM modules directly to avoid loopback HTTP overhead.",
|
|
97
|
+
},
|
|
98
|
+
schema: [],
|
|
99
|
+
messages: {
|
|
100
|
+
internalApiFetch:
|
|
101
|
+
"Do not fetch `/api/*` from a Server Component — import the data/service module directly instead of loopback HTTP.",
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
defaultOptions: [],
|
|
105
|
+
create(context) {
|
|
106
|
+
let serverFile = false;
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
Program(node: TSESTree.Program) {
|
|
110
|
+
serverFile = isServerAppFile(context.filename, node);
|
|
111
|
+
},
|
|
112
|
+
CallExpression(node: TSESTree.CallExpression) {
|
|
113
|
+
if (!serverFile) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (
|
|
118
|
+
(isFetchCall(node) || isAxiosApiCall(node)) &&
|
|
119
|
+
firstArgLooksLikeInternalApi(node.arguments)
|
|
120
|
+
) {
|
|
121
|
+
context.report({ node, messageId: "internalApiFetch" });
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
import type { JSONSchema4 } from "@typescript-eslint/utils/json-schema";
|
|
3
|
+
|
|
4
|
+
import { createRule } from "../../create-rule";
|
|
5
|
+
import { isServerAppFile } from "../utils";
|
|
6
|
+
|
|
7
|
+
export const RULE_NAME = "no-secret-props-to-client";
|
|
8
|
+
|
|
9
|
+
export interface INoSecretPropsToClientOptions {
|
|
10
|
+
readonly secretPropPatterns?: readonly string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type RuleOptions = [INoSecretPropsToClientOptions];
|
|
14
|
+
type MessageIds = "secretPropToClient";
|
|
15
|
+
|
|
16
|
+
const DEFAULT_SECRET_PROP_PATTERNS = [
|
|
17
|
+
"secret",
|
|
18
|
+
"password",
|
|
19
|
+
"token",
|
|
20
|
+
"apiKey",
|
|
21
|
+
"api_key",
|
|
22
|
+
"privateKey",
|
|
23
|
+
"private_key",
|
|
24
|
+
"credential",
|
|
25
|
+
"authToken",
|
|
26
|
+
"accessToken",
|
|
27
|
+
"refreshToken",
|
|
28
|
+
] as const;
|
|
29
|
+
|
|
30
|
+
const optionSchema: JSONSchema4 = {
|
|
31
|
+
type: "object",
|
|
32
|
+
additionalProperties: false,
|
|
33
|
+
properties: {
|
|
34
|
+
secretPropPatterns: {
|
|
35
|
+
type: "array",
|
|
36
|
+
items: { type: "string", minLength: 1 },
|
|
37
|
+
uniqueItems: true,
|
|
38
|
+
minItems: 1,
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function propNameLooksSecret(
|
|
44
|
+
name: string,
|
|
45
|
+
patterns: readonly string[]
|
|
46
|
+
): boolean {
|
|
47
|
+
const lower = name.toLowerCase();
|
|
48
|
+
|
|
49
|
+
return patterns.some((pattern) => lower.includes(pattern.toLowerCase()));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getJsxAttributeName(
|
|
53
|
+
attribute: TSESTree.JSXAttribute | TSESTree.JSXSpreadAttribute
|
|
54
|
+
): string | null {
|
|
55
|
+
if (attribute.type === AST_NODE_TYPES.JSXSpreadAttribute) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const name = attribute.name;
|
|
60
|
+
|
|
61
|
+
if (name.type === AST_NODE_TYPES.JSXIdentifier) {
|
|
62
|
+
return name.name;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (name.type === AST_NODE_TYPES.JSXNamespacedName) {
|
|
66
|
+
return name.name.name;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const noSecretPropsToClientRule = createRule<RuleOptions, MessageIds>({
|
|
73
|
+
name: RULE_NAME,
|
|
74
|
+
meta: {
|
|
75
|
+
type: "suggestion",
|
|
76
|
+
docs: {
|
|
77
|
+
description:
|
|
78
|
+
"Warn when Server Components pass secret-looking props to JSX — values may cross the client boundary.",
|
|
79
|
+
},
|
|
80
|
+
schema: [optionSchema],
|
|
81
|
+
messages: {
|
|
82
|
+
secretPropToClient:
|
|
83
|
+
"Prop `{{name}}` looks like a secret — do not pass it from Server Components to client-rendered JSX.",
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
defaultOptions: [{ secretPropPatterns: [...DEFAULT_SECRET_PROP_PATTERNS] }],
|
|
87
|
+
create(context, [options]) {
|
|
88
|
+
const patterns = options.secretPropPatterns ?? DEFAULT_SECRET_PROP_PATTERNS;
|
|
89
|
+
let serverFile = false;
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
Program(node: TSESTree.Program) {
|
|
93
|
+
serverFile = isServerAppFile(context.filename, node);
|
|
94
|
+
},
|
|
95
|
+
JSXOpeningElement(node) {
|
|
96
|
+
if (!serverFile) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const attr of node.attributes) {
|
|
101
|
+
const propName = getJsxAttributeName(attr);
|
|
102
|
+
|
|
103
|
+
if (propName === null) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (propNameLooksSecret(propName, patterns)) {
|
|
108
|
+
context.report({
|
|
109
|
+
node: attr,
|
|
110
|
+
messageId: "secretPropToClient",
|
|
111
|
+
data: { name: propName },
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
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-sensitive-next-public-env";
|
|
6
|
+
|
|
7
|
+
type MessageIds = "sensitiveNextPublic";
|
|
8
|
+
|
|
9
|
+
const SENSITIVE_NAME = /(?:SECRET|PRIVATE|PASSWORD|TOKEN|DATABASE|STRIPE|KEY)/i;
|
|
10
|
+
|
|
11
|
+
function isProcessEnvMember(node: TSESTree.MemberExpression): string | null {
|
|
12
|
+
if (node.computed) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (
|
|
17
|
+
node.object.type !== AST_NODE_TYPES.MemberExpression ||
|
|
18
|
+
node.object.computed ||
|
|
19
|
+
node.object.object.type !== AST_NODE_TYPES.Identifier ||
|
|
20
|
+
node.object.object.name !== "process" ||
|
|
21
|
+
node.object.property.type !== AST_NODE_TYPES.Identifier ||
|
|
22
|
+
node.object.property.name !== "env"
|
|
23
|
+
) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (node.property.type !== AST_NODE_TYPES.Identifier) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return node.property.name;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isSensitiveNextPublicName(name: string): boolean {
|
|
35
|
+
if (!name.startsWith("NEXT_PUBLIC_")) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return SENSITIVE_NAME.test(name);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const noSensitiveNextPublicEnvRule = createRule<[], MessageIds>({
|
|
43
|
+
name: RULE_NAME,
|
|
44
|
+
meta: {
|
|
45
|
+
type: "problem",
|
|
46
|
+
docs: {
|
|
47
|
+
description:
|
|
48
|
+
"Disallow NEXT_PUBLIC_* env vars whose names suggest secrets — public build-time vars are visible in the client bundle.",
|
|
49
|
+
},
|
|
50
|
+
schema: [],
|
|
51
|
+
messages: {
|
|
52
|
+
sensitiveNextPublic:
|
|
53
|
+
"`process.env.{{name}}` looks like a secret exposed to the browser — remove NEXT_PUBLIC_ or keep it server-only.",
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
defaultOptions: [],
|
|
57
|
+
create(context) {
|
|
58
|
+
return {
|
|
59
|
+
MemberExpression(node: TSESTree.MemberExpression) {
|
|
60
|
+
const envName = isProcessEnvMember(node);
|
|
61
|
+
|
|
62
|
+
if (envName !== null && isSensitiveNextPublicName(envName)) {
|
|
63
|
+
context.report({
|
|
64
|
+
node,
|
|
65
|
+
messageId: "sensitiveNextPublic",
|
|
66
|
+
data: { name: envName },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
import { createRule } from "../../create-rule";
|
|
4
|
+
import { walkSome } from "../../utils";
|
|
5
|
+
import { calleeName } from "../utils";
|
|
6
|
+
|
|
7
|
+
export const RULE_NAME = "prefer-lazy-use-state-init";
|
|
8
|
+
|
|
9
|
+
type MessageIds = "preferLazyInit";
|
|
10
|
+
|
|
11
|
+
function isExpression(node: TSESTree.Node): node is TSESTree.Expression {
|
|
12
|
+
return node.type !== AST_NODE_TYPES.SpreadElement;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function touchesBrowserStorage(node: TSESTree.Node): boolean {
|
|
16
|
+
return walkSome(node, (current) => {
|
|
17
|
+
if (current.type !== AST_NODE_TYPES.MemberExpression || current.computed) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (current.object.type !== AST_NODE_TYPES.Identifier) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
current.object.name === "localStorage" ||
|
|
27
|
+
current.object.name === "sessionStorage"
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isLazyInitializer(
|
|
33
|
+
init: TSESTree.Expression | null | undefined
|
|
34
|
+
): boolean {
|
|
35
|
+
if (init === null || init === undefined) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
init.type === AST_NODE_TYPES.ArrowFunctionExpression ||
|
|
41
|
+
init.type === AST_NODE_TYPES.FunctionExpression
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const preferLazyUseStateInitRule = createRule<[], MessageIds>({
|
|
46
|
+
name: RULE_NAME,
|
|
47
|
+
meta: {
|
|
48
|
+
type: "suggestion",
|
|
49
|
+
docs: {
|
|
50
|
+
description:
|
|
51
|
+
"Prefer lazy useState initializers when parsing localStorage/sessionStorage — avoids re-parsing on every render.",
|
|
52
|
+
},
|
|
53
|
+
schema: [],
|
|
54
|
+
messages: {
|
|
55
|
+
preferLazyInit:
|
|
56
|
+
"Wrap expensive storage parsing in a lazy initializer: `useState(() => JSON.parse(localStorage.getItem('key') ?? '{}'))`.",
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
defaultOptions: [],
|
|
60
|
+
create(context) {
|
|
61
|
+
return {
|
|
62
|
+
CallExpression(node: TSESTree.CallExpression) {
|
|
63
|
+
const name = calleeName(node.callee);
|
|
64
|
+
|
|
65
|
+
if (name !== "useState") {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const init = node.arguments[0];
|
|
70
|
+
|
|
71
|
+
if (init === undefined || !isExpression(init)) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (isLazyInitializer(init)) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (touchesBrowserStorage(init)) {
|
|
80
|
+
context.report({ node, messageId: "preferLazyInit" });
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
import type { JSONSchema4 } from "@typescript-eslint/utils/json-schema";
|
|
3
|
+
|
|
4
|
+
import { createRule } from "../../create-rule";
|
|
5
|
+
import { walkSome } from "../../utils";
|
|
6
|
+
import {
|
|
7
|
+
containsAuthzCall,
|
|
8
|
+
defaultAuthzOptions,
|
|
9
|
+
getFunctionLikeBody,
|
|
10
|
+
hasUseServerDirective,
|
|
11
|
+
isDbMutationCall,
|
|
12
|
+
resolveAuthzFunctions,
|
|
13
|
+
type FunctionLike,
|
|
14
|
+
type IAuthzOptions,
|
|
15
|
+
} from "../../authorization/utils";
|
|
16
|
+
|
|
17
|
+
export const RULE_NAME = "server-action-requires-authz-and-validation";
|
|
18
|
+
|
|
19
|
+
export interface IServerActionRequiresAuthzAndValidationOptions extends IAuthzOptions {
|
|
20
|
+
readonly parseMethods?: readonly string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type RuleOptions = [IServerActionRequiresAuthzAndValidationOptions];
|
|
24
|
+
type MessageIds = "missingAuthz" | "missingValidation";
|
|
25
|
+
|
|
26
|
+
const DEFAULT_PARSE_METHODS = ["parse", "safeParse"] as const;
|
|
27
|
+
|
|
28
|
+
const extendedOptionSchema: JSONSchema4 = {
|
|
29
|
+
type: "object",
|
|
30
|
+
additionalProperties: false,
|
|
31
|
+
properties: {
|
|
32
|
+
authzFunctions: {
|
|
33
|
+
type: "array",
|
|
34
|
+
items: { type: "string" },
|
|
35
|
+
uniqueItems: true,
|
|
36
|
+
minItems: 1,
|
|
37
|
+
},
|
|
38
|
+
parseMethods: {
|
|
39
|
+
type: "array",
|
|
40
|
+
items: { type: "string" },
|
|
41
|
+
uniqueItems: true,
|
|
42
|
+
minItems: 1,
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function getFunctionName(node: FunctionLike): string {
|
|
48
|
+
if (node.type === AST_NODE_TYPES.FunctionDeclaration && node.id !== null) {
|
|
49
|
+
return node.id.name;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const parent = node.parent;
|
|
53
|
+
|
|
54
|
+
if (
|
|
55
|
+
parent?.type === AST_NODE_TYPES.VariableDeclarator &&
|
|
56
|
+
parent.id.type === AST_NODE_TYPES.Identifier
|
|
57
|
+
) {
|
|
58
|
+
return parent.id.name;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return "server action";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function containsParseCall(
|
|
65
|
+
root: TSESTree.Node,
|
|
66
|
+
parseMethods: ReadonlySet<string>
|
|
67
|
+
): boolean {
|
|
68
|
+
return walkSome(root, (node) => {
|
|
69
|
+
if (node.type !== AST_NODE_TYPES.CallExpression) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const callee = node.callee;
|
|
74
|
+
|
|
75
|
+
if (
|
|
76
|
+
callee.type === AST_NODE_TYPES.MemberExpression &&
|
|
77
|
+
!callee.computed &&
|
|
78
|
+
callee.property.type === AST_NODE_TYPES.Identifier &&
|
|
79
|
+
parseMethods.has(callee.property.name)
|
|
80
|
+
) {
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (
|
|
85
|
+
callee.type === AST_NODE_TYPES.Identifier &&
|
|
86
|
+
parseMethods.has(callee.name)
|
|
87
|
+
) {
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return false;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const serverActionRequiresAuthzAndValidationRule = createRule<
|
|
96
|
+
RuleOptions,
|
|
97
|
+
MessageIds
|
|
98
|
+
>({
|
|
99
|
+
name: RULE_NAME,
|
|
100
|
+
meta: {
|
|
101
|
+
type: "problem",
|
|
102
|
+
docs: {
|
|
103
|
+
description:
|
|
104
|
+
'Server actions (`"use server"`) that mutate the database must call authorization helpers and validate input with `.parse()` / `.safeParse()`.',
|
|
105
|
+
},
|
|
106
|
+
schema: [extendedOptionSchema],
|
|
107
|
+
messages: {
|
|
108
|
+
missingAuthz:
|
|
109
|
+
'Server action "{{name}}" performs a database mutation but does not call an authorization helper (e.g. {{examples}}).',
|
|
110
|
+
missingValidation:
|
|
111
|
+
'Server action "{{name}}" performs a database mutation but does not validate input with `.parse()` / `.safeParse()`.',
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
defaultOptions: [
|
|
115
|
+
{
|
|
116
|
+
...defaultAuthzOptions(),
|
|
117
|
+
parseMethods: [...DEFAULT_PARSE_METHODS],
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
create(context, [options]) {
|
|
121
|
+
const authzNames = resolveAuthzFunctions(options);
|
|
122
|
+
const parseMethods = new Set(options.parseMethods ?? DEFAULT_PARSE_METHODS);
|
|
123
|
+
const examples = [...authzNames].slice(0, 2).join(", ");
|
|
124
|
+
let useServerFile = false;
|
|
125
|
+
|
|
126
|
+
function visitFunction(node: FunctionLike): void {
|
|
127
|
+
if (!useServerFile) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const body = getFunctionLikeBody(node);
|
|
132
|
+
|
|
133
|
+
if (body === null) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const hasMutation = walkSome(
|
|
138
|
+
body,
|
|
139
|
+
(child) =>
|
|
140
|
+
child.type === AST_NODE_TYPES.CallExpression &&
|
|
141
|
+
isDbMutationCall(child)
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
if (!hasMutation) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const name = getFunctionName(node);
|
|
149
|
+
const hasAuthz = containsAuthzCall(body, authzNames);
|
|
150
|
+
const hasValidation = containsParseCall(body, parseMethods);
|
|
151
|
+
|
|
152
|
+
if (!hasAuthz) {
|
|
153
|
+
context.report({
|
|
154
|
+
node,
|
|
155
|
+
messageId: "missingAuthz",
|
|
156
|
+
data: { name, examples },
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!hasValidation) {
|
|
161
|
+
context.report({
|
|
162
|
+
node,
|
|
163
|
+
messageId: "missingValidation",
|
|
164
|
+
data: { name },
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
Program(node: TSESTree.Program) {
|
|
171
|
+
useServerFile = hasUseServerDirective(node);
|
|
172
|
+
},
|
|
173
|
+
FunctionDeclaration: visitFunction,
|
|
174
|
+
FunctionExpression: visitFunction,
|
|
175
|
+
ArrowFunctionExpression: visitFunction,
|
|
176
|
+
};
|
|
177
|
+
},
|
|
178
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
import type { JSONSchema4 } from "@typescript-eslint/utils/json-schema";
|
|
3
|
+
|
|
4
|
+
import { createRule } from "../../create-rule";
|
|
5
|
+
import { hasDirective, isAppRouterFile } from "../utils";
|
|
6
|
+
|
|
7
|
+
export const RULE_NAME = "server-only-modules-import-server-only";
|
|
8
|
+
|
|
9
|
+
export interface IServerOnlyModulesImportServerOnlyOptions {
|
|
10
|
+
readonly serverOnlyModule?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type RuleOptions = [IServerOnlyModulesImportServerOnlyOptions];
|
|
14
|
+
type MessageIds = "missingServerOnlyImport";
|
|
15
|
+
|
|
16
|
+
const DEFAULT_SERVER_ONLY_MODULE = "server-only";
|
|
17
|
+
|
|
18
|
+
const optionSchema: JSONSchema4 = {
|
|
19
|
+
type: "object",
|
|
20
|
+
additionalProperties: false,
|
|
21
|
+
properties: {
|
|
22
|
+
serverOnlyModule: { type: "string", minLength: 1 },
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function hasServerOnlyImport(
|
|
27
|
+
program: TSESTree.Program,
|
|
28
|
+
moduleName: string
|
|
29
|
+
): boolean {
|
|
30
|
+
for (const stmt of program.body) {
|
|
31
|
+
if (stmt.type !== AST_NODE_TYPES.ImportDeclaration) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (stmt.source.value === moduleName) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const serverOnlyModulesImportServerOnlyRule = createRule<
|
|
44
|
+
RuleOptions,
|
|
45
|
+
MessageIds
|
|
46
|
+
>({
|
|
47
|
+
name: RULE_NAME,
|
|
48
|
+
meta: {
|
|
49
|
+
type: "problem",
|
|
50
|
+
docs: {
|
|
51
|
+
description:
|
|
52
|
+
'App-router server modules must import `"server-only"` so accidental client bundling fails at build time.',
|
|
53
|
+
},
|
|
54
|
+
schema: [optionSchema],
|
|
55
|
+
messages: {
|
|
56
|
+
missingServerOnlyImport:
|
|
57
|
+
'Add `import "{{module}}";` — server modules under `app/` should fail fast if bundled for the client.',
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
defaultOptions: [{ serverOnlyModule: DEFAULT_SERVER_ONLY_MODULE }],
|
|
61
|
+
create(context, [options]) {
|
|
62
|
+
const serverOnlyModule =
|
|
63
|
+
options.serverOnlyModule ?? DEFAULT_SERVER_ONLY_MODULE;
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
Program(node) {
|
|
67
|
+
if (!isAppRouterFile(context.filename)) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (hasDirective(node, "use client")) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (hasServerOnlyImport(node, serverOnlyModule)) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
context.report({
|
|
80
|
+
node,
|
|
81
|
+
messageId: "missingServerOnlyImport",
|
|
82
|
+
data: { module: serverOnlyModule },
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
});
|
|
@@ -36,6 +36,24 @@ export function hasDirective(
|
|
|
36
36
|
return false;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/** True if the file is a Next.js error boundary route file. */
|
|
40
|
+
export function isErrorBoundaryFile(filename: string): boolean {
|
|
41
|
+
const base = filename.split(/[\\/]/).pop() ?? "";
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
isAppRouterFile(filename) &&
|
|
45
|
+
/^(?:error|global-error)\.(?:tsx|ts|jsx|js)$/.test(base)
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** True if the file is an app-router file defaulting to a Server Component. */
|
|
50
|
+
export function isServerAppFile(
|
|
51
|
+
filename: string,
|
|
52
|
+
program: TSESTree.Program
|
|
53
|
+
): boolean {
|
|
54
|
+
return isAppRouterFile(filename) && !hasDirective(program, "use client");
|
|
55
|
+
}
|
|
56
|
+
|
|
39
57
|
/** Resolve a call's callee to a simple name: `useState` or `React.useState`
|
|
40
58
|
* → "useState". Returns null for computed or complex callees. */
|
|
41
59
|
export function calleeName(callee: TSESTree.Node): string | null {
|