@noctcore/eslint-plugin-async-safety 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.
@@ -0,0 +1,46 @@
1
+ # `noctcore-async-safety/forward-abort-signal`
2
+
3
+ > A function that accepts an `AbortSignal` but never forwards it to the work it awaits leaves that work uncancellable.
4
+
5
+ ## Why
6
+
7
+ Threading an `AbortSignal` through a call graph is only useful if every layer passes it down. A function that
8
+ takes a `signal`, maybe checks `signal.aborted`, but then `await`s a `fetch`/call without passing the signal
9
+ along has a dead parameter: callers think they can cancel, but the actual I/O ignores them. The cancel never
10
+ reaches the socket.
11
+
12
+ ## What it flags
13
+
14
+ A function (declaration, expression, or arrow) that:
15
+
16
+ - accepts an AbortSignal-shaped parameter — named `signal`, typed `AbortSignal`, or the destructured `{ signal }` form; **and**
17
+ - contains an awaited call or a `fetch(...)` (something that could have received the signal); **and**
18
+ - never forwards the signal — every use is a member-access check (`signal.aborted`, `signal.throwIfAborted()`), or it is unused.
19
+
20
+ "Forwarding" is deliberately generous: passing the signal as an argument, into an options object, assigning it,
21
+ or returning it all count — so the rule errs toward silence rather than false positives.
22
+
23
+ ```ts
24
+ // ✗ signal accepted, fetch left uncancellable
25
+ async function load(url: string, signal: AbortSignal) {
26
+ return await fetch(url);
27
+ }
28
+
29
+ // ✓ signal forwarded
30
+ async function load(url: string, signal: AbortSignal) {
31
+ return await fetch(url, { signal });
32
+ }
33
+ ```
34
+
35
+ A function with no awaited call or `fetch` (e.g. a pure `while (!signal.aborted)` polling loop) is never flagged
36
+ — there is nothing to forward to.
37
+
38
+ ## Options
39
+
40
+ None.
41
+
42
+ ## When not to use it
43
+
44
+ If you deliberately accept a signal only to poll `.aborted` in a compute loop with no downstream call, this rule
45
+ will still fire when that loop `await`s something (a `sleep`, say) that cannot accept a signal. Disable it inline
46
+ for those functions.
@@ -0,0 +1,48 @@
1
+ # `noctcore-async-safety/no-concurrent-shared-mutation`
2
+
3
+ > A read-modify-write of an outer-scope binding inside a concurrent `Promise.all(arr.map(async …))` callback can lose updates.
4
+
5
+ ## Why
6
+
7
+ `Promise.all(arr.map(async …))` starts every callback before any of them resolve, so their `await` points
8
+ interleave. A read-modify-write of a shared outer binding — `total = total + await amount()`, `total += …`,
9
+ `count++` after an await — can have two iterations read the same value and one clobber the other. The classic
10
+ lost update: the final total is wrong, non-deterministically.
11
+
12
+ ## What it flags
13
+
14
+ Inside an `async` callback passed to `.map`/`.flatMap`/`.forEach` whose result is wrapped in `Promise.all` /
15
+ `Promise.allSettled`, and which contains at least one `await`:
16
+
17
+ - a compound assignment to an outer binding (`x += …`, `x *= …`, …);
18
+ - a self-referential assignment (`x = x + …`);
19
+ - an update expression on an outer binding (`x++`, `--x`).
20
+
21
+ ```ts
22
+ // ✗ lost updates
23
+ let total = 0;
24
+ await Promise.all(items.map(async (item) => {
25
+ total += await priceOf(item);
26
+ }));
27
+
28
+ // ✓ collect, then reduce
29
+ const prices = await Promise.all(items.map((item) => priceOf(item)));
30
+ const total = prices.reduce((a, b) => a + b, 0);
31
+ ```
32
+
33
+ Deliberately conservative to distinguish real races from safe patterns:
34
+
35
+ - `arr.push(…)`, `map.set(…)`, and distinct-index writes (`results[i] = …`) are **not** flagged — they are
36
+ order-tolerant, not lost updates;
37
+ - a plain overwrite (`x = await f()`, no self-reference) is **not** flagged — that is last-write-wins, not a read-modify-write;
38
+ - a binding declared inside the callback is local per iteration, so it is never flagged;
39
+ - the callback must be `async` and contain an `await` — a synchronous callback has no interleaving point.
40
+
41
+ ## Options
42
+
43
+ None.
44
+
45
+ ## When not to use it
46
+
47
+ 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.
@@ -0,0 +1,62 @@
1
+ # `noctcore-async-safety/no-shared-mutable-module-state`
2
+
3
+ > A module-scoped mutable binding written from an exported async/handler function is shared across concurrent requests.
4
+
5
+ ## Why
6
+
7
+ On a server, module scope is process-wide: every concurrent request runs against the same module-level
8
+ variables. A `let` counter or a `const cache = new Map()` written inside a request handler is not per-request
9
+ state — request B observes and overwrites what request A left behind. This leaks data across users and produces
10
+ races that never appear in single-request local testing.
11
+
12
+ ## What it flags
13
+
14
+ Only when the file matches an `include` glob (see Options — the rule is **off by default**), a write to a
15
+ module-scoped mutable binding performed inside an **exported async function** or a **handler-named export**:
16
+
17
+ - a module-level `let`/`var` reassigned (`x = …`, `x += …`, `x++`); or
18
+ - a module-level mutable container `const` (`[]`, `{}`, `new Map()`/`Set()`/`WeakMap()`/`WeakSet()`) mutated
19
+ (`c.push(…)`, `c.set(…)`, `c[i] = …`, `c.prop = …`).
20
+
21
+ Exported functions qualify when they are `async` or named like a handler (`GET`/`POST`/…, `loader`, `action`,
22
+ `handler`, `*Handler`, `middleware`).
23
+
24
+ ```ts
25
+ // server.ts (include: ['**/server/**'])
26
+
27
+ // ✗ shared across requests
28
+ let requestCount = 0;
29
+ export async function GET() {
30
+ requestCount += 1;
31
+ }
32
+
33
+ // ✓ per-request
34
+ export async function GET() {
35
+ const requestCount = 1;
36
+ }
37
+ ```
38
+
39
+ The lazy-initialization idioms `x ??= …` and `x ||= …` are always skipped (the common singleton/memoization
40
+ pattern), and names in `allow` are exempt.
41
+
42
+ ## Options
43
+
44
+ | Option | Type | Default | Meaning |
45
+ | --- | --- | --- | --- |
46
+ | `include` | `string[]` (globs) | `[]` | Glob patterns for server files to arm the rule on. Empty = the rule does nothing. |
47
+ | `allow` | `string[]` | `[]` | Binding names to exempt (intentional singletons / process-wide caches). |
48
+
49
+ Globs support `**` (any run including `/`), `*` (any run except `/`), `?`, and a leading `**/` that also matches
50
+ zero leading segments.
51
+
52
+ ```js
53
+ 'noctcore-async-safety/no-shared-mutable-module-state': [
54
+ 'error',
55
+ { include: ['**/server/**', '**/*.server.ts'], allow: ['metricsRegistry'] },
56
+ ]
57
+ ```
58
+
59
+ ## When not to use it
60
+
61
+ Client-side modules legitimately hold shared mutable state (caches, stores, singletons). Keep `include` scoped
62
+ to server code so those are never touched.
@@ -0,0 +1,45 @@
1
+ # `noctcore-async-safety/prefer-parallel-awaits`
2
+
3
+ > Consecutive independent awaits run sequentially — they could run concurrently with `Promise.all`. 💡
4
+
5
+ ## Why
6
+
7
+ `const a = await getUser(); const b = await getFeed();` waits for `getUser` to finish before `getFeed` even
8
+ starts. When the two have no data dependency, that is latency added for nothing: `await Promise.all([...])`
9
+ runs them concurrently and finishes in the time of the slower one.
10
+
11
+ ## What it flags
12
+
13
+ A run of two or more **consecutive** statements of the exact shape `const <id> = await <call>()`, where:
14
+
15
+ - every awaited expression is a single flat call whose arguments hide no nested call, `await`, or assignment
16
+ (so side-effect ordering stays out of scope);
17
+ - the callee does not look like a mutation (`save*`, `create*`, `send*`, `commit*`, `push*`, …) — sequencing
18
+ writes is usually intentional;
19
+ - no later statement references an earlier statement's binding (no data dependency).
20
+
21
+ ```ts
22
+ // ✗ sequential, but independent
23
+ const user = await getUser();
24
+ const feed = await getFeed();
25
+
26
+ // ✓ concurrent
27
+ const [user, feed] = await Promise.all([getUser(), getFeed()]);
28
+ ```
29
+
30
+ The rule is intentionally conservative — a data dependency, a mutation-looking callee, a `let`, a nested call
31
+ argument, or any non-await statement between them all suppress it.
32
+
33
+ ## Suggestion
34
+
35
+ Reports offer an editor suggestion (never an autofix, since parallelizing also changes rejection timing) that
36
+ rewrites the run into a single `const [a, b] = await Promise.all([...])`.
37
+
38
+ ## Options
39
+
40
+ None.
41
+
42
+ ## When not to use it
43
+
44
+ If your consecutive awaits carry side effects whose ordering matters but that this rule cannot see (e.g. reads
45
+ that mutate a stream), disable it — the suggestion assumes the awaited reads are independent.
@@ -0,0 +1,55 @@
1
+ # `noctcore-async-safety/require-fetch-timeout`
2
+
3
+ > A `fetch` (or configured wrapper) call must carry a cancellation signal or timeout — an unbounded request can hang forever. 💡
4
+
5
+ ## Why
6
+
7
+ `fetch` has no default timeout. A hung TCP connection or a server that accepts but never responds leaves the
8
+ promise pending indefinitely, tying up a request slot, a connection, and any resources awaiting it. Passing
9
+ `signal: AbortSignal.timeout(ms)` (or an equivalent `timeout` option on a wrapper) bounds the wait and makes
10
+ the failure observable.
11
+
12
+ ## What it flags
13
+
14
+ A call to global `fetch` — or any wrapper named in the `callees` option (e.g. `undici.request`, `axios`) —
15
+ whose options carry neither a `signal` nor a `timeout`.
16
+
17
+ The rule is purely syntactic and stays silent whenever it cannot see the arguments:
18
+
19
+ - a spread argument (`fetch(url, ...opts)`) or a `...spread` inside the options object — opaque, skipped;
20
+ - an options slot that is an identifier/call/member (`fetch(url, opts)`) — that bag may already set a signal, skipped;
21
+ - a single non-string argument (`fetch(request)`) — could be a `Request` carrying its own signal, skipped.
22
+
23
+ It reports only when the arguments are plainly signal-free: a bare string/template URL, or a visible options
24
+ object literal with neither key.
25
+
26
+ ```ts
27
+ // ✗ no timeout — can hang forever
28
+ await fetch('https://api.example.com/data');
29
+ await fetch(url, { method: 'POST' });
30
+
31
+ // ✓ bounded
32
+ await fetch('https://api.example.com/data', { signal: AbortSignal.timeout(10000) });
33
+ await fetch(url, { method: 'POST', signal: controller.signal });
34
+ ```
35
+
36
+ ## Suggestion
37
+
38
+ Reports offer an editor suggestion (not an autofix) that inserts `signal: AbortSignal.timeout(<defaultTimeoutMs>)`
39
+ into the options object, creating one if needed.
40
+
41
+ ## Options
42
+
43
+ | Option | Type | Default | Meaning |
44
+ | --- | --- | --- | --- |
45
+ | `callees` | `string[]` | `[]` | Additional callee texts to check, matched literally (`'axios'`, `'undici.request'`). `fetch` is always checked. |
46
+ | `defaultTimeoutMs` | `integer` (≥1) | `10000` | The timeout value used in the suggestion. |
47
+
48
+ ```js
49
+ 'noctcore-async-safety/require-fetch-timeout': ['error', { callees: ['undici.request'], defaultTimeoutMs: 5000 }]
50
+ ```
51
+
52
+ ## When not to use it
53
+
54
+ If your `fetch` wrapper enforces a timeout centrally (an interceptor, a default `AbortSignal`), the per-call
55
+ signal is redundant — leave `callees` at its default so only bare `fetch` is checked, or disable the rule.
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@noctcore/eslint-plugin-async-safety",
3
+ "version": "0.1.0",
4
+ "description": "ESLint rules for async correctness: fetch timeouts, AbortSignal forwarding, and shared-state / concurrency races.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.cjs",
8
+ "module": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js",
14
+ "require": "./dist/index.cjs"
15
+ },
16
+ "./package.json": "./package.json"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "docs",
21
+ "README.md"
22
+ ],
23
+ "sideEffects": false,
24
+ "keywords": [
25
+ "eslint",
26
+ "eslintplugin",
27
+ "eslint-plugin",
28
+ "async",
29
+ "abortsignal",
30
+ "fetch",
31
+ "concurrency",
32
+ "noctcore"
33
+ ],
34
+ "publishConfig": {
35
+ "access": "public",
36
+ "provenance": true
37
+ },
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/noctcore/eslint-plugins.git",
41
+ "directory": "packages/eslint-plugin-async-safety"
42
+ },
43
+ "homepage": "https://github.com/noctcore/eslint-plugins/tree/main/packages/eslint-plugin-async-safety",
44
+ "bugs": "https://github.com/noctcore/eslint-plugins/issues",
45
+ "scripts": {
46
+ "build": "tsup src/index.ts --format esm,cjs --dts --clean",
47
+ "typecheck": "tsc --noEmit",
48
+ "test": "vitest run"
49
+ },
50
+ "dependencies": {
51
+ "@noctcore/eslint-utils": "^0.1.0",
52
+ "@typescript-eslint/utils": "^8.61.1"
53
+ },
54
+ "peerDependencies": {
55
+ "eslint": ">=9.0.0",
56
+ "typescript": ">=5.0.0"
57
+ },
58
+ "devDependencies": {
59
+ "@noctcore/eslint-test-utils": "workspace:*",
60
+ "@types/node": "^22.0.0",
61
+ "@typescript-eslint/parser": "^8.61.1",
62
+ "@typescript-eslint/rule-tester": "^8.61.1",
63
+ "tsup": "^8.5.1",
64
+ "typescript": "^5.6.0",
65
+ "vitest": "^3"
66
+ }
67
+ }