@agjs/tsforge 0.1.19 → 0.2.1

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 (122) hide show
  1. package/package.json +6 -2
  2. package/scripts/browser-check.ts +41 -5
  3. package/scripts/build-rules-md.ts +78 -21
  4. package/scripts/cli-metrics.ts +10 -0
  5. package/scripts/sweep.ts +53 -23
  6. package/scripts/web-sweep.ts +292 -0
  7. package/src/browser/index.ts +3 -0
  8. package/src/browser/oracle.ts +215 -8
  9. package/src/cli.ts +22 -4
  10. package/src/config/index.ts +8 -0
  11. package/src/config/profiles.ts +150 -0
  12. package/src/config/tsforge-config.ts +64 -5
  13. package/src/detect-gate.ts +144 -13
  14. package/src/eval/eval.types.ts +9 -0
  15. package/src/eval/failure-class.ts +263 -0
  16. package/src/eval/index.ts +8 -0
  17. package/src/eval/metrics.ts +7 -0
  18. package/src/eval/parse-log.ts +105 -0
  19. package/src/eval/report.ts +19 -0
  20. package/src/eval/score.ts +10 -0
  21. package/src/loop/feedback/meta-rule-docs.ts +48 -0
  22. package/src/loop/feedback/rule-docs.ts +150 -0
  23. package/src/loop/loop.types.ts +4 -0
  24. package/src/loop/rule-docs.generated.json +131 -1
  25. package/src/loop/ttsr-defaults.ts +175 -4
  26. package/src/loop/turn.ts +3 -0
  27. package/src/meta-rules/registry.ts +32 -0
  28. package/src/meta-rules/rules/ci/no-github-context-in-shell.ts +40 -0
  29. package/src/meta-rules/rules/ci/no-pull-request-target-untrusted-checkout.ts +42 -0
  30. package/src/meta-rules/rules/ci/workflow-permissions-explicit.ts +49 -0
  31. package/src/meta-rules/rules/ci/workflow-permissions-least-privilege.ts +44 -0
  32. package/src/meta-rules/rules/config/next-image-remote-patterns-no-wildcards.ts +77 -0
  33. package/src/meta-rules/rules/config/next-instrumentation-present.ts +66 -0
  34. package/src/meta-rules/rules/config/next-proxy-over-middleware.ts +64 -0
  35. package/src/meta-rules/rules/config/tsconfig-recommended-flags.ts +75 -0
  36. package/src/meta-rules/rules/supply-chain/dependency-overrides-require-comment.ts +61 -0
  37. package/src/meta-rules/rules/supply-chain/fastify-security-plugins.ts +54 -0
  38. package/src/meta-rules/rules/supply-chain/lockfile-required.ts +51 -0
  39. package/src/meta-rules/rules/supply-chain/migrations-must-be-checked-in.ts +49 -0
  40. package/src/meta-rules/rules/supply-chain/no-git-or-tarball-dependencies.ts +70 -0
  41. package/src/meta-rules/rules/supply-chain/package-manager-field-required.ts +31 -0
  42. package/src/meta-rules/rules/supply-chain/production-must-not-use-drizzle-push.ts +75 -0
  43. package/src/meta-rules/rules/supply-chain/single-package-manager.ts +30 -0
  44. package/src/meta-rules/utils/lockfiles.ts +105 -0
  45. package/src/meta-rules/utils/workflow-yaml.ts +86 -0
  46. package/src/rule-packs/authorization/index.ts +26 -0
  47. package/src/rule-packs/authorization/rules/id-param-requires-object-authz.ts +87 -0
  48. package/src/rule-packs/authorization/rules/mutating-route-requires-authz.ts +116 -0
  49. package/src/rule-packs/authorization/rules/server-action-requires-authz.ts +101 -0
  50. package/src/rule-packs/authorization/utils.ts +285 -0
  51. package/src/rule-packs/boundary-utils.ts +13 -0
  52. package/src/rule-packs/code-flow/index.ts +4 -1
  53. package/src/rule-packs/code-flow/rules/no-throw-literal.ts +67 -0
  54. package/src/rule-packs/drizzle/index.ts +7 -0
  55. package/src/rule-packs/drizzle/rules/update-delete-account-scoped-must-filter-scope.ts +106 -0
  56. package/src/rule-packs/drizzle/rules/update-delete-must-have-where.ts +73 -0
  57. package/src/rule-packs/drizzle/utils.ts +133 -1
  58. package/src/rule-packs/fastify/index.ts +38 -0
  59. package/src/rule-packs/fastify/rules/error-handler-must-set-status.ts +78 -0
  60. package/src/rule-packs/fastify/rules/prefer-return-over-reply-send.ts +104 -0
  61. package/src/rule-packs/fastify/rules/require-fp-for-shared-plugins.ts +106 -0
  62. package/src/rule-packs/fastify/rules/require-plugin-name.ts +54 -0
  63. package/src/rule-packs/fastify/rules/require-response-schema.ts +62 -0
  64. package/src/rule-packs/fastify/rules/require-route-schema.ts +104 -0
  65. package/src/rule-packs/fastify/rules/test-inject-must-close-app.ts +44 -0
  66. package/src/rule-packs/fastify/utils/fastifyChain.ts +231 -0
  67. package/src/rule-packs/index.ts +10 -0
  68. package/src/rule-packs/jwt-cookies/index.ts +10 -0
  69. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-set-maxage-or-expires.ts +132 -0
  70. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-set-samesite.ts +151 -0
  71. package/src/rule-packs/jwt-cookies/rules/jwt-must-verify-not-decode.ts +124 -0
  72. package/src/rule-packs/module-boundaries/index.ts +3 -0
  73. package/src/rule-packs/module-boundaries/rules/no-react-in-services.ts +111 -0
  74. package/src/rule-packs/nextjs/index.ts +32 -0
  75. package/src/rule-packs/nextjs/rules/await-dynamic-request-apis.ts +65 -0
  76. package/src/rule-packs/nextjs/rules/error-boundary-require-use-client.ts +38 -0
  77. package/src/rule-packs/nextjs/rules/mutation-should-revalidate-cache.ts +152 -0
  78. package/src/rule-packs/nextjs/rules/no-html-img-element.ts +45 -0
  79. package/src/rule-packs/nextjs/rules/no-internal-api-fetch.ts +126 -0
  80. package/src/rule-packs/nextjs/rules/no-secret-props-to-client.ts +118 -0
  81. package/src/rule-packs/nextjs/rules/no-sensitive-next-public-env.ts +72 -0
  82. package/src/rule-packs/nextjs/rules/prefer-lazy-use-state-init.ts +85 -0
  83. package/src/rule-packs/nextjs/rules/server-action-requires-authz-and-validation.ts +178 -0
  84. package/src/rule-packs/nextjs/rules/server-only-modules-import-server-only.ts +87 -0
  85. package/src/rule-packs/nextjs/utils.ts +18 -0
  86. package/src/rule-packs/react-component-architecture/index.ts +18 -0
  87. package/src/rule-packs/react-component-architecture/rules/dangerous-html-requires-sanitize.ts +83 -0
  88. package/src/rule-packs/react-component-architecture/rules/no-anonymous-useEffect.ts +61 -0
  89. package/src/rule-packs/react-component-architecture/rules/no-component-invocation.ts +55 -0
  90. package/src/rule-packs/react-component-architecture/rules/no-derived-state-in-effect.ts +204 -0
  91. package/src/rule-packs/react-component-architecture/rules/no-nested-component.ts +152 -0
  92. package/src/rule-packs/react-component-architecture/rules/no-react-fc.ts +57 -0
  93. package/src/rule-packs/rule-catalog.types.ts +21 -0
  94. package/src/rule-packs/rule-metadata.ts +163 -0
  95. package/src/rule-packs/runtime-boundaries/index.ts +33 -0
  96. package/src/rule-packs/runtime-boundaries/rules/no-prototype-polluting-merge.ts +113 -0
  97. package/src/rule-packs/runtime-boundaries/rules/no-user-controlled-fetch-url.ts +69 -0
  98. package/src/rule-packs/runtime-boundaries/rules/no-user-controlled-redirect.ts +79 -0
  99. package/src/rule-packs/runtime-boundaries/rules/upload-must-set-limits.ts +126 -0
  100. package/src/rule-packs/runtime-boundaries/rules/webhook-must-verify-signature-before-parse.ts +87 -0
  101. package/src/rule-packs/security/index.ts +35 -0
  102. package/src/rule-packs/security/rules/catch-must-handle.ts +126 -0
  103. package/src/rule-packs/security/rules/no-auth-token-in-storage.ts +107 -0
  104. package/src/rule-packs/security/rules/no-child-process-exec.ts +72 -0
  105. package/src/rule-packs/security/rules/no-dynamic-regexp.ts +56 -0
  106. package/src/rule-packs/security/rules/no-inner-html-assignment.ts +42 -0
  107. package/src/rule-packs/security/rules/no-spawn-with-shell.ts +106 -0
  108. package/src/rule-packs/structured-logging/index.ts +6 -0
  109. package/src/rule-packs/structured-logging/rules/caught-error-log-requires-cause.ts +234 -0
  110. package/src/rule-packs/structured-logging/rules/logger-not-console.ts +146 -0
  111. package/src/rule-packs/test-conventions/index.ts +9 -0
  112. package/src/rule-packs/test-conventions/rules/fake-timers-must-be-restored.ts +143 -0
  113. package/src/rule-packs/test-conventions/rules/no-conditional-expect.ts +77 -0
  114. package/src/rule-packs/test-conventions/rules/no-real-network-in-unit-tests.ts +174 -0
  115. package/src/rule-packs/typescript-core/index.ts +30 -0
  116. package/src/rule-packs/typescript-core/rules/exported-functions-require-return-type.ts +74 -0
  117. package/src/rule-packs/typescript-core/rules/fetch-must-check-ok.ts +106 -0
  118. package/src/rule-packs/typescript-core/rules/json-parse-must-validate.ts +97 -0
  119. package/src/rule-packs/typescript-core/rules/no-unsafe-boundary-cast.ts +70 -0
  120. package/src/stack-detection/packs.ts +57 -0
  121. package/strict.type-aware.eslint.config.mjs +33 -0
  122. package/strict.web.eslint.config.mjs +32 -1
