@agjs/tsforge 0.1.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 (216) hide show
  1. package/bin/tsforge.js +2 -0
  2. package/package.json +35 -0
  3. package/src/agent/agent.constants.ts +382 -0
  4. package/src/agent/agent.types.ts +34 -0
  5. package/src/agent/index.ts +4 -0
  6. package/src/agent/model-agent.ts +297 -0
  7. package/src/agent/tool-repair.ts +194 -0
  8. package/src/agent/tools.ts +190 -0
  9. package/src/browser/checks.ts +96 -0
  10. package/src/browser/index.ts +8 -0
  11. package/src/browser/oracle.ts +303 -0
  12. package/src/classify.ts +48 -0
  13. package/src/cli.ts +1333 -0
  14. package/src/config/config.constants.ts +9 -0
  15. package/src/config/flags.ts +32 -0
  16. package/src/config/index.ts +8 -0
  17. package/src/config/tsforge-config.ts +301 -0
  18. package/src/constitution/baseline.ts +257 -0
  19. package/src/detect-gate.ts +498 -0
  20. package/src/eval/eval.types.ts +36 -0
  21. package/src/eval/index.ts +3 -0
  22. package/src/eval/judge.ts +62 -0
  23. package/src/eval/score.ts +39 -0
  24. package/src/files/create.ts +22 -0
  25. package/src/files/edit.ts +193 -0
  26. package/src/files/files.constants.ts +11 -0
  27. package/src/files/files.types.ts +81 -0
  28. package/src/files/hashline-format.ts +110 -0
  29. package/src/files/hashline.ts +689 -0
  30. package/src/files/index.ts +19 -0
  31. package/src/index.ts +8 -0
  32. package/src/inference/index.ts +6 -0
  33. package/src/inference/inference.constants.ts +34 -0
  34. package/src/inference/inference.types.ts +123 -0
  35. package/src/inference/openai-compatible.ts +113 -0
  36. package/src/inference/stream-guard.ts +161 -0
  37. package/src/inference/stream.ts +370 -0
  38. package/src/inference/transport.ts +78 -0
  39. package/src/inference/wire.ts +0 -0
  40. package/src/lib/fs/fs.ts +126 -0
  41. package/src/lib/fs/fs.types.ts +5 -0
  42. package/src/lib/fs/index.ts +3 -0
  43. package/src/lib/fs/process.ts +146 -0
  44. package/src/lib/guards/guards.ts +9 -0
  45. package/src/lib/guards/index.ts +1 -0
  46. package/src/lib/json/index.ts +1 -0
  47. package/src/lib/json/json.ts +12 -0
  48. package/src/lib/scope/index.ts +2 -0
  49. package/src/lib/scope/scope.constants.ts +3 -0
  50. package/src/lib/scope/scope.ts +40 -0
  51. package/src/loop/astgrep-fix.ts +228 -0
  52. package/src/loop/feedback/feedback.ts +138 -0
  53. package/src/loop/feedback/index.ts +8 -0
  54. package/src/loop/feedback/meta-rule-docs.ts +41 -0
  55. package/src/loop/feedback/meta-rule-feedback.ts +61 -0
  56. package/src/loop/feedback/rule-docs.generated.json +112 -0
  57. package/src/loop/feedback/rule-docs.ts +342 -0
  58. package/src/loop/index.ts +19 -0
  59. package/src/loop/loop.constants.ts +68 -0
  60. package/src/loop/loop.types.ts +99 -0
  61. package/src/loop/prompt/index.ts +2 -0
  62. package/src/loop/prompt/project-map.ts +69 -0
  63. package/src/loop/prompt/prompt.ts +107 -0
  64. package/src/loop/quality.ts +174 -0
  65. package/src/loop/rule-docs.generated.json +367 -0
  66. package/src/loop/run-spec.ts +88 -0
  67. package/src/loop/run.ts +400 -0
  68. package/src/loop/session.ts +1410 -0
  69. package/src/loop/tools/add-dependency.ts +71 -0
  70. package/src/loop/tools/condense.ts +498 -0
  71. package/src/loop/tools/edit-hashline.ts +80 -0
  72. package/src/loop/tools/execute-tool.ts +80 -0
  73. package/src/loop/tools/file-ops.ts +323 -0
  74. package/src/loop/tools/index.ts +2 -0
  75. package/src/loop/tools/lsp-ops.ts +222 -0
  76. package/src/loop/tools/scaffold-routes.ts +68 -0
  77. package/src/loop/tools/scaffold-ui.ts +62 -0
  78. package/src/loop/tools/scaffold-web.ts +35 -0
  79. package/src/loop/tools/tool-context.ts +126 -0
  80. package/src/loop/ttsr-defaults.ts +53 -0
  81. package/src/loop/ttsr.ts +322 -0
  82. package/src/loop/turn.ts +856 -0
  83. package/src/lsp/index.ts +2 -0
  84. package/src/lsp/lsp.types.ts +56 -0
  85. package/src/lsp/service.ts +500 -0
  86. package/src/meta-rules/context.ts +195 -0
  87. package/src/meta-rules/index.ts +9 -0
  88. package/src/meta-rules/meta-rules.types.ts +47 -0
  89. package/src/meta-rules/parsers/package-json-parser.ts +51 -0
  90. package/src/meta-rules/registry.ts +37 -0
  91. package/src/meta-rules/rules/ci/workflow-actions-pinned.ts +59 -0
  92. package/src/meta-rules/rules/ci/workflow-runner-pinned.ts +57 -0
  93. package/src/meta-rules/rules/ci/workflow-timeout-required.ts +114 -0
  94. package/src/meta-rules/rules/config/tsconfig-paths-exist.ts +117 -0
  95. package/src/meta-rules/rules/config/tsconfig-strict.ts +91 -0
  96. package/src/meta-rules/rules/source-text/no-eslint-disable-comments.ts +34 -0
  97. package/src/meta-rules/rules/source-text/no-ts-suppressions.ts +38 -0
  98. package/src/meta-rules/rules/supply-chain/no-overlapping-libs.ts +57 -0
  99. package/src/meta-rules/rules/supply-chain/package-exact-deps.ts +55 -0
  100. package/src/meta-rules/rules/testing/test-sibling-required.ts +110 -0
  101. package/src/meta-rules/runner.ts +64 -0
  102. package/src/models-config.ts +196 -0
  103. package/src/render/ansi.ts +289 -0
  104. package/src/render/banner.ts +113 -0
  105. package/src/render/box.ts +134 -0
  106. package/src/render/index.ts +7 -0
  107. package/src/render/markdown.ts +123 -0
  108. package/src/render/render.types.ts +21 -0
  109. package/src/render/stream-markdown.ts +128 -0
  110. package/src/render/style.ts +26 -0
  111. package/src/rule-packs/bullmq/index.ts +39 -0
  112. package/src/rule-packs/bullmq/rules/index.ts +7 -0
  113. package/src/rule-packs/bullmq/rules/job-name-must-be-constant.ts +141 -0
  114. package/src/rule-packs/bullmq/rules/job-options-must-set-attempts.ts +174 -0
  115. package/src/rule-packs/bullmq/rules/no-blocking-concurrency-zero.ts +103 -0
  116. package/src/rule-packs/bullmq/rules/queue-options-must-set-removeoncomplete.ts +130 -0
  117. package/src/rule-packs/bullmq/rules/queue-options-must-set-removeonfail.ts +130 -0
  118. package/src/rule-packs/bullmq/rules/worker-must-implement-close.ts +182 -0
  119. package/src/rule-packs/bullmq/rules/worker-must-listen-failed.ts +140 -0
  120. package/src/rule-packs/bullmq/utils.ts +334 -0
  121. package/src/rule-packs/code-flow/index.ts +25 -0
  122. package/src/rule-packs/code-flow/rules/index.ts +3 -0
  123. package/src/rule-packs/code-flow/rules/no-bare-date-now.ts +138 -0
  124. package/src/rule-packs/code-flow/rules/no-template-trim-empty-ternary.ts +87 -0
  125. package/src/rule-packs/code-flow/rules/prefer-early-return.ts +80 -0
  126. package/src/rule-packs/code-flow/utils/prefer-early-return.ts +132 -0
  127. package/src/rule-packs/comment-hygiene/index.ts +25 -0
  128. package/src/rule-packs/comment-hygiene/rules/index.ts +3 -0
  129. package/src/rule-packs/comment-hygiene/rules/no-historical-comments.ts +102 -0
  130. package/src/rule-packs/comment-hygiene/rules/no-narration-comments.ts +83 -0
  131. package/src/rule-packs/comment-hygiene/rules/no-pr-reference-comments.ts +90 -0
  132. package/src/rule-packs/create-rule.ts +9 -0
  133. package/src/rule-packs/drizzle/index.ts +41 -0
  134. package/src/rule-packs/drizzle/rules/account-scoped-tables-require-where.ts +371 -0
  135. package/src/rule-packs/drizzle/rules/index.ts +8 -0
  136. package/src/rule-packs/drizzle/rules/no-nested-db-transaction.ts +127 -0
  137. package/src/rule-packs/drizzle/rules/no-raw-sql-outside-allowlist.ts +100 -0
  138. package/src/rule-packs/drizzle/rules/relations-must-cover-fks.ts +209 -0
  139. package/src/rule-packs/drizzle/rules/schema-files-must-not-import-driver.ts +127 -0
  140. package/src/rule-packs/drizzle/rules/schema-files-must-only-export-schema.ts +149 -0
  141. package/src/rule-packs/drizzle/rules/tables-must-have-timestamps.ts +312 -0
  142. package/src/rule-packs/drizzle/rules/timestamp-must-specify-mode.ts +166 -0
  143. package/src/rule-packs/drizzle/utils.ts +115 -0
  144. package/src/rule-packs/elysia/index.ts +43 -0
  145. package/src/rule-packs/elysia/rules/consistent-status-via-set.ts +69 -0
  146. package/src/rule-packs/elysia/rules/no-decorate-state-collision.ts +276 -0
  147. package/src/rule-packs/elysia/rules/no-separate-model-interfaces.ts +144 -0
  148. package/src/rule-packs/elysia/rules/prefer-destructured-context.ts +155 -0
  149. package/src/rule-packs/elysia/rules/prefer-direct-return.ts +176 -0
  150. package/src/rule-packs/elysia/rules/prefer-static-services.ts +159 -0
  151. package/src/rule-packs/elysia/rules/prefer-throw-status.ts +151 -0
  152. package/src/rule-packs/elysia/rules/require-hooks-before-routes.ts +209 -0
  153. package/src/rule-packs/elysia/rules/require-plugin-name.ts +107 -0
  154. package/src/rule-packs/elysia/utils/elysiaChain.ts +306 -0
  155. package/src/rule-packs/env-access/index.ts +23 -0
  156. package/src/rule-packs/env-access/rules/index.ts +2 -0
  157. package/src/rule-packs/env-access/rules/no-direct-process-env.ts +133 -0
  158. package/src/rule-packs/env-access/rules/no-process-exit.ts +95 -0
  159. package/src/rule-packs/i18n-keys/index.ts +19 -0
  160. package/src/rule-packs/i18n-keys/rules/static-translation-key-exists.ts +173 -0
  161. package/src/rule-packs/index.ts +139 -0
  162. package/src/rule-packs/jwt-cookies/index.ts +25 -0
  163. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-httponly.ts +150 -0
  164. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-secure-in-prod.ts +149 -0
  165. package/src/rule-packs/jwt-cookies/rules/bcrypt-rounds-min.ts +195 -0
  166. package/src/rule-packs/jwt-cookies/utils.ts +188 -0
  167. package/src/rule-packs/oauth-security/index.ts +25 -0
  168. package/src/rule-packs/oauth-security/rules/pkce-required-for-oidc.ts +296 -0
  169. package/src/rule-packs/oauth-security/rules/state-must-be-redis-backed.ts +193 -0
  170. package/src/rule-packs/oauth-security/rules/state-ttl-bounded.ts +219 -0
  171. package/src/rule-packs/oauth-security/utils.ts +127 -0
  172. package/src/rule-packs/react-component-architecture/index.ts +35 -0
  173. package/src/rule-packs/react-component-architecture/rules/component-folder-structure.ts +123 -0
  174. package/src/rule-packs/react-component-architecture/rules/forwardref-display-name.ts +93 -0
  175. package/src/rule-packs/react-component-architecture/rules/index-must-reexport-default.ts +123 -0
  176. package/src/rule-packs/react-component-architecture/rules/max-hooks-per-file.ts +122 -0
  177. package/src/rule-packs/react-component-architecture/rules/no-cross-feature-imports.ts +170 -0
  178. package/src/rule-packs/react-component-architecture/rules/no-inline-jsx-functions.ts +66 -0
  179. package/src/rule-packs/react-component-architecture/utils.ts +47 -0
  180. package/src/rule-packs/rule-packs.types.ts +18 -0
  181. package/src/rule-packs/structured-logging/index.ts +26 -0
  182. package/src/rule-packs/structured-logging/rules/mask-pii-fields.ts +221 -0
  183. package/src/rule-packs/structured-logging/rules/no-error-stringify.ts +217 -0
  184. package/src/rule-packs/structured-logging/rules/require-event-field.ts +136 -0
  185. package/src/rule-packs/structured-logging/utils/logger.ts +104 -0
  186. package/src/rule-packs/tanstack-query/index.ts +20 -0
  187. package/src/rule-packs/tanstack-query/rules/prefix-query-key-must-use-set-queries-data.ts +321 -0
  188. package/src/rule-packs/test-conventions/index.ts +23 -0
  189. package/src/rule-packs/test-conventions/rules/index.ts +2 -0
  190. package/src/rule-packs/test-conventions/rules/no-focused-tests.ts +170 -0
  191. package/src/rule-packs/test-conventions/rules/test-file-mirrors-source.ts +127 -0
  192. package/src/rule-packs/utils.ts +142 -0
  193. package/src/session-store.ts +359 -0
  194. package/src/spec/generate-tests.ts +213 -0
  195. package/src/spec/index.ts +5 -0
  196. package/src/spec/parse.ts +152 -0
  197. package/src/spec/review-tests.ts +162 -0
  198. package/src/spec/spec.constants.ts +13 -0
  199. package/src/spec/spec.types.ts +79 -0
  200. package/src/stack-detection/detect.ts +246 -0
  201. package/src/stack-detection/index.ts +3 -0
  202. package/src/stack-detection/packs.ts +174 -0
  203. package/src/stack-detection/stack-detection.types.ts +47 -0
  204. package/src/validate/accept.ts +49 -0
  205. package/src/validate/errors.ts +35 -0
  206. package/src/validate/index.ts +12 -0
  207. package/src/validate/parse.ts +148 -0
  208. package/src/validate/run-tests.ts +59 -0
  209. package/src/validate/validate.ts +40 -0
  210. package/src/validate/validate.types.ts +52 -0
  211. package/src/web-components.ts +638 -0
  212. package/src/web-coverage.ts +89 -0
  213. package/src/web-routes.ts +151 -0
  214. package/src/web-templates.ts +1011 -0
  215. package/strict.eslint.config.mjs +84 -0
  216. package/strict.web.eslint.config.mjs +185 -0
