@sarj/eslint-plugin 4.0.0 → 4.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.
package/README.md CHANGED
@@ -12,10 +12,47 @@ import sarj from "@sarj/eslint-plugin";
12
12
  export default [...sarj.configs.recommended];
13
13
  ```
14
14
 
15
- 46 rules. Each rule's source under `src/rules/` carries its own `@fileoverview` rationale plus `meta.docs.description` + `meta.messages` — read the file for the full reasoning, including the false positives it deliberately does not fire on.
15
+ 51 rules. Each rule's source under `src/rules/` carries its own `@fileoverview` rationale plus `meta.docs.description` + `meta.messages` — read the file for the full reasoning, including the false positives it deliberately does not fire on.
16
16
 
17
17
  Presets: `recommended` (warn-first), `strict` (every rule at error), `style-guide` (formatting/naming subset).
18
18
 
19
+ ## New in 4.1.0 — `no-hand-rolled-sleep`
20
+
21
+ `new Promise((resolve) => setTimeout(resolve, ms))` is `node:timers/promises`'s
22
+ `setTimeout` rewritten by hand, minus the `AbortSignal` — so it is a capability
23
+ loss, not verbosity. The hand-rolled sleep holds a live timer that nothing can
24
+ clear, and its mirror image, the `Promise.race([work, rejectAfter(ms)])` timeout
25
+ arm, leaks the timer the other way: when `work` wins, nothing clears it and it
26
+ keeps the event loop alive until it fires.
27
+
28
+ Shipped only after confirming nothing already enabled reports this position. The
29
+ enabled set was resolved with `ESLint#calculateConfigForFile` against the shipped
30
+ `eslint.strict.mjs` (204 rules before this one) and a file containing every
31
+ shape was linted through it: no report. `eslint-plugin-unicorn` 72 has no
32
+ promisified-timer rule among its 341; `unicorn/prefer-abort-signal-timeout` covers the
33
+ `AbortController` + `setTimeout` idiom and not the race arm; core
34
+ `no-promise-executor-return` fires on the concise-arrow spelling only and its
35
+ remedy ("add braces") entrenches the hand-rolled sleep. The polling-loop variant
36
+ (`while (!done) await sleep(ms)`) is deliberately absent — core `no-await-in-loop`
37
+ is already enabled and reports that exact position.
38
+
39
+ Measured over 1,471 files containing `setTimeout` across 15 OSS repos (hono,
40
+ tRPC, drizzle-orm, undici, vitest, got, cal.com, documenso, dub, formbricks,
41
+ midday, openstatus, papermark, unkey, zod) and seven internal ones: 85 + 11
42
+ sleeps and 2 + 1 leaky race arms at the default settings, **0 false positives**.
43
+
44
+ `checkClientModules` is the option that matters. A browser or React Native
45
+ bundle cannot import `node:timers/promises` and the web platform ships no
46
+ equivalent, so client modules are skipped by default — 79% of the internal
47
+ corpus's occurrences live in `.tsx` components where the fix cannot be applied.
48
+ Turn it on only in a tree where every file resolves `node:` builtins. The race
49
+ message is reported everywhere regardless: `AbortSignal.timeout` is on the web
50
+ platform too.
51
+
52
+ | Rule | What it catches | Preset |
53
+ |---|---|---|
54
+ | `no-hand-rolled-sleep` | `new Promise((r) => setTimeout(r, ms))` in any spelling, and an uncleared `Promise.race`/`Promise.any` timeout arm. Options: `checkClientModules` (default `false`), `allowIn`. | warn / error |
55
+
19
56
  ## New in 2.14.0 — `no-tautological-expect`
20
57
 
21
58
  The TS half of SARJ057. An `expect(...)` whose operands are all literals has
@@ -125,7 +162,7 @@ Glob patterns whose files opt out (generated code already opts out by default).
125
162
 
126
163
  ## Configurable rules
127
164
 
128
- Most rules take no options. These three do, because they encode a codebase's
165
+ Most rules take no options. These do, because they encode a codebase's
129
166
  architecture rather than a language fact — the defaults describe one convention
130
167
  and every repo gets to name its own.
131
168
 
@@ -135,10 +172,15 @@ and every repo gets to name its own.
135
172
  | `no-dynamic-sql` | `methods` | `["prepare", "exec", "query"]` | Statement-taking methods to inspect |
