@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.
Files changed (115) hide show
  1. package/package.json +4 -1
  2. package/scripts/build-rules-md.ts +78 -21
  3. package/scripts/sweep.ts +25 -20
  4. package/scripts/web-sweep.ts +292 -0
  5. package/src/browser/oracle.ts +29 -1
  6. package/src/cli.ts +9 -3
  7. package/src/config/index.ts +8 -0
  8. package/src/config/profiles.ts +150 -0
  9. package/src/config/tsforge-config.ts +64 -5
  10. package/src/detect-gate.ts +34 -1
  11. package/src/inference/inference.types.ts +8 -0
  12. package/src/inference/request.ts +5 -1
  13. package/src/inference/stream.ts +21 -2
  14. package/src/inference/wire.ts +0 -0
  15. package/src/loop/feedback/meta-rule-docs.ts +48 -0
  16. package/src/loop/feedback/rule-docs.ts +150 -0
  17. package/src/loop/rule-docs.generated.json +131 -1
  18. package/src/loop/run.ts +3 -0
  19. package/src/loop/session.ts +12 -5
  20. package/src/loop/ttsr-defaults.ts +175 -4
  21. package/src/meta-rules/registry.ts +32 -0
  22. package/src/meta-rules/rules/ci/no-github-context-in-shell.ts +40 -0
  23. package/src/meta-rules/rules/ci/no-pull-request-target-untrusted-checkout.ts +42 -0
  24. package/src/meta-rules/rules/ci/workflow-permissions-explicit.ts +49 -0
  25. package/src/meta-rules/rules/ci/workflow-permissions-least-privilege.ts +44 -0
  26. package/src/meta-rules/rules/config/next-image-remote-patterns-no-wildcards.ts +77 -0
  27. package/src/meta-rules/rules/config/next-instrumentation-present.ts +66 -0
  28. package/src/meta-rules/rules/config/next-proxy-over-middleware.ts +64 -0
  29. package/src/meta-rules/rules/config/tsconfig-recommended-flags.ts +75 -0
  30. package/src/meta-rules/rules/supply-chain/dependency-overrides-require-comment.ts +61 -0
  31. package/src/meta-rules/rules/supply-chain/fastify-security-plugins.ts +54 -0
  32. package/src/meta-rules/rules/supply-chain/lockfile-required.ts +51 -0
  33. package/src/meta-rules/rules/supply-chain/migrations-must-be-checked-in.ts +49 -0
  34. package/src/meta-rules/rules/supply-chain/no-git-or-tarball-dependencies.ts +70 -0
  35. package/src/meta-rules/rules/supply-chain/package-manager-field-required.ts +31 -0
  36. package/src/meta-rules/rules/supply-chain/production-must-not-use-drizzle-push.ts +75 -0
  37. package/src/meta-rules/rules/supply-chain/single-package-manager.ts +30 -0
  38. package/src/meta-rules/utils/lockfiles.ts +105 -0
  39. package/src/meta-rules/utils/workflow-yaml.ts +86 -0
  40. package/src/rule-packs/authorization/index.ts +26 -0
  41. package/src/rule-packs/authorization/rules/id-param-requires-object-authz.ts +87 -0
  42. package/src/rule-packs/authorization/rules/mutating-route-requires-authz.ts +116 -0
  43. package/src/rule-packs/authorization/rules/server-action-requires-authz.ts +101 -0
  44. package/src/rule-packs/authorization/utils.ts +285 -0
  45. package/src/rule-packs/boundary-utils.ts +13 -0
  46. package/src/rule-packs/code-flow/index.ts +4 -1
  47. package/src/rule-packs/code-flow/rules/no-throw-literal.ts +67 -0
  48. package/src/rule-packs/drizzle/index.ts +7 -0
  49. package/src/rule-packs/drizzle/rules/update-delete-account-scoped-must-filter-scope.ts +106 -0
  50. package/src/rule-packs/drizzle/rules/update-delete-must-have-where.ts +73 -0
  51. package/src/rule-packs/drizzle/utils.ts +133 -1
  52. package/src/rule-packs/fastify/index.ts +38 -0
  53. package/src/rule-packs/fastify/rules/error-handler-must-set-status.ts +78 -0
  54. package/src/rule-packs/fastify/rules/prefer-return-over-reply-send.ts +104 -0
  55. package/src/rule-packs/fastify/rules/require-fp-for-shared-plugins.ts +106 -0
  56. package/src/rule-packs/fastify/rules/require-plugin-name.ts +54 -0
  57. package/src/rule-packs/fastify/rules/require-response-schema.ts +62 -0
  58. package/src/rule-packs/fastify/rules/require-route-schema.ts +104 -0
  59. package/src/rule-packs/fastify/rules/test-inject-must-close-app.ts +44 -0
  60. package/src/rule-packs/fastify/utils/fastifyChain.ts +231 -0
  61. package/src/rule-packs/index.ts +10 -0
  62. package/src/rule-packs/jwt-cookies/index.ts +10 -0
  63. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-set-maxage-or-expires.ts +132 -0
  64. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-set-samesite.ts +151 -0
  65. package/src/rule-packs/jwt-cookies/rules/jwt-must-verify-not-decode.ts +124 -0
  66. package/src/rule-packs/module-boundaries/index.ts +3 -0
  67. package/src/rule-packs/module-boundaries/rules/no-react-in-services.ts +111 -0
  68. package/src/rule-packs/nextjs/index.ts +32 -0
  69. package/src/rule-packs/nextjs/rules/await-dynamic-request-apis.ts +65 -0
  70. package/src/rule-packs/nextjs/rules/error-boundary-require-use-client.ts +38 -0
  71. package/src/rule-packs/nextjs/rules/mutation-should-revalidate-cache.ts +152 -0
  72. package/src/rule-packs/nextjs/rules/no-html-img-element.ts +45 -0
  73. package/src/rule-packs/nextjs/rules/no-internal-api-fetch.ts +126 -0
  74. package/src/rule-packs/nextjs/rules/no-secret-props-to-client.ts +118 -0
  75. package/src/rule-packs/nextjs/rules/no-sensitive-next-public-env.ts +72 -0
  76. package/src/rule-packs/nextjs/rules/prefer-lazy-use-state-init.ts +85 -0
  77. package/src/rule-packs/nextjs/rules/server-action-requires-authz-and-validation.ts +178 -0
  78. package/src/rule-packs/nextjs/rules/server-only-modules-import-server-only.ts +87 -0
  79. package/src/rule-packs/nextjs/utils.ts +18 -0
  80. package/src/rule-packs/react-component-architecture/index.ts +18 -0
  81. package/src/rule-packs/react-component-architecture/rules/dangerous-html-requires-sanitize.ts +83 -0
  82. package/src/rule-packs/react-component-architecture/rules/no-anonymous-useEffect.ts +61 -0
  83. package/src/rule-packs/react-component-architecture/rules/no-component-invocation.ts +55 -0
  84. package/src/rule-packs/react-component-architecture/rules/no-derived-state-in-effect.ts +204 -0
  85. package/src/rule-packs/react-component-architecture/rules/no-nested-component.ts +152 -0
  86. package/src/rule-packs/react-component-architecture/rules/no-react-fc.ts +57 -0
  87. package/src/rule-packs/rule-catalog.types.ts +21 -0
  88. package/src/rule-packs/rule-metadata.ts +163 -0
  89. package/src/rule-packs/runtime-boundaries/index.ts +33 -0
  90. package/src/rule-packs/runtime-boundaries/rules/no-prototype-polluting-merge.ts +113 -0
  91. package/src/rule-packs/runtime-boundaries/rules/no-user-controlled-fetch-url.ts +69 -0
  92. package/src/rule-packs/runtime-boundaries/rules/no-user-controlled-redirect.ts +79 -0
  93. package/src/rule-packs/runtime-boundaries/rules/upload-must-set-limits.ts +126 -0
  94. package/src/rule-packs/runtime-boundaries/rules/webhook-must-verify-signature-before-parse.ts +87 -0
  95. package/src/rule-packs/security/index.ts +35 -0
  96. package/src/rule-packs/security/rules/catch-must-handle.ts +126 -0
  97. package/src/rule-packs/security/rules/no-auth-token-in-storage.ts +107 -0
  98. package/src/rule-packs/security/rules/no-child-process-exec.ts +72 -0
  99. package/src/rule-packs/security/rules/no-dynamic-regexp.ts +56 -0
  100. package/src/rule-packs/security/rules/no-inner-html-assignment.ts +42 -0
  101. package/src/rule-packs/security/rules/no-spawn-with-shell.ts +106 -0
  102. package/src/rule-packs/structured-logging/index.ts +6 -0
  103. package/src/rule-packs/structured-logging/rules/caught-error-log-requires-cause.ts +234 -0
  104. package/src/rule-packs/structured-logging/rules/logger-not-console.ts +146 -0
  105. package/src/rule-packs/test-conventions/index.ts +9 -0
  106. package/src/rule-packs/test-conventions/rules/fake-timers-must-be-restored.ts +143 -0
  107. package/src/rule-packs/test-conventions/rules/no-conditional-expect.ts +77 -0
  108. package/src/rule-packs/test-conventions/rules/no-real-network-in-unit-tests.ts +174 -0
  109. package/src/rule-packs/typescript-core/index.ts +30 -0
  110. package/src/rule-packs/typescript-core/rules/exported-functions-require-return-type.ts +74 -0
  111. package/src/rule-packs/typescript-core/rules/fetch-must-check-ok.ts +106 -0
  112. package/src/rule-packs/typescript-core/rules/json-parse-must-validate.ts +97 -0
  113. package/src/rule-packs/typescript-core/rules/no-unsafe-boundary-cast.ts +70 -0
  114. package/src/stack-detection/packs.ts +57 -0
  115. 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
+ });