@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,221 @@
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
+ DEFAULT_LOGGER_METHODS,
7
+ DEFAULT_LOGGER_NAMES,
8
+ getStructuredPayload,
9
+ matchLoggerCall,
10
+ } from "../utils/logger";
11
+
12
+ export const RULE_NAME = "mask-pii-fields";
13
+
14
+ export interface MaskPiiFieldsOptions {
15
+ readonly loggerNames?: readonly string[];
16
+ readonly loggerMethods?: readonly string[];
17
+ readonly piiFieldNames?: readonly string[];
18
+ readonly maskFunctions?: readonly string[];
19
+ }
20
+
21
+ type RuleOptions = [MaskPiiFieldsOptions];
22
+ type MessageIds = "unmaskedPii";
23
+
24
+ const DEFAULT_PII_FIELDS: readonly string[] = [
25
+ "email",
26
+ "phone",
27
+ "password",
28
+ "token",
29
+ "apiKey",
30
+ "secret",
31
+ "ssn",
32
+ "creditCard",
33
+ "authorization",
34
+ ];
35
+
36
+ const DEFAULT_MASK_FUNCTIONS: readonly string[] = [
37
+ "maskEmailForLogging",
38
+ "maskToken",
39
+ "maskPii",
40
+ "redact",
41
+ "mask",
42
+ ];
43
+
44
+ const optionSchema: JSONSchema4 = {
45
+ type: "object",
46
+ additionalProperties: false,
47
+ properties: {
48
+ loggerNames: {
49
+ type: "array",
50
+ items: { type: "string" },
51
+ uniqueItems: true,
52
+ minItems: 1,
53
+ },
54
+ loggerMethods: {
55
+ type: "array",
56
+ items: { type: "string" },
57
+ uniqueItems: true,
58
+ minItems: 1,
59
+ },
60
+ piiFieldNames: {
61
+ type: "array",
62
+ items: { type: "string" },
63
+ uniqueItems: true,
64
+ },
65
+ maskFunctions: {
66
+ type: "array",
67
+ items: { type: "string" },
68
+ uniqueItems: true,
69
+ },
70
+ },
71
+ };
72
+
73
+ function getPropertyKeyName(prop: TSESTree.Property): string | null {
74
+ if (prop.computed) {
75
+ return null;
76
+ }
77
+
78
+ if (prop.key.type === AST_NODE_TYPES.Identifier) {
79
+ return prop.key.name;
80
+ }
81
+
82
+ if (
83
+ prop.key.type === AST_NODE_TYPES.Literal &&
84
+ typeof prop.key.value === "string"
85
+ ) {
86
+ return prop.key.value;
87
+ }
88
+
89
+ return null;
90
+ }
91
+
92
+ const REDACTED_LITERAL = /^(\[?REDACTED\]?|\*+|<masked>|<redacted>)$/i;
93
+
94
+ const PATTERN_TYPES = new Set<string>([
95
+ AST_NODE_TYPES.AssignmentPattern,
96
+ AST_NODE_TYPES.RestElement,
97
+ AST_NODE_TYPES.ArrayPattern,
98
+ AST_NODE_TYPES.ObjectPattern,
99
+ AST_NODE_TYPES.TSEmptyBodyFunctionExpression,
100
+ ]);
101
+
102
+ function isExpression(
103
+ node: TSESTree.Property["value"]
104
+ ): node is TSESTree.Expression {
105
+ return !PATTERN_TYPES.has(node.type);
106
+ }
107
+
108
+ function isMaskedValue(
109
+ value: TSESTree.Expression,
110
+ maskFunctions: ReadonlySet<string>
111
+ ): boolean {
112
+ // Already-masked literal: `"[REDACTED]"`, `"***"`, etc.
113
+ if (
114
+ value.type === AST_NODE_TYPES.Literal &&
115
+ typeof value.value === "string" &&
116
+ REDACTED_LITERAL.test(value.value)
117
+ ) {
118
+ return true;
119
+ }
120
+
121
+ // Wrapped in a configured mask function: `maskEmailForLogging(email)`,
122
+ // `redact(token)`, `someObj.maskToken(t)`.
123
+ if (value.type !== AST_NODE_TYPES.CallExpression) {
124
+ return false;
125
+ }
126
+
127
+ const callee = value.callee;
128
+
129
+ if (callee.type === AST_NODE_TYPES.Identifier) {
130
+ return maskFunctions.has(callee.name);
131
+ }
132
+
133
+ if (
134
+ callee.type === AST_NODE_TYPES.MemberExpression &&
135
+ !callee.computed &&
136
+ callee.property.type === AST_NODE_TYPES.Identifier
137
+ ) {
138
+ return maskFunctions.has(callee.property.name);
139
+ }
140
+
141
+ return false;
142
+ }
143
+
144
+ export const maskPiiFieldsRule = createRule<RuleOptions, MessageIds>({
145
+ name: RULE_NAME,
146
+ meta: {
147
+ type: "problem",
148
+ docs: {
149
+ description:
150
+ "Disallow unmasked PII (email, phone, password, token, ...) in structured-logger payloads — the #1 way data leaks quietly.",
151
+ },
152
+ schema: [optionSchema],
153
+ messages: {
154
+ unmaskedPii:
155
+ "Field '{{field}}' may contain PII — wrap it in {{maskExample}}(...) or use a literal mask before logging.",
156
+ },
157
+ },
158
+ defaultOptions: [
159
+ {
160
+ loggerNames: [...DEFAULT_LOGGER_NAMES],
161
+ loggerMethods: [...DEFAULT_LOGGER_METHODS],
162
+ piiFieldNames: [...DEFAULT_PII_FIELDS],
163
+ maskFunctions: [...DEFAULT_MASK_FUNCTIONS],
164
+ },
165
+ ],
166
+ create(context, [options]) {
167
+ const loggerNames = new Set(options.loggerNames ?? DEFAULT_LOGGER_NAMES);
168
+ const loggerMethods = new Set(
169
+ options.loggerMethods ?? DEFAULT_LOGGER_METHODS
170
+ );
171
+ const piiFieldNames = new Set(options.piiFieldNames ?? DEFAULT_PII_FIELDS);
172
+ const maskFunctions = new Set(
173
+ options.maskFunctions ?? DEFAULT_MASK_FUNCTIONS
174
+ );
175
+ const maskExample =
176
+ options.maskFunctions?.[0] ?? DEFAULT_MASK_FUNCTIONS[0] ?? "mask";
177
+
178
+ return {
179
+ CallExpression(node) {
180
+ if (matchLoggerCall(node, loggerNames, loggerMethods) === null) {
181
+ return;
182
+ }
183
+
184
+ const payload = getStructuredPayload(node);
185
+
186
+ if (payload === null) {
187
+ return;
188
+ }
189
+
190
+ for (const prop of payload.properties) {
191
+ if (prop.type !== AST_NODE_TYPES.Property) {
192
+ continue;
193
+ }
194
+
195
+ const name = getPropertyKeyName(prop);
196
+
197
+ if (name === null || !piiFieldNames.has(name)) {
198
+ continue;
199
+ }
200
+
201
+ const value = prop.value;
202
+
203
+ if (!isExpression(value)) {
204
+ // Pattern nodes appear only in destructuring contexts; skip.
205
+ continue;
206
+ }
207
+
208
+ if (isMaskedValue(value, maskFunctions)) {
209
+ continue;
210
+ }
211
+
212
+ context.report({
213
+ node: prop,
214
+ messageId: "unmaskedPii",
215
+ data: { field: name, maskExample },
216
+ });
217
+ }
218
+ },
219
+ };
220
+ },
221
+ });
@@ -0,0 +1,217 @@
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-error-stringify";
7
+
8
+ export interface NoErrorStringifyOptions {
9
+ readonly errorIdentifierNames?: readonly string[];
10
+ readonly extractorName?: string;
11
+ }
12
+
13
+ type RuleOptions = [NoErrorStringifyOptions];
14
+ type MessageIds = "noErrorStringify";
15
+
16
+ const DEFAULT_ERROR_NAMES: readonly string[] = ["error", "err", "e", "cause"];
17
+ const DEFAULT_EXTRACTOR = "getErrorMessage";
18
+
19
+ const optionSchema: JSONSchema4 = {
20
+ type: "object",
21
+ additionalProperties: false,
22
+ properties: {
23
+ errorIdentifierNames: {
24
+ type: "array",
25
+ items: { type: "string" },
26
+ uniqueItems: true,
27
+ minItems: 1,
28
+ },
29
+ extractorName: { type: "string", minLength: 1 },
30
+ },
31
+ };
32
+
33
+ function isErrorIdentifier(
34
+ node: TSESTree.Node,
35
+ names: ReadonlySet<string>
36
+ ): node is TSESTree.Identifier {
37
+ return node.type === AST_NODE_TYPES.Identifier && names.has(node.name);
38
+ }
39
+
40
+ function isExtractorImported(
41
+ program: TSESTree.Program,
42
+ extractor: string
43
+ ): boolean {
44
+ for (const statement of program.body) {
45
+ if (statement.type !== AST_NODE_TYPES.ImportDeclaration) {
46
+ continue;
47
+ }
48
+
49
+ for (const spec of statement.specifiers) {
50
+ if (
51
+ spec.type === AST_NODE_TYPES.ImportSpecifier &&
52
+ spec.local.name === extractor
53
+ ) {
54
+ return true;
55
+ }
56
+ }
57
+ }
58
+
59
+ return false;
60
+ }
61
+
62
+ export const noErrorStringifyRule = createRule<RuleOptions, MessageIds>({
63
+ name: RULE_NAME,
64
+ meta: {
65
+ type: "problem",
66
+ docs: {
67
+ description:
68
+ "Disallow stringifying errors with `String(error)` / `${error}` / `error.toString()` — strips the cause chain. Use a configured extractor instead.",
69
+ },
70
+ fixable: "code",
71
+ schema: [optionSchema],
72
+ messages: {
73
+ noErrorStringify:
74
+ "Stringifying an error drops its cause chain — call `{{extractor}}(error)` instead.",
75
+ },
76
+ },
77
+ defaultOptions: [
78
+ {
79
+ errorIdentifierNames: [...DEFAULT_ERROR_NAMES],
80
+ extractorName: DEFAULT_EXTRACTOR,
81
+ },
82
+ ],
83
+ create(context, [options]) {
84
+ const errorNames = new Set(
85
+ options.errorIdentifierNames ?? DEFAULT_ERROR_NAMES
86
+ );
87
+ const extractor = options.extractorName ?? DEFAULT_EXTRACTOR;
88
+
89
+ let extractorImported = false;
90
+
91
+ return {
92
+ Program(program) {
93
+ extractorImported = isExtractorImported(program, extractor);
94
+ },
95
+ // `String(error)` / `String(err)` ...
96
+ CallExpression(node) {
97
+ const firstArg = node.arguments[0];
98
+
99
+ if (
100
+ node.callee.type === AST_NODE_TYPES.Identifier &&
101
+ node.callee.name === "String" &&
102
+ node.arguments.length === 1 &&
103
+ firstArg !== undefined &&
104
+ isErrorIdentifier(firstArg, errorNames)
105
+ ) {
106
+ const errIdent = firstArg;
107
+
108
+ context.report({
109
+ node,
110
+ messageId: "noErrorStringify",
111
+ data: { extractor },
112
+ ...(extractorImported
113
+ ? {
114
+ fix(fixer) {
115
+ return fixer.replaceText(
116
+ node,
117
+ `${extractor}(${errIdent.name})`
118
+ );
119
+ },
120
+ }
121
+ : {}),
122
+ });
123
+ }
124
+ },
125
+ // `error.toString()`
126
+ "CallExpression[callee.type='MemberExpression']"(
127
+ node: TSESTree.CallExpression
128
+ ) {
129
+ const { callee } = node;
130
+
131
+ if (callee.type !== AST_NODE_TYPES.MemberExpression) {
132
+ return;
133
+ }
134
+
135
+ if (
136
+ !callee.computed &&
137
+ callee.property.type === AST_NODE_TYPES.Identifier &&
138
+ callee.property.name === "toString" &&
139
+ isErrorIdentifier(callee.object, errorNames) &&
140
+ node.arguments.length === 0
141
+ ) {
142
+ const errIdent = callee.object;
143
+
144
+ context.report({
145
+ node,
146
+ messageId: "noErrorStringify",
147
+ data: { extractor },
148
+ ...(extractorImported
149
+ ? {
150
+ fix(fixer) {
151
+ return fixer.replaceText(
152
+ node,
153
+ `${extractor}(${errIdent.name})`
154
+ );
155
+ },
156
+ }
157
+ : {}),
158
+ });
159
+ }
160
+ },
161
+ // `${error}` inside a TemplateLiteral
162
+ TemplateLiteral(node) {
163
+ for (const expr of node.expressions) {
164
+ if (isErrorIdentifier(expr, errorNames)) {
165
+ const errIdent = expr;
166
+
167
+ context.report({
168
+ node: expr,
169
+ messageId: "noErrorStringify",
170
+ data: { extractor },
171
+ ...(extractorImported
172
+ ? {
173
+ fix(fixer) {
174
+ return fixer.replaceText(
175
+ errIdent,
176
+ `${extractor}(${errIdent.name})`
177
+ );
178
+ },
179
+ }
180
+ : {}),
181
+ });
182
+ }
183
+ }
184
+ },
185
+ // `error + ""` or `"" + error`
186
+ BinaryExpression(node) {
187
+ if (node.operator !== "+") {
188
+ return;
189
+ }
190
+
191
+ const sides: TSESTree.Node[] = [node.left, node.right];
192
+ const hasEmptyString = sides.some(
193
+ (n) =>
194
+ n.type === AST_NODE_TYPES.Literal &&
195
+ typeof n.value === "string" &&
196
+ n.value === ""
197
+ );
198
+
199
+ if (!hasEmptyString) {
200
+ return;
201
+ }
202
+
203
+ for (const side of sides) {
204
+ if (isErrorIdentifier(side, errorNames)) {
205
+ context.report({
206
+ node,
207
+ messageId: "noErrorStringify",
208
+ data: { extractor },
209
+ });
210
+
211
+ return;
212
+ }
213
+ }
214
+ },
215
+ };
216
+ },
217
+ });
@@ -0,0 +1,136 @@
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
+ DEFAULT_LOGGER_METHODS,
7
+ DEFAULT_LOGGER_NAMES,
8
+ getStructuredPayload,
9
+ matchLoggerCall,
10
+ } from "../utils/logger";
11
+
12
+ export const RULE_NAME = "require-event-field";
13
+
14
+ export interface RequireEventFieldOptions {
15
+ readonly loggerNames?: readonly string[];
16
+ readonly loggerMethods?: readonly string[];
17
+ readonly eventField?: string;
18
+ }
19
+
20
+ type RuleOptions = [RequireEventFieldOptions];
21
+ type MessageIds = "missingEventField";
22
+
23
+ const optionSchema: JSONSchema4 = {
24
+ type: "object",
25
+ additionalProperties: false,
26
+ properties: {
27
+ loggerNames: {
28
+ type: "array",
29
+ items: { type: "string" },
30
+ uniqueItems: true,
31
+ minItems: 1,
32
+ },
33
+ loggerMethods: {
34
+ type: "array",
35
+ items: { type: "string" },
36
+ uniqueItems: true,
37
+ minItems: 1,
38
+ },
39
+ eventField: { type: "string", minLength: 1 },
40
+ },
41
+ };
42
+
43
+ function payloadHasField(
44
+ payload: TSESTree.ObjectExpression,
45
+ field: string
46
+ ): boolean {
47
+ for (const prop of payload.properties) {
48
+ if (prop.type === AST_NODE_TYPES.SpreadElement) {
49
+ // Spread of an external object — can't inspect statically.
50
+ // Be permissive: assume the spread might supply the field.
51
+ return true;
52
+ }
53
+
54
+ if (prop.type !== AST_NODE_TYPES.Property) {
55
+ continue;
56
+ }
57
+
58
+ if (
59
+ prop.key.type === AST_NODE_TYPES.Identifier &&
60
+ prop.key.name === field
61
+ ) {
62
+ return true;
63
+ }
64
+
65
+ if (
66
+ prop.key.type === AST_NODE_TYPES.Literal &&
67
+ typeof prop.key.value === "string" &&
68
+ prop.key.value === field
69
+ ) {
70
+ return true;
71
+ }
72
+ }
73
+
74
+ return false;
75
+ }
76
+
77
+ export const requireEventFieldRule = createRule<RuleOptions, MessageIds>({
78
+ name: RULE_NAME,
79
+ meta: {
80
+ type: "problem",
81
+ docs: {
82
+ description:
83
+ "Require structured logger calls to include an `event` field in their payload, so log searches in ELK/Datadog/Loki don't fall back to substring match.",
84
+ },
85
+ schema: [optionSchema],
86
+ messages: {
87
+ missingEventField:
88
+ "Logger call '{{method}}' missing `{{field}}:` field — add a stable string identifier so searches can filter by event.",
89
+ },
90
+ },
91
+ defaultOptions: [
92
+ {
93
+ loggerNames: [...DEFAULT_LOGGER_NAMES],
94
+ loggerMethods: [...DEFAULT_LOGGER_METHODS],
95
+ eventField: "event",
96
+ },
97
+ ],
98
+ create(context, [options]) {
99
+ const loggerNames = new Set(options.loggerNames ?? DEFAULT_LOGGER_NAMES);
100
+ const loggerMethods = new Set(
101
+ options.loggerMethods ?? DEFAULT_LOGGER_METHODS
102
+ );
103
+ const eventField = options.eventField ?? "event";
104
+
105
+ return {
106
+ CallExpression(node) {
107
+ const method = matchLoggerCall(node, loggerNames, loggerMethods);
108
+
109
+ if (method === null) {
110
+ return;
111
+ }
112
+
113
+ const payload = getStructuredPayload(node);
114
+
115
+ if (payload === null) {
116
+ // No structured payload at all — report on the whole call.
117
+ context.report({
118
+ node,
119
+ messageId: "missingEventField",
120
+ data: { method, field: eventField },
121
+ });
122
+
123
+ return;
124
+ }
125
+
126
+ if (!payloadHasField(payload, eventField)) {
127
+ context.report({
128
+ node: payload,
129
+ messageId: "missingEventField",
130
+ data: { method, field: eventField },
131
+ });
132
+ }
133
+ },
134
+ };
135
+ },
136
+ });
@@ -0,0 +1,104 @@
1
+ import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
2
+
3
+ export const DEFAULT_LOGGER_NAMES: readonly string[] = [
4
+ "logger",
5
+ "log",
6
+ "reqLogger",
7
+ "requestLogger",
8
+ ];
9
+
10
+ export const DEFAULT_LOGGER_METHODS: readonly string[] = [
11
+ "fatal",
12
+ "error",
13
+ "warn",
14
+ "info",
15
+ "debug",
16
+ "trace",
17
+ ];
18
+
19
+ /**
20
+ * Returns the matched method name when `node` is a logger call —
21
+ * `<id>.<method>(...)` where the receiver name is in `loggerNames`
22
+ * and the method is in `loggerMethods`. Returns null otherwise.
23
+ *
24
+ * Best-effort: we don't try to resolve types. A symbol named `logger`
25
+ * is the strong convention; project-specific aliases (e.g. `reqLogger`
26
+ * from `logger.child(...)`) are configurable via `loggerNames`.
27
+ */
28
+ export function matchLoggerCall(
29
+ node: TSESTree.CallExpression,
30
+ loggerNames: ReadonlySet<string>,
31
+ loggerMethods: ReadonlySet<string>
32
+ ): string | null {
33
+ const callee = node.callee;
34
+
35
+ if (callee.type !== AST_NODE_TYPES.MemberExpression || callee.computed) {
36
+ return null;
37
+ }
38
+
39
+ if (callee.property.type !== AST_NODE_TYPES.Identifier) {
40
+ return null;
41
+ }
42
+
43
+ if (!loggerMethods.has(callee.property.name)) {
44
+ return null;
45
+ }
46
+
47
+ const receiver = unwrapReceiver(callee.object);
48
+
49
+ if (!receiver) {
50
+ return null;
51
+ }
52
+
53
+ if (!loggerNames.has(receiver)) {
54
+ return null;
55
+ }
56
+
57
+ return callee.property.name;
58
+ }
59
+
60
+ /**
61
+ * Walks down a possibly-chained MemberExpression, returning the
62
+ * leftmost identifier name. Catches `this.logger.info(...)`,
63
+ * `ctx.log.info(...)`, etc. (returns "logger" / "log" — useful when
64
+ * those names are in the configured set).
65
+ */
66
+ function unwrapReceiver(node: TSESTree.Expression): string | null {
67
+ if (node.type === AST_NODE_TYPES.Identifier) {
68
+ return node.name;
69
+ }
70
+
71
+ if (
72
+ node.type === AST_NODE_TYPES.MemberExpression &&
73
+ !node.computed &&
74
+ node.property.type === AST_NODE_TYPES.Identifier
75
+ ) {
76
+ return node.property.name;
77
+ }
78
+
79
+ return null;
80
+ }
81
+
82
+ /**
83
+ * Returns the second positional argument when it's an ObjectExpression
84
+ * — that's where structured-logging payloads live. Many loggers also
85
+ * accept `(obj, message)` (e.g. pino) — handle both shapes by checking
86
+ * the FIRST arg if no ObjectExpression is found at index 1.
87
+ */
88
+ export function getStructuredPayload(
89
+ node: TSESTree.CallExpression
90
+ ): TSESTree.ObjectExpression | null {
91
+ const second = node.arguments[1];
92
+
93
+ if (second?.type === AST_NODE_TYPES.ObjectExpression) {
94
+ return second;
95
+ }
96
+
97
+ const first = node.arguments[0];
98
+
99
+ if (first?.type === AST_NODE_TYPES.ObjectExpression) {
100
+ return first;
101
+ }
102
+
103
+ return null;
104
+ }
@@ -0,0 +1,20 @@
1
+ import type { TSESLint } from "@typescript-eslint/utils";
2
+
3
+ import { prefixQueryKeyMustUseSetQueriesDataRule } from "./rules/prefix-query-key-must-use-set-queries-data";
4
+ import type { IRulePack } from "../rule-packs.types";
5
+
6
+ const rules: Record<string, TSESLint.RuleModule<string, readonly unknown[]>> = {
7
+ "prefix-query-key-must-use-set-queries-data":
8
+ prefixQueryKeyMustUseSetQueriesDataRule,
9
+ };
10
+
11
+ export const tanstackQueryPack: IRulePack = {
12
+ id: "tanstack-query",
13
+ description: "Patterns for data fetching with TanStack Query",
14
+ rules,
15
+ rulesConfig: {
16
+ "prefix-query-key-must-use-set-queries-data": "error",
17
+ },
18
+ };
19
+
20
+ export default tanstackQueryPack;