136
173
  | `no-storage-in-stateless-modules` | `modules` | `[]` (rule off) | Directories declared stateless |
137
174
  | `no-storage-in-stateless-modules` | `methods` | `["prepare", "put", "getWithMetadata"]` | Storage methods to flag |
138
-
139
- Every option value is a **regular-expression source matched against the absolute
140
- filename**, not a glob — so it can express both path separators. Supplying an
141
- option **replaces** the default rather than extending it.
175
+ | `no-hand-rolled-sleep` | `checkClientModules` | `false` | Also report the sleep form in browser/React Native modules |
176
+ | `no-hand-rolled-sleep` | `allowIn` | `[]` | Glob patterns for a sanctioned sleep wrapper module |
177
+
178
+ The path options on the first three rules are **regular-expression sources
179
+ matched against the absolute filename**, not globs — so they can express both
180
+ path separators. `allowIn` is the exception, on `no-hand-rolled-sleep` as on
181
+ `require-fetch-timeout`: it takes minimatch-ish **globs**, also matched against
182
+ the absolute path, so anchor them with a `**/` prefix. Supplying an option
183
+ **replaces** the default rather than extending it.
142
184
 
143
185
  `no-storage-in-stateless-modules` is a **no-op until `modules` is set**. The
144
186
  method names alone (`put`, `prepare`) carry no type information, so the rule is
package/dist/index.cjs CHANGED
@@ -7388,9 +7388,186 @@ var no_async_callback_in_waitfor_default = import_utils54.ESLintUtils.RuleCreato
7388
7388
  }
7389
7389
  });
7390
7390
 
7391
- // src/rules/prefer-setup-file-mocks.ts
7391
+ // src/rules/no-hand-rolled-sleep.ts
7392
7392
  var import_utils55 = require("@typescript-eslint/utils");
