@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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
|
|
2
2
|
|
|
3
|
-
import { walkAll } from "../utils";
|
|
3
|
+
import { pushChildNodes, walkAll } from "../utils";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Helper utilities for Drizzle rules.
|
|
@@ -113,3 +113,135 @@ export function findCallExpressionsDeep(
|
|
|
113
113
|
|
|
114
114
|
return results;
|
|
115
115
|
}
|
|
116
|
+
|
|
117
|
+
function getParent(node: TSESTree.Node): TSESTree.Node | undefined {
|
|
118
|
+
return node.parent;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Walk up a fluent call chain (`db.update(t).set(x).where(...)`) looking for a
|
|
123
|
+
* `.<methodName>(...)` link whose call satisfies `callMatches`.
|
|
124
|
+
*/
|
|
125
|
+
export function chainCallProvides(
|
|
126
|
+
startCall: TSESTree.CallExpression,
|
|
127
|
+
methodName: string,
|
|
128
|
+
callMatches: (call: TSESTree.CallExpression) => boolean
|
|
129
|
+
): boolean {
|
|
130
|
+
let current: TSESTree.Node = startCall;
|
|
131
|
+
let parent = getParent(current);
|
|
132
|
+
|
|
133
|
+
while (parent !== undefined) {
|
|
134
|
+
if (
|
|
135
|
+
parent.type === AST_NODE_TYPES.MemberExpression &&
|
|
136
|
+
parent.object === current &&
|
|
137
|
+
parent.property.type === AST_NODE_TYPES.Identifier &&
|
|
138
|
+
parent.property.name === methodName
|
|
139
|
+
) {
|
|
140
|
+
const call = getParent(parent);
|
|
141
|
+
|
|
142
|
+
if (call?.type === AST_NODE_TYPES.CallExpression && callMatches(call)) {
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (
|
|
148
|
+
parent.type === AST_NODE_TYPES.MemberExpression ||
|
|
149
|
+
parent.type === AST_NODE_TYPES.CallExpression
|
|
150
|
+
) {
|
|
151
|
+
current = parent;
|
|
152
|
+
parent = getParent(current);
|
|
153
|
+
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function chainContainsWhere(
|
|
164
|
+
startCall: TSESTree.CallExpression
|
|
165
|
+
): boolean {
|
|
166
|
+
return chainCallProvides(startCall, "where", () => true);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface IUpdateDeleteQuery {
|
|
170
|
+
readonly kind: "update" | "delete";
|
|
171
|
+
readonly table: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function identifyUpdateDeleteQuery(
|
|
175
|
+
node: TSESTree.CallExpression
|
|
176
|
+
): IUpdateDeleteQuery | null {
|
|
177
|
+
if (node.callee.type !== AST_NODE_TYPES.MemberExpression) {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const property = node.callee.property;
|
|
182
|
+
|
|
183
|
+
if (property.type !== AST_NODE_TYPES.Identifier) {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (property.name !== "update" && property.name !== "delete") {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const arg = node.arguments[0];
|
|
192
|
+
|
|
193
|
+
if (arg?.type !== AST_NODE_TYPES.Identifier) {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { kind: property.name, table: arg.name };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function subtreeReferencesIdentifier(
|
|
201
|
+
root: TSESTree.Node,
|
|
202
|
+
name: string
|
|
203
|
+
): boolean {
|
|
204
|
+
const stack: TSESTree.Node[] = [root];
|
|
205
|
+
const visited = new WeakSet<TSESTree.Node>();
|
|
206
|
+
|
|
207
|
+
for (let node = stack.pop(); node !== undefined; node = stack.pop()) {
|
|
208
|
+
if (visited.has(node)) {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
visited.add(node);
|
|
213
|
+
|
|
214
|
+
if (node.type === AST_NODE_TYPES.Identifier && node.name === name) {
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (
|
|
219
|
+
node.type === AST_NODE_TYPES.MemberExpression &&
|
|
220
|
+
node.property.type === AST_NODE_TYPES.Identifier &&
|
|
221
|
+
node.property.name === name
|
|
222
|
+
) {
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
pushChildNodes(node, stack);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function chainContainsWhereWithScope(
|
|
233
|
+
startCall: TSESTree.CallExpression,
|
|
234
|
+
scopeColumns: readonly string[]
|
|
235
|
+
): boolean {
|
|
236
|
+
return chainCallProvides(startCall, "where", (call) => {
|
|
237
|
+
const firstArg = call.arguments[0];
|
|
238
|
+
|
|
239
|
+
if (firstArg === undefined) {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return scopeColumns.some((col) =>
|
|
244
|
+
subtreeReferencesIdentifier(firstArg, col)
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { TSESLint } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
import { errorHandlerMustSetStatusRule } from "./rules/error-handler-must-set-status";
|
|
4
|
+
import { preferReturnOverReplySendRule } from "./rules/prefer-return-over-reply-send";
|
|
5
|
+
import { requireFpForSharedPluginsRule } from "./rules/require-fp-for-shared-plugins";
|
|
6
|
+
import { requirePluginNameRule } from "./rules/require-plugin-name";
|
|
7
|
+
import { requireResponseSchemaRule } from "./rules/require-response-schema";
|
|
8
|
+
import { requireRouteSchemaRule } from "./rules/require-route-schema";
|
|
9
|
+
import { testInjectMustCloseAppRule } from "./rules/test-inject-must-close-app";
|
|
10
|
+
import type { IRulePack } from "../rule-packs.types";
|
|
11
|
+
|
|
12
|
+
const rules: Record<string, TSESLint.RuleModule<string, readonly unknown[]>> = {
|
|
13
|
+
"error-handler-must-set-status": errorHandlerMustSetStatusRule,
|
|
14
|
+
"prefer-return-over-reply-send": preferReturnOverReplySendRule,
|
|
15
|
+
"require-fp-for-shared-plugins": requireFpForSharedPluginsRule,
|
|
16
|
+
"require-plugin-name": requirePluginNameRule,
|
|
17
|
+
"require-response-schema": requireResponseSchemaRule,
|
|
18
|
+
"require-route-schema": requireRouteSchemaRule,
|
|
19
|
+
"test-inject-must-close-app": testInjectMustCloseAppRule,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const fastifyPack: IRulePack = {
|
|
23
|
+
id: "fastify",
|
|
24
|
+
description:
|
|
25
|
+
"Fastify schema-first routing, plugin encapsulation, and test hygiene",
|
|
26
|
+
rules,
|
|
27
|
+
rulesConfig: {
|
|
28
|
+
"error-handler-must-set-status": "error",
|
|
29
|
+
"prefer-return-over-reply-send": "warn",
|
|
30
|
+
"require-fp-for-shared-plugins": "error",
|
|
31
|
+
"require-plugin-name": "error",
|
|
32
|
+
"require-response-schema": "warn",
|
|
33
|
+
"require-route-schema": "error",
|
|
34
|
+
"test-inject-must-close-app": "error",
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export default fastifyPack;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
import { createRule } from "../../create-rule";
|
|
4
|
+
import { nodeContainsCallNamed } from "../utils/fastifyChain";
|
|
5
|
+
|
|
6
|
+
export const RULE_NAME = "error-handler-must-set-status";
|
|
7
|
+
|
|
8
|
+
type MessageIds = "missingReplyCode";
|
|
9
|
+
|
|
10
|
+
function isSetErrorHandlerCall(node: TSESTree.CallExpression): boolean {
|
|
11
|
+
const callee = node.callee;
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
callee.type === AST_NODE_TYPES.MemberExpression &&
|
|
15
|
+
!callee.computed &&
|
|
16
|
+
callee.property.type === AST_NODE_TYPES.Identifier &&
|
|
17
|
+
callee.property.name === "setErrorHandler"
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function handlerSetsStatus(
|
|
22
|
+
handler:
|
|
23
|
+
| TSESTree.ArrowFunctionExpression
|
|
24
|
+
| TSESTree.FunctionExpression
|
|
25
|
+
| TSESTree.FunctionDeclaration
|
|
26
|
+
): boolean {
|
|
27
|
+
const body = handler.body;
|
|
28
|
+
|
|
29
|
+
if (body.type === AST_NODE_TYPES.BlockStatement) {
|
|
30
|
+
for (const stmt of body.body) {
|
|
31
|
+
if (nodeContainsCallNamed(stmt, "reply", "code")) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (nodeContainsCallNamed(stmt, "reply", "status")) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const errorHandlerMustSetStatusRule = createRule<[], MessageIds>({
|
|
45
|
+
name: RULE_NAME,
|
|
46
|
+
meta: {
|
|
47
|
+
type: "problem",
|
|
48
|
+
docs: {
|
|
49
|
+
description:
|
|
50
|
+
"Custom Fastify setErrorHandler callbacks must call reply.code() or reply.status() — automatic status mapping is disabled when a custom handler is registered.",
|
|
51
|
+
},
|
|
52
|
+
schema: [],
|
|
53
|
+
messages: {
|
|
54
|
+
missingReplyCode:
|
|
55
|
+
"Custom `setErrorHandler` must call `reply.code(...)` (or `reply.status(...)`) before sending — Fastify does not auto-map status codes in custom error handlers.",
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
defaultOptions: [],
|
|
59
|
+
create(context) {
|
|
60
|
+
return {
|
|
61
|
+
CallExpression(node: TSESTree.CallExpression) {
|
|
62
|
+
if (!isSetErrorHandlerCall(node)) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const arg of node.arguments) {
|
|
67
|
+
if (
|
|
68
|
+
(arg.type === AST_NODE_TYPES.ArrowFunctionExpression ||
|
|
69
|
+
arg.type === AST_NODE_TYPES.FunctionExpression) &&
|
|
70
|
+
!handlerSetsStatus(arg)
|
|
71
|
+
) {
|
|
72
|
+
context.report({ node: arg, messageId: "missingReplyCode" });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
import { createRule } from "../../create-rule";
|
|
4
|
+
import {
|
|
5
|
+
collectFastifyVariables,
|
|
6
|
+
getRouteHandler,
|
|
7
|
+
getRouteMethodName,
|
|
8
|
+
} from "../utils/fastifyChain";
|
|
9
|
+
|
|
10
|
+
export const RULE_NAME = "prefer-return-over-reply-send";
|
|
11
|
+
|
|
12
|
+
type MessageIds = "preferReturn";
|
|
13
|
+
|
|
14
|
+
function isReplySendReturn(node: TSESTree.ReturnStatement): boolean {
|
|
15
|
+
const arg = node.argument;
|
|
16
|
+
|
|
17
|
+
if (arg?.type !== AST_NODE_TYPES.CallExpression) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const callee = arg.callee;
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
callee.type === AST_NODE_TYPES.MemberExpression &&
|
|
25
|
+
!callee.computed &&
|
|
26
|
+
callee.object.type === AST_NODE_TYPES.Identifier &&
|
|
27
|
+
callee.object.name === "reply" &&
|
|
28
|
+
callee.property.type === AST_NODE_TYPES.Identifier &&
|
|
29
|
+
callee.property.name === "send"
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const preferReturnOverReplySendRule = createRule<[], MessageIds>({
|
|
34
|
+
name: RULE_NAME,
|
|
35
|
+
meta: {
|
|
36
|
+
type: "suggestion",
|
|
37
|
+
docs: {
|
|
38
|
+
description:
|
|
39
|
+
"Inside Fastify route handlers, prefer `return data` over `return reply.send(data)` so fast-json-stringify can serialize responses.",
|
|
40
|
+
},
|
|
41
|
+
schema: [],
|
|
42
|
+
messages: {
|
|
43
|
+
preferReturn:
|
|
44
|
+
"Return the payload directly instead of `reply.send(...)` — Fastify serializes returned values when a response schema is defined.",
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
defaultOptions: [],
|
|
48
|
+
create(context) {
|
|
49
|
+
let fastifyVars = new Set<string>();
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
Program(program: TSESTree.Program) {
|
|
53
|
+
fastifyVars = collectFastifyVariables(program);
|
|
54
|
+
},
|
|
55
|
+
ReturnStatement(node: TSESTree.ReturnStatement) {
|
|
56
|
+
if (!isReplySendReturn(node)) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let parent: TSESTree.Node | undefined = node.parent;
|
|
61
|
+
|
|
62
|
+
while (parent) {
|
|
63
|
+
if (
|
|
64
|
+
parent.type === AST_NODE_TYPES.ArrowFunctionExpression ||
|
|
65
|
+
parent.type === AST_NODE_TYPES.FunctionExpression
|
|
66
|
+
) {
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
parent = parent.parent;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (parent === undefined) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let routeCall: TSESTree.CallExpression | null = null;
|
|
78
|
+
let cursor: TSESTree.Node | undefined = parent.parent;
|
|
79
|
+
|
|
80
|
+
while (cursor) {
|
|
81
|
+
if (cursor.type === AST_NODE_TYPES.CallExpression) {
|
|
82
|
+
routeCall = cursor;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
cursor = cursor.parent;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (routeCall === null) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const method = getRouteMethodName(routeCall, fastifyVars);
|
|
94
|
+
const handler = getRouteHandler(routeCall);
|
|
95
|
+
|
|
96
|
+
if (method === null || handler !== parent) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
context.report({ node, messageId: "preferReturn" });
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
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 = "require-fp-for-shared-plugins";
|
|
7
|
+
|
|
8
|
+
type MessageIds = "needsFpWrapper";
|
|
9
|
+
|
|
10
|
+
function pluginFunctionMutatesFastify(node: TSESTree.Node): boolean {
|
|
11
|
+
return walkSome(node, (current) => {
|
|
12
|
+
if (current.type !== AST_NODE_TYPES.CallExpression) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const callee = current.callee;
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
callee.type === AST_NODE_TYPES.MemberExpression &&
|
|
20
|
+
!callee.computed &&
|
|
21
|
+
callee.object.type === AST_NODE_TYPES.Identifier &&
|
|
22
|
+
callee.object.name === "fastify" &&
|
|
23
|
+
callee.property.type === AST_NODE_TYPES.Identifier &&
|
|
24
|
+
(callee.property.name === "decorate" ||
|
|
25
|
+
callee.property.name === "addHook" ||
|
|
26
|
+
callee.property.name === "register")
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isWrappedInFp(node: TSESTree.Node): boolean {
|
|
32
|
+
let parent = node.parent;
|
|
33
|
+
|
|
34
|
+
while (parent) {
|
|
35
|
+
if (
|
|
36
|
+
parent.type === AST_NODE_TYPES.CallExpression &&
|
|
37
|
+
parent.callee.type === AST_NODE_TYPES.Identifier &&
|
|
38
|
+
parent.callee.name === "fp"
|
|
39
|
+
) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
parent = parent.parent;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const requireFpForSharedPluginsRule = createRule<[], MessageIds>({
|
|
50
|
+
name: RULE_NAME,
|
|
51
|
+
meta: {
|
|
52
|
+
type: "problem",
|
|
53
|
+
docs: {
|
|
54
|
+
description:
|
|
55
|
+
"Fastify plugins that call fastify.decorate, fastify.addHook, or fastify.register must be wrapped in fastify-plugin (fp) to break encapsulation and share state.",
|
|
56
|
+
},
|
|
57
|
+
schema: [],
|
|
58
|
+
messages: {
|
|
59
|
+
needsFpWrapper:
|
|
60
|
+
"Plugin function mutates the Fastify instance — export it wrapped in `fastify-plugin` (`fp(...)`) so decorators and hooks are visible outside the plugin context.",
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
defaultOptions: [],
|
|
64
|
+
create(context) {
|
|
65
|
+
function checkPluginFunction(
|
|
66
|
+
node:
|
|
67
|
+
| TSESTree.FunctionDeclaration
|
|
68
|
+
| TSESTree.FunctionExpression
|
|
69
|
+
| TSESTree.ArrowFunctionExpression
|
|
70
|
+
): void {
|
|
71
|
+
if (!pluginFunctionMutatesFastify(node) || isWrappedInFp(node)) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
context.report({ node, messageId: "needsFpWrapper" });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
ExportDefaultDeclaration(node: TSESTree.ExportDefaultDeclaration) {
|
|
80
|
+
const decl = node.declaration;
|
|
81
|
+
|
|
82
|
+
if (
|
|
83
|
+
decl.type === AST_NODE_TYPES.FunctionDeclaration ||
|
|
84
|
+
decl.type === AST_NODE_TYPES.FunctionExpression ||
|
|
85
|
+
decl.type === AST_NODE_TYPES.ArrowFunctionExpression
|
|
86
|
+
) {
|
|
87
|
+
checkPluginFunction(decl);
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
AssignmentExpression(node: TSESTree.AssignmentExpression) {
|
|
91
|
+
if (
|
|
92
|
+
node.left.type === AST_NODE_TYPES.MemberExpression &&
|
|
93
|
+
!node.left.computed &&
|
|
94
|
+
node.left.object.type === AST_NODE_TYPES.Identifier &&
|
|
95
|
+
node.left.object.name === "module" &&
|
|
96
|
+
node.left.property.type === AST_NODE_TYPES.Identifier &&
|
|
97
|
+
node.left.property.name === "exports" &&
|
|
98
|
+
(node.right.type === AST_NODE_TYPES.FunctionExpression ||
|
|
99
|
+
node.right.type === AST_NODE_TYPES.ArrowFunctionExpression)
|
|
100
|
+
) {
|
|
101
|
+
checkPluginFunction(node.right);
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
},
|
|
106
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
import { createRule } from "../../create-rule";
|
|
4
|
+
import { findObjectProperty } from "../utils/fastifyChain";
|
|
5
|
+
|
|
6
|
+
export const RULE_NAME = "require-plugin-name";
|
|
7
|
+
|
|
8
|
+
type MessageIds = "missingPluginName";
|
|
9
|
+
|
|
10
|
+
function isFpCall(node: TSESTree.CallExpression): boolean {
|
|
11
|
+
const callee = node.callee;
|
|
12
|
+
|
|
13
|
+
return callee.type === AST_NODE_TYPES.Identifier && callee.name === "fp";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const requirePluginNameRule = createRule<[], MessageIds>({
|
|
17
|
+
name: RULE_NAME,
|
|
18
|
+
meta: {
|
|
19
|
+
type: "problem",
|
|
20
|
+
docs: {
|
|
21
|
+
description:
|
|
22
|
+
"fastify-plugin (fp) wrappers must include a `name` option so Fastify can deduplicate plugin registration.",
|
|
23
|
+
},
|
|
24
|
+
schema: [],
|
|
25
|
+
messages: {
|
|
26
|
+
missingPluginName:
|
|
27
|
+
"`fp(..., { name: '...' })` must include a `name` property in the options object.",
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
defaultOptions: [],
|
|
31
|
+
create(context) {
|
|
32
|
+
return {
|
|
33
|
+
CallExpression(node: TSESTree.CallExpression) {
|
|
34
|
+
if (!isFpCall(node)) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const optionsArg = node.arguments[1];
|
|
39
|
+
|
|
40
|
+
if (optionsArg?.type !== AST_NODE_TYPES.ObjectExpression) {
|
|
41
|
+
context.report({ node, messageId: "missingPluginName" });
|
|
42
|
+
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const nameProp = findObjectProperty(optionsArg, "name");
|
|
47
|
+
|
|
48
|
+
if (nameProp === null) {
|
|
49
|
+
context.report({ node, messageId: "missingPluginName" });
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
import { createRule } from "../../create-rule";
|
|
4
|
+
import {
|
|
5
|
+
collectFastifyVariables,
|
|
6
|
+
findNestedProperty,
|
|
7
|
+
findRouteOptionsArg,
|
|
8
|
+
getRouteMethodName,
|
|
9
|
+
} from "../utils/fastifyChain";
|
|
10
|
+
|
|
11
|
+
export const RULE_NAME = "require-response-schema";
|
|
12
|
+
|
|
13
|
+
type MessageIds = "missingResponseSchema";
|
|
14
|
+
|
|
15
|
+
export const requireResponseSchemaRule = createRule<[], MessageIds>({
|
|
16
|
+
name: RULE_NAME,
|
|
17
|
+
meta: {
|
|
18
|
+
type: "suggestion",
|
|
19
|
+
docs: {
|
|
20
|
+
description:
|
|
21
|
+
"Fastify routes should declare schema.response for compiled fast-json-stringify serialization.",
|
|
22
|
+
},
|
|
23
|
+
schema: [],
|
|
24
|
+
messages: {
|
|
25
|
+
missingResponseSchema:
|
|
26
|
+
"Route `.{{method}}(...)` should declare `schema.response` so Fastify can compile serializers at startup.",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
defaultOptions: [],
|
|
30
|
+
create(context) {
|
|
31
|
+
let fastifyVars = new Set<string>();
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
Program(program: TSESTree.Program) {
|
|
35
|
+
fastifyVars = collectFastifyVariables(program);
|
|
36
|
+
},
|
|
37
|
+
CallExpression(node: TSESTree.CallExpression) {
|
|
38
|
+
const method = getRouteMethodName(node, fastifyVars);
|
|
39
|
+
|
|
40
|
+
if (method === null) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const options = findRouteOptionsArg(node);
|
|
45
|
+
|
|
46
|
+
if (options === null) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const responseProp = findNestedProperty(options, "schema", "response");
|
|
51
|
+
|
|
52
|
+
if (responseProp === null) {
|
|
53
|
+
context.report({
|
|
54
|
+
node,
|
|
55
|
+
messageId: "missingResponseSchema",
|
|
56
|
+
data: { method },
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
import { createRule } from "../../create-rule";
|
|
4
|
+
import {
|
|
5
|
+
MUTATING_METHODS,
|
|
6
|
+
collectFastifyVariables,
|
|
7
|
+
findNestedProperty,
|
|
8
|
+
findRouteOptionsArg,
|
|
9
|
+
getRouteMethodName,
|
|
10
|
+
} from "../utils/fastifyChain";
|
|
11
|
+
|
|
12
|
+
export const RULE_NAME = "require-route-schema";
|
|
13
|
+
|
|
14
|
+
type MessageIds = "missingSchema" | "missingBodySchema";
|
|
15
|
+
|
|
16
|
+
export const requireRouteSchemaRule = createRule<[], MessageIds>({
|
|
17
|
+
name: RULE_NAME,
|
|
18
|
+
meta: {
|
|
19
|
+
type: "problem",
|
|
20
|
+
docs: {
|
|
21
|
+
description:
|
|
22
|
+
"Fastify POST/PUT/PATCH routes must declare schema.body; GET/DELETE routes must declare schema.querystring or schema.params.",
|
|
23
|
+
},
|
|
24
|
+
schema: [],
|
|
25
|
+
messages: {
|
|
26
|
+
missingSchema:
|
|
27
|
+
"Route `.{{method}}(...)` must declare a `schema` object with validation for inputs.",
|
|
28
|
+
missingBodySchema:
|
|
29
|
+
"Mutating route `.{{method}}(...)` must declare `schema.body` for request validation.",
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
defaultOptions: [],
|
|
33
|
+
create(context) {
|
|
34
|
+
let fastifyVars = new Set<string>();
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
Program(program: TSESTree.Program) {
|
|
38
|
+
fastifyVars = collectFastifyVariables(program);
|
|
39
|
+
},
|
|
40
|
+
CallExpression(node: TSESTree.CallExpression) {
|
|
41
|
+
const method = getRouteMethodName(node, fastifyVars);
|
|
42
|
+
|
|
43
|
+
if (method === null) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const options = findRouteOptionsArg(node);
|
|
48
|
+
|
|
49
|
+
if (options === null) {
|
|
50
|
+
context.report({
|
|
51
|
+
node,
|
|
52
|
+
messageId: "missingSchema",
|
|
53
|
+
data: { method },
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const schemaProp = findNestedProperty(options, "schema");
|
|
60
|
+
const schemaObject =
|
|
61
|
+
schemaProp?.value.type === AST_NODE_TYPES.ObjectExpression
|
|
62
|
+
? schemaProp.value
|
|
63
|
+
: null;
|
|
64
|
+
|
|
65
|
+
if (schemaObject === null) {
|
|
66
|
+
context.report({
|
|
67
|
+
node,
|
|
68
|
+
messageId: "missingSchema",
|
|
69
|
+
data: { method },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (MUTATING_METHODS.has(method)) {
|
|
76
|
+
const bodyProp = findNestedProperty(options, "schema", "body");
|
|
77
|
+
|
|
78
|
+
if (bodyProp === null) {
|
|
79
|
+
context.report({
|
|
80
|
+
node,
|
|
81
|
+
messageId: "missingBodySchema",
|
|
82
|
+
data: { method },
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
const queryProp = findNestedProperty(
|
|
87
|
+
options,
|
|
88
|
+
"schema",
|
|
89
|
+
"querystring"
|
|
90
|
+
);
|
|
91
|
+
const paramsProp = findNestedProperty(options, "schema", "params");
|
|
92
|
+
|
|
93
|
+
if (queryProp === null && paramsProp === null) {
|
|
94
|
+
context.report({
|
|
95
|
+
node,
|
|
96
|
+
messageId: "missingSchema",
|
|
97
|
+
data: { method },
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
});
|