@agjs/tsforge 0.1.18 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/package.json +4 -1
  2. package/scripts/build-rules-md.ts +78 -21
  3. package/scripts/sweep.ts +25 -20
  4. package/scripts/web-sweep.ts +292 -0
  5. package/src/browser/oracle.ts +29 -1
  6. package/src/cli.ts +9 -3
  7. package/src/config/index.ts +8 -0
  8. package/src/config/profiles.ts +150 -0
  9. package/src/config/tsforge-config.ts +64 -5
  10. package/src/detect-gate.ts +34 -1
  11. package/src/inference/inference.types.ts +8 -0
  12. package/src/inference/request.ts +5 -1
  13. package/src/inference/stream.ts +21 -2
  14. package/src/inference/wire.ts +0 -0
  15. package/src/loop/feedback/meta-rule-docs.ts +48 -0
  16. package/src/loop/feedback/rule-docs.ts +150 -0
  17. package/src/loop/rule-docs.generated.json +131 -1
  18. package/src/loop/run.ts +3 -0
  19. package/src/loop/session.ts +12 -5
  20. package/src/loop/ttsr-defaults.ts +175 -4
  21. package/src/meta-rules/registry.ts +32 -0
  22. package/src/meta-rules/rules/ci/no-github-context-in-shell.ts +40 -0
  23. package/src/meta-rules/rules/ci/no-pull-request-target-untrusted-checkout.ts +42 -0
  24. package/src/meta-rules/rules/ci/workflow-permissions-explicit.ts +49 -0
  25. package/src/meta-rules/rules/ci/workflow-permissions-least-privilege.ts +44 -0
  26. package/src/meta-rules/rules/config/next-image-remote-patterns-no-wildcards.ts +77 -0
  27. package/src/meta-rules/rules/config/next-instrumentation-present.ts +66 -0
  28. package/src/meta-rules/rules/config/next-proxy-over-middleware.ts +64 -0
  29. package/src/meta-rules/rules/config/tsconfig-recommended-flags.ts +75 -0
  30. package/src/meta-rules/rules/supply-chain/dependency-overrides-require-comment.ts +61 -0
  31. package/src/meta-rules/rules/supply-chain/fastify-security-plugins.ts +54 -0
  32. package/src/meta-rules/rules/supply-chain/lockfile-required.ts +51 -0
  33. package/src/meta-rules/rules/supply-chain/migrations-must-be-checked-in.ts +49 -0
  34. package/src/meta-rules/rules/supply-chain/no-git-or-tarball-dependencies.ts +70 -0
  35. package/src/meta-rules/rules/supply-chain/package-manager-field-required.ts +31 -0
  36. package/src/meta-rules/rules/supply-chain/production-must-not-use-drizzle-push.ts +75 -0
  37. package/src/meta-rules/rules/supply-chain/single-package-manager.ts +30 -0
  38. package/src/meta-rules/utils/lockfiles.ts +105 -0
  39. package/src/meta-rules/utils/workflow-yaml.ts +86 -0
  40. package/src/rule-packs/authorization/index.ts +26 -0
  41. package/src/rule-packs/authorization/rules/id-param-requires-object-authz.ts +87 -0
  42. package/src/rule-packs/authorization/rules/mutating-route-requires-authz.ts +116 -0
  43. package/src/rule-packs/authorization/rules/server-action-requires-authz.ts +101 -0
  44. package/src/rule-packs/authorization/utils.ts +285 -0
  45. package/src/rule-packs/boundary-utils.ts +13 -0
  46. package/src/rule-packs/code-flow/index.ts +4 -1
  47. package/src/rule-packs/code-flow/rules/no-throw-literal.ts +67 -0
  48. package/src/rule-packs/drizzle/index.ts +7 -0
  49. package/src/rule-packs/drizzle/rules/update-delete-account-scoped-must-filter-scope.ts +106 -0
  50. package/src/rule-packs/drizzle/rules/update-delete-must-have-where.ts +73 -0
  51. package/src/rule-packs/drizzle/utils.ts +133 -1
  52. package/src/rule-packs/fastify/index.ts +38 -0
  53. package/src/rule-packs/fastify/rules/error-handler-must-set-status.ts +78 -0
  54. package/src/rule-packs/fastify/rules/prefer-return-over-reply-send.ts +104 -0
  55. package/src/rule-packs/fastify/rules/require-fp-for-shared-plugins.ts +106 -0
  56. package/src/rule-packs/fastify/rules/require-plugin-name.ts +54 -0
  57. package/src/rule-packs/fastify/rules/require-response-schema.ts +62 -0
  58. package/src/rule-packs/fastify/rules/require-route-schema.ts +104 -0
  59. package/src/rule-packs/fastify/rules/test-inject-must-close-app.ts +44 -0
  60. package/src/rule-packs/fastify/utils/fastifyChain.ts +231 -0
  61. package/src/rule-packs/index.ts +10 -0
  62. package/src/rule-packs/jwt-cookies/index.ts +10 -0
  63. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-set-maxage-or-expires.ts +132 -0
  64. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-set-samesite.ts +151 -0
  65. package/src/rule-packs/jwt-cookies/rules/jwt-must-verify-not-decode.ts +124 -0
  66. package/src/rule-packs/module-boundaries/index.ts +3 -0
  67. package/src/rule-packs/module-boundaries/rules/no-react-in-services.ts +111 -0
  68. package/src/rule-packs/nextjs/index.ts +32 -0
  69. package/src/rule-packs/nextjs/rules/await-dynamic-request-apis.ts +65 -0
  70. package/src/rule-packs/nextjs/rules/error-boundary-require-use-client.ts +38 -0
  71. package/src/rule-packs/nextjs/rules/mutation-should-revalidate-cache.ts +152 -0
  72. package/src/rule-packs/nextjs/rules/no-html-img-element.ts +45 -0
  73. package/src/rule-packs/nextjs/rules/no-internal-api-fetch.ts +126 -0
  74. package/src/rule-packs/nextjs/rules/no-secret-props-to-client.ts +118 -0
  75. package/src/rule-packs/nextjs/rules/no-sensitive-next-public-env.ts +72 -0
  76. package/src/rule-packs/nextjs/rules/prefer-lazy-use-state-init.ts +85 -0
  77. package/src/rule-packs/nextjs/rules/server-action-requires-authz-and-validation.ts +178 -0
  78. package/src/rule-packs/nextjs/rules/server-only-modules-import-server-only.ts +87 -0
  79. package/src/rule-packs/nextjs/utils.ts +18 -0
  80. package/src/rule-packs/react-component-architecture/index.ts +18 -0
  81. package/src/rule-packs/react-component-architecture/rules/dangerous-html-requires-sanitize.ts +83 -0
  82. package/src/rule-packs/react-component-architecture/rules/no-anonymous-useEffect.ts +61 -0
  83. package/src/rule-packs/react-component-architecture/rules/no-component-invocation.ts +55 -0
  84. package/src/rule-packs/react-component-architecture/rules/no-derived-state-in-effect.ts +204 -0
  85. package/src/rule-packs/react-component-architecture/rules/no-nested-component.ts +152 -0
  86. package/src/rule-packs/react-component-architecture/rules/no-react-fc.ts +57 -0
  87. package/src/rule-packs/rule-catalog.types.ts +21 -0
  88. package/src/rule-packs/rule-metadata.ts +163 -0
  89. package/src/rule-packs/runtime-boundaries/index.ts +33 -0
  90. package/src/rule-packs/runtime-boundaries/rules/no-prototype-polluting-merge.ts +113 -0
  91. package/src/rule-packs/runtime-boundaries/rules/no-user-controlled-fetch-url.ts +69 -0
  92. package/src/rule-packs/runtime-boundaries/rules/no-user-controlled-redirect.ts +79 -0
  93. package/src/rule-packs/runtime-boundaries/rules/upload-must-set-limits.ts +126 -0
  94. package/src/rule-packs/runtime-boundaries/rules/webhook-must-verify-signature-before-parse.ts +87 -0
  95. package/src/rule-packs/security/index.ts +35 -0
  96. package/src/rule-packs/security/rules/catch-must-handle.ts +126 -0
  97. package/src/rule-packs/security/rules/no-auth-token-in-storage.ts +107 -0
  98. package/src/rule-packs/security/rules/no-child-process-exec.ts +72 -0
  99. package/src/rule-packs/security/rules/no-dynamic-regexp.ts +56 -0
  100. package/src/rule-packs/security/rules/no-inner-html-assignment.ts +42 -0
  101. package/src/rule-packs/security/rules/no-spawn-with-shell.ts +106 -0
  102. package/src/rule-packs/structured-logging/index.ts +6 -0
  103. package/src/rule-packs/structured-logging/rules/caught-error-log-requires-cause.ts +234 -0
  104. package/src/rule-packs/structured-logging/rules/logger-not-console.ts +146 -0
  105. package/src/rule-packs/test-conventions/index.ts +9 -0
  106. package/src/rule-packs/test-conventions/rules/fake-timers-must-be-restored.ts +143 -0
  107. package/src/rule-packs/test-conventions/rules/no-conditional-expect.ts +77 -0
  108. package/src/rule-packs/test-conventions/rules/no-real-network-in-unit-tests.ts +174 -0
  109. package/src/rule-packs/typescript-core/index.ts +30 -0
  110. package/src/rule-packs/typescript-core/rules/exported-functions-require-return-type.ts +74 -0
  111. package/src/rule-packs/typescript-core/rules/fetch-must-check-ok.ts +106 -0
  112. package/src/rule-packs/typescript-core/rules/json-parse-must-validate.ts +97 -0
  113. package/src/rule-packs/typescript-core/rules/no-unsafe-boundary-cast.ts +70 -0
  114. package/src/stack-detection/packs.ts +57 -0
  115. package/strict.web.eslint.config.mjs +32 -1