7393
- var prefer_setup_file_mocks_default = import_utils55.ESLintUtils.RuleCreator(
7393
+ var GLOBAL_OBJECTS2 = /* @__PURE__ */ new Set([
7394
+ "globalThis",
7395
+ "window",
7396
+ "self",
7397
+ "global"
7398
+ ]);
7399
+ var CLIENT_ONLY_MODULES = /^(react|react-dom|react-native|svelte|vue|preact|solid-js)(\/|$)|^next\/(navigation|router|link|image)$/;
7400
+ var RACE_METHODS = /* @__PURE__ */ new Set(["race", "any"]);
7401
+ function matchesAnyPattern3(filename, patterns) {
7402
+ for (const pattern of patterns) {
7403
+ const regexSource = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "::DOUBLESTAR::").replace(/\*/g, "[^/\\\\]*").replace(/::DOUBLESTAR::/g, ".*");
7404
+ if (new RegExp(`^${regexSource}$`).test(filename)) {
7405
+ return true;
7406
+ }
7407
+ }
7408
+ return false;
7409
+ }
7410
+ function isSetTimeoutCallee(callee) {
7411
+ if (callee.type === import_utils55.AST_NODE_TYPES.Identifier) {
7412
+ return callee.name === "setTimeout";
7413
+ }
7414
+ return callee.type === import_utils55.AST_NODE_TYPES.MemberExpression && !callee.computed && callee.property.type === import_utils55.AST_NODE_TYPES.Identifier && callee.property.name === "setTimeout" && callee.object.type === import_utils55.AST_NODE_TYPES.Identifier && GLOBAL_OBJECTS2.has(callee.object.name);
7415
+ }
7416
+ function soleCall(fn) {
7417
+ if (fn.body.type !== import_utils55.AST_NODE_TYPES.BlockStatement) {
7418
+ return fn.body.type === import_utils55.AST_NODE_TYPES.CallExpression ? fn.body : null;
7419
+ }
7420
+ if (fn.body.body.length !== 1) {
7421
+ return null;
7422
+ }
7423
+ const [only] = fn.body.body;
7424
+ if (only?.type !== import_utils55.AST_NODE_TYPES.ExpressionStatement) {
7425
+ return null;
7426
+ }
7427
+ return only.expression.type === import_utils55.AST_NODE_TYPES.CallExpression ? only.expression : null;
7428
+ }
7429
+ function isTimedDelay(delay) {
7430
+ if (delay === void 0) {
7431
+ return false;
7432
+ }
7433
+ if (delay.type === import_utils55.AST_NODE_TYPES.Literal && typeof delay.value === "number") {
7434
+ return delay.value !== 0;
7435
+ }
7436
+ return true;
7437
+ }
7438
+ function settlesWithoutValue(callback, name) {
7439
+ if (callback.type === import_utils55.AST_NODE_TYPES.Identifier) {
7440
+ return callback.name === name;
7441
+ }
7442
+ if (callback.type !== import_utils55.AST_NODE_TYPES.ArrowFunctionExpression && callback.type !== import_utils55.AST_NODE_TYPES.FunctionExpression) {
7443
+ return false;
7444
+ }
7445
+ const call = soleCall(callback);
7446
+ return call !== null && call.arguments.length === 0 && call.callee.type === import_utils55.AST_NODE_TYPES.Identifier && call.callee.name === name;
7447
+ }
7448
+ function rejectsInCallback(callback, name) {
7449
+ if (callback.type === import_utils55.AST_NODE_TYPES.Identifier) {
7450
+ return callback.name === name;
7451
+ }
7452
+ if (callback.type !== import_utils55.AST_NODE_TYPES.ArrowFunctionExpression && callback.type !== import_utils55.AST_NODE_TYPES.FunctionExpression) {
7453
+ return false;
7454
+ }
7455
+ const call = soleCall(callback);
7456
+ return call !== null && call.callee.type === import_utils55.AST_NODE_TYPES.Identifier && call.callee.name === name;
7457
+ }
7458
+ function parameterName(fn, index) {
7459
+ const parameter = fn.params[index];
7460
+ return parameter?.type === import_utils55.AST_NODE_TYPES.Identifier ? parameter.name : null;
7461
+ }
7462
+ function isRaceArm(node) {
7463
+ const array = node.parent;
7464
+ if (array?.type !== import_utils55.AST_NODE_TYPES.ArrayExpression) {
7465
+ return false;
7466
+ }
7467
+ const call = array.parent;
7468
+ return call?.type === import_utils55.AST_NODE_TYPES.CallExpression && call.arguments[0] === array && call.callee.type === import_utils55.AST_NODE_TYPES.MemberExpression && !call.callee.computed && call.callee.object.type === import_utils55.AST_NODE_TYPES.Identifier && call.callee.object.name === "Promise" && call.callee.property.type === import_utils55.AST_NODE_TYPES.Identifier && RACE_METHODS.has(call.callee.property.name);
7469
+ }
7470
+ var no_hand_rolled_sleep_default = import_utils55.ESLintUtils.RuleCreator(
7471
+ (name) => `https://github.com/sarj-ai/standards/blob/main/packages/typescript/src/rules/${name}.ts`
7472
+ )({
7473
+ name: "no-hand-rolled-sleep",
7474
+ meta: {
7475
+ type: "problem",
7476
+ docs: {
7477
+ description: "Disallow hand-rolled promisified timers (`new Promise((r) => setTimeout(r, ms))`) and hand-rolled `Promise.race` timeout arms; the stdlib forms are cancellable, these are not."
7478
+ },
7479
+ schema: [
7480
+ {
7481
+ type: "object",
7482
+ additionalProperties: false,
7483
+ properties: {
7484
+ allowIn: {
7485
+ description: "Glob patterns for modules exempt from the rule (e.g. a single sanctioned `sleep` utility). Matched against the ABSOLUTE file path, so anchor with a `**/` prefix (e.g. `**/lib/sleep.ts`).",
7486
+ type: "array",
7487
+ items: { type: "string" }
7488
+ },
7489
+ checkClientModules: {
7490
+ description: "Also report the sleep form in browser/React Native modules. Off by default: those bundles cannot import `node:timers/promises` and the web platform has no equivalent, so the fix is impossible to follow. Turn on only where every file can resolve `node:` builtins.",
7491
+ type: "boolean"
7492
+ }
7493
+ }
7494
+ }
7495
+ ],
7496
+ messages: {
7497
+ handRolledSleep: 'Hand-rolled sleep: `new Promise((resolve) => setTimeout(resolve, ms))` cannot be cancelled, so an aborted request or a lost race still waits out the full delay. Use `import { setTimeout as sleep } from "node:timers/promises"` and pass `{ signal }`.',
7498
+ handRolledTimeoutRace: "Hand-rolled timeout arm: when the other promise wins, this timer is never cleared and keeps the event loop alive until it fires. Use `AbortSignal.timeout(ms)` and pass the signal to the operation."
7499
+ }
7500
+ },
7501
+ defaultOptions: [{}],
7502
+ create(context, [optionsArg]) {
7503
+ const { filename, sourceCode } = context;
7504
+ if (isTestFile(filename) || isScriptFile(filename) || isGeneratedFile(filename, sourceCode.getText())) {
7505
+ return {};
7506
+ }
7507
+ const allowIn = optionsArg?.allowIn ?? [];
7508
+ if (allowIn.length > 0 && matchesAnyPattern3(filename, allowIn)) {
7509
+ return {};
7510
+ }
7511
+ const checkClientModules = optionsArg?.checkClientModules ?? false;
7512
+ function isClientModule() {
7513
+ if (/\.[cm]?[jt]sx$/.test(filename)) {
7514
+ return true;
7515
+ }
7516
+ const program = sourceCode.ast;
7517
+ for (const statement of program.body) {
7518
+ if (statement.type === import_utils55.AST_NODE_TYPES.ExpressionStatement && statement.expression.type === import_utils55.AST_NODE_TYPES.Literal && statement.expression.value === "use client") {
7519
+ return true;
7520
+ }
7521
+ if (statement.type === import_utils55.AST_NODE_TYPES.ImportDeclaration && typeof statement.source.value === "string" && CLIENT_ONLY_MODULES.test(statement.source.value)) {
7522
+ return true;
7523
+ }
7524
+ }
7525
+ return false;
7526
+ }
7527
+ let clientModule = null;
7528
+ const reportsSleepHere = () => {
7529
+ if (checkClientModules) {
7530
+ return true;
7531
+ }
7532
+ clientModule ??= isClientModule();
7533
+ return !clientModule;
7534
+ };
7535
+ return {
7536
+ NewExpression(node) {
7537
+ if (node.callee.type !== import_utils55.AST_NODE_TYPES.Identifier || node.callee.name !== "Promise") {
7538
+ return;
7539
+ }
7540
+ const executor = node.arguments[0];
7541
+ if (executor?.type !== import_utils55.AST_NODE_TYPES.ArrowFunctionExpression && executor?.type !== import_utils55.AST_NODE_TYPES.FunctionExpression) {
7542
+ return;
7543
+ }
7544
+ const call = soleCall(executor);
7545
+ if (call === null || !isSetTimeoutCallee(call.callee)) {
7546
+ return;
7547
+ }
7548
+ const [callback, delay] = call.arguments;
7549
+ if (callback === void 0 || !isTimedDelay(delay)) {
7550
+ return;
7551
+ }
7552
+ const resolveName = parameterName(executor, 0);
7553
+ if (resolveName !== null && settlesWithoutValue(callback, resolveName)) {
7554
+ if (reportsSleepHere()) {
7555
+ context.report({ node, messageId: "handRolledSleep" });
7556
+ }
7557
+ return;
7558
+ }
7559
+ const rejectName = parameterName(executor, 1);
7560
+ if (rejectName !== null && isRaceArm(node) && rejectsInCallback(callback, rejectName)) {
7561
+ context.report({ node, messageId: "handRolledTimeoutRace" });
7562
+ }
7563
+ }
7564
+ };
7565
+ }
7566
+ });
7567
+
7568
+ // src/rules/prefer-setup-file-mocks.ts
7569
+ var import_utils56 = require("@typescript-eslint/utils");
7570
+ var prefer_setup_file_mocks_default = import_utils56.ESLintUtils.RuleCreator(
7394
7571
  (name) => `https://github.com/sarj-ai/standards/blob/main/packages/typescript/src/rules/${name}.ts`
7395
7572
  )({
7396
7573
  name: "prefer-setup-file-mocks",
@@ -7411,7 +7588,7 @@ var prefer_setup_file_mocks_default = import_utils55.ESLintUtils.RuleCreator(
7411
7588
  }
7412
7589
  return {
7413
7590
  CallExpression(node) {
7414
- if (node.callee.type === import_utils55.AST_NODE_TYPES.MemberExpression && node.callee.object.type === import_utils55.AST_NODE_TYPES.Identifier && (node.callee.object.name === "vi" || node.callee.object.name === "jest") && node.callee.property.type === import_utils55.AST_NODE_TYPES.Identifier && node.callee.property.name === "mock") {
7591
+ if (node.callee.type === import_utils56.AST_NODE_TYPES.MemberExpression && node.callee.object.type === import_utils56.AST_NODE_TYPES.Identifier && (node.callee.object.name === "vi" || node.callee.object.name === "jest") && node.callee.property.type === import_utils56.AST_NODE_TYPES.Identifier && node.callee.property.name === "mock") {
7415
7592
  context.report({
7416
7593
  node,
7417
7594
  messageId: "preferSetupFileMocks"
@@ -7456,6 +7633,7 @@ var rules = {
7456
7633
  "no-repeated-string-literal": no_repeated_string_literal_default,
7457
7634
  "no-select-star": no_select_star_default,
7458
7635
  "no-sleep-in-test-body": no_sleep_in_test_body_default,
7636
+ "no-hand-rolled-sleep": no_hand_rolled_sleep_default,
7459
7637
  "no-conditional-in-test": no_conditional_in_test_default,
7460
7638
  "prefer-constant-time-secret-compare": prefer_constant_time_secret_compare_default,
7461
7639
  "store-insert-requires-on-conflict": store_insert_requires_on_conflict_default,
@@ -7478,7 +7656,7 @@ var rules = {
7478
7656
  var plugin = {
7479
7657
  meta: {
7480
7658
  name: "@sarj/eslint-plugin",
7481
- version: "4.0.0"
7659
+ version: "4.1.0"
7482
7660
  },
7483
7661
  rules,
7484
7662
  configs: {
@@ -7525,6 +7703,10 @@ var plugin = {
7525
7703
  "@sarj/store-insert-requires-on-conflict": "warn",
7526
7704
  "@sarj/no-offset-pagination": "warn",
7527
7705
  "@sarj/no-select-star": "warn",
7706
+ // Uncancellable hand-rolled timers. Verified against the shipped
7707
+ // strict config (205 enabled rules) that nothing already reports this
7708
+ // position; `unicorn` 72 has no promisified-timer rule at all.
7709
+ "@sarj/no-hand-rolled-sleep": "warn",
7528
7710
  "@sarj/no-sleep-in-test-body": "warn",
7529
7711
  "@sarj/no-conditional-in-test": "warn",
7530
7712
  "@sarj/no-repeated-string-literal": "warn",
@@ -7541,7 +7723,8 @@ var plugin = {
7541
7723
  // Anti-comment-verbosity family (2026-07), from a 37,918-comment,
7542
7724
  // nine-repo measurement study. Each is a deletion-class finding, so each
7543
7725
  // was validated against pydantic / trio / attrs as well as the maintained
7544
- // repos: `no-restated-comment` 0 hits in bulbul and 4 in the three famous
7726
+ // repos: `no-restated-comment` 0 hits in the flagship first-party
7727
+ // repo and 4 in the three famous
7545
7728
  // corpora combined; `trailing-value-narration` 18 hits, 18 true
7546
7729
  // positives; `jsdoc-restates-signature` 36 hits, 0 measured false
7547
7730
  // positives, and it offers a suggestion rather than a `--fix` because a
@@ -7552,7 +7735,7 @@ var plugin = {
7552
7735
  // The TS half of SARJ057 (2026-07). Python has caught the
7553
7736
  // assertion-FREE test since 0.15.0 (SARJ043) and had no TS
7554
7737
  // counterpart, which is how `expect(true).toBe(true); // placeholder`
7555
- // survived in internal-automations: the file HAS an assertion.
7738
+ // survived in a first-party repo: the file HAS an assertion.
7556
7739
  // Measured across 5,819 .ts/.tsx files (1,003 of them test files) in
7557
7740
  // six internal repos plus got / hono / swr / trpc: 3 hits, 3 true
7558
7741
  // positives, 0 false positives.
@@ -7612,6 +7795,10 @@ var plugin = {
7612
7795
  "@sarj/store-insert-requires-on-conflict": "error",
7613
7796
  "@sarj/no-offset-pagination": "error",
7614
7797
  "@sarj/no-select-star": "error",
7798
+ // Uncancellable hand-rolled timers. Verified against the shipped
7799
+ // strict config (205 enabled rules) that nothing already reports this
7800
+ // position; `unicorn` 72 has no promisified-timer rule at all.
7801
+ "@sarj/no-hand-rolled-sleep": "error",
7615
7802
  "@sarj/no-sleep-in-test-body": "error",
7616
7803
  "@sarj/no-conditional-in-test": "error",
7617
7804
  "@sarj/no-repeated-string-literal": "error",