@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,321 @@
1
+ import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
2
+
3
+ import { createRule } from "../../create-rule";
4
+ import { isNodeLike } from "../../utils";
5
+
6
+ const HOOK_NAMES = new Set([
7
+ "useQuery",
8
+ "useInfiniteQuery",
9
+ "useSuspenseQuery",
10
+ "useSuspenseInfiniteQuery",
11
+ ]);
12
+
13
+ const PREFIX_UNSAFE_METHODS = new Set([
14
+ "setQueryData",
15
+ "getQueryData",
16
+ "cancelQueries",
17
+ "removeQueries",
18
+ "resetQueries",
19
+ "prefetchQuery",
20
+ ]);
21
+
22
+ const METHODS_ALLOWING_PREFIX_FILTER = new Set([
23
+ "cancelQueries",
24
+ "removeQueries",
25
+ "resetQueries",
26
+ "prefetchQuery",
27
+ ]);
28
+
29
+ const SKIP_TRAVERSE_KEYS = new Set([
30
+ "parent",
31
+ "tokens",
32
+ "comments",
33
+ "loc",
34
+ "range",
35
+ ]);
36
+
37
+ type MessageIds = "useMatcherApi";
38
+
39
+ function unwrapExpression(node: TSESTree.Expression): TSESTree.Expression {
40
+ let current: TSESTree.Expression = node;
41
+
42
+ for (;;) {
43
+ if (current.type === AST_NODE_TYPES.TSAsExpression) {
44
+ current = current.expression;
45
+ continue;
46
+ }
47
+
48
+ if (current.type === AST_NODE_TYPES.TSNonNullExpression) {
49
+ current = current.expression;
50
+ continue;
51
+ }
52
+
53
+ return current;
54
+ }
55
+ }
56
+
57
+ function getSpreadPrefixText(
58
+ node: TSESTree.Expression,
59
+ getText: (n: TSESTree.Node) => string
60
+ ): string | null {
61
+ const inner = unwrapExpression(node);
62
+
63
+ if (inner.type !== AST_NODE_TYPES.ArrayExpression) {
64
+ return null;
65
+ }
66
+
67
+ const { elements } = inner;
68
+
69
+ if (elements.length < 2) {
70
+ return null;
71
+ }
72
+
73
+ const firstElement = elements[0];
74
+
75
+ if (firstElement?.type !== AST_NODE_TYPES.SpreadElement) {
76
+ return null;
77
+ }
78
+
79
+ const { argument } = firstElement;
80
+
81
+ if (
82
+ argument.type !== AST_NODE_TYPES.Identifier &&
83
+ argument.type !== AST_NODE_TYPES.MemberExpression
84
+ ) {
85
+ return null;
86
+ }
87
+
88
+ return getText(argument);
89
+ }
90
+
91
+ function findQueryKeyProperty(
92
+ obj: TSESTree.ObjectExpression
93
+ ): TSESTree.Property | undefined {
94
+ return obj.properties.find(
95
+ (p): p is TSESTree.Property =>
96
+ p.type === AST_NODE_TYPES.Property &&
97
+ !p.computed &&
98
+ p.key.type === AST_NODE_TYPES.Identifier &&
99
+ p.key.name === "queryKey"
100
+ );
101
+ }
102
+
103
+ function walkAst(
104
+ node: TSESTree.Node,
105
+ visitor: (n: TSESTree.Node) => void
106
+ ): void {
107
+ visitor(node);
108
+
109
+ for (const key of Object.keys(node)) {
110
+ if (SKIP_TRAVERSE_KEYS.has(key)) {
111
+ continue;
112
+ }
113
+
114
+ const child = Object.getOwnPropertyDescriptor(node, key)?.value;
115
+
116
+ if (child === null || child === undefined) {
117
+ continue;
118
+ }
119
+
120
+ if (Array.isArray(child)) {
121
+ for (const c of child) {
122
+ if (isNodeLike(c)) {
123
+ walkAst(c, visitor);
124
+ }
125
+ }
126
+ } else if (isNodeLike(child)) {
127
+ walkAst(child, visitor);
128
+ }
129
+ }
130
+ }
131
+
132
+ function collectExtendedPrefixes(
133
+ program: TSESTree.Program,
134
+ getText: (n: TSESTree.Node) => string
135
+ ): ReadonlySet<string> {
136
+ const out = new Set<string>();
137
+
138
+ walkAst(program, (node) => {
139
+ if (node.type !== AST_NODE_TYPES.CallExpression) {
140
+ return;
141
+ }
142
+
143
+ const { callee } = node;
144
+
145
+ if (
146
+ callee.type !== AST_NODE_TYPES.Identifier ||
147
+ !HOOK_NAMES.has(callee.name) ||
148
+ node.arguments[0]?.type !== AST_NODE_TYPES.ObjectExpression
149
+ ) {
150
+ return;
151
+ }
152
+
153
+ const prop = findQueryKeyProperty(node.arguments[0]);
154
+
155
+ if (prop === undefined) {
156
+ return;
157
+ }
158
+
159
+ // prop.value might be a Pattern node; only process Expressions
160
+ if (
161
+ prop.value.type === AST_NODE_TYPES.AssignmentPattern ||
162
+ prop.value.type === AST_NODE_TYPES.ArrayPattern ||
163
+ prop.value.type === AST_NODE_TYPES.ObjectPattern ||
164
+ prop.value.type === AST_NODE_TYPES.TSEmptyBodyFunctionExpression
165
+ ) {
166
+ return;
167
+ }
168
+
169
+ const prefix = getSpreadPrefixText(prop.value, getText);
170
+
171
+ if (prefix !== null) {
172
+ out.add(prefix);
173
+ }
174
+ });
175
+
176
+ return out;
177
+ }
178
+
179
+ function queryFilterAllowsPrefixMatch(
180
+ arg: TSESTree.CallExpressionArgument | undefined
181
+ ): boolean {
182
+ if (arg?.type !== AST_NODE_TYPES.ObjectExpression) {
183
+ return false;
184
+ }
185
+
186
+ for (const prop of arg.properties) {
187
+ if (
188
+ prop.type !== AST_NODE_TYPES.Property ||
189
+ prop.computed ||
190
+ prop.key.type !== AST_NODE_TYPES.Identifier
191
+ ) {
192
+ continue;
193
+ }
194
+
195
+ if (prop.key.name === "predicate") {
196
+ return true;
197
+ }
198
+
199
+ if (
200
+ prop.key.name === "exact" &&
201
+ prop.value.type === AST_NODE_TYPES.Literal &&
202
+ prop.value.value === false
203
+ ) {
204
+ return true;
205
+ }
206
+ }
207
+
208
+ return false;
209
+ }
210
+
211
+ function getFirstArgQueryKeyText(
212
+ args: readonly TSESTree.CallExpressionArgument[],
213
+ getText: (n: TSESTree.Node) => string
214
+ ): string | null {
215
+ const first = args[0];
216
+
217
+ if (first === undefined) {
218
+ return null;
219
+ }
220
+
221
+ if (first.type === AST_NODE_TYPES.ObjectExpression) {
222
+ const prop = findQueryKeyProperty(first);
223
+
224
+ if (prop === undefined) {
225
+ return null;
226
+ }
227
+
228
+ // prop.value might be a Pattern node; only process Expressions
229
+ if (
230
+ prop.value.type === AST_NODE_TYPES.AssignmentPattern ||
231
+ prop.value.type === AST_NODE_TYPES.ArrayPattern ||
232
+ prop.value.type === AST_NODE_TYPES.ObjectPattern ||
233
+ prop.value.type === AST_NODE_TYPES.TSEmptyBodyFunctionExpression
234
+ ) {
235
+ return null;
236
+ }
237
+
238
+ return getText(unwrapExpression(prop.value));
239
+ }
240
+
241
+ if (first.type === AST_NODE_TYPES.SpreadElement) {
242
+ return null;
243
+ }
244
+
245
+ return getText(unwrapExpression(first));
246
+ }
247
+
248
+ export const prefixQueryKeyMustUseSetQueriesDataRule = createRule<
249
+ [],
250
+ MessageIds
251
+ >({
252
+ name: "prefix-query-key-must-use-set-queries-data",
253
+ meta: {
254
+ type: "problem",
255
+ docs: {
256
+ description:
257
+ "When a hook uses `queryKey: [...prefix, extra]`, do not call `setQueryData(prefix, …)`, `cancelQueries({ queryKey: prefix })`, etc. — those only touch one cache entry. Use `setQueriesData({ queryKey: prefix }, …)` and matcher-style `cancelQueries` / `invalidateQueries` so every variant is covered.",
258
+ },
259
+ schema: [],
260
+ messages: {
261
+ useMatcherApi:
262
+ "Query key spreads `{{prefix}}` with extra segments in this file. Use `setQueriesData` / predicate or `{ queryKey: prefix, exact: false }`-style APIs instead of `{{method}}` with the bare prefix (stale cache for other key variants).",
263
+ },
264
+ },
265
+ defaultOptions: [],
266
+ create(context) {
267
+ const sourceCode = context.sourceCode;
268
+ const getText = (n: TSESTree.Node) => sourceCode.getText(n);
269
+
270
+ return {
271
+ "Program:exit"(program: TSESTree.Program): void {
272
+ const extendedPrefixes = collectExtendedPrefixes(program, getText);
273
+
274
+ if (extendedPrefixes.size === 0) {
275
+ return;
276
+ }
277
+
278
+ walkAst(program, (node) => {
279
+ if (node.type !== AST_NODE_TYPES.CallExpression) {
280
+ return;
281
+ }
282
+
283
+ const { callee } = node;
284
+
285
+ if (callee.type !== AST_NODE_TYPES.MemberExpression) {
286
+ return;
287
+ }
288
+
289
+ if (callee.property.type !== AST_NODE_TYPES.Identifier) {
290
+ return;
291
+ }
292
+
293
+ const method = callee.property.name;
294
+
295
+ if (!PREFIX_UNSAFE_METHODS.has(method)) {
296
+ return;
297
+ }
298
+
299
+ if (
300
+ METHODS_ALLOWING_PREFIX_FILTER.has(method) &&
301
+ queryFilterAllowsPrefixMatch(node.arguments[0])
302
+ ) {
303
+ return;
304
+ }
305
+
306
+ const keyText = getFirstArgQueryKeyText(node.arguments, getText);
307
+
308
+ if (keyText === null || !extendedPrefixes.has(keyText)) {
309
+ return;
310
+ }
311
+
312
+ context.report({
313
+ node: callee.property,
314
+ messageId: "useMatcherApi",
315
+ data: { prefix: keyText, method },
316
+ });
317
+ });
318
+ },
319
+ };
320
+ },
321
+ });
@@ -0,0 +1,23 @@
1
+ import type { TSESLint } from "@typescript-eslint/utils";
2
+
3
+ import { noFocusedTestsRule } from "./rules/no-focused-tests";
4
+ import { testFileMirrorsSourceRule } from "./rules/test-file-mirrors-source";
5
+ import type { IRulePack } from "../rule-packs.types";
6
+
7
+ const rules: Record<string, TSESLint.RuleModule<string, readonly unknown[]>> = {
8
+ "no-focused-tests": noFocusedTestsRule,
9
+ "test-file-mirrors-source": testFileMirrorsSourceRule,
10
+ };
11
+
12
+ export const testConventionsPack: IRulePack = {
13
+ id: "test-conventions",
14
+ description:
15
+ "Testing patterns and file structure for vitest, jest, or Bun tests",
16
+ rules,
17
+ rulesConfig: {
18
+ "no-focused-tests": "error",
19
+ "test-file-mirrors-source": "error",
20
+ },
21
+ };
22
+
23
+ export default testConventionsPack;
@@ -0,0 +1,2 @@
1
+ export { noFocusedTestsRule } from "./no-focused-tests";
2
+ export { testFileMirrorsSourceRule } from "./test-file-mirrors-source";
@@ -0,0 +1,170 @@
1
+ import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
2
+ import type { JSONSchema4 } from "@typescript-eslint/utils/json-schema";
3
+
4
+ import { createRule } from "../../create-rule";
5
+
6
+ export const RULE_NAME = "no-focused-tests";
7
+
8
+ export interface NoFocusedTestsOptions {
9
+ readonly testGlobals?: readonly string[];
10
+ readonly focusedAliases?: readonly string[];
11
+ }
12
+
13
+ type RuleOptions = [NoFocusedTestsOptions];
14
+ type MessageIds = "focusedTest";
15
+
16
+ const DEFAULT_TEST_GLOBALS: readonly string[] = [
17
+ "test",
18
+ "it",
19
+ "describe",
20
+ "suite",
21
+ ];
22
+
23
+ const DEFAULT_FOCUSED_ALIASES: readonly string[] = [
24
+ "fdescribe",
25
+ "fit",
26
+ "fcontext",
27
+ ];
28
+
29
+ const optionSchema: JSONSchema4 = {
30
+ type: "object",
31
+ additionalProperties: false,
32
+ properties: {
33
+ testGlobals: {
34
+ type: "array",
35
+ items: { type: "string" },
36
+ uniqueItems: true,
37
+ minItems: 1,
38
+ },
39
+ focusedAliases: {
40
+ type: "array",
41
+ items: { type: "string" },
42
+ uniqueItems: true,
43
+ },
44
+ },
45
+ };
46
+
47
+ function getMemberPropertyName(
48
+ member: TSESTree.MemberExpression
49
+ ): string | null {
50
+ if (!member.computed && member.property.type === AST_NODE_TYPES.Identifier) {
51
+ return member.property.name;
52
+ }
53
+
54
+ if (
55
+ member.computed &&
56
+ member.property.type === AST_NODE_TYPES.Literal &&
57
+ typeof member.property.value === "string"
58
+ ) {
59
+ return member.property.value;
60
+ }
61
+
62
+ return null;
63
+ }
64
+
65
+ function getRootIdentifierName(node: TSESTree.Node): string | null {
66
+ let current: TSESTree.Node = node;
67
+
68
+ while (current.type === AST_NODE_TYPES.MemberExpression) {
69
+ current = current.object;
70
+ }
71
+
72
+ if (current.type === AST_NODE_TYPES.Identifier) {
73
+ return current.name;
74
+ }
75
+
76
+ return null;
77
+ }
78
+
79
+ /**
80
+ * Returns true when any segment in the chain of MemberExpression
81
+ * properties is `only`. Catches `test.only`, `test.skip.only`,
82
+ * `describe.each.only`, etc., as well as the computed-key form
83
+ * `test["only"]`.
84
+ */
85
+ function chainHasOnly(node: TSESTree.Node): boolean {
86
+ let current: TSESTree.Node = node;
87
+
88
+ while (current.type === AST_NODE_TYPES.MemberExpression) {
89
+ const name = getMemberPropertyName(current);
90
+
91
+ if (name === "only") {
92
+ return true;
93
+ }
94
+
95
+ current = current.object;
96
+ }
97
+
98
+ return false;
99
+ }
100
+
101
+ export const noFocusedTestsRule = createRule<RuleOptions, MessageIds>({
102
+ name: RULE_NAME,
103
+ meta: {
104
+ type: "problem",
105
+ docs: {
106
+ description:
107
+ "Disallow focused tests (`test.only`, `it.only`, `fdescribe`, ...) — the canonical 'I forgot to remove this before committing' leak.",
108
+ },
109
+ schema: [optionSchema],
110
+ messages: {
111
+ focusedTest:
112
+ "Focused test '{{name}}' left in source — this skips every other test in CI. Remove the `.only` / `f`-prefix before committing.",
113
+ },
114
+ },
115
+ defaultOptions: [
116
+ {
117
+ testGlobals: [...DEFAULT_TEST_GLOBALS],
118
+ focusedAliases: [...DEFAULT_FOCUSED_ALIASES],
119
+ },
120
+ ],
121
+ create(context, [options]) {
122
+ const testGlobals = new Set(options.testGlobals ?? DEFAULT_TEST_GLOBALS);
123
+ const focusedAliases = new Set(
124
+ options.focusedAliases ?? DEFAULT_FOCUSED_ALIASES
125
+ );
126
+
127
+ return {
128
+ CallExpression(node) {
129
+ const callee = node.callee;
130
+
131
+ // Bare `fdescribe(...)` / `fit(...)`.
132
+ if (
133
+ callee.type === AST_NODE_TYPES.Identifier &&
134
+ focusedAliases.has(callee.name)
135
+ ) {
136
+ context.report({
137
+ node: callee,
138
+ messageId: "focusedTest",
139
+ data: { name: callee.name },
140
+ });
141
+
142
+ return;
143
+ }
144
+
145
+ // `test.only(...)`, `test.skip.only(...)`, `test["only"](...)`.
146
+ if (callee.type !== AST_NODE_TYPES.MemberExpression) {
147
+ return;
148
+ }
149
+
150
+ const root = getRootIdentifierName(callee);
151
+
152
+ if (root === null || !testGlobals.has(root)) {
153
+ return;
154
+ }
155
+
156
+ if (!chainHasOnly(callee)) {
157
+ return;
158
+ }
159
+
160
+ const sourceText = context.sourceCode.getText(callee);
161
+
162
+ context.report({
163
+ node: callee,
164
+ messageId: "focusedTest",
165
+ data: { name: sourceText },
166
+ });
167
+ },
168
+ };
169
+ },
170
+ });
@@ -0,0 +1,127 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import type { JSONSchema4 } from "@typescript-eslint/utils/json-schema";
5
+
6
+ import { createRule } from "../../create-rule";
7
+ import { toPosixRelative } from "../../utils";
8
+
9
+ export const RULE_NAME = "test-file-mirrors-source";
10
+
11
+ export interface TestFileMirrorsSourceOptions {
12
+ readonly testRoot?: string;
13
+ readonly sourceRoot?: string;
14
+ readonly testSuffix?: string;
15
+ readonly additionalSourceRoots?: readonly string[];
16
+ }
17
+
18
+ type RuleOptions = [TestFileMirrorsSourceOptions];
19
+ type MessageIds = "orphanedTest";
20
+
21
+ const DEFAULT_TEST_ROOT = "tests";
22
+ const DEFAULT_SOURCE_ROOT = "src";
23
+ const DEFAULT_TEST_SUFFIX = ".test.ts";
24
+ const DEFAULT_ADDITIONAL_SOURCE_ROOTS: readonly string[] = [];
25
+
26
+ const optionSchema: JSONSchema4 = {
27
+ type: "object",
28
+ additionalProperties: false,
29
+ properties: {
30
+ testRoot: { type: "string", minLength: 1 },
31
+ sourceRoot: { type: "string", minLength: 1 },
32
+ testSuffix: { type: "string", minLength: 1 },
33
+ additionalSourceRoots: {
34
+ type: "array",
35
+ items: { type: "string" },
36
+ uniqueItems: true,
37
+ },
38
+ },
39
+ };
40
+
41
+ /**
42
+ * Indirection so test suites can stub fs lookups without spinning up a
43
+ * real filesystem. Defaults to the real `fs.existsSync`.
44
+ */
45
+ export type FileExistsFn = (absolutePath: string) => boolean;
46
+
47
+ let fileExists: FileExistsFn = (p) => fs.existsSync(p);
48
+
49
+ export function setFileExistsForTesting(fn: FileExistsFn | null): void {
50
+ fileExists = fn ?? ((p) => fs.existsSync(p));
51
+ }
52
+
53
+ export const testFileMirrorsSourceRule = createRule<RuleOptions, MessageIds>({
54
+ name: RULE_NAME,
55
+ meta: {
56
+ type: "problem",
57
+ docs: {
58
+ description:
59
+ "Every test file under `tests/` must mirror a source file under `src/`. Catches orphaned tests left behind after refactors and renames.",
60
+ },
61
+ schema: [optionSchema],
62
+ messages: {
63
+ orphanedTest:
64
+ "Test file '{{file}}' has no matching source — expected '{{expected}}'. Either rename the test or delete it.",
65
+ },
66
+ },
67
+ defaultOptions: [
68
+ {
69
+ testRoot: DEFAULT_TEST_ROOT,
70
+ sourceRoot: DEFAULT_SOURCE_ROOT,
71
+ testSuffix: DEFAULT_TEST_SUFFIX,
72
+ additionalSourceRoots: [...DEFAULT_ADDITIONAL_SOURCE_ROOTS],
73
+ },
74
+ ],
75
+ create(context, [options]) {
76
+ const testRoot = options.testRoot ?? DEFAULT_TEST_ROOT;
77
+ const sourceRoot = options.sourceRoot ?? DEFAULT_SOURCE_ROOT;
78
+ const testSuffix = options.testSuffix ?? DEFAULT_TEST_SUFFIX;
79
+ const additionalSourceRoots =
80
+ options.additionalSourceRoots ?? DEFAULT_ADDITIONAL_SOURCE_ROOTS;
81
+
82
+ return {
83
+ Program(program) {
84
+ const filename = context.filename;
85
+ const cwd = context.cwd;
86
+ const relative = toPosixRelative(filename, cwd);
87
+
88
+ const testRootPrefix = `${testRoot.replace(/\/$/, "")}/`;
89
+
90
+ if (!relative.startsWith(testRootPrefix)) {
91
+ return;
92
+ }
93
+
94
+ if (!relative.endsWith(testSuffix)) {
95
+ return;
96
+ }
97
+
98
+ const innerPath = relative.slice(
99
+ testRootPrefix.length,
100
+ relative.length - testSuffix.length
101
+ );
102
+
103
+ const candidateRoots = [sourceRoot, ...additionalSourceRoots];
104
+ const candidates = candidateRoots.map((root) =>
105
+ path.resolve(cwd, `${root.replace(/\/$/, "")}/${innerPath}.ts`)
106
+ );
107
+
108
+ for (const candidate of candidates) {
109
+ if (fileExists(candidate)) {
110
+ return;
111
+ }
112
+ }
113
+
114
+ const expectedRelative = `${sourceRoot.replace(/\/$/, "")}/${innerPath}.ts`;
115
+
116
+ context.report({
117
+ node: program,
118
+ messageId: "orphanedTest",
119
+ data: {
120
+ file: relative,
121
+ expected: expectedRelative,
122
+ },
123
+ });
124
+ },
125
+ };
126
+ },
127
+ });