@@ -0,0 +1,234 @@
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 = "caught-error-log-requires-cause";
13
+
14
+ export interface ICaughtErrorLogRequiresCauseOptions {
15
+ readonly loggerNames?: readonly string[];
16
+ readonly loggerMethods?: readonly string[];
17
+ readonly errorIdentifierNames?: readonly string[];
18
+ readonly causeField?: string;
19
+ }
20
+
21
+ type RuleOptions = [ICaughtErrorLogRequiresCauseOptions];
22
+ type MessageIds = "missingCause";
23
+
24
+ const DEFAULT_ERROR_NAMES: readonly string[] = ["error", "err", "e", "cause"];
25
+
26
+ const optionSchema: JSONSchema4 = {
27
+ type: "object",
28
+ additionalProperties: false,
29
+ properties: {
30
+ loggerNames: {
31
+ type: "array",
32
+ items: { type: "string" },
33
+ uniqueItems: true,
34
+ minItems: 1,
35
+ },
36
+ loggerMethods: {
37
+ type: "array",
38
+ items: { type: "string" },
39
+ uniqueItems: true,
40
+ minItems: 1,
41
+ },
42
+ errorIdentifierNames: {
43
+ type: "array",
44
+ items: { type: "string" },
45
+ uniqueItems: true,
46
+ minItems: 1,
47
+ },
48
+ causeField: { type: "string", minLength: 1 },
49
+ },
50
+ };
51
+
52
+ function isErrorIdentifier(
53
+ node: TSESTree.Node,
54
+ names: ReadonlySet<string>
55
+ ): node is TSESTree.Identifier {
56
+ return node.type === AST_NODE_TYPES.Identifier && names.has(node.name);
57
+ }
58
+
59
+ function payloadHasField(
60
+ payload: TSESTree.ObjectExpression,
61
+ field: string
62
+ ): boolean {
63
+ for (const prop of payload.properties) {
64
+ if (prop.type === AST_NODE_TYPES.SpreadElement) {
65
+ return true;
66
+ }
67
+
68
+ if (prop.type !== AST_NODE_TYPES.Property) {
69
+ continue;
70
+ }
71
+
72
+ if (
73
+ prop.key.type === AST_NODE_TYPES.Identifier &&
74
+ prop.key.name === field
75
+ ) {
76
+ return true;
77
+ }
78
+
79
+ if (
80
+ prop.key.type === AST_NODE_TYPES.Literal &&
81
+ typeof prop.key.value === "string" &&
82
+ prop.key.value === field
83
+ ) {
84
+ return true;
85
+ }
86
+ }
87
+
88
+ return false;
89
+ }
90
+
91
+ function loggerCallReferencesCaughtError(
92
+ node: TSESTree.CallExpression,
93
+ catchParam: TSESTree.Identifier | null,
94
+ errorNames: ReadonlySet<string>
95
+ ): boolean {
96
+ if (catchParam !== null) {
97
+ for (const arg of node.arguments) {
98
+ if (arg.type === AST_NODE_TYPES.SpreadElement) {
99
+ continue;
100
+ }
101
+
102
+ if (isErrorIdentifier(arg, new Set([catchParam.name]))) {
103
+ return true;
104
+ }
105
+ }
106
+ }
107
+
108
+ const payload = getStructuredPayload(node);
109
+
110
+ if (payload === null) {
111
+ return false;
112
+ }
113
+
114
+ for (const prop of payload.properties) {
115
+ if (prop.type !== AST_NODE_TYPES.Property) {
116
+ continue;
117
+ }
118
+
119
+ if (isErrorIdentifier(prop.value, errorNames)) {
120
+ return true;
121
+ }
122
+
123
+ if (
124
+ prop.key.type === AST_NODE_TYPES.Identifier &&
125
+ prop.key.name === "err" &&
126
+ isErrorIdentifier(prop.value, errorNames)
127
+ ) {
128
+ return true;
129
+ }
130
+ }
131
+
132
+ return false;
133
+ }
134
+
135
+ export const caughtErrorLogRequiresCauseRule = createRule<
136
+ RuleOptions,
137
+ MessageIds
138
+ >({
139
+ name: RULE_NAME,
140
+ meta: {
141
+ type: "problem",
142
+ docs: {
143
+ description:
144
+ "When logging a caught error, include a `cause` field in the structured payload so downstream tools preserve the error chain.",
145
+ },
146
+ schema: [optionSchema],
147
+ messages: {
148
+ missingCause:
149
+ "Logger call for caught error missing `{{field}}:` — include the original error so cause chains survive structured logging.",
150
+ },
151
+ },
152
+ defaultOptions: [
153
+ {
154
+ loggerNames: [...DEFAULT_LOGGER_NAMES],
155
+ loggerMethods: [...DEFAULT_LOGGER_METHODS],
156
+ errorIdentifierNames: [...DEFAULT_ERROR_NAMES],
157
+ causeField: "cause",
158
+ },
159
+ ],
160
+ create(context, [options]) {
161
+ const loggerNames = new Set(options.loggerNames ?? DEFAULT_LOGGER_NAMES);
162
+ const loggerMethods = new Set(
163
+ options.loggerMethods ?? DEFAULT_LOGGER_METHODS
164
+ );
165
+ const errorNames = new Set(
166
+ options.errorIdentifierNames ?? DEFAULT_ERROR_NAMES
167
+ );
168
+ const causeField = options.causeField ?? "cause";
169
+
170
+ return {
171
+ CatchClause(node) {
172
+ const catchParam =
173
+ node.param?.type === AST_NODE_TYPES.Identifier ? node.param : null;
174
+ const body = node.body;
175
+
176
+ const visit = (current: TSESTree.Node): void => {
177
+ if (current.type === AST_NODE_TYPES.CallExpression) {
178
+ const method = matchLoggerCall(current, loggerNames, loggerMethods);
179
+
180
+ if (
181
+ method !== null &&
182
+ loggerCallReferencesCaughtError(current, catchParam, errorNames)
183
+ ) {
184
+ const payload = getStructuredPayload(current);
185
+
186
+ if (payload === null || !payloadHasField(payload, causeField)) {
187
+ context.report({
188
+ node: current,
189
+ messageId: "missingCause",
190
+ data: { field: causeField },
191
+ });
192
+ }
193
+ }
194
+ }
195
+
196
+ if (
197
+ current.type === AST_NODE_TYPES.BlockStatement ||
198
+ current.type === AST_NODE_TYPES.Program
199
+ ) {
200
+ for (const stmt of current.body) {
201
+ visit(stmt);
202
+ }
203
+ }
204
+
205
+ if (current.type === AST_NODE_TYPES.ExpressionStatement) {
206
+ visit(current.expression);
207
+ }
208
+
209
+ if (current.type === AST_NODE_TYPES.IfStatement) {
210
+ visit(current.consequent);
211
+
212
+ if (current.alternate !== null) {
213
+ visit(current.alternate);
214
+ }
215
+ }
216
+
217
+ if (current.type === AST_NODE_TYPES.TryStatement) {
218
+ visit(current.block);
219
+
220
+ if (current.handler !== null) {
221
+ visit(current.handler.body);
222
+ }
223
+
224
+ if (current.finalizer !== null) {
225
+ visit(current.finalizer);
226
+ }
227
+ }
228
+ };
229
+
230
+ visit(body);
231
+ },
232
+ };
233
+ },
234
+ });
@@ -0,0 +1,146 @@
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 { matchesAnyGlobPattern, toPosixRelative } from "../../utils";
6
+
7
+ export const RULE_NAME = "logger-not-console";
8
+
9
+ export interface ILoggerNotConsoleOptions {
10
+ readonly serviceGlobs?: readonly string[];
11
+ readonly consoleMethods?: readonly string[];
12
+ }
13
+
14
+ type RuleOptions = [ILoggerNotConsoleOptions];
15
+ type MessageIds = "consoleInService";
16
+
17
+ const DEFAULT_SERVICE_GLOBS = [
18
+ "**/services/**",
19
+ "**/*.service.ts",
20
+ "**/*.queries.ts",
21
+ ] as const;
22
+
23
+ const DEFAULT_CONSOLE_METHODS = [
24
+ "log",
25
+ "info",
26
+ "warn",
27
+ "error",
28
+ "debug",
29
+ "trace",
30
+ ] as const;
31
+
32
+ const DEFAULT_SERVICE_PATH_PATTERNS = [
33
+ /(^|\/)services\//,
34
+ /\.service\.tsx?$/,
35
+ /\.queries\.ts$/,
36
+ ] as const;
37
+
38
+ const optionSchema: JSONSchema4 = {
39
+ type: "object",
40
+ additionalProperties: false,
41
+ properties: {
42
+ serviceGlobs: {
43
+ type: "array",
44
+ items: { type: "string" },
45
+ },
46
+ consoleMethods: {
47
+ type: "array",
48
+ items: { type: "string" },
49
+ },
50
+ },
51
+ };
52
+
53
+ function isServiceFile(
54
+ filename: string,
55
+ cwd: string,
56
+ globs: readonly string[]
57
+ ): boolean {
58
+ const rel = toPosixRelative(filename, cwd);
59
+
60
+ if (DEFAULT_SERVICE_PATH_PATTERNS.some((pattern) => pattern.test(rel))) {
61
+ return true;
62
+ }
63
+
64
+ return matchesAnyGlobPattern(rel, globs);
65
+ }
66
+
67
+ function isConsoleCall(
68
+ node: TSESTree.CallExpression,
69
+ methods: ReadonlySet<string>
70
+ ): boolean {
71
+ const callee = node.callee;
72
+
73
+ if (callee.type !== AST_NODE_TYPES.MemberExpression || callee.computed) {
74
+ return false;
75
+ }
76
+
77
+ if (
78
+ callee.object.type !== AST_NODE_TYPES.Identifier ||
79
+ callee.object.name !== "console"
80
+ ) {
81
+ return false;
82
+ }
83
+
84
+ if (callee.property.type !== AST_NODE_TYPES.Identifier) {
85
+ return false;
86
+ }
87
+
88
+ return methods.has(callee.property.name);
89
+ }
90
+
91
+ export const loggerNotConsoleRule = createRule<RuleOptions, MessageIds>({
92
+ name: RULE_NAME,
93
+ meta: {
94
+ type: "suggestion",
95
+ docs: {
96
+ description:
97
+ "Service modules should use the structured logger instead of `console.*` — console output is unstructured and hard to search.",
98
+ },
99
+ schema: [optionSchema],
100
+ messages: {
101
+ consoleInService:
102
+ "Use the structured logger instead of `console.{{method}}()` in service modules.",
103
+ },
104
+ },
105
+ defaultOptions: [
106
+ {
107
+ serviceGlobs: [...DEFAULT_SERVICE_GLOBS],
108
+ consoleMethods: [...DEFAULT_CONSOLE_METHODS],
109
+ },
110
+ ],
111
+ create(context, [options]) {
112
+ const serviceGlobs = options.serviceGlobs ?? DEFAULT_SERVICE_GLOBS;
113
+ const consoleMethods = new Set(
114
+ options.consoleMethods ?? DEFAULT_CONSOLE_METHODS
115
+ );
116
+ const cwd = context.cwd;
117
+
118
+ if (!isServiceFile(context.filename, cwd, serviceGlobs)) {
119
+ return {};
120
+ }
121
+
122
+ return {
123
+ CallExpression(node) {
124
+ if (!isConsoleCall(node, consoleMethods)) {
125
+ return;
126
+ }
127
+
128
+ const callee = node.callee;
129
+
130
+ if (callee.type !== AST_NODE_TYPES.MemberExpression) {
131
+ return;
132
+ }
133
+
134
+ if (callee.property.type !== AST_NODE_TYPES.Identifier) {
135
+ return;
136
+ }
137
+
138
+ context.report({
139
+ node,
140
+ messageId: "consoleInService",
141
+ data: { method: callee.property.name },
142
+ });
143
+ },
144
+ };
145
+ },
146
+ });
@@ -1,11 +1,17 @@
1
1
  import type { TSESLint } from "@typescript-eslint/utils";
