@noctcore/eslint-plugin-async-safety 0.1.0 → 0.3.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 +7 -3
- package/dist/index.cjs +127 -18
- package/dist/index.d.cts +19 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +127 -18
- package/docs/rules/no-concurrent-shared-mutation.md +1 -1
- package/docs/rules/require-client-timeout.md +90 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -42,6 +42,7 @@ export default [
|
|
|
42
42
|
| Rule | Description | 💡 |
|
|
43
43
|
| --- | --- | --- |
|
|
44
44
|
| [`require-fetch-timeout`](./docs/rules/require-fetch-timeout.md) | A `fetch` (or configured wrapper) call must carry a `signal`/`timeout` — an unbounded request can hang forever. | 💡 |
|
|
45
|
+
| [`require-client-timeout`](./docs/rules/require-client-timeout.md) | A configured network client (`new S3Client(...)`, `nodemailer.createTransport(...)`) must be built with one of its timeout options. Ships with no client list. | |
|
|
45
46
|
| [`forward-abort-signal`](./docs/rules/forward-abort-signal.md) | A function that accepts an `AbortSignal` but awaits a call without forwarding it leaves that work uncancellable. | |
|
|
46
47
|
| [`no-shared-mutable-module-state`](./docs/rules/no-shared-mutable-module-state.md) | A module-scoped mutable binding written from an exported async/handler function is shared across concurrent requests (opt in via `include`). | |
|
|
47
48
|
| [`prefer-parallel-awaits`](./docs/rules/prefer-parallel-awaits.md) | Consecutive independent awaits can run concurrently with `Promise.all`. | 💡 |
|
|
@@ -52,10 +53,13 @@ export default [
|
|
|
52
53
|
| Rule | Severity | Notes |
|
|
53
54
|
| --- | --- | --- |
|
|
54
55
|
| `require-fetch-timeout` | `error` | Precise and syntactic. |
|
|
56
|
+
| `require-client-timeout` | `error` | Inert until you list `clients`, so it ships enabled but checks nothing by default. |
|
|
55
57
|
| `no-shared-mutable-module-state` | `error` | Inert until you set `include` globs, so it ships enabled but off by default. |
|
|
56
|
-
| `forward-abort-signal` | `
|
|
57
|
-
| `
|
|
58
|
-
| `
|
|
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.
|
|
59
63
|
|
|
60
64
|
The 💡 rules provide editor suggestions (not autofixes) — parallelizing awaits and adding a timeout both change
|
|
61
65
|
runtime behavior, so they are never applied automatically.
|
package/dist/index.cjs
CHANGED
|
@@ -30,12 +30,20 @@ module.exports = __toCommonJS(index_exports);
|
|
|
30
30
|
var recommended = {
|
|
31
31
|
// Precise, syntactic — safe as errors.
|
|
32
32
|
"noctcore-async-safety/require-fetch-timeout": "error",
|
|
33
|
+
// Inert until you list `clients`, so it ships enabled but checks nothing by default.
|
|
34
|
+
"noctcore-async-safety/require-client-timeout": "error",
|
|
33
35
|
// Inert until you set `include` globs, so it ships enabled but off by default.
|
|
34
36
|
"noctcore-async-safety/no-shared-mutable-module-state": "error",
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
"noctcore-async-safety/
|
|
38
|
-
|
|
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"
|
|
39
47
|
};
|
|
40
48
|
|
|
41
49
|
// src/rules/forward-abort-signal.ts
|
|
@@ -703,29 +711,129 @@ var preferParallelAwaitsRule = createRule({
|
|
|
703
711
|
}
|
|
704
712
|
});
|
|
705
713
|
|
|
706
|
-
// src/rules/require-
|
|
714
|
+
// src/rules/require-client-timeout.ts
|
|
707
715
|
var import_utils10 = require("@typescript-eslint/utils");
|
|
708
|
-
var RULE_NAME5 = "require-
|
|
709
|
-
var
|
|
710
|
-
|
|
716
|
+
var RULE_NAME5 = "require-client-timeout";
|
|
717
|
+
var clientSchema = {
|
|
718
|
+
type: "object",
|
|
719
|
+
additionalProperties: false,
|
|
720
|
+
required: ["callee", "requireAnyOf"],
|
|
721
|
+
properties: {
|
|
722
|
+
callee: { type: "string", minLength: 1 },
|
|
723
|
+
construct: { type: "boolean", default: false },
|
|
724
|
+
requireAnyOf: {
|
|
725
|
+
type: "array",
|
|
726
|
+
items: { type: "string", minLength: 1 },
|
|
727
|
+
minItems: 1,
|
|
728
|
+
uniqueItems: true
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
};
|
|
711
732
|
var optionSchema2 = {
|
|
712
733
|
type: "object",
|
|
713
734
|
additionalProperties: false,
|
|
714
735
|
properties: {
|
|
715
|
-
|
|
716
|
-
defaultTimeoutMs: { type: "integer", minimum: 1 }
|
|
736
|
+
clients: { type: "array", items: clientSchema, default: [] }
|
|
717
737
|
}
|
|
718
738
|
};
|
|
719
739
|
function isUrlLike(node) {
|
|
720
740
|
return node.type === import_utils10.AST_NODE_TYPES.TemplateLiteral || node.type === import_utils10.AST_NODE_TYPES.Literal && typeof node.value === "string";
|
|
721
741
|
}
|
|
722
|
-
function probeOptionsObject(object) {
|
|
742
|
+
function probeOptionsObject(object, keys) {
|
|
723
743
|
for (const property of object.properties) {
|
|
724
744
|
if (property.type === import_utils10.AST_NODE_TYPES.SpreadElement) {
|
|
725
745
|
return { satisfied: false, opaque: true };
|
|
726
746
|
}
|
|
727
747
|
const key = property.key;
|
|
728
748
|
const name = key.type === import_utils10.AST_NODE_TYPES.Identifier ? key.name : key.type === import_utils10.AST_NODE_TYPES.Literal && typeof key.value === "string" ? key.value : null;
|
|
749
|
+
if (name !== null && keys.has(name)) {
|
|
750
|
+
return { satisfied: true, opaque: false };
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
return { satisfied: false, opaque: false };
|
|
754
|
+
}
|
|
755
|
+
function lacksTimeout(args, keys) {
|
|
756
|
+
if (args.some((arg) => arg.type === import_utils10.AST_NODE_TYPES.SpreadElement)) {
|
|
757
|
+
return false;
|
|
758
|
+
}
|
|
759
|
+
const objectArgs = args.filter(
|
|
760
|
+
(arg) => arg.type === import_utils10.AST_NODE_TYPES.ObjectExpression
|
|
761
|
+
);
|
|
762
|
+
if (objectArgs.length > 0) {
|
|
763
|
+
const probes = objectArgs.map((object) => probeOptionsObject(object, keys));
|
|
764
|
+
return !probes.some((probe) => probe.satisfied || probe.opaque);
|
|
765
|
+
}
|
|
766
|
+
return args.every(isUrlLike);
|
|
767
|
+
}
|
|
768
|
+
var requireClientTimeoutRule = createRule({
|
|
769
|
+
name: RULE_NAME5,
|
|
770
|
+
meta: {
|
|
771
|
+
type: "problem",
|
|
772
|
+
docs: {
|
|
773
|
+
description: "A configured network client must be constructed with a timeout option; an unbounded client can hang forever."
|
|
774
|
+
},
|
|
775
|
+
schema: [optionSchema2],
|
|
776
|
+
messages: {
|
|
777
|
+
missingTimeout: "`{{callee}}` is created without a timeout. Set one of {{keys}} in its options so a stalled peer cannot hang the caller indefinitely."
|
|
778
|
+
}
|
|
779
|
+
},
|
|
780
|
+
defaultOptions: [{ clients: [] }],
|
|
781
|
+
create(context, [options]) {
|
|
782
|
+
const clients = (options.clients ?? []).map((client) => ({
|
|
783
|
+
callee: client.callee,
|
|
784
|
+
construct: client.construct ?? false,
|
|
785
|
+
keys: new Set(client.requireAnyOf),
|
|
786
|
+
label: client.requireAnyOf.map((key) => `\`${key}\``).join(", ")
|
|
787
|
+
}));
|
|
788
|
+
if (clients.length === 0) {
|
|
789
|
+
return {};
|
|
790
|
+
}
|
|
791
|
+
const check = (node) => {
|
|
792
|
+
const name = calleeText(node.callee);
|
|
793
|
+
if (name === null) {
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
const construct = node.type === import_utils10.AST_NODE_TYPES.NewExpression;
|
|
797
|
+
for (const client of clients) {
|
|
798
|
+
if (client.callee !== name || client.construct !== construct) {
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
if (lacksTimeout(node.arguments, client.keys)) {
|
|
802
|
+
context.report({
|
|
803
|
+
node: node.callee,
|
|
804
|
+
messageId: "missingTimeout",
|
|
805
|
+
data: { callee: name, keys: client.label }
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
};
|
|
810
|
+
return { CallExpression: check, NewExpression: check };
|
|
811
|
+
}
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
// src/rules/require-fetch-timeout.ts
|
|
815
|
+
var import_utils12 = require("@typescript-eslint/utils");
|
|
816
|
+
var RULE_NAME6 = "require-fetch-timeout";
|
|
817
|
+
var DEFAULT_TIMEOUT_MS = 1e4;
|
|
818
|
+
var SIGNAL_KEYS = /* @__PURE__ */ new Set(["signal", "timeout"]);
|
|
819
|
+
var optionSchema3 = {
|
|
820
|
+
type: "object",
|
|
821
|
+
additionalProperties: false,
|
|
822
|
+
properties: {
|
|
823
|
+
callees: { type: "array", items: { type: "string" }, uniqueItems: true },
|
|
824
|
+
defaultTimeoutMs: { type: "integer", minimum: 1 }
|
|
825
|
+
}
|
|
826
|
+
};
|
|
827
|
+
function isUrlLike2(node) {
|
|
828
|
+
return node.type === import_utils12.AST_NODE_TYPES.TemplateLiteral || node.type === import_utils12.AST_NODE_TYPES.Literal && typeof node.value === "string";
|
|
829
|
+
}
|
|
830
|
+
function probeOptionsObject2(object) {
|
|
831
|
+
for (const property of object.properties) {
|
|
832
|
+
if (property.type === import_utils12.AST_NODE_TYPES.SpreadElement) {
|
|
833
|
+
return { satisfied: false, opaque: true };
|
|
834
|
+
}
|
|
835
|
+
const key = property.key;
|
|
836
|
+
const name = key.type === import_utils12.AST_NODE_TYPES.Identifier ? key.name : key.type === import_utils12.AST_NODE_TYPES.Literal && typeof key.value === "string" ? key.value : null;
|
|
729
837
|
if (name !== null && SIGNAL_KEYS.has(name)) {
|
|
730
838
|
return { satisfied: true, opaque: false };
|
|
731
839
|
}
|
|
@@ -733,14 +841,14 @@ function probeOptionsObject(object) {
|
|
|
733
841
|
return { satisfied: false, opaque: false };
|
|
734
842
|
}
|
|
735
843
|
var requireFetchTimeoutRule = createRule({
|
|
736
|
-
name:
|
|
844
|
+
name: RULE_NAME6,
|
|
737
845
|
meta: {
|
|
738
846
|
type: "problem",
|
|
739
847
|
docs: {
|
|
740
848
|
description: "A `fetch` (or configured wrapper) call must carry a `signal`/`timeout` in its options \u2014 an unbounded request can hang forever."
|
|
741
849
|
},
|
|
742
850
|
hasSuggestions: true,
|
|
743
|
-
schema: [
|
|
851
|
+
schema: [optionSchema3],
|
|
744
852
|
messages: {
|
|
745
853
|
missingTimeout: "`{{callee}}` has no timeout \u2014 pass a `signal` (e.g. `AbortSignal.timeout({{ms}})`) or a `timeout` option so the request cannot hang indefinitely.",
|
|
746
854
|
addTimeout: "Add `signal: AbortSignal.timeout({{ms}})`."
|
|
@@ -757,14 +865,14 @@ var requireFetchTimeoutRule = createRule({
|
|
|
757
865
|
return;
|
|
758
866
|
}
|
|
759
867
|
const args = node.arguments;
|
|
760
|
-
if (args.some((arg) => arg.type ===
|
|
868
|
+
if (args.some((arg) => arg.type === import_utils12.AST_NODE_TYPES.SpreadElement)) {
|
|
761
869
|
return;
|
|
762
870
|
}
|
|
763
871
|
const objectArgs = args.filter(
|
|
764
|
-
(arg) => arg.type ===
|
|
872
|
+
(arg) => arg.type === import_utils12.AST_NODE_TYPES.ObjectExpression
|
|
765
873
|
);
|
|
766
874
|
if (objectArgs.length > 0) {
|
|
767
|
-
const probes = objectArgs.map(
|
|
875
|
+
const probes = objectArgs.map(probeOptionsObject2);
|
|
768
876
|
if (probes.some((probe) => probe.satisfied || probe.opaque)) {
|
|
769
877
|
return;
|
|
770
878
|
}
|
|
@@ -793,7 +901,7 @@ var requireFetchTimeoutRule = createRule({
|
|
|
793
901
|
});
|
|
794
902
|
return;
|
|
795
903
|
}
|
|
796
|
-
if (args.length === 0 || !args.every(
|
|
904
|
+
if (args.length === 0 || !args.every(isUrlLike2)) {
|
|
797
905
|
return;
|
|
798
906
|
}
|
|
799
907
|
const lastArg = args[args.length - 1];
|
|
@@ -820,6 +928,7 @@ var requireFetchTimeoutRule = createRule({
|
|
|
820
928
|
// src/rules/index.ts
|
|
821
929
|
var rules = {
|
|
822
930
|
"require-fetch-timeout": requireFetchTimeoutRule,
|
|
931
|
+
"require-client-timeout": requireClientTimeoutRule,
|
|
823
932
|
"forward-abort-signal": forwardAbortSignalRule,
|
|
824
933
|
"no-shared-mutable-module-state": noSharedMutableModuleStateRule,
|
|
825
934
|
"prefer-parallel-awaits": preferParallelAwaitsRule,
|
|
@@ -828,7 +937,7 @@ var rules = {
|
|
|
828
937
|
|
|
829
938
|
// src/index.ts
|
|
830
939
|
var NAMESPACE = "noctcore-async-safety";
|
|
831
|
-
var VERSION = "0.
|
|
940
|
+
var VERSION = "0.2.0";
|
|
832
941
|
var plugin = {
|
|
833
942
|
meta: { name: "@noctcore/eslint-plugin-async-safety", version: VERSION },
|
|
834
943
|
rules,
|
package/dist/index.d.cts
CHANGED
|
@@ -5,6 +5,19 @@ interface NoSharedMutableModuleStateOptions {
|
|
|
5
5
|
readonly allow?: readonly string[];
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
/** One client constructor or factory whose options must bound its waits. */
|
|
9
|
+
interface ClientTimeoutSpec {
|
|
10
|
+
/** Dotted callee text, matched literally (`S3Client`, `nodemailer.createTransport`). */
|
|
11
|
+
readonly callee: string;
|
|
12
|
+
/** Match `new <callee>(...)` instead of `<callee>(...)`. Default `false`. */
|
|
13
|
+
readonly construct?: boolean;
|
|
14
|
+
/** The options object must carry at least one of these top-level keys. */
|
|
15
|
+
readonly requireAnyOf: readonly string[];
|
|
16
|
+
}
|
|
17
|
+
interface RequireClientTimeoutOptions {
|
|
18
|
+
readonly clients?: readonly ClientTimeoutSpec[];
|
|
19
|
+
}
|
|
20
|
+
|
|
8
21
|
interface RequireFetchTimeoutOptions {
|
|
9
22
|
readonly callees?: readonly string[];
|
|
10
23
|
readonly defaultTimeoutMs?: number;
|
|
@@ -15,6 +28,9 @@ declare const rules: {
|
|
|
15
28
|
'require-fetch-timeout': _typescript_eslint_utils_ts_eslint.RuleModule<"missingTimeout" | "addTimeout", [RequireFetchTimeoutOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
16
29
|
name: string;
|
|
17
30
|
};
|
|
31
|
+
'require-client-timeout': _typescript_eslint_utils_ts_eslint.RuleModule<"missingTimeout", [RequireClientTimeoutOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
32
|
+
name: string;
|
|
33
|
+
};
|
|
18
34
|
'forward-abort-signal': _typescript_eslint_utils_ts_eslint.RuleModule<"unforwardedSignal", [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
19
35
|
name: string;
|
|
20
36
|
};
|
|
@@ -38,6 +54,9 @@ declare const plugin: {
|
|
|
38
54
|
'require-fetch-timeout': _typescript_eslint_utils_ts_eslint.RuleModule<"missingTimeout" | "addTimeout", [RequireFetchTimeoutOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
39
55
|
name: string;
|
|
40
56
|
};
|
|
57
|
+
'require-client-timeout': _typescript_eslint_utils_ts_eslint.RuleModule<"missingTimeout", [RequireClientTimeoutOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
58
|
+
name: string;
|
|
59
|
+
};
|
|
41
60
|
'forward-abort-signal': _typescript_eslint_utils_ts_eslint.RuleModule<"unforwardedSignal", [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
42
61
|
name: string;
|
|
43
62
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -5,6 +5,19 @@ interface NoSharedMutableModuleStateOptions {
|
|
|
5
5
|
readonly allow?: readonly string[];
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
/** One client constructor or factory whose options must bound its waits. */
|
|
9
|
+
interface ClientTimeoutSpec {
|
|
10
|
+
/** Dotted callee text, matched literally (`S3Client`, `nodemailer.createTransport`). */
|
|
11
|
+
readonly callee: string;
|
|
12
|
+
/** Match `new <callee>(...)` instead of `<callee>(...)`. Default `false`. */
|
|
13
|
+
readonly construct?: boolean;
|
|
14
|
+
/** The options object must carry at least one of these top-level keys. */
|
|
15
|
+
readonly requireAnyOf: readonly string[];
|
|
16
|
+
}
|
|
17
|
+
interface RequireClientTimeoutOptions {
|
|
18
|
+
readonly clients?: readonly ClientTimeoutSpec[];
|
|
19
|
+
}
|
|
20
|
+
|
|
8
21
|
interface RequireFetchTimeoutOptions {
|
|
9
22
|
readonly callees?: readonly string[];
|
|
10
23
|
readonly defaultTimeoutMs?: number;
|
|
@@ -15,6 +28,9 @@ declare const rules: {
|
|
|
15
28
|
'require-fetch-timeout': _typescript_eslint_utils_ts_eslint.RuleModule<"missingTimeout" | "addTimeout", [RequireFetchTimeoutOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
16
29
|
name: string;
|
|
17
30
|
};
|
|
31
|
+
'require-client-timeout': _typescript_eslint_utils_ts_eslint.RuleModule<"missingTimeout", [RequireClientTimeoutOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
32
|
+
name: string;
|
|
33
|
+
};
|
|
18
34
|
'forward-abort-signal': _typescript_eslint_utils_ts_eslint.RuleModule<"unforwardedSignal", [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
19
35
|
name: string;
|
|
20
36
|
};
|
|
@@ -38,6 +54,9 @@ declare const plugin: {
|
|
|
38
54
|
'require-fetch-timeout': _typescript_eslint_utils_ts_eslint.RuleModule<"missingTimeout" | "addTimeout", [RequireFetchTimeoutOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
39
55
|
name: string;
|
|
40
56
|
};
|
|
57
|
+
'require-client-timeout': _typescript_eslint_utils_ts_eslint.RuleModule<"missingTimeout", [RequireClientTimeoutOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
58
|
+
name: string;
|
|
59
|
+
};
|
|
41
60
|
'forward-abort-signal': _typescript_eslint_utils_ts_eslint.RuleModule<"unforwardedSignal", [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
42
61
|
name: string;
|
|
43
62
|
};
|
package/dist/index.js
CHANGED
|
@@ -2,12 +2,20 @@
|
|
|
2
2
|
var recommended = {
|
|
3
3
|
// Precise, syntactic — safe as errors.
|
|
4
4
|
"noctcore-async-safety/require-fetch-timeout": "error",
|
|
5
|
+
// Inert until you list `clients`, so it ships enabled but checks nothing by default.
|
|
6
|
+
"noctcore-async-safety/require-client-timeout": "error",
|
|
5
7
|
// Inert until you set `include` globs, so it ships enabled but off by default.
|
|
6
8
|
"noctcore-async-safety/no-shared-mutable-module-state": "error",
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
"noctcore-async-safety/
|
|
10
|
-
|
|
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"
|
|
11
19
|
};
|
|
12
20
|
|
|
13
21
|
// src/rules/forward-abort-signal.ts
|
|
@@ -675,29 +683,129 @@ var preferParallelAwaitsRule = createRule({
|
|
|
675
683
|
}
|
|
676
684
|
});
|
|
677
685
|
|
|
678
|
-
// src/rules/require-
|
|
686
|
+
// src/rules/require-client-timeout.ts
|
|
679
687
|
import { AST_NODE_TYPES as AST_NODE_TYPES6 } from "@typescript-eslint/utils";
|
|
680
|
-
var RULE_NAME5 = "require-
|
|
681
|
-
var
|
|
682
|
-
|
|
688
|
+
var RULE_NAME5 = "require-client-timeout";
|
|
689
|
+
var clientSchema = {
|
|
690
|
+
type: "object",
|
|
691
|
+
additionalProperties: false,
|
|
692
|
+
required: ["callee", "requireAnyOf"],
|
|
693
|
+
properties: {
|
|
694
|
+
callee: { type: "string", minLength: 1 },
|
|
695
|
+
construct: { type: "boolean", default: false },
|
|
696
|
+
requireAnyOf: {
|
|
697
|
+
type: "array",
|
|
698
|
+
items: { type: "string", minLength: 1 },
|
|
699
|
+
minItems: 1,
|
|
700
|
+
uniqueItems: true
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
};
|
|
683
704
|
var optionSchema2 = {
|
|
684
705
|
type: "object",
|
|
685
706
|
additionalProperties: false,
|
|
686
707
|
properties: {
|
|
687
|
-
|
|
688
|
-
defaultTimeoutMs: { type: "integer", minimum: 1 }
|
|
708
|
+
clients: { type: "array", items: clientSchema, default: [] }
|
|
689
709
|
}
|
|
690
710
|
};
|
|
691
711
|
function isUrlLike(node) {
|
|
692
712
|
return node.type === AST_NODE_TYPES6.TemplateLiteral || node.type === AST_NODE_TYPES6.Literal && typeof node.value === "string";
|
|
693
713
|
}
|
|
694
|
-
function probeOptionsObject(object) {
|
|
714
|
+
function probeOptionsObject(object, keys) {
|
|
695
715
|
for (const property of object.properties) {
|
|
696
716
|
if (property.type === AST_NODE_TYPES6.SpreadElement) {
|
|
697
717
|
return { satisfied: false, opaque: true };
|
|
698
718
|
}
|
|
699
719
|
const key = property.key;
|
|
700
720
|
const name = key.type === AST_NODE_TYPES6.Identifier ? key.name : key.type === AST_NODE_TYPES6.Literal && typeof key.value === "string" ? key.value : null;
|
|
721
|
+
if (name !== null && keys.has(name)) {
|
|
722
|
+
return { satisfied: true, opaque: false };
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
return { satisfied: false, opaque: false };
|
|
726
|
+
}
|
|
727
|
+
function lacksTimeout(args, keys) {
|
|
728
|
+
if (args.some((arg) => arg.type === AST_NODE_TYPES6.SpreadElement)) {
|
|
729
|
+
return false;
|
|
730
|
+
}
|
|
731
|
+
const objectArgs = args.filter(
|
|
732
|
+
(arg) => arg.type === AST_NODE_TYPES6.ObjectExpression
|
|
733
|
+
);
|
|
734
|
+
if (objectArgs.length > 0) {
|
|
735
|
+
const probes = objectArgs.map((object) => probeOptionsObject(object, keys));
|
|
736
|
+
return !probes.some((probe) => probe.satisfied || probe.opaque);
|
|
737
|
+
}
|
|
738
|
+
return args.every(isUrlLike);
|
|
739
|
+
}
|
|
740
|
+
var requireClientTimeoutRule = createRule({
|
|
741
|
+
name: RULE_NAME5,
|
|
742
|
+
meta: {
|
|
743
|
+
type: "problem",
|
|
744
|
+
docs: {
|
|
745
|
+
description: "A configured network client must be constructed with a timeout option; an unbounded client can hang forever."
|
|
746
|
+
},
|
|
747
|
+
schema: [optionSchema2],
|
|
748
|
+
messages: {
|
|
749
|
+
missingTimeout: "`{{callee}}` is created without a timeout. Set one of {{keys}} in its options so a stalled peer cannot hang the caller indefinitely."
|
|
750
|
+
}
|
|
751
|
+
},
|
|
752
|
+
defaultOptions: [{ clients: [] }],
|
|
753
|
+
create(context, [options]) {
|
|
754
|
+
const clients = (options.clients ?? []).map((client) => ({
|
|
755
|
+
callee: client.callee,
|
|
756
|
+
construct: client.construct ?? false,
|
|
757
|
+
keys: new Set(client.requireAnyOf),
|
|
758
|
+
label: client.requireAnyOf.map((key) => `\`${key}\``).join(", ")
|
|
759
|
+
}));
|
|
760
|
+
if (clients.length === 0) {
|
|
761
|
+
return {};
|
|
762
|
+
}
|
|
763
|
+
const check = (node) => {
|
|
764
|
+
const name = calleeText(node.callee);
|
|
765
|
+
if (name === null) {
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
const construct = node.type === AST_NODE_TYPES6.NewExpression;
|
|
769
|
+
for (const client of clients) {
|
|
770
|
+
if (client.callee !== name || client.construct !== construct) {
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
if (lacksTimeout(node.arguments, client.keys)) {
|
|
774
|
+
context.report({
|
|
775
|
+
node: node.callee,
|
|
776
|
+
messageId: "missingTimeout",
|
|
777
|
+
data: { callee: name, keys: client.label }
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
};
|
|
782
|
+
return { CallExpression: check, NewExpression: check };
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
// src/rules/require-fetch-timeout.ts
|
|
787
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES7 } from "@typescript-eslint/utils";
|
|
788
|
+
var RULE_NAME6 = "require-fetch-timeout";
|
|
789
|
+
var DEFAULT_TIMEOUT_MS = 1e4;
|
|
790
|
+
var SIGNAL_KEYS = /* @__PURE__ */ new Set(["signal", "timeout"]);
|
|
791
|
+
var optionSchema3 = {
|
|
792
|
+
type: "object",
|
|
793
|
+
additionalProperties: false,
|
|
794
|
+
properties: {
|
|
795
|
+
callees: { type: "array", items: { type: "string" }, uniqueItems: true },
|
|
796
|
+
defaultTimeoutMs: { type: "integer", minimum: 1 }
|
|
797
|
+
}
|
|
798
|
+
};
|
|
799
|
+
function isUrlLike2(node) {
|
|
800
|
+
return node.type === AST_NODE_TYPES7.TemplateLiteral || node.type === AST_NODE_TYPES7.Literal && typeof node.value === "string";
|
|
801
|
+
}
|
|
802
|
+
function probeOptionsObject2(object) {
|
|
803
|
+
for (const property of object.properties) {
|
|
804
|
+
if (property.type === AST_NODE_TYPES7.SpreadElement) {
|
|
805
|
+
return { satisfied: false, opaque: true };
|
|
806
|
+
}
|
|
807
|
+
const key = property.key;
|
|
808
|
+
const name = key.type === AST_NODE_TYPES7.Identifier ? key.name : key.type === AST_NODE_TYPES7.Literal && typeof key.value === "string" ? key.value : null;
|
|
701
809
|
if (name !== null && SIGNAL_KEYS.has(name)) {
|
|
702
810
|
return { satisfied: true, opaque: false };
|
|
703
811
|
}
|
|
@@ -705,14 +813,14 @@ function probeOptionsObject(object) {
|
|
|
705
813
|
return { satisfied: false, opaque: false };
|
|
706
814
|
}
|
|
707
815
|
var requireFetchTimeoutRule = createRule({
|
|
708
|
-
name:
|
|
816
|
+
name: RULE_NAME6,
|
|
709
817
|
meta: {
|
|
710
818
|
type: "problem",
|
|
711
819
|
docs: {
|
|
712
820
|
description: "A `fetch` (or configured wrapper) call must carry a `signal`/`timeout` in its options \u2014 an unbounded request can hang forever."
|
|
713
821
|
},
|
|
714
822
|
hasSuggestions: true,
|
|
715
|
-
schema: [
|
|
823
|
+
schema: [optionSchema3],
|
|
716
824
|
messages: {
|
|
717
825
|
missingTimeout: "`{{callee}}` has no timeout \u2014 pass a `signal` (e.g. `AbortSignal.timeout({{ms}})`) or a `timeout` option so the request cannot hang indefinitely.",
|
|
718
826
|
addTimeout: "Add `signal: AbortSignal.timeout({{ms}})`."
|
|
@@ -729,14 +837,14 @@ var requireFetchTimeoutRule = createRule({
|
|
|
729
837
|
return;
|
|
730
838
|
}
|
|
731
839
|
const args = node.arguments;
|
|
732
|
-
if (args.some((arg) => arg.type ===
|
|
840
|
+
if (args.some((arg) => arg.type === AST_NODE_TYPES7.SpreadElement)) {
|
|
733
841
|
return;
|
|
734
842
|
}
|
|
735
843
|
const objectArgs = args.filter(
|
|
736
|
-
(arg) => arg.type ===
|
|
844
|
+
(arg) => arg.type === AST_NODE_TYPES7.ObjectExpression
|
|
737
845
|
);
|
|
738
846
|
if (objectArgs.length > 0) {
|
|
739
|
-
const probes = objectArgs.map(
|
|
847
|
+
const probes = objectArgs.map(probeOptionsObject2);
|
|
740
848
|
if (probes.some((probe) => probe.satisfied || probe.opaque)) {
|
|
741
849
|
return;
|
|
742
850
|
}
|
|
@@ -765,7 +873,7 @@ var requireFetchTimeoutRule = createRule({
|
|
|
765
873
|
});
|
|
766
874
|
return;
|
|
767
875
|
}
|
|
768
|
-
if (args.length === 0 || !args.every(
|
|
876
|
+
if (args.length === 0 || !args.every(isUrlLike2)) {
|
|
769
877
|
return;
|
|
770
878
|
}
|
|
771
879
|
const lastArg = args[args.length - 1];
|
|
@@ -792,6 +900,7 @@ var requireFetchTimeoutRule = createRule({
|
|
|
792
900
|
// src/rules/index.ts
|
|
793
901
|
var rules = {
|
|
794
902
|
"require-fetch-timeout": requireFetchTimeoutRule,
|
|
903
|
+
"require-client-timeout": requireClientTimeoutRule,
|
|
795
904
|
"forward-abort-signal": forwardAbortSignalRule,
|
|
796
905
|
"no-shared-mutable-module-state": noSharedMutableModuleStateRule,
|
|
797
906
|
"prefer-parallel-awaits": preferParallelAwaitsRule,
|
|
@@ -800,7 +909,7 @@ var rules = {
|
|
|
800
909
|
|
|
801
910
|
// src/index.ts
|
|
802
911
|
var NAMESPACE = "noctcore-async-safety";
|
|
803
|
-
var VERSION = "0.
|
|
912
|
+
var VERSION = "0.2.0";
|
|
804
913
|
var plugin = {
|
|
805
914
|
meta: { name: "@noctcore/eslint-plugin-async-safety", version: VERSION },
|
|
806
915
|
rules,
|
|
@@ -45,4 +45,4 @@ None.
|
|
|
45
45
|
## When not to use it
|
|
46
46
|
|
|
47
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
|
|
48
|
+
a concurrency limit of 1), this rule's report is a false positive: disable it inline for that block.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# `noctcore-async-safety/require-client-timeout`
|
|
2
|
+
|
|
3
|
+
> A configured network client must be constructed with a timeout option. An unbounded client can hang
|
|
4
|
+
> forever.
|
|
5
|
+
|
|
6
|
+
## Why
|
|
7
|
+
|
|
8
|
+
Many clients are configured once, at construction, and never see a per-request `signal`: an S3 client
|
|
9
|
+
whose request handler has no connection timeout, an SMTP transport with no connection or socket
|
|
10
|
+
timeout, a database pool with no connect timeout. A peer that accepts the connection and never answers
|
|
11
|
+
leaves every caller waiting indefinitely. [`require-fetch-timeout`](./require-fetch-timeout.md)
|
|
12
|
+
covers `fetch`; this rule covers the clients you name.
|
|
13
|
+
|
|
14
|
+
## What it flags
|
|
15
|
+
|
|
16
|
+
The rule ships knowing **no** client. For each entry in `clients`, it reports a matching call (or
|
|
17
|
+
`new`, with `construct: true`) whose options visibly carry none of the `requireAnyOf` keys.
|
|
18
|
+
|
|
19
|
+
It follows the same precision contract as `require-fetch-timeout`: no type information, and silent
|
|
20
|
+
whenever it cannot see the options.
|
|
21
|
+
|
|
22
|
+
- A spread argument (`new Client(...args)`) or a `...spread` inside the options literal is opaque:
|
|
23
|
+
skipped.
|
|
24
|
+
- An options slot that is an identifier, call or member (`new Client(config)`) may already set a
|
|
25
|
+
timeout: skipped.
|
|
26
|
+
- It reports a visible options object literal with none of the keys, or an argument list with nothing
|
|
27
|
+
but string/template literals (a connection URL), including no arguments at all.
|
|
28
|
+
|
|
29
|
+
Keys are matched at the top level of the options object only.
|
|
30
|
+
|
|
31
|
+
## Options
|
|
32
|
+
|
|
33
|
+
| Option | Type | Default | Meaning |
|
|
34
|
+
| --- | --- | --- | --- |
|
|
35
|
+
| `clients` | `{ callee, construct?, requireAnyOf }[]` | `[]` | The clients to check. |
|
|
36
|
+
| `clients[].callee` | `string` | (required) | Dotted callee text, matched literally: `'S3Client'`, `'nodemailer.createTransport'`. |
|
|
37
|
+
| `clients[].construct` | `boolean` | `false` | Match `new <callee>(...)` instead of `<callee>(...)`. |
|
|
38
|
+
| `clients[].requireAnyOf` | `string[]` (at least one) | (required) | The options object must carry at least one of these top-level keys. |
|
|
39
|
+
|
|
40
|
+
With the default `clients: []` the rule is inert, which is why `recommended` can ship it at `error`.
|
|
41
|
+
|
|
42
|
+
## Worked example
|
|
43
|
+
|
|
44
|
+
A NestJS API that talks to S3 and sends mail over SMTP:
|
|
45
|
+
|
|
46
|
+
```js
|
|
47
|
+
'noctcore-async-safety/require-client-timeout': [
|
|
48
|
+
'error',
|
|
49
|
+
{
|
|
50
|
+
clients: [
|
|
51
|
+
// AWS SDK v3: timeouts live on the request handler.
|
|
52
|
+
{ callee: 'S3Client', construct: true, requireAnyOf: ['requestHandler'] },
|
|
53
|
+
// Nodemailer SMTP transport.
|
|
54
|
+
{
|
|
55
|
+
callee: 'nodemailer.createTransport',
|
|
56
|
+
requireAnyOf: ['connectionTimeout', 'socketTimeout'],
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
// Bad: no request handler, so no connection or request timeout
|
|
65
|
+
this.s3 = new S3Client({ region, credentials });
|
|
66
|
+
|
|
67
|
+
// Bad: SMTP transport with default (unbounded in practice) timeouts
|
|
68
|
+
this.transporter = nodemailer.createTransport({ host, port, secure: true });
|
|
69
|
+
|
|
70
|
+
// Good
|
|
71
|
+
this.s3 = new S3Client({
|
|
72
|
+
region,
|
|
73
|
+
credentials,
|
|
74
|
+
requestHandler: { connectionTimeout: 5_000, requestTimeout: 30_000 },
|
|
75
|
+
});
|
|
76
|
+
this.transporter = nodemailer.createTransport({
|
|
77
|
+
host,
|
|
78
|
+
port,
|
|
79
|
+
secure: true,
|
|
80
|
+
connectionTimeout: 10_000,
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Limits
|
|
85
|
+
|
|
86
|
+
- It enforces that a listed key is **present**, not that its value is a sane timeout.
|
|
87
|
+
`requestHandler: new NodeHttpHandler({})` passes.
|
|
88
|
+
- Matching is by callee text. An aliased import (`import { S3Client as S3 }`) or a destructured factory
|
|
89
|
+
(`const { createTransport } = nodemailer`) is not matched unless you list that name too.
|
|
90
|
+
- A client built from an opaque config object (`new S3Client(config)`) is never reported.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noctcore/eslint-plugin-async-safety",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|
|
@@ -62,6 +62,6 @@
|
|
|
62
62
|
"@typescript-eslint/rule-tester": "^8.61.1",
|
|
63
63
|
"tsup": "^8.5.1",
|
|
64
64
|
"typescript": "^5.6.0",
|
|
65
|
-
"vitest": "^
|
|
65
|
+
"vitest": "^4"
|
|
66
66
|
}
|
|
67
67
|
}
|