@@ -0,0 +1,285 @@
1
+ import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
2
+ import type { JSONSchema4 } from "@typescript-eslint/utils/json-schema";
3
+
4
+ import { walkSome } from "../utils";
5
+
6
+ export const DEFAULT_AUTHZ_FUNCTIONS = [
7
+ "requireUser",
8
+ "authorize",
9
+ "requireAuth",
10
+ "assertAuthorized",
11
+ ] as const;
12
+
13
+ export interface IAuthzOptions {
14
+ readonly authzFunctions?: readonly string[];
15
+ }
16
+
17
+ export type AuthzRuleOptions = [IAuthzOptions];
18
+
19
+ export const authzOptionSchema: JSONSchema4 = {
20
+ type: "object",
21
+ additionalProperties: false,
22
+ properties: {
23
+ authzFunctions: {
24
+ type: "array",
25
+ items: { type: "string" },
26
+ uniqueItems: true,
27
+ minItems: 1,
28
+ },
29
+ },
30
+ };
31
+
32
+ export const MUTATING_HTTP_METHODS = new Set([
33
+ "POST",
34
+ "PUT",
35
+ "PATCH",
36
+ "DELETE",
37
+ ]);
38
+
39
+ const DB_MUTATION_METHODS = new Set(["update", "insert", "delete"]);
40
+ const DB_QUERY_METHODS = new Set([
41
+ "select",
42
+ "query",
43
+ "findFirst",
44
+ "findMany",
45
+ "findUnique",
46
+ "get",
47
+ "execute",
48
+ ]);
49
+
50
+ export function defaultAuthzOptions(): IAuthzOptions {
51
+ return { authzFunctions: [...DEFAULT_AUTHZ_FUNCTIONS] };
52
+ }
53
+
54
+ export function resolveAuthzFunctions(options: IAuthzOptions): Set<string> {
55
+ return new Set(options.authzFunctions ?? DEFAULT_AUTHZ_FUNCTIONS);
56
+ }
57
+
58
+ export function calleeName(callee: TSESTree.Node): string | null {
59
+ if (callee.type === AST_NODE_TYPES.Identifier) {
60
+ return callee.name;
61
+ }
62
+
63
+ if (
64
+ callee.type === AST_NODE_TYPES.MemberExpression &&
65
+ !callee.computed &&
66
+ callee.property.type === AST_NODE_TYPES.Identifier
67
+ ) {
68
+ return callee.property.name;
69
+ }
70
+
71
+ return null;
72
+ }
73
+
74
+ export function isAuthzCall(
75
+ node: TSESTree.CallExpression,
76
+ authzNames: Set<string>
77
+ ): boolean {
78
+ const name = calleeName(node.callee);
79
+
80
+ return name !== null && authzNames.has(name);
81
+ }
82
+
83
+ export function containsAuthzCall(
84
+ root: TSESTree.Node,
85
+ authzNames: Set<string>
86
+ ): boolean {
87
+ return walkSome(
88
+ root,
89
+ (node) =>
90
+ node.type === AST_NODE_TYPES.CallExpression &&
91
+ isAuthzCall(node, authzNames)
92
+ );
93
+ }
94
+
95
+ export function isRouteHandlerFile(filename: string): boolean {
96
+ const base = filename.split(/[\\/]/).pop() ?? "";
97
+
98
+ return /^route\.(?:tsx|ts|jsx|js)$/.test(base);
99
+ }
100
+
101
+ export function hasUseServerDirective(program: TSESTree.Program): boolean {
102
+ for (const stmt of program.body) {
103
+ if (
104
+ stmt.type !== AST_NODE_TYPES.ExpressionStatement ||
105
+ stmt.expression.type !== AST_NODE_TYPES.Literal ||
106
+ typeof stmt.expression.value !== "string"
107
+ ) {
108
+ return false;
109
+ }
110
+
111
+ if (stmt.expression.value === "use server") {
112
+ return true;
113
+ }
114
+ }
115
+
116
+ return false;
117
+ }
118
+
119
+ function dbReceiverName(callee: TSESTree.MemberExpression): string | null {
120
+ const object = callee.object;
121
+
122
+ if (object.type === AST_NODE_TYPES.Identifier) {
123
+ return object.name;
124
+ }
125
+
126
+ if (
127
+ object.type === AST_NODE_TYPES.MemberExpression &&
128
+ !object.computed &&
129
+ object.property.type === AST_NODE_TYPES.Identifier
130
+ ) {
131
+ return object.property.name;
132
+ }
133
+
134
+ return null;
135
+ }
136
+
137
+ function looksLikeDbReceiver(name: string | null): boolean {
138
+ if (name === null) {
139
+ return false;
140
+ }
141
+
142
+ return name === "db" || name === "tx" || name.endsWith("Db");
143
+ }
144
+
145
+ export function isDbMutationCall(node: TSESTree.CallExpression): boolean {
146
+ const callee = node.callee;
147
+
148
+ if (callee.type !== AST_NODE_TYPES.MemberExpression || callee.computed) {
149
+ return false;
150
+ }
151
+
152
+ const method = calleeName(callee);
153
+
154
+ if (method === null || !DB_MUTATION_METHODS.has(method)) {
155
+ return false;
156
+ }
157
+
158
+ return looksLikeDbReceiver(dbReceiverName(callee));
159
+ }
160
+
161
+ export function isDbQueryCall(node: TSESTree.CallExpression): boolean {
162
+ const callee = node.callee;
163
+
164
+ if (callee.type !== AST_NODE_TYPES.MemberExpression || callee.computed) {
165
+ return false;
166
+ }
167
+
168
+ const method = calleeName(callee);
169
+
170
+ if (method === null || !DB_QUERY_METHODS.has(method)) {
171
+ return false;
172
+ }
173
+
174
+ return looksLikeDbReceiver(dbReceiverName(callee));
175
+ }
176
+
177
+ export function isParamsIdRead(node: TSESTree.Node): boolean {
178
+ if (node.type !== AST_NODE_TYPES.MemberExpression || node.computed) {
179
+ return false;
180
+ }
181
+
182
+ const object = node.object;
183
+ const property = node.property;
184
+
185
+ if (
186
+ object.type !== AST_NODE_TYPES.Identifier ||
187
+ object.name !== "params" ||
188
+ property.type !== AST_NODE_TYPES.Identifier ||
189
+ property.name !== "id"
190
+ ) {
191
+ return false;
192
+ }
193
+
194
+ return true;
195
+ }
196
+
197
+ export type FunctionLike =
198
+ | TSESTree.FunctionDeclaration
199
+ | TSESTree.FunctionExpression
200
+ | TSESTree.ArrowFunctionExpression;
201
+
202
+ export function getExportedMutatingHandlerName(
203
+ node: TSESTree.Node
204
+ ): string | null {
205
+ if (node.type === AST_NODE_TYPES.ExportNamedDeclaration) {
206
+ const declaration = node.declaration;
207
+
208
+ if (declaration === null) {
209
+ return null;
210
+ }
211
+
212
+ if (declaration.type === AST_NODE_TYPES.FunctionDeclaration) {
213
+ return getMutatingHandlerNameFromFunction(declaration);
214
+ }
215
+
216
+ if (declaration.type === AST_NODE_TYPES.VariableDeclaration) {
217
+ for (const declarator of declaration.declarations) {
218
+ const name = getMutatingHandlerNameFromVariableDeclarator(declarator);
219
+
220
+ if (name !== null) {
221
+ return name;
222
+ }
223
+ }
224
+ }
225
+
226
+ return null;
227
+ }
228
+
229
+ if (
230
+ node.type === AST_NODE_TYPES.FunctionDeclaration &&
231
+ node.parent?.type === AST_NODE_TYPES.ExportNamedDeclaration
232
+ ) {
233
+ return getMutatingHandlerNameFromFunction(node);
234
+ }
235
+
236
+ return null;
237
+ }
238
+
239
+ function getMutatingHandlerNameFromFunction(
240
+ node: TSESTree.FunctionDeclaration
241
+ ): string | null {
242
+ if (node.id === null) {
243
+ return null;
244
+ }
245
+
246
+ if (!MUTATING_HTTP_METHODS.has(node.id.name)) {
247
+ return null;
248
+ }
249
+
250
+ return node.id.name;
251
+ }
252
+
253
+ function getMutatingHandlerNameFromVariableDeclarator(
254
+ node: TSESTree.VariableDeclarator
255
+ ): string | null {
256
+ if (node.id.type !== AST_NODE_TYPES.Identifier) {
257
+ return null;
258
+ }
259
+
260
+ if (!MUTATING_HTTP_METHODS.has(node.id.name)) {
261
+ return null;
262
+ }
263
+
264
+ const init = node.init;
265
+
266
+ if (
267
+ init === null ||
268
+ (init.type !== AST_NODE_TYPES.FunctionExpression &&
269
+ init.type !== AST_NODE_TYPES.ArrowFunctionExpression)
270
+ ) {
271
+ return null;
272
+ }
273
+
274
+ return node.id.name;
275
+ }
276
+
277
+ export function getFunctionLikeBody(
278
+ node: FunctionLike
279
+ ): TSESTree.BlockStatement | TSESTree.Expression | null {
280
+ if (node.type === AST_NODE_TYPES.ArrowFunctionExpression) {
281
+ return node.body;
282
+ }
283
+
284
+ return node.body;
285
+ }
@@ -0,0 +1,13 @@
1
+ import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
2
+
3
+ export function isExpression(node: TSESTree.Node): node is TSESTree.Expression {
4
+ return node.type !== AST_NODE_TYPES.SpreadElement;
5
+ }
6
+
7
+ export function isStringLiteral(node: TSESTree.Expression): boolean {
8
+ return node.type === AST_NODE_TYPES.Literal && typeof node.value === "string";
9
+ }
10
+
11
+ export function isIdentifierNamed(node: TSESTree.Node, name: string): boolean {
12
+ return node.type === AST_NODE_TYPES.Identifier && node.name === name;
13
+ }
@@ -2,12 +2,14 @@ import type { TSESLint } from "@typescript-eslint/utils";
2
2
 