@@ -0,0 +1,276 @@
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 {
6
+ collectElysiaVariables,
7
+ getChainRoot,
8
+ getMemberMethodName,
9
+ isNewElysiaExpression,
10
+ } from "../utils/elysiaChain";
11
+
12
+ export const RULE_NAME = "no-decorate-state-collision";
13
+
14
+ export interface NoDecorateStateCollisionOptions {
15
+ readonly methods?: readonly string[];
16
+ }
17
+
18
+ type RuleOptions = [NoDecorateStateCollisionOptions];
19
+ type MessageIds = "decorateKeyCollision";
20
+
21
+ const DEFAULT_METHODS = ["decorate", "state", "derive", "resolve"] as const;
22
+
23
+ const optionSchema: JSONSchema4 = {
24
+ type: "object",
25
+ additionalProperties: false,
26
+ properties: {
27
+ methods: {
28
+ type: "array",
29
+ items: { type: "string" },
30
+ uniqueItems: true,
31
+ minItems: 1,
32
+ },
33
+ },
34
+ };
35
+
36
+ export const noDecorateStateCollisionRule = createRule<RuleOptions, MessageIds>(
37
+ {
38
+ name: RULE_NAME,
39
+ meta: {
40
+ type: "problem",
41
+ docs: {
42
+ description:
43
+ "Disallow duplicate keys across `.decorate()` / `.state()` / `.derive()` / `.resolve()` calls on a single Elysia instance — duplicates silently overwrite and break plugin composition.",
44
+ },
45
+ schema: [optionSchema],
46
+ messages: {
47
+ decorateKeyCollision:
48
+ "Key '{{key}}' is registered more than once on this Elysia instance (previously by `.{{previous}}(...)`). Duplicate decorate/state keys silently overwrite each other and break plugin composition.",
49
+ },
50
+ },
51
+ defaultOptions: [{ methods: [...DEFAULT_METHODS] }],
52
+ create(context, [options]) {
53
+ const methods = new Set(options.methods ?? DEFAULT_METHODS);
54
+
55
+ let elysiaVars = new Set<string>();
56
+
57
+ interface Registration {
58
+ readonly key: string;
59
+ readonly method: string;
60
+ readonly node: TSESTree.Node;
61
+ readonly line: number;
62
+ }
63
+ const registrationsByOwner = new Map<string, Registration[]>();
64
+ const processedChains = new WeakSet();
65
+
66
+ function ownerKey(rootCall: TSESTree.CallExpression): string {
67
+ const root = getChainRoot(rootCall);
68
+
69
+ if (isNewElysiaExpression(root)) {
70
+ return `chain@${root.range[0]}`;
71
+ }
72
+
73
+ if (root.type === AST_NODE_TYPES.Identifier) {
74
+ return `var:${root.name}`;
75
+ }
76
+
77
+ return `chain@${rootCall.range[0]}`;
78
+ }
79
+
80
+ function checkChain(rootCall: TSESTree.CallExpression): void {
81
+ if (processedChains.has(rootCall)) {
82
+ return;
83
+ }
84
+
85
+ processedChains.add(rootCall);
86
+
87
+ const ordered = collectChainCalls(rootCall);
88
+ const owner = ownerKey(rootCall);
89
+ const existing = registrationsByOwner.get(owner) ?? [];
90
+
91
+ for (const call of ordered) {
92
+ if (!methods.has(call.method)) {
93
+ continue;
94
+ }
95
+
96
+ for (const reg of extractRegistrations(call.node, call.method)) {
97
+ const prior = existing.find((e) => e.key === reg.key);
98
+
99
+ if (prior) {
100
+ context.report({
101
+ node: reg.node,
102
+ messageId: "decorateKeyCollision",
103
+ data: {
104
+ key: reg.key,
105
+ previous: prior.method,
106
+ },
107
+ });
108
+ }
109
+
110
+ existing.push(reg);
111
+ }
112
+ }
113
+
114
+ registrationsByOwner.set(owner, existing);
115
+ }
116
+
117
+ return {
118
+ Program(program) {
119
+ elysiaVars = collectElysiaVariables(program);
120
+ },
121
+ CallExpression(node) {
122
+ if (node.callee.type !== AST_NODE_TYPES.MemberExpression) {
123
+ return;
124
+ }
125
+
126
+ const root = getChainRoot(node);
127
+
128
+ const isElysiaChain =
129
+ isNewElysiaExpression(root) ||
130
+ (root.type === AST_NODE_TYPES.Identifier &&
131
+ elysiaVars.has(root.name));
132
+
133
+ if (!isElysiaChain) {
134
+ return;
135
+ }
136
+
137
+ if (
138
+ node.parent?.type === AST_NODE_TYPES.MemberExpression &&
139
+ node.parent.parent?.type === AST_NODE_TYPES.CallExpression
140
+ ) {
141
+ return;
142
+ }
143
+
144
+ checkChain(node);
145
+ },
146
+ };
147
+ },
148
+ }
149
+ );
150
+
151
+ interface OrderedCall {
152
+ readonly method: string;
153
+ readonly node: TSESTree.CallExpression;
154
+ }
155
+
156
+ function collectChainCalls(outermost: TSESTree.CallExpression): OrderedCall[] {
157
+ const calls: OrderedCall[] = [];
158
+ let current: TSESTree.Expression = outermost;
159
+
160
+ while (
161
+ current.type === AST_NODE_TYPES.CallExpression &&
162
+ current.callee.type === AST_NODE_TYPES.MemberExpression
163
+ ) {
164
+ const method = getMemberMethodName(current);
165
+
166
+ if (method) {
167
+ calls.push({ method, node: current });
168
+ }
169
+
170
+ current = current.callee.object;
171
+ }
172
+
173
+ return calls.reverse();
174
+ }
175
+
176
+ interface RegistrationCandidate {
177
+ readonly key: string;
178
+ readonly method: string;
179
+ readonly node: TSESTree.Node;
180
+ readonly line: number;
181
+ }
182
+
183
+ function extractRegistrations(
184
+ call: TSESTree.CallExpression,
185
+ method: string
186
+ ): RegistrationCandidate[] {
187
+ const arg = call.arguments[0];
188
+
189
+ if (!arg) {
190
+ return [];
191
+ }
192
+
193
+ if (arg.type === AST_NODE_TYPES.Literal && typeof arg.value === "string") {
194
+ return [
195
+ {
196
+ key: arg.value,
197
+ method,
198
+ node: arg,
199
+ line: arg.loc.start.line,
200
+ },
201
+ ];
202
+ }
203
+
204
+ if (arg.type === AST_NODE_TYPES.ObjectExpression) {
205
+ return objectExpressionKeys(arg, method);
206
+ }
207
+
208
+ if (
209
+ arg.type === AST_NODE_TYPES.ArrowFunctionExpression ||
210
+ arg.type === AST_NODE_TYPES.FunctionExpression
211
+ ) {
212
+ const returned = getReturnedObjectExpression(arg);
213
+
214
+ if (returned) {
215
+ return objectExpressionKeys(returned, method);
216
+ }
217
+ }
218
+
219
+ return [];
220
+ }
221
+
222
+ function objectExpressionKeys(
223
+ obj: TSESTree.ObjectExpression,
224
+ method: string
225
+ ): RegistrationCandidate[] {
226
+ const out: RegistrationCandidate[] = [];
227
+
228
+ for (const property of obj.properties) {
229
+ if (property.type !== AST_NODE_TYPES.Property) {
230
+ continue;
231
+ }
232
+
233
+ let keyName: string | null = null;
234
+
235
+ if (property.key.type === AST_NODE_TYPES.Identifier && !property.computed) {
236
+ keyName = property.key.name;
237
+ } else if (
238
+ property.key.type === AST_NODE_TYPES.Literal &&
239
+ typeof property.key.value === "string"
240
+ ) {
241
+ keyName = property.key.value;
242
+ }
243
+
244
+ if (keyName !== null) {
245
+ out.push({
246
+ key: keyName,
247
+ method,
248
+ node: property,
249
+ line: property.loc.start.line,
250
+ });
251
+ }
252
+ }
253
+
254
+ return out;
255
+ }
256
+
257
+ function getReturnedObjectExpression(
258
+ fn: TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression
259
+ ): TSESTree.ObjectExpression | null {
260
+ if (fn.body.type === AST_NODE_TYPES.ObjectExpression) {
261
+ return fn.body;
262
+ }
263
+
264
+ if (fn.body.type === AST_NODE_TYPES.BlockStatement) {
265
+ for (const stmt of fn.body.body) {
266
+ if (
267
+ stmt.type === AST_NODE_TYPES.ReturnStatement &&
268
+ stmt.argument?.type === AST_NODE_TYPES.ObjectExpression
269
+ ) {
270
+ return stmt.argument;
271
+ }
272
+ }
273
+ }
274
+
275
+ return null;
276
+ }
@@ -0,0 +1,144 @@
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 { getCalleeName } from "../utils/elysiaChain";
6
+
7
+ export const RULE_NAME = "no-separate-model-interfaces";
8
+
9
+ export interface NoSeparateModelInterfacesOptions {
10
+ readonly schemaSuffixes?: readonly string[];
11
+ readonly schemaFactoryNames?: readonly string[];
12
+ }
13
+
14
+ type RuleOptions = [NoSeparateModelInterfacesOptions];
15
+ type MessageIds = "noSeparateModelInterface";
16
+
17
+ const DEFAULT_SCHEMA_SUFFIXES = [
18
+ "Schema",
19
+ "Model",
20
+ "Dto",
21
+ "DTO",
22
+ "Request",
23
+ "Response",
24
+ ] as const;
25
+
26
+ const DEFAULT_SCHEMA_FACTORY_NAMES = [
27
+ "t.Object",
28
+ "Elysia.t.Object",
29
+ "Type.Object",
30
+ "z.object",
31
+ "v.object",
32
+ ] as const;
33
+
34
+ const optionSchema: JSONSchema4 = {
35
+ type: "object",
36
+ additionalProperties: false,
37
+ properties: {
38
+ schemaSuffixes: {
39
+ type: "array",
40
+ items: { type: "string" },
41
+ uniqueItems: true,
42
+ },
43
+ schemaFactoryNames: {
44
+ type: "array",
45
+ items: { type: "string" },
46
+ uniqueItems: true,
47
+ },
48
+ },
49
+ };
50
+
51
+ export const noSeparateModelInterfacesRule = createRule<
52
+ RuleOptions,
53
+ MessageIds
54
+ >({
55
+ name: RULE_NAME,
56
+ meta: {
57
+ type: "suggestion",
58
+ docs: {
59
+ description:
60
+ "Disallow TypeScript interfaces that duplicate the shape of a runtime schema with a matching name. Use `typeof Schema.static` (or your project's equivalent) instead.",
61
+ },
62
+ schema: [optionSchema],
63
+ messages: {
64
+ noSeparateModelInterface:
65
+ "Interface '{{interface}}' duplicates schema '{{schema}}'. Replace with `type {{interface}} = typeof {{schema}}.static` (or equivalent) to keep the type and the runtime validator from drifting.",
66
+ },
67
+ },
68
+ defaultOptions: [
69
+ {
70
+ schemaSuffixes: [...DEFAULT_SCHEMA_SUFFIXES],
71
+ schemaFactoryNames: [...DEFAULT_SCHEMA_FACTORY_NAMES],
72
+ },
73
+ ],
74
+ create(context, [options]) {
75
+ const suffixes = options.schemaSuffixes ?? DEFAULT_SCHEMA_SUFFIXES;
76
+ const factoryNames = new Set(
77
+ options.schemaFactoryNames ?? DEFAULT_SCHEMA_FACTORY_NAMES
78
+ );
79
+
80
+ const schemaByBase = new Map<string, string>();
81
+ const interfaces: TSESTree.TSInterfaceDeclaration[] = [];
82
+
83
+ return {
84
+ VariableDeclarator(node) {
85
+ if (node.id.type !== AST_NODE_TYPES.Identifier) {
86
+ return;
87
+ }
88
+
89
+ if (node.init?.type !== AST_NODE_TYPES.CallExpression) {
90
+ return;
91
+ }
92
+
93
+ const calleeName = getCalleeName(node.init);
94
+
95
+ if (!calleeName || !factoryNames.has(calleeName)) {
96
+ return;
97
+ }
98
+
99
+ const base = stripSuffix(node.id.name, suffixes);
100
+
101
+ if (base.length > 0) {
102
+ schemaByBase.set(base, node.id.name);
103
+ }
104
+ },
105
+ TSInterfaceDeclaration(node) {
106
+ interfaces.push(node);
107
+ },
108
+ "Program:exit"() {
109
+ for (const iface of interfaces) {
110
+ const base = stripSuffix(iface.id.name, suffixes);
111
+
112
+ if (base.length === 0) {
113
+ continue;
114
+ }
115
+
116
+ const schemaName = schemaByBase.get(base);
117
+
118
+ if (!schemaName) {
119
+ continue;
120
+ }
121
+
122
+ context.report({
123
+ node: iface.id,
124
+ messageId: "noSeparateModelInterface",
125
+ data: {
126
+ interface: iface.id.name,
127
+ schema: schemaName,
128
+ },
129
+ });
130
+ }
131
+ },
132
+ };
133
+ },
134
+ });
135
+
136
+ function stripSuffix(name: string, suffixes: readonly string[]): string {
137
+ for (const suffix of suffixes) {
138
+ if (name.endsWith(suffix) && name.length > suffix.length) {
139
+ return name.slice(0, -suffix.length);
140
+ }
141
+ }
142
+
143
+ return name;
144
+ }
@@ -0,0 +1,155 @@
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 { pushChildNodes } from "../../utils";
6
+ import {
7
+ collectElysiaVariables,
8
+ getRouteHandlerFunction,
9
+ isElysiaRouteCall,
10
+ } from "../utils/elysiaChain";
11
+
12
+ export const RULE_NAME = "prefer-destructured-context";
13
+
14
+ export interface PreferDestructuredContextOptions {
15
+ readonly allowNames?: readonly string[];
16
+ }
17
+
18
+ type RuleOptions = [PreferDestructuredContextOptions];
19
+ type MessageIds = "preferDestructuredContext";
20
+
21
+ const optionSchema: JSONSchema4 = {
22
+ type: "object",
23
+ additionalProperties: false,
24
+ properties: {
25
+ allowNames: {
26
+ type: "array",
27
+ items: { type: "string" },
28
+ uniqueItems: true,
29
+ },
30
+ },
31
+ };
32
+
33
+ export const preferDestructuredContextRule = createRule<
34
+ RuleOptions,
35
+ MessageIds
36
+ >({
37
+ name: RULE_NAME,
38
+ meta: {
39
+ type: "suggestion",
40
+ docs: {
41
+ description:
42
+ "Prefer destructured context (`{ body, set, ... }`) over passing the entire dynamic Elysia context object into controllers/services.",
43
+ },
44
+ schema: [optionSchema],
45
+ messages: {
46
+ preferDestructuredContext:
47
+ "Do not pass the full Elysia context (`{{name}}`) into another function. Destructure only the properties you need at the route boundary.",
48
+ },
49
+ },
50
+ defaultOptions: [{ allowNames: [] }],
51
+ create(context, [options]) {
52
+ const allowNames = new Set(options.allowNames ?? []);
53
+ let elysiaVars = new Set<string>();
54
+
55
+ return {
56
+ Program(program) {
57
+ elysiaVars = collectElysiaVariables(program);
58
+ },
59
+ CallExpression(node) {
60
+ if (!isElysiaRouteCall(node, elysiaVars)) {
61
+ return;
62
+ }
63
+
64
+ const handler = getRouteHandlerFunction(node);
65
+
66
+ if (handler?.params.length !== 1) {
67
+ return;
68
+ }
69
+
70
+ const param = handler.params[0];
71
+
72
+ if (param?.type !== AST_NODE_TYPES.Identifier) {
73
+ return;
74
+ }
75
+
76
+ if (allowNames.has(param.name)) {
77
+ return;
78
+ }
79
+
80
+ const ctxName = param.name;
81
+
82
+ for (const violation of collectContextPassThrough(
83
+ handler.body,
84
+ ctxName
85
+ )) {
86
+ context.report({
87
+ node: violation,
88
+ messageId: "preferDestructuredContext",
89
+ data: { name: ctxName },
90
+ });
91
+ }
92
+ },
93
+ };
94
+ },
95
+ });
96
+
97
+ function isFunctionNode(node: TSESTree.Node): boolean {
98
+ return (
99
+ node.type === AST_NODE_TYPES.ArrowFunctionExpression ||
100
+ node.type === AST_NODE_TYPES.FunctionExpression ||
101
+ node.type === AST_NODE_TYPES.FunctionDeclaration
102
+ );
103
+ }
104
+
105
+ /** True when a call/new expression forwards `ctxName` (directly or spread). */
106
+ function callPassesContext(
107
+ call: TSESTree.CallExpression | TSESTree.NewExpression,
108
+ ctxName: string
109
+ ): boolean {
110
+ return call.arguments.some((arg) => {
111
+ if (arg.type === AST_NODE_TYPES.Identifier) {
112
+ return arg.name === ctxName;
113
+ }
114
+
115
+ return (
116
+ arg.type === AST_NODE_TYPES.SpreadElement &&
117
+ arg.argument.type === AST_NODE_TYPES.Identifier &&
118
+ arg.argument.name === ctxName
119
+ );
120
+ });
121
+ }
122
+
123
+ /** Calls inside the handler body (excluding nested functions) that forward the context. */
124
+ function collectContextPassThrough(
125
+ root: TSESTree.Node,
126
+ ctxName: string
127
+ ): TSESTree.Node[] {
128
+ const violations: TSESTree.Node[] = [];
129
+ const stack: TSESTree.Node[] = [root];
130
+ const visited = new WeakSet();
131
+
132
+ for (let node = stack.pop(); node !== undefined; node = stack.pop()) {
133
+ if (visited.has(node)) {
134
+ continue;
135
+ }
136
+
137
+ visited.add(node);
138
+
139
+ if (isFunctionNode(node) && node !== root) {
140
+ continue;
141
+ }
142
+
143
+ if (
144
+ (node.type === AST_NODE_TYPES.CallExpression ||
145
+ node.type === AST_NODE_TYPES.NewExpression) &&
146
+ callPassesContext(node, ctxName)
147
+ ) {
148
+ violations.push(node);
149
+ }
150
+
151
+ pushChildNodes(node, stack);
152
+ }
153
+
154
+ return violations;
155
+ }