@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,124 @@
|
|
|
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
|
+
|
|
6
|
+
export const RULE_NAME = "jwt-must-verify-not-decode";
|
|
7
|
+
|
|
8
|
+
export interface IJwtMustVerifyNotDecodeOptions {
|
|
9
|
+
readonly jwtObjectNames?: readonly string[];
|
|
10
|
+
readonly decodeMethods?: readonly string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type RuleOptions = [IJwtMustVerifyNotDecodeOptions];
|
|
14
|
+
type MessageIds = "useVerifyNotDecode";
|
|
15
|
+
|
|
16
|
+
const DEFAULT_JWT_OBJECTS: readonly string[] = ["jwt", "jsonwebtoken"];
|
|
17
|
+
const DEFAULT_DECODE_METHODS: readonly string[] = ["decode", "decodeJwt"];
|
|
18
|
+
|
|
19
|
+
const optionSchema: JSONSchema4 = {
|
|
20
|
+
type: "object",
|
|
21
|
+
additionalProperties: false,
|
|
22
|
+
properties: {
|
|
23
|
+
jwtObjectNames: {
|
|
24
|
+
type: "array",
|
|
25
|
+
items: { type: "string" },
|
|
26
|
+
uniqueItems: true,
|
|
27
|
+
minItems: 1,
|
|
28
|
+
},
|
|
29
|
+
decodeMethods: {
|
|
30
|
+
type: "array",
|
|
31
|
+
items: { type: "string" },
|
|
32
|
+
uniqueItems: true,
|
|
33
|
+
minItems: 1,
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function getMemberPropertyName(
|
|
39
|
+
member: TSESTree.MemberExpression
|
|
40
|
+
): string | null {
|
|
41
|
+
if (!member.computed && member.property.type === AST_NODE_TYPES.Identifier) {
|
|
42
|
+
return member.property.name;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (
|
|
46
|
+
member.computed &&
|
|
47
|
+
member.property.type === AST_NODE_TYPES.Literal &&
|
|
48
|
+
typeof member.property.value === "string"
|
|
49
|
+
) {
|
|
50
|
+
return member.property.value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isJwtDecodeCall(
|
|
57
|
+
node: TSESTree.CallExpression,
|
|
58
|
+
jwtObjects: ReadonlySet<string>,
|
|
59
|
+
decodeMethods: ReadonlySet<string>
|
|
60
|
+
): boolean {
|
|
61
|
+
const callee = node.callee;
|
|
62
|
+
|
|
63
|
+
if (callee.type !== AST_NODE_TYPES.MemberExpression || callee.computed) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const method = getMemberPropertyName(callee);
|
|
68
|
+
|
|
69
|
+
if (method === null || !decodeMethods.has(method)) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const receiver = callee.object;
|
|
74
|
+
|
|
75
|
+
if (receiver.type === AST_NODE_TYPES.Identifier) {
|
|
76
|
+
return jwtObjects.has(receiver.name);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (
|
|
80
|
+
receiver.type === AST_NODE_TYPES.MemberExpression &&
|
|
81
|
+
!receiver.computed &&
|
|
82
|
+
receiver.property.type === AST_NODE_TYPES.Identifier
|
|
83
|
+
) {
|
|
84
|
+
return jwtObjects.has(receiver.property.name);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const jwtMustVerifyNotDecodeRule = createRule<RuleOptions, MessageIds>({
|
|
91
|
+
name: RULE_NAME,
|
|
92
|
+
meta: {
|
|
93
|
+
type: "problem",
|
|
94
|
+
docs: {
|
|
95
|
+
description:
|
|
96
|
+
"Disallow `jwt.decode` / `decodeJwt` — decoding without verification accepts forged tokens. Use `jwt.verify` or `jwtVerify` instead.",
|
|
97
|
+
},
|
|
98
|
+
schema: [optionSchema],
|
|
99
|
+
messages: {
|
|
100
|
+
useVerifyNotDecode:
|
|
101
|
+
"Do not decode JWTs without verification — use `jwt.verify(...)` or `jwtVerify(...)` so signatures are checked.",
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
defaultOptions: [
|
|
105
|
+
{
|
|
106
|
+
jwtObjectNames: [...DEFAULT_JWT_OBJECTS],
|
|
107
|
+
decodeMethods: [...DEFAULT_DECODE_METHODS],
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
create(context, [options]) {
|
|
111
|
+
const jwtObjects = new Set(options.jwtObjectNames ?? DEFAULT_JWT_OBJECTS);
|
|
112
|
+
const decodeMethods = new Set(
|
|
113
|
+
options.decodeMethods ?? DEFAULT_DECODE_METHODS
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
CallExpression(node) {
|
|
118
|
+
if (isJwtDecodeCall(node, jwtObjects, decodeMethods)) {
|
|
119
|
+
context.report({ node, messageId: "useVerifyNotDecode" });
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
},
|
|
124
|
+
});
|
|
@@ -2,11 +2,13 @@ import type { TSESLint } from "@typescript-eslint/utils";
|
|
|
2
2
|
|
|
3
3
|
import { noImportBuildOutputRule } from "./rules/no-import-build-output";
|
|
4
4
|
import { noImportTestFromSourceRule } from "./rules/no-import-test-from-source";
|
|
5
|
+
import { noReactInServicesRule } from "./rules/no-react-in-services";
|
|
5
6
|
import type { IRulePack } from "../rule-packs.types";
|
|
6
7
|
|
|
7
8
|
const rules: Record<string, TSESLint.RuleModule<string, readonly unknown[]>> = {
|
|
8
9
|
"no-import-build-output": noImportBuildOutputRule,
|
|
9
10
|
"no-import-test-from-source": noImportTestFromSourceRule,
|
|
11
|
+
"no-react-in-services": noReactInServicesRule,
|
|
10
12
|
};
|
|
11
13
|
|
|
12
14
|
export const moduleBoundariesPack: IRulePack = {
|
|
@@ -17,6 +19,7 @@ export const moduleBoundariesPack: IRulePack = {
|
|
|
17
19
|
rulesConfig: {
|
|
18
20
|
"no-import-build-output": "error",
|
|
19
21
|
"no-import-test-from-source": "error",
|
|
22
|
+
"no-react-in-services": "error",
|
|
20
23
|
},
|
|
21
24
|
};
|
|
22
25
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import 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 { matchesAnyGlobPattern, toPosixRelative } from "../../utils";
|
|
6
|
+
|
|
7
|
+
export const RULE_NAME = "no-react-in-services";
|
|
8
|
+
|
|
9
|
+
export interface INoReactInServicesOptions {
|
|
10
|
+
readonly serviceGlobs?: readonly string[];
|
|
11
|
+
readonly forbiddenModules?: readonly string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type RuleOptions = [INoReactInServicesOptions];
|
|
15
|
+
type MessageIds = "reactInService";
|
|
16
|
+
|
|
17
|
+
const DEFAULT_SERVICE_GLOBS = [
|
|
18
|
+
"**/services/**",
|
|
19
|
+
"**/*.service.ts",
|
|
20
|
+
"**/*.queries.ts",
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
23
|
+
const DEFAULT_FORBIDDEN = ["react", "react-dom"] as const;
|
|
24
|
+
|
|
25
|
+
const DEFAULT_SERVICE_PATH_PATTERNS = [
|
|
26
|
+
/(^|\/)services\//,
|
|
27
|
+
/\.service\.tsx?$/,
|
|
28
|
+
/\.queries\.ts$/,
|
|
29
|
+
] as const;
|
|
30
|
+
|
|
31
|
+
const optionSchema: JSONSchema4 = {
|
|
32
|
+
type: "object",
|
|
33
|
+
additionalProperties: false,
|
|
34
|
+
properties: {
|
|
35
|
+
serviceGlobs: {
|
|
36
|
+
type: "array",
|
|
37
|
+
items: { type: "string" },
|
|
38
|
+
},
|
|
39
|
+
forbiddenModules: {
|
|
40
|
+
type: "array",
|
|
41
|
+
items: { type: "string" },
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function isServiceFile(
|
|
47
|
+
filename: string,
|
|
48
|
+
cwd: string,
|
|
49
|
+
globs: readonly string[]
|
|
50
|
+
): boolean {
|
|
51
|
+
const rel = toPosixRelative(filename, cwd);
|
|
52
|
+
|
|
53
|
+
if (DEFAULT_SERVICE_PATH_PATTERNS.some((pattern) => pattern.test(rel))) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return matchesAnyGlobPattern(rel, globs);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const noReactInServicesRule = createRule<RuleOptions, MessageIds>({
|
|
61
|
+
name: RULE_NAME,
|
|
62
|
+
meta: {
|
|
63
|
+
type: "problem",
|
|
64
|
+
docs: {
|
|
65
|
+
description:
|
|
66
|
+
"Service and data-fetch modules must not import React — keep business logic decoupled from the view layer.",
|
|
67
|
+
},
|
|
68
|
+
schema: [optionSchema],
|
|
69
|
+
messages: {
|
|
70
|
+
reactInService:
|
|
71
|
+
"Service/data layer file must not import `{{module}}` — keep React in components and hooks only.",
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
defaultOptions: [
|
|
75
|
+
{
|
|
76
|
+
serviceGlobs: [...DEFAULT_SERVICE_GLOBS],
|
|
77
|
+
forbiddenModules: [...DEFAULT_FORBIDDEN],
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
create(context, [options]) {
|
|
81
|
+
const serviceGlobs = options.serviceGlobs ?? DEFAULT_SERVICE_GLOBS;
|
|
82
|
+
const forbidden = new Set(options.forbiddenModules ?? DEFAULT_FORBIDDEN);
|
|
83
|
+
const cwd = context.cwd;
|
|
84
|
+
|
|
85
|
+
if (!isServiceFile(context.filename, cwd, serviceGlobs)) {
|
|
86
|
+
return {};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
ImportDeclaration(node: TSESTree.ImportDeclaration) {
|
|
91
|
+
const source = node.source.value;
|
|
92
|
+
|
|
93
|
+
if (typeof source !== "string") {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const base = source.split("/")[0];
|
|
98
|
+
|
|
99
|
+
if (base === undefined || !forbidden.has(base)) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
context.report({
|
|
104
|
+
node,
|
|
105
|
+
messageId: "reactInService",
|
|
106
|
+
data: { module: base },
|
|
107
|
+
});
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
});
|
|
@@ -1,14 +1,36 @@
|
|
|
1
1
|
import type { TSESLint } from "@typescript-eslint/utils";
|
|
2
2
|
|
|
3
|
+
import { awaitDynamicRequestApisRule } from "./rules/await-dynamic-request-apis";
|
|
3
4
|
import { clientHooksRequireUseClientRule } from "./rules/client-hooks-require-use-client";
|
|
5
|
+
import { errorBoundaryRequireUseClientRule } from "./rules/error-boundary-require-use-client";
|
|
6
|
+
import { mutationShouldRevalidateCacheRule } from "./rules/mutation-should-revalidate-cache";
|
|
7
|
+
import { noHtmlImgElementRule } from "./rules/no-html-img-element";
|
|
8
|
+
import { noInternalApiFetchRule } from "./rules/no-internal-api-fetch";
|
|
4
9
|
import { noNextHeadInAppRule } from "./rules/no-next-head-in-app";
|
|
5
10
|
import { noPagesRouterDataFetchingInAppRule } from "./rules/no-pages-router-data-fetching-in-app";
|
|
11
|
+
import { noSecretPropsToClientRule } from "./rules/no-secret-props-to-client";
|
|
12
|
+
import { noSensitiveNextPublicEnvRule } from "./rules/no-sensitive-next-public-env";
|
|
13
|
+
import { preferLazyUseStateInitRule } from "./rules/prefer-lazy-use-state-init";
|
|
14
|
+
import { serverActionRequiresAuthzAndValidationRule } from "./rules/server-action-requires-authz-and-validation";
|
|
15
|
+
import { serverOnlyModulesImportServerOnlyRule } from "./rules/server-only-modules-import-server-only";
|
|
6
16
|
import type { IRulePack } from "../rule-packs.types";
|
|
7
17
|
|
|
8
18
|
const rules: Record<string, TSESLint.RuleModule<string, readonly unknown[]>> = {
|
|
19
|
+
"await-dynamic-request-apis": awaitDynamicRequestApisRule,
|
|
9
20
|
"client-hooks-require-use-client": clientHooksRequireUseClientRule,
|
|
21
|
+
"error-boundary-require-use-client": errorBoundaryRequireUseClientRule,
|
|
22
|
+
"mutation-should-revalidate-cache": mutationShouldRevalidateCacheRule,
|
|
23
|
+
"no-html-img-element": noHtmlImgElementRule,
|
|
24
|
+
"no-internal-api-fetch": noInternalApiFetchRule,
|
|
10
25
|
"no-next-head-in-app": noNextHeadInAppRule,
|
|
11
26
|
"no-pages-router-data-fetching-in-app": noPagesRouterDataFetchingInAppRule,
|
|
27
|
+
"no-secret-props-to-client": noSecretPropsToClientRule,
|
|
28
|
+
"no-sensitive-next-public-env": noSensitiveNextPublicEnvRule,
|
|
29
|
+
"prefer-lazy-use-state-init": preferLazyUseStateInitRule,
|
|
30
|
+
"server-action-requires-authz-and-validation":
|
|
31
|
+
serverActionRequiresAuthzAndValidationRule,
|
|
32
|
+
"server-only-modules-import-server-only":
|
|
33
|
+
serverOnlyModulesImportServerOnlyRule,
|
|
12
34
|
};
|
|
13
35
|
|
|
14
36
|
export const nextjsPack: IRulePack = {
|
|
@@ -17,9 +39,19 @@ export const nextjsPack: IRulePack = {
|
|
|
17
39
|
"Next.js app-router correctness: server/client component boundaries and dead pages-router APIs.",
|
|
18
40
|
rules,
|
|
19
41
|
rulesConfig: {
|
|
42
|
+
"await-dynamic-request-apis": "error",
|
|
20
43
|
"client-hooks-require-use-client": "error",
|
|
44
|
+
"error-boundary-require-use-client": "error",
|
|
45
|
+
"mutation-should-revalidate-cache": "warn",
|
|
46
|
+
"no-html-img-element": "warn",
|
|
47
|
+
"no-internal-api-fetch": "error",
|
|
21
48
|
"no-next-head-in-app": "error",
|
|
22
49
|
"no-pages-router-data-fetching-in-app": "error",
|
|
50
|
+
"no-secret-props-to-client": "warn",
|
|
51
|
+
"no-sensitive-next-public-env": "error",
|
|
52
|
+
"prefer-lazy-use-state-init": "warn",
|
|
53
|
+
"server-action-requires-authz-and-validation": "error",
|
|
54
|
+
"server-only-modules-import-server-only": "error",
|
|
23
55
|
},
|
|
24
56
|
};
|
|
25
57
|
|
|
@@ -0,0 +1,65 @@
|
|
|
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 = "await-dynamic-request-apis";
|
|
7
|
+
|
|
8
|
+
type MessageIds = "mustAwait";
|
|
9
|
+
|
|
10
|
+
const DYNAMIC_REQUEST_APIS = new Set(["cookies", "headers", "draftMode"]);
|
|
11
|
+
|
|
12
|
+
function isAwaited(node: TSESTree.Node): boolean {
|
|
13
|
+
let current: TSESTree.Node | null | undefined = node.parent;
|
|
14
|
+
|
|
15
|
+
while (current !== undefined && current !== null) {
|
|
16
|
+
if (current.type === AST_NODE_TYPES.AwaitExpression) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
current = current.parent;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const awaitDynamicRequestApisRule = createRule<[], MessageIds>({
|
|
27
|
+
name: RULE_NAME,
|
|
28
|
+
meta: {
|
|
29
|
+
type: "problem",
|
|
30
|
+
docs: {
|
|
31
|
+
description:
|
|
32
|
+
"Require awaiting Next.js dynamic request APIs (cookies, headers, draftMode) in app-router Server Components.",
|
|
33
|
+
},
|
|
34
|
+
schema: [],
|
|
35
|
+
messages: {
|
|
36
|
+
mustAwait:
|
|
37
|
+
"`{{api}}()` must be awaited in Server Components — use `await {{api}}()` (Next.js 15+ async request APIs).",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
defaultOptions: [],
|
|
41
|
+
create(context) {
|
|
42
|
+
let serverFile = false;
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
Program(node: TSESTree.Program) {
|
|
46
|
+
serverFile = isServerAppFile(context.filename, node);
|
|
47
|
+
},
|
|
48
|
+
CallExpression(node: TSESTree.CallExpression) {
|
|
49
|
+
if (!serverFile || isAwaited(node)) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const name = calleeName(node.callee);
|
|
54
|
+
|
|
55
|
+
if (name !== null && DYNAMIC_REQUEST_APIS.has(name)) {
|
|
56
|
+
context.report({
|
|
57
|
+
node,
|
|
58
|
+
messageId: "mustAwait",
|
|
59
|
+
data: { api: name },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
import { createRule } from "../../create-rule";
|
|
4
|
+
import { hasDirective, isErrorBoundaryFile } from "../utils";
|
|
5
|
+
|
|
6
|
+
export const RULE_NAME = "error-boundary-require-use-client";
|
|
7
|
+
|
|
8
|
+
type MessageIds = "missingUseClient";
|
|
9
|
+
|
|
10
|
+
export const errorBoundaryRequireUseClientRule = createRule<[], MessageIds>({
|
|
11
|
+
name: RULE_NAME,
|
|
12
|
+
meta: {
|
|
13
|
+
type: "problem",
|
|
14
|
+
docs: {
|
|
15
|
+
description:
|
|
16
|
+
"Require 'use client' in app-router error.tsx and global-error.tsx — Next.js error boundaries must be Client Components.",
|
|
17
|
+
},
|
|
18
|
+
schema: [],
|
|
19
|
+
messages: {
|
|
20
|
+
missingUseClient:
|
|
21
|
+
"Error boundaries under app/ must start with `'use client'` — Next.js requires Client Components for error.tsx and global-error.tsx.",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
defaultOptions: [],
|
|
25
|
+
create(context) {
|
|
26
|
+
if (!isErrorBoundaryFile(context.filename)) {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
Program(node: TSESTree.Program) {
|
|
32
|
+
if (!hasDirective(node, "use client")) {
|
|
33
|
+
context.report({ node, messageId: "missingUseClient" });
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
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
|
+
getFunctionLikeBody,
|
|
8
|
+
hasUseServerDirective,
|
|
9
|
+
isDbMutationCall,
|
|
10
|
+
isRouteHandlerFile,
|
|
11
|
+
type FunctionLike,
|
|
12
|
+
} from "../../authorization/utils";
|
|
13
|
+
|
|
14
|
+
export const RULE_NAME = "mutation-should-revalidate-cache";
|
|
15
|
+
|
|
16
|
+
export interface IMutationShouldRevalidateCacheOptions {
|
|
17
|
+
readonly revalidateFunctions?: readonly string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type RuleOptions = [IMutationShouldRevalidateCacheOptions];
|
|
21
|
+
type MessageIds = "missingRevalidation";
|
|
22
|
+
|
|
23
|
+
const DEFAULT_REVALIDATE_FUNCTIONS = [
|
|
24
|
+
"revalidatePath",
|
|
25
|
+
"revalidateTag",
|
|
26
|
+
] as const;
|
|
27
|
+
|
|
28
|
+
const optionSchema: JSONSchema4 = {
|
|
29
|
+
type: "object",
|
|
30
|
+
additionalProperties: false,
|
|
31
|
+
properties: {
|
|
32
|
+
revalidateFunctions: {
|
|
33
|
+
type: "array",
|
|
34
|
+
items: { type: "string" },
|
|
35
|
+
uniqueItems: true,
|
|
36
|
+
minItems: 1,
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function containsRevalidateCall(
|
|
42
|
+
root: TSESTree.Node,
|
|
43
|
+
names: ReadonlySet<string>
|
|
44
|
+
): boolean {
|
|
45
|
+
return walkSome(root, (node) => {
|
|
46
|
+
if (node.type !== AST_NODE_TYPES.CallExpression) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const callee = node.callee;
|
|
51
|
+
|
|
52
|
+
if (callee.type === AST_NODE_TYPES.Identifier) {
|
|
53
|
+
return names.has(callee.name);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (
|
|
57
|
+
callee.type === AST_NODE_TYPES.MemberExpression &&
|
|
58
|
+
!callee.computed &&
|
|
59
|
+
callee.property.type === AST_NODE_TYPES.Identifier
|
|
60
|
+
) {
|
|
61
|
+
return names.has(callee.property.name);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return false;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getFunctionName(node: FunctionLike): string {
|
|
69
|
+
if (node.type === AST_NODE_TYPES.FunctionDeclaration && node.id !== null) {
|
|
70
|
+
return node.id.name;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const parent = node.parent;
|
|
74
|
+
|
|
75
|
+
if (
|
|
76
|
+
parent?.type === AST_NODE_TYPES.VariableDeclarator &&
|
|
77
|
+
parent.id.type === AST_NODE_TYPES.Identifier
|
|
78
|
+
) {
|
|
79
|
+
return parent.id.name;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return "handler";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const mutationShouldRevalidateCacheRule = createRule<
|
|
86
|
+
RuleOptions,
|
|
87
|
+
MessageIds
|
|
88
|
+
>({
|
|
89
|
+
name: RULE_NAME,
|
|
90
|
+
meta: {
|
|
91
|
+
type: "suggestion",
|
|
92
|
+
docs: {
|
|
93
|
+
description:
|
|
94
|
+
"After database mutations in server actions or route handlers, call `revalidatePath` or `revalidateTag` so cached pages reflect the change.",
|
|
95
|
+
},
|
|
96
|
+
schema: [optionSchema],
|
|
97
|
+
messages: {
|
|
98
|
+
missingRevalidation:
|
|
99
|
+
"{{name}} mutates data but does not call `revalidatePath` or `revalidateTag` — stale cached pages may be served.",
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
defaultOptions: [{ revalidateFunctions: [...DEFAULT_REVALIDATE_FUNCTIONS] }],
|
|
103
|
+
create(context, [options]) {
|
|
104
|
+
const revalidateFunctions = new Set(
|
|
105
|
+
options.revalidateFunctions ?? DEFAULT_REVALIDATE_FUNCTIONS
|
|
106
|
+
);
|
|
107
|
+
let useServerFile = false;
|
|
108
|
+
const isRouteHandler = isRouteHandlerFile(context.filename);
|
|
109
|
+
|
|
110
|
+
function visitFunction(node: FunctionLike): void {
|
|
111
|
+
if (!useServerFile && !isRouteHandler) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const body = getFunctionLikeBody(node);
|
|
116
|
+
|
|
117
|
+
if (body === null) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const hasMutation = walkSome(
|
|
122
|
+
body,
|
|
123
|
+
(child) =>
|
|
124
|
+
child.type === AST_NODE_TYPES.CallExpression &&
|
|
125
|
+
isDbMutationCall(child)
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
if (!hasMutation) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (containsRevalidateCall(body, revalidateFunctions)) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
context.report({
|
|
137
|
+
node,
|
|
138
|
+
messageId: "missingRevalidation",
|
|
139
|
+
data: { name: getFunctionName(node) },
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
Program(node: TSESTree.Program) {
|
|
145
|
+
useServerFile = hasUseServerDirective(node);
|
|
146
|
+
},
|
|
147
|
+
FunctionDeclaration: visitFunction,
|
|
148
|
+
FunctionExpression: visitFunction,
|
|
149
|
+
ArrowFunctionExpression: visitFunction,
|
|
150
|
+
};
|
|
151
|
+
},
|
|
152
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
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-html-img-element";
|
|
6
|
+
|
|
7
|
+
type MessageIds = "useNextImage";
|
|
8
|
+
|
|
9
|
+
function isHtmlImgElement(name: TSESTree.JSXTagNameExpression): boolean {
|
|
10
|
+
if (name.type === AST_NODE_TYPES.JSXIdentifier) {
|
|
11
|
+
return name.name === "img";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const noHtmlImgElementRule = createRule<[], MessageIds>({
|
|
18
|
+
name: RULE_NAME,
|
|
19
|
+
meta: {
|
|
20
|
+
type: "suggestion",
|
|
21
|
+
docs: {
|
|
22
|
+
description:
|
|
23
|
+
"Prefer next/image over raw <img> elements for optimized responsive images and Core Web Vitals.",
|
|
24
|
+
},
|
|
25
|
+
schema: [],
|
|
26
|
+
messages: {
|
|
27
|
+
useNextImage:
|
|
28
|
+
"Use `next/image` `<Image />` instead of `<img>` — it optimizes formats, sizing, and LCP preload.",
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
defaultOptions: [],
|
|
32
|
+
create(context) {
|
|
33
|
+
if (!context.filename.endsWith(".tsx")) {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
JSXOpeningElement(node: TSESTree.JSXOpeningElement) {
|
|
39
|
+
if (isHtmlImgElement(node.name)) {
|
|
40
|
+
context.report({ node, messageId: "useNextImage" });
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
});
|