3
3
  import { noBareDateNowRule } from "./rules/no-bare-date-now";
4
4
  import { noTemplateTrimEmptyTernaryRule } from "./rules/no-template-trim-empty-ternary";
5
+ import { noThrowLiteralRule } from "./rules/no-throw-literal";
5
6
  import { preferEarlyReturnRule } from "./rules/prefer-early-return";
6
7
  import type { IRulePack } from "../rule-packs.types";
7
8
 
8
9
  const rules: Record<string, TSESLint.RuleModule<string, readonly unknown[]>> = {
9
10
  "no-bare-date-now": noBareDateNowRule,
10
11
  "no-template-trim-empty-ternary": noTemplateTrimEmptyTernaryRule,
12
+ "no-throw-literal": noThrowLiteralRule,
11
13
  "prefer-early-return": preferEarlyReturnRule,
12
14
  };
13
15
 
@@ -18,7 +20,8 @@ export const codeFlowPack: IRulePack = {
18
20
  rulesConfig: {
19
21
  "no-bare-date-now": "error",
20
22
  "no-template-trim-empty-ternary": "error",
21
- "prefer-early-return": "error",
23
+ "no-throw-literal": "error",
24
+ "prefer-early-return": "warn",
22
25
  },
23
26
  };
24
27
 
