@noctcore/eslint-plugin-async-safety 0.2.0 → 0.3.1

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
@@ -55,9 +55,11 @@ export default [
55
55
  | `require-fetch-timeout` | `error` | Precise and syntactic. |
56
56
  | `require-client-timeout` | `error` | Inert until you list `clients`, so it ships enabled but checks nothing by default. |
57
57
  | `no-shared-mutable-module-state` | `error` | Inert until you set `include` globs, so it ships enabled but off by default. |
58
- | `forward-abort-signal` | `warn` | Heuristic advisory. |
59
- | `prefer-parallel-awaits` | `warn` | Heuristic advisory suggestion. |
60
- | `no-concurrent-shared-mutation` | `warn` | Heuristic advisory. |
58
+ | `forward-abort-signal` | `error` | A dead `signal` is a real bug; any forwarding shape counts as a pass. |
59
+ | `no-concurrent-shared-mutation` | `error` | A lost update is a real bug; order-tolerant writes are skipped. |
60
+ | `prefer-parallel-awaits` | `off` | A latency hint, not a bug. Sequential awaits are often deliberate. Opt in where you want it. |
61
+
62
+ Every rule is `error` or `off`, never `warn`: a warning is a rule nobody obeys.
61
63
 
62
64
  The 💡 rules provide editor suggestions (not autofixes) — parallelizing awaits and adding a timeout both change
63
65
  runtime behavior, so they are never applied automatically.
package/dist/index.cjs CHANGED
@@ -34,10 +34,16 @@ var recommended = {
34
34
  "noctcore-async-safety/require-client-timeout": "error",
35
35
  // Inert until you set `include` globs, so it ships enabled but off by default.
36
36
  "noctcore-async-safety/no-shared-mutable-module-state": "error",
37
- // Heuristic advisory. Warns rather than blocking.
38
- "noctcore-async-safety/forward-abort-signal": "warn",
39
- "noctcore-async-safety/prefer-parallel-awaits": "warn",
40
- "noctcore-async-safety/no-concurrent-shared-mutation": "warn"
37
+ // A dead `signal` parameter is a real bug (the cancel never reaches the I/O), and
38
+ // the rule counts any forwarding shape as a pass, so it errs toward silence.
39
+ "noctcore-async-safety/forward-abort-signal": "error",
40
+ // A lost update is a real bug, and the rule skips order-tolerant writes
41
+ // (`push`, `set`, distinct-index) and plain overwrites.
42
+ "noctcore-async-safety/no-concurrent-shared-mutation": "error",
43
+ // Ships OFF: a latency hint, not a correctness bug. Sequential awaits are often
44
+ // deliberate (one transaction client, rate limits, deterministic test setup), and
45
+ // the rule cannot see that. Enable it where you want the nudge.
46
+ "noctcore-async-safety/prefer-parallel-awaits": "off"
41
47
  };
42
48
 
43
49
  // src/rules/forward-abort-signal.ts
package/dist/index.js CHANGED
@@ -6,10 +6,16 @@ var recommended = {
6
6
  "noctcore-async-safety/require-client-timeout": "error",
7
7
  // Inert until you set `include` globs, so it ships enabled but off by default.
8
8
  "noctcore-async-safety/no-shared-mutable-module-state": "error",
9
- // Heuristic advisory. Warns rather than blocking.
10
- "noctcore-async-safety/forward-abort-signal": "warn",
11
- "noctcore-async-safety/prefer-parallel-awaits": "warn",
12
- "noctcore-async-safety/no-concurrent-shared-mutation": "warn"
9
+ // A dead `signal` parameter is a real bug (the cancel never reaches the I/O), and
10
+ // the rule counts any forwarding shape as a pass, so it errs toward silence.
11
+ "noctcore-async-safety/forward-abort-signal": "error",
12
+ // A lost update is a real bug, and the rule skips order-tolerant writes
13
+ // (`push`, `set`, distinct-index) and plain overwrites.
14
+ "noctcore-async-safety/no-concurrent-shared-mutation": "error",
15
+ // Ships OFF: a latency hint, not a correctness bug. Sequential awaits are often
16
+ // deliberate (one transaction client, rate limits, deterministic test setup), and
17
+ // the rule cannot see that. Enable it where you want the nudge.
18
+ "noctcore-async-safety/prefer-parallel-awaits": "off"
13
19
  };
14
20
 
15
21
  // src/rules/forward-abort-signal.ts
@@ -20,13 +20,15 @@ A function (declaration, expression, or arrow) that:
20
20
  "Forwarding" is deliberately generous: passing the signal as an argument, into an options object, assigning it,
21
21
  or returning it all count — so the rule errs toward silence rather than false positives.
22
22
 
23
- ```ts
24
- // signal accepted, fetch left uncancellable
23
+ ```ts bad
24
+ // signal accepted, fetch left uncancellable
25
25
  async function load(url: string, signal: AbortSignal) {
26
26
  return await fetch(url);
27
27
  }
28
+ ```
28
29
 
29
- // ✓ signal forwarded
30
+ ```ts good
31
+ // signal forwarded
30
32
  async function load(url: string, signal: AbortSignal) {
31
33
  return await fetch(url, { signal });
32
34
  }
@@ -18,14 +18,16 @@ Inside an `async` callback passed to `.map`/`.flatMap`/`.forEach` whose result i
18
18
  - a self-referential assignment (`x = x + …`);
19
19
  - an update expression on an outer binding (`x++`, `--x`).
20
20
 
21
- ```ts
22
- // lost updates
21
+ ```ts bad
22
+ // lost updates
23
23
  let total = 0;
24
24
  await Promise.all(items.map(async (item) => {
25
25
  total += await priceOf(item);
26
26
  }));
27
+ ```
27
28
 
28
- // ✓ collect, then reduce
29
+ ```ts good
30
+ // collect, then reduce
29
31
  const prices = await Promise.all(items.map((item) => priceOf(item)));
30
32
  const total = prices.reduce((a, b) => a + b, 0);
31
33
  ```
@@ -45,4 +47,4 @@ None.
45
47
  ## When not to use it
46
48
 
47
49
  If you intentionally accumulate into shared state and have externally serialized the callbacks (e.g. a mutex, or
48
- a concurrency limit of 1), this rule's warning is a false positive disable it inline for that block.
50
+ a concurrency limit of 1), this rule's report is a false positive: disable it inline for that block.
@@ -21,16 +21,16 @@ module-scoped mutable binding performed inside an **exported async function** or
21
21
  Exported functions qualify when they are `async` or named like a handler (`GET`/`POST`/…, `loader`, `action`,
22
22
  `handler`, `*Handler`, `middleware`).
23
23
 
24
- ```ts
25
- // server.ts (include: ['**/server/**'])
26
-
27
- // ✗ shared across requests
24
+ ```ts bad filename=src/server/metrics.ts options={"include":["**/server/**"]}
25
+ // shared across requests
28
26
  let requestCount = 0;
29
27
  export async function GET() {
30
28
  requestCount += 1;
31
29
  }
30
+ ```
32
31
 
33
- // per-request
32
+ ```ts good filename=src/server/metrics.ts options={"include":["**/server/**"]}
33
+ // per-request
34
34
  export async function GET() {
35
35
  const requestCount = 1;
36
36
  }
@@ -18,15 +18,26 @@ A run of two or more **consecutive** statements of the exact shape `const <id> =
18
18
  writes is usually intentional;
19
19
  - no later statement references an earlier statement's binding (no data dependency).
20
20
 
21
- ```ts
22
- // sequential, but independent
23
- const user = await getUser();
24
- const feed = await getFeed();
21
+ ```ts bad
22
+ // sequential, but independent
23
+ async function loadHome() {
24
+ const user = await getUser();
25
+ const feed = await getFeed();
26
+ return { user, feed };
27
+ }
28
+ ```
25
29
 
26
- // ✓ concurrent
27
- const [user, feed] = await Promise.all([getUser(), getFeed()]);
30
+ ```ts good
31
+ // concurrent
32
+ async function loadHome() {
33
+ const [user, feed] = await Promise.all([getUser(), getFeed()]);
34
+ return { user, feed };
35
+ }
28
36
  ```
29
37
 
38
+ Only runs inside a block (a function body, or any `{ ... }`) are checked. Top-level `await`s at
39
+ module scope are not.
40
+
30
41
  The rule is intentionally conservative — a data dependency, a mutation-looking callee, a `let`, a nested call
31
42
  argument, or any non-await statement between them all suppress it.
32
43
 
@@ -60,14 +60,15 @@ A NestJS API that talks to S3 and sends mail over SMTP:
60
60
  ],
61
61
  ```
62
62
 
63
- ```ts
64
- // Bad: no request handler, so no connection or request timeout
63
+ ```ts bad reports=2 options={"clients":[{"callee":"S3Client","construct":true,"requireAnyOf":["requestHandler"]},{"callee":"nodemailer.createTransport","requireAnyOf":["connectionTimeout","socketTimeout"]}]}
64
+ // no request handler, so no connection or request timeout
65
65
  this.s3 = new S3Client({ region, credentials });
66
66
 
67
- // Bad: SMTP transport with default (unbounded in practice) timeouts
67
+ // SMTP transport with default (unbounded in practice) timeouts
68
68
  this.transporter = nodemailer.createTransport({ host, port, secure: true });
69
+ ```
69
70
 
70
- // Good
71
+ ```ts good options={"clients":[{"callee":"S3Client","construct":true,"requireAnyOf":["requestHandler"]},{"callee":"nodemailer.createTransport","requireAnyOf":["connectionTimeout","socketTimeout"]}]}
71
72
  this.s3 = new S3Client({
72
73
  region,
73
74
  credentials,
@@ -23,12 +23,14 @@ The rule is purely syntactic and stays silent whenever it cannot see the argumen
23
23
  It reports only when the arguments are plainly signal-free: a bare string/template URL, or a visible options
24
24
  object literal with neither key.
25
25
 
26
- ```ts
27
- // no timeout can hang forever
26
+ ```ts bad reports=2
27
+ // no timeout: can hang forever
28
28
  await fetch('https://api.example.com/data');
29
29
  await fetch(url, { method: 'POST' });
30
+ ```
30
31
 
31
- // ✓ bounded
32
+ ```ts good
33
+ // bounded
32
34
  await fetch('https://api.example.com/data', { signal: AbortSignal.timeout(10000) });
33
35
  await fetch(url, { method: 'POST', signal: controller.signal });
34
36
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noctcore/eslint-plugin-async-safety",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "ESLint rules for async correctness: fetch timeouts, AbortSignal forwarding, and shared-state / concurrency races.",
5
5
  "license": "MIT",
6
6
  "type": "module",