2
2
 
3
+ import { fakeTimersMustBeRestoredRule } from "./rules/fake-timers-must-be-restored";
4
+ import { noConditionalExpectRule } from "./rules/no-conditional-expect";
3
5
  import { noFocusedTestsRule } from "./rules/no-focused-tests";
6
+ import { noRealNetworkInUnitTestsRule } from "./rules/no-real-network-in-unit-tests";
4
7
  import { testFileMirrorsSourceRule } from "./rules/test-file-mirrors-source";
5
8
  import type { IRulePack } from "../rule-packs.types";
6
9
 
7
10
  const rules: Record<string, TSESLint.RuleModule<string, readonly unknown[]>> = {
11
+ "fake-timers-must-be-restored": fakeTimersMustBeRestoredRule,
12
+ "no-conditional-expect": noConditionalExpectRule,
8
13
  "no-focused-tests": noFocusedTestsRule,
14
+ "no-real-network-in-unit-tests": noRealNetworkInUnitTestsRule,
9
15
  "test-file-mirrors-source": testFileMirrorsSourceRule,
10
16
  };
11
17
 
@@ -15,7 +21,10 @@ export const testConventionsPack: IRulePack = {
15
21
  "Testing patterns and file structure for vitest, jest, or Bun tests",
16
22
  rules,
17
23
  rulesConfig: {
24
+ "fake-timers-must-be-restored": "error",
25
+ "no-conditional-expect": "error",
18
26
  "no-focused-tests": "error",
27
+ "no-real-network-in-unit-tests": "warn",
19
28
  "test-file-mirrors-source": "error",
20
29
  },
21
30
  };