@@ -0,0 +1,67 @@
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-throw-literal";
6
+
7
+ type MessageIds = "throwLiteral";
8
+
9
+ function isErrorConstruction(node: TSESTree.Expression): boolean {
10
+ if (node.type === AST_NODE_TYPES.NewExpression) {
11
+ const callee = node.callee;
12
+
13
+ if (callee.type === AST_NODE_TYPES.Identifier && callee.name === "Error") {
14
+ return true;
15
+ }
16
+
17
+ if (
18
+ callee.type === AST_NODE_TYPES.MemberExpression &&
19
+ !callee.computed &&
20
+ callee.property.type === AST_NODE_TYPES.Identifier &&
21
+ callee.property.name === "Error"
22
+ ) {
23
+ return true;
24
+ }
25
+ }
26
+
27
+ return false;
28
+ }
29
+
30
+ export const noThrowLiteralRule = createRule<[], MessageIds>({
31
+ name: RULE_NAME,
32
+ meta: {
33
+ type: "problem",
34
+ docs: {
35
+ description:
36
+ "Disallow throwing primitive literals (strings, numbers) — throw Error instances so error handlers can propagate status and stack traces correctly.",
37
+ },
38
+ schema: [],
39
+ messages: {
40
+ throwLiteral:
41
+ "Do not throw a literal value — throw an `Error` instance (e.g. `throw new Error('...')`) so framework error handlers can propagate it correctly.",
42
+ },
43
+ },
44
+ defaultOptions: [],
45
+ create(context) {
46
+ return {
47
+ ThrowStatement(node: TSESTree.ThrowStatement) {
48
+ const argument = node.argument;
49
+
50
+ if (argument === null) {
51
+ return;
52
+ }
53
+
54
+ if (isErrorConstruction(argument)) {
55
+ return;
56
+ }
57
+
58
+ if (
59
+ argument.type === AST_NODE_TYPES.Literal ||
60
+ argument.type === AST_NODE_TYPES.TemplateLiteral
61
+ ) {
62
+ context.report({ node, messageId: "throwLiteral" });
63
+ }
64
+ },
65
+ };
66
+ },
67
+ });
@@ -8,6 +8,8 @@ import { schemaFilesMustNotImportDriverRule } from "./rules/schema-files-must-no
8
8
  import { schemaFilesMustOnlyExportSchemaRule } from "./rules/schema-files-must-only-export-schema";