@@ -0,0 +1,143 @@
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 { walkAll } from "../../utils";
6
+
7
+ export const RULE_NAME = "fake-timers-must-be-restored";
8
+
9
+ export interface IFakeTimersMustBeRestoredOptions {
10
+ readonly fakeTimerMethods?: readonly string[];
11
+ readonly restoreTimerMethods?: readonly string[];
12
+ }
13
+
14
+ type RuleOptions = [IFakeTimersMustBeRestoredOptions];
15
+ type MessageIds = "timersNotRestored";
16
+
17
+ const DEFAULT_FAKE_TIMER_METHODS = ["useFakeTimers"] as const;
18
+ const DEFAULT_RESTORE_TIMER_METHODS = ["useRealTimers"] as const;
19
+
20
+ const optionSchema: JSONSchema4 = {
21
+ type: "object",
22
+ additionalProperties: false,
23
+ properties: {
24
+ fakeTimerMethods: {
25
+ type: "array",
26
+ items: { type: "string" },
27
+ uniqueItems: true,
28
+ minItems: 1,
29
+ },
30
+ restoreTimerMethods: {
31
+ type: "array",
32
+ items: { type: "string" },
33
+ uniqueItems: true,
34
+ minItems: 1,
35
+ },
36
+ },
37
+ };
38
+
39
+ function getMemberPropertyName(
40
+ member: TSESTree.MemberExpression
41
+ ): string | null {
42
+ if (!member.computed && member.property.type === AST_NODE_TYPES.Identifier) {
43
+ return member.property.name;
44
+ }
45
+
46
+ return null;
47
+ }
48
+
49
+ function callUsesMethod(
50
+ node: TSESTree.CallExpression,
51
+ methods: ReadonlySet<string>
52
+ ): boolean {
53
+ const callee = node.callee;
54
+
55
+ if (callee.type === AST_NODE_TYPES.Identifier) {
56
+ return methods.has(callee.name);
57
+ }
58
+
59
+ if (callee.type === AST_NODE_TYPES.MemberExpression) {
60
+ const name = getMemberPropertyName(callee);
61
+
62
+ return name !== null && methods.has(name);
63
+ }
64
+
65
+ return false;
66
+ }
67
+
68
+ export const fakeTimersMustBeRestoredRule = createRule<RuleOptions, MessageIds>(
69
+ {
70
+ name: RULE_NAME,
71
+ meta: {
72
+ type: "problem",
73
+ docs: {
74
+ description:
75
+ "When a test file calls `useFakeTimers()`, it must also call `useRealTimers()` so later tests are not affected.",
76
+ },
77
+ schema: [optionSchema],
78
+ messages: {
79
+ timersNotRestored:
80
+ "`{{method}}()` was called without a matching restore call — fake timers leak into other tests.",
81
+ },
82
+ },
83
+ defaultOptions: [
84
+ {
85
+ fakeTimerMethods: [...DEFAULT_FAKE_TIMER_METHODS],
86
+ restoreTimerMethods: [...DEFAULT_RESTORE_TIMER_METHODS],
87
+ },
88
+ ],
89
+ create(context, [options]) {
90
+ const fakeMethods = new Set(
91
+ options.fakeTimerMethods ?? DEFAULT_FAKE_TIMER_METHODS
92
+ );
93
+ const restoreMethods = new Set(
94
+ options.restoreTimerMethods ?? DEFAULT_RESTORE_TIMER_METHODS
95
+ );
96
+
97
+ return {
98
+ Program(node) {
99
+ const fakeCalls: TSESTree.CallExpression[] = [];
100
+ let hasRestore = false;
101
+
102
+ walkAll(node, (child) => {
103
+ if (child.type !== AST_NODE_TYPES.CallExpression) {
104
+ return;
105
+ }
106
+
107
+ if (callUsesMethod(child, fakeMethods)) {
108
+ fakeCalls.push(child);
109
+ }
110
+
111
+ if (callUsesMethod(child, restoreMethods)) {
112
+ hasRestore = true;
113
+ }
114
+ });
115
+
116
+ if (fakeCalls.length === 0 || hasRestore) {
117
+ return;
118
+ }
119
+
120
+ for (const call of fakeCalls) {
121
+ const callee = call.callee;
122
+ let method = "useFakeTimers";
123
+
124
+ if (
125
+ callee.type === AST_NODE_TYPES.MemberExpression &&
126
+ callee.property.type === AST_NODE_TYPES.Identifier
127
+ ) {
128
+ method = callee.property.name;
129
+ } else if (callee.type === AST_NODE_TYPES.Identifier) {
130
+ method = callee.name;
131
+ }
132
+
133
+ context.report({
134
+ node: call,
135
+ messageId: "timersNotRestored",
136
+ data: { method },
137
+ });
138
+ }
139
+ },
140
+ };
141
+ },
142
+ }
143
+ );
@@ -0,0 +1,77 @@
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-conditional-expect";
6
+
7
+ type MessageIds = "conditionalExpect";
8
+
9
+ const CONDITIONAL_PARENTS = new Set([
10
+ AST_NODE_TYPES.IfStatement,
11
+ AST_NODE_TYPES.ForStatement,
12
+ AST_NODE_TYPES.ForInStatement,
13
+ AST_NODE_TYPES.ForOfStatement,
14
+ AST_NODE_TYPES.WhileStatement,
15
+ AST_NODE_TYPES.DoWhileStatement,
16
+ AST_NODE_TYPES.SwitchCase,
17
+ AST_NODE_TYPES.ConditionalExpression,
18
+ ]);
19
+
20
+ function isExpectCall(node: TSESTree.CallExpression): boolean {
21
+ const callee = node.callee;
22
+
23
+ if (callee.type === AST_NODE_TYPES.Identifier) {
24
+ return callee.name === "expect";
25
+ }
26
+
27
+ if (
28
+ callee.type === AST_NODE_TYPES.MemberExpression &&
29
+ !callee.computed &&
30
+ callee.property.type === AST_NODE_TYPES.Identifier &&
31
+ callee.property.name === "expect"
32
+ ) {
33
+ return true;
34
+ }
35
+
36
+ return false;
37
+ }
38
+
39
+ function isInsideConditional(node: TSESTree.Node): boolean {
40
+ let current = node.parent;
41
+
42
+ while (current !== undefined && current !== null) {
43
+ if (CONDITIONAL_PARENTS.has(current.type)) {
44
+ return true;
45
+ }
46
+
47
+ current = current.parent;
48
+ }
49
+
50
+ return false;
51
+ }
52
+
53
+ export const noConditionalExpectRule = createRule<[], MessageIds>({
54
+ name: RULE_NAME,
55
+ meta: {
56
+ type: "problem",
57
+ docs: {
58
+ description:
59
+ "Disallow `expect()` inside conditionals — tests must fail when assertions are skipped.",
60
+ },
61
+ schema: [],
62
+ messages: {
63
+ conditionalExpect:
64
+ "Do not call `expect()` inside a conditional — skipped assertions hide regressions.",
65
+ },
66
+ },
67
+ defaultOptions: [],
68
+ create(context) {
69
+ return {
70
+ CallExpression(node) {
71
+ if (isExpectCall(node) && isInsideConditional(node)) {
72
+ context.report({ node, messageId: "conditionalExpect" });
73
+ }
74
+ },
75
+ };
76
+ },
77
+ });