9
9
  import { tablesMustHaveTimestampsRule } from "./rules/tables-must-have-timestamps";
10
10
  import { timestampMustSpecifyModeRule } from "./rules/timestamp-must-specify-mode";
11
+ import { updateDeleteAccountScopedMustFilterScopeRule } from "./rules/update-delete-account-scoped-must-filter-scope";
12
+ import { updateDeleteMustHaveWhereRule } from "./rules/update-delete-must-have-where";
11
13
  import type { IRulePack } from "../rule-packs.types";
12
14
 
13
15
  const rules: Record<string, TSESLint.RuleModule<string, readonly unknown[]>> = {
@@ -19,6 +21,9 @@ const rules: Record<string, TSESLint.RuleModule<string, readonly unknown[]>> = {
19
21
  "schema-files-must-only-export-schema": schemaFilesMustOnlyExportSchemaRule,
20
22
  "tables-must-have-timestamps": tablesMustHaveTimestampsRule,
21
23
  "timestamp-must-specify-mode": timestampMustSpecifyModeRule,
24
+ "update-delete-account-scoped-must-filter-scope":
25
+ updateDeleteAccountScopedMustFilterScopeRule,
26
+ "update-delete-must-have-where": updateDeleteMustHaveWhereRule,
22
27
  };
23
28
 
24
29
  export const drizzlePack: IRulePack = {
@@ -35,6 +40,8 @@ export const drizzlePack: IRulePack = {
35
40
  "schema-files-must-only-export-schema": "warn",
36
41
  "tables-must-have-timestamps": "warn",
37
42
  "timestamp-must-specify-mode": "error",
43
+ "update-delete-account-scoped-must-filter-scope": "error",
44
+ "update-delete-must-have-where": "error",
38
45
  },
39
46
  };
40
47
 
@@ -0,0 +1,106 @@
1
+ import type { JSONSchema4 } from "@typescript-eslint/utils/json-schema";
2
+
3
+ import { createRule } from "../../create-rule";
4
+ import { matchesAnyGlobPattern } from "../../utils";
5
+ import {
6
+ chainContainsWhereWithScope,
7
+ identifyUpdateDeleteQuery,
8
+ } from "../utils";
9
+
10
+ export const RULE_NAME = "update-delete-account-scoped-must-filter-scope";
11
+
12
+ export interface IUpdateDeleteAccountScopedMustFilterScopeOptions {
13
+ readonly tables?: readonly string[];
14
+ readonly scopeColumn?: string;
15
+ readonly alternateScopeColumns?: readonly string[];
16
+ readonly allowFiles?: readonly string[];
17
+ }
18
+
19
+ type RuleOptions = [IUpdateDeleteAccountScopedMustFilterScopeOptions];
20
+ type MessageIds = "missingScopeFilter";
21
+
22
+ const DEFAULT_SCOPE_COLUMN = "accountId";
23
+
24
+ const optionSchema: JSONSchema4 = {
25
+ type: "object",
26
+ additionalProperties: false,
27
+ properties: {
28
+ tables: {
29
+ type: "array",
30
+ items: { type: "string" },
31
+ uniqueItems: true,
32
+ minItems: 1,
33
+ },
34
+ scopeColumn: { type: "string", minLength: 1 },
35
+ alternateScopeColumns: {
36
+ type: "array",
37
+ items: { type: "string", minLength: 1 },
38
+ uniqueItems: true,
39
+ },
40
+ allowFiles: {
41
+ type: "array",
42
+ items: { type: "string", minLength: 1 },
43
+ uniqueItems: true,
44
+ },
45
+ },
46
+ };
47
+
48
+ export const updateDeleteAccountScopedMustFilterScopeRule = createRule<
49
+ RuleOptions,
50
+ MessageIds
51
+ >({
52
+ name: RULE_NAME,
53
+ meta: {
54
+ type: "problem",
55
+ docs: {
56
+ description:
57
+ "Require Drizzle `.update()` / `.delete()` against account-scoped tables to filter by a scope column in `.where()`.",
58
+ },
59
+ schema: [optionSchema],
60
+ messages: {
61
+ missingScopeFilter:
62
+ "Drizzle `.{{kind}}()` on account-scoped table `{{table}}` is missing a `{{scopeColumn}}` filter in `.where()` — tenant data can leak across accounts.",
63
+ },
64
+ },
65
+ defaultOptions: [{}],
66
+ create(context, [options]) {
67
+ const tables = new Set(options.tables ?? []);
68
+ const scopeColumn = options.scopeColumn ?? DEFAULT_SCOPE_COLUMN;
69
+ const alternateScopeColumns = options.alternateScopeColumns ?? [];
70
+ const allowFiles = options.allowFiles ?? [];
71
+
72
+ if (tables.size === 0) {
73
+ return {};
74
+ }
75
+
76
+ if (matchesAnyGlobPattern(context.filename, allowFiles)) {
77
+ return {};
78
+ }
79
+
80
+ const scopeColumns = [scopeColumn, ...alternateScopeColumns];
81
+
82
+ return {
83
+ CallExpression(node) {
84
+ const query = identifyUpdateDeleteQuery(node);
85
+
86
+ if (query === null || !tables.has(query.table)) {
87
+ return;
88
+ }
89
+
90
+ if (chainContainsWhereWithScope(node, scopeColumns)) {
91
+ return;
92
+ }
93
+
94
+ context.report({
95
+ node,
96
+ messageId: "missingScopeFilter",
97
+ data: {
98
+ kind: query.kind,
99
+ table: query.table,
100
+ scopeColumn,
101
+ },
102
+ });
103
+ },
104
+ };
105
+ },
106
+ });
@@ -0,0 +1,73 @@
1
+ import type { JSONSchema4 } from "@typescript-eslint/utils/json-schema";
2
+
3
+ import { createRule } from "../../create-rule";
4
+ import { matchesAnyGlobPattern } from "../../utils";
5
+ import { chainContainsWhere, identifyUpdateDeleteQuery } from "../utils";
6
+
7
+ export const RULE_NAME = "update-delete-must-have-where";
8
+
9
+ export interface IUpdateDeleteMustHaveWhereOptions {
10
+ readonly allowFiles?: readonly string[];
11
+ }
12
+
13
+ type RuleOptions = [IUpdateDeleteMustHaveWhereOptions];
14
+ type MessageIds = "missingWhere";
15
+
16
+ const optionSchema: JSONSchema4 = {
17
+ type: "object",
18
+ additionalProperties: false,
19
+ properties: {
20
+ allowFiles: {
21
+ type: "array",
22
+ items: { type: "string", minLength: 1 },
23
+ uniqueItems: true,
24
+ },
25
+ },
26
+ };
27
+
28
+ export const updateDeleteMustHaveWhereRule = createRule<
29
+ RuleOptions,
30
+ MessageIds
31
+ >({
32
+ name: RULE_NAME,
33
+ meta: {
34
+ type: "problem",
35
+ docs: {
36
+ description:
37
+ "Require every Drizzle `.update()` and `.delete()` call to include a `.where()` clause — unscoped writes affect every row.",
38
+ },
39
+ schema: [optionSchema],
40
+ messages: {
41
+ missingWhere:
42
+ "Drizzle `.{{kind}}()` on `{{table}}` is missing `.where()` — unscoped writes can mutate every row in the table.",
43
+ },
44
+ },
45
+ defaultOptions: [{}],
46
+ create(context, [options]) {
47
+ const allowFiles = options.allowFiles ?? [];
48
+
49
+ if (matchesAnyGlobPattern(context.filename, allowFiles)) {
50
+ return {};
51
+ }
52
+
53
+ return {
54
+ CallExpression(node) {
55
+ const query = identifyUpdateDeleteQuery(node);
56
+
57
+ if (query === null) {
58
+ return;
59
+ }
60
+
61
+ if (chainContainsWhere(node)) {
62
+ return;
63
+ }
64
+
65
+ context.report({
66
+ node,
67
+ messageId: "missingWhere",
68
+ data: { kind: query.kind, table: query.table },
69
+ });
70
+ },
71
+ };
72
+ },
73
+ });