@noctcore/eslint-plugin-async-safety 0.1.0 → 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.
- package/README.md +2 -0
- package/dist/index.cjs +117 -14
- package/dist/index.d.cts +19 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +117 -14
- 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,6 +53,7 @@ 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
58
|
| `forward-abort-signal` | `warn` | Heuristic — advisory. |
|
|
57
59
|
| `prefer-parallel-awaits` | `warn` | Heuristic — advisory suggestion. |
|
package/dist/index.cjs
CHANGED
|
@@ -30,6 +30,8 @@ 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
37
|
// Heuristic — advisory. Warns rather than blocking.
|
|
@@ -703,29 +705,129 @@ var preferParallelAwaitsRule = createRule({
|
|
|
703
705
|
}
|
|
704
706
|
});
|
|
705
707
|
|
|
706
|
-
// src/rules/require-
|
|
708
|
+
// src/rules/require-client-timeout.ts
|
|
707
709
|
var import_utils10 = require("@typescript-eslint/utils");
|
|
708
|
-
var RULE_NAME5 = "require-
|
|
709
|
-
var
|
|
710
|
-
|
|
710
|
+
var RULE_NAME5 = "require-client-timeout";
|
|
711
|
+
var clientSchema = {
|
|
712
|
+
type: "object",
|
|
713
|
+
additionalProperties: false,
|
|
714
|
+
required: ["callee", "requireAnyOf"],
|
|
715
|
+
properties: {
|
|
716
|
+
callee: { type: "string", minLength: 1 },
|
|
717
|
+
construct: { type: "boolean", default: false },
|
|
718
|
+
requireAnyOf: {
|
|
719
|
+
type: "array",
|
|
720
|
+
items: { type: "string", minLength: 1 },
|
|
721
|
+
minItems: 1,
|
|
722
|
+
uniqueItems: true
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
};
|
|
711
726
|
var optionSchema2 = {
|
|
712
727
|
type: "object",
|
|
713
728
|
additionalProperties: false,
|
|
714
729
|
properties: {
|
|
715
|
-
|
|
716
|
-
defaultTimeoutMs: { type: "integer", minimum: 1 }
|
|
730
|
+
clients: { type: "array", items: clientSchema, default: [] }
|
|
717
731
|
}
|
|
718
732
|
};
|
|
719
733
|
function isUrlLike(node) {
|
|
720
734
|
return node.type === import_utils10.AST_NODE_TYPES.TemplateLiteral || node.type === import_utils10.AST_NODE_TYPES.Literal && typeof node.value === "string";
|
|
721
735
|
}
|
|
722
|
-
function probeOptionsObject(object) {
|
|
736
|
+
function probeOptionsObject(object, keys) {
|
|
723
737
|
for (const property of object.properties) {
|
|
724
738
|
if (property.type === import_utils10.AST_NODE_TYPES.SpreadElement) {
|
|
725
739
|
return { satisfied: false, opaque: true };
|
|
726
740
|
}
|
|
727
741
|
const key = property.key;
|
|
728
742
|
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;
|
|
743
|
+
if (name !== null && keys.has(name)) {
|
|
744
|
+
return { satisfied: true, opaque: false };
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return { satisfied: false, opaque: false };
|
|
748
|
+
}
|
|
749
|
+
function lacksTimeout(args, keys) {
|
|
750
|
+
if (args.some((arg) => arg.type === import_utils10.AST_NODE_TYPES.SpreadElement)) {
|
|
751
|
+
return false;
|
|
752
|
+
}
|
|
753
|
+
const objectArgs = args.filter(
|
|
754
|
+
(arg) => arg.type === import_utils10.AST_NODE_TYPES.ObjectExpression
|
|
755
|
+
);
|
|
756
|
+
if (objectArgs.length > 0) {
|
|
757
|
+
const probes = objectArgs.map((object) => probeOptionsObject(object, keys));
|
|
758
|
+
return !probes.some((probe) => probe.satisfied || probe.opaque);
|
|
759
|
+
}
|
|
760
|
+
return args.every(isUrlLike);
|
|
761
|
+
}
|
|
762
|
+
var requireClientTimeoutRule = createRule({
|
|
763
|
+
name: RULE_NAME5,
|
|
764
|
+
meta: {
|
|
765
|
+
type: "problem",
|
|
766
|
+
docs: {
|
|
767
|
+
description: "A configured network client must be constructed with a timeout option; an unbounded client can hang forever."
|
|
768
|
+
},
|
|
769
|
+
schema: [optionSchema2],
|
|
770
|
+
messages: {
|
|
771
|
+
missingTimeout: "`{{callee}}` is created without a timeout. Set one of {{keys}} in its options so a stalled peer cannot hang the caller indefinitely."
|
|
772
|
+
}
|
|
773
|
+
},
|
|
774
|
+
defaultOptions: [{ clients: [] }],
|
|
775
|
+
create(context, [options]) {
|
|
776
|
+
const clients = (options.clients ?? []).map((client) => ({
|
|
777
|
+
callee: client.callee,
|
|
778
|
+
construct: client.construct ?? false,
|
|
779
|
+
keys: new Set(client.requireAnyOf),
|
|
780
|
+
label: client.requireAnyOf.map((key) => `\`${key}\``).join(", ")
|
|
781
|
+
}));
|
|
782
|
+
if (clients.length === 0) {
|
|
783
|
+
return {};
|
|
784
|
+
}
|
|
785
|
+
const check = (node) => {
|
|
786
|
+
const name = calleeText(node.callee);
|
|
787
|
+
if (name === null) {
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
const construct = node.type === import_utils10.AST_NODE_TYPES.NewExpression;
|
|
791
|
+
for (const client of clients) {
|
|
792
|
+
if (client.callee !== name || client.construct !== construct) {
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
if (lacksTimeout(node.arguments, client.keys)) {
|
|
796
|
+
context.report({
|
|
797
|
+
node: node.callee,
|
|
798
|
+
messageId: "missingTimeout",
|
|
799
|
+
data: { callee: name, keys: client.label }
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
};
|
|
804
|
+
return { CallExpression: check, NewExpression: check };
|
|
805
|
+
}
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
// src/rules/require-fetch-timeout.ts
|
|
809
|
+
var import_utils12 = require("@typescript-eslint/utils");
|
|
810
|
+
var RULE_NAME6 = "require-fetch-timeout";
|
|
811
|
+
var DEFAULT_TIMEOUT_MS = 1e4;
|
|
812
|
+
var SIGNAL_KEYS = /* @__PURE__ */ new Set(["signal", "timeout"]);
|
|
813
|
+
var optionSchema3 = {
|
|
814
|
+
type: "object",
|
|
815
|
+
additionalProperties: false,
|
|
816
|
+
properties: {
|
|
817
|
+
callees: { type: "array", items: { type: "string" }, uniqueItems: true },
|
|
818
|
+
defaultTimeoutMs: { type: "integer", minimum: 1 }
|
|
819
|
+
}
|
|
820
|
+
};
|
|
821
|
+
function isUrlLike2(node) {
|
|
822
|
+
return node.type === import_utils12.AST_NODE_TYPES.TemplateLiteral || node.type === import_utils12.AST_NODE_TYPES.Literal && typeof node.value === "string";
|
|
823
|
+
}
|
|
824
|
+
function probeOptionsObject2(object) {
|
|
825
|
+
for (const property of object.properties) {
|
|
826
|
+
if (property.type === import_utils12.AST_NODE_TYPES.SpreadElement) {
|
|
827
|
+
return { satisfied: false, opaque: true };
|
|
828
|
+
}
|
|
829
|
+
const key = property.key;
|
|
830
|
+
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
831
|
if (name !== null && SIGNAL_KEYS.has(name)) {
|
|
730
832
|
return { satisfied: true, opaque: false };
|
|
731
833
|
}
|
|
@@ -733,14 +835,14 @@ function probeOptionsObject(object) {
|
|
|
733
835
|
return { satisfied: false, opaque: false };
|
|
734
836
|
}
|
|
735
837
|
var requireFetchTimeoutRule = createRule({
|
|
736
|
-
name:
|
|
838
|
+
name: RULE_NAME6,
|
|
737
839
|
meta: {
|
|
738
840
|
type: "problem",
|
|
739
841
|
docs: {
|
|
740
842
|
description: "A `fetch` (or configured wrapper) call must carry a `signal`/`timeout` in its options \u2014 an unbounded request can hang forever."
|
|
741
843
|
},
|
|
742
844
|
hasSuggestions: true,
|
|
743
|
-
schema: [
|
|
845
|
+
schema: [optionSchema3],
|
|
744
846
|
messages: {
|
|
745
847
|
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
848
|
addTimeout: "Add `signal: AbortSignal.timeout({{ms}})`."
|
|
@@ -757,14 +859,14 @@ var requireFetchTimeoutRule = createRule({
|
|
|
757
859
|
return;
|
|
758
860
|
}
|
|
759
861
|
const args = node.arguments;
|
|
760
|
-
if (args.some((arg) => arg.type ===
|
|
862
|
+
if (args.some((arg) => arg.type === import_utils12.AST_NODE_TYPES.SpreadElement)) {
|
|
761
863
|
return;
|
|
762
864
|
}
|
|
763
865
|
const objectArgs = args.filter(
|
|
764
|
-
(arg) => arg.type ===
|
|
866
|
+
(arg) => arg.type === import_utils12.AST_NODE_TYPES.ObjectExpression
|
|
765
867
|
);
|
|
766
868
|
if (objectArgs.length > 0) {
|
|
767
|
-
const probes = objectArgs.map(
|
|
869
|
+
const probes = objectArgs.map(probeOptionsObject2);
|
|
768
870
|
if (probes.some((probe) => probe.satisfied || probe.opaque)) {
|
|
769
871
|
return;
|
|
770
872
|
}
|
|
@@ -793,7 +895,7 @@ var requireFetchTimeoutRule = createRule({
|
|
|
793
895
|
});
|
|
794
896
|
return;
|
|
795
897
|
}
|
|
796
|
-
if (args.length === 0 || !args.every(
|
|
898
|
+
if (args.length === 0 || !args.every(isUrlLike2)) {
|
|
797
899
|
return;
|
|
798
900
|
}
|
|
799
901
|
const lastArg = args[args.length - 1];
|
|
@@ -820,6 +922,7 @@ var requireFetchTimeoutRule = createRule({
|
|
|
820
922
|
// src/rules/index.ts
|
|
821
923
|
var rules = {
|
|
822
924
|
"require-fetch-timeout": requireFetchTimeoutRule,
|
|
925
|
+
"require-client-timeout": requireClientTimeoutRule,
|
|
823
926
|
"forward-abort-signal": forwardAbortSignalRule,
|
|
824
927
|
"no-shared-mutable-module-state": noSharedMutableModuleStateRule,
|
|
825
928
|
"prefer-parallel-awaits": preferParallelAwaitsRule,
|
|
@@ -828,7 +931,7 @@ var rules = {
|
|
|
828
931
|
|
|
829
932
|
// src/index.ts
|
|
830
933
|
var NAMESPACE = "noctcore-async-safety";
|
|
831
|
-
var VERSION = "0.
|
|
934
|
+
var VERSION = "0.2.0";
|
|
832
935
|
var plugin = {
|
|
833
936
|
meta: { name: "@noctcore/eslint-plugin-async-safety", version: VERSION },
|
|
834
937
|
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,6 +2,8 @@
|
|
|
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
9
|
// Heuristic — advisory. Warns rather than blocking.
|
|
@@ -675,29 +677,129 @@ var preferParallelAwaitsRule = createRule({
|
|
|
675
677
|
}
|
|
676
678
|
});
|
|
677
679
|
|
|
678
|
-
// src/rules/require-
|
|
680
|
+
// src/rules/require-client-timeout.ts
|
|
679
681
|
import { AST_NODE_TYPES as AST_NODE_TYPES6 } from "@typescript-eslint/utils";
|
|
680
|
-
var RULE_NAME5 = "require-
|
|
681
|
-
var
|
|
682
|
-
|
|
682
|
+
var RULE_NAME5 = "require-client-timeout";
|
|
683
|
+
var clientSchema = {
|
|
684
|
+
type: "object",
|
|
685
|
+
additionalProperties: false,
|
|
686
|
+
required: ["callee", "requireAnyOf"],
|
|
687
|
+
properties: {
|
|
688
|
+
callee: { type: "string", minLength: 1 },
|
|
689
|
+
construct: { type: "boolean", default: false },
|
|
690
|
+
requireAnyOf: {
|
|
691
|
+
type: "array",
|
|
692
|
+
items: { type: "string", minLength: 1 },
|
|
693
|
+
minItems: 1,
|
|
694
|
+
uniqueItems: true
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
};
|
|
683
698
|
var optionSchema2 = {
|
|
684
699
|
type: "object",
|
|
685
700
|
additionalProperties: false,
|
|
686
701
|
properties: {
|
|
687
|
-
|
|
688
|
-
defaultTimeoutMs: { type: "integer", minimum: 1 }
|
|
702
|
+
clients: { type: "array", items: clientSchema, default: [] }
|
|
689
703
|
}
|
|
690
704
|
};
|
|
691
705
|
function isUrlLike(node) {
|
|
692
706
|
return node.type === AST_NODE_TYPES6.TemplateLiteral || node.type === AST_NODE_TYPES6.Literal && typeof node.value === "string";
|
|
693
707
|
}
|
|
694
|
-
function probeOptionsObject(object) {
|
|
708
|
+
function probeOptionsObject(object, keys) {
|
|
695
709
|
for (const property of object.properties) {
|
|
696
710
|
if (property.type === AST_NODE_TYPES6.SpreadElement) {
|
|
697
711
|
return { satisfied: false, opaque: true };
|
|
698
712
|
}
|
|
699
713
|
const key = property.key;
|
|
700
714
|
const name = key.type === AST_NODE_TYPES6.Identifier ? key.name : key.type === AST_NODE_TYPES6.Literal && typeof key.value === "string" ? key.value : null;
|
|
715
|
+
if (name !== null && keys.has(name)) {
|
|
716
|
+
return { satisfied: true, opaque: false };
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return { satisfied: false, opaque: false };
|
|
720
|
+
}
|
|
721
|
+
function lacksTimeout(args, keys) {
|
|
722
|
+
if (args.some((arg) => arg.type === AST_NODE_TYPES6.SpreadElement)) {
|
|
723
|
+
return false;
|
|
724
|
+
}
|
|
725
|
+
const objectArgs = args.filter(
|
|
726
|
+
(arg) => arg.type === AST_NODE_TYPES6.ObjectExpression
|
|
727
|
+
);
|
|
728
|
+
if (objectArgs.length > 0) {
|
|
729
|
+
const probes = objectArgs.map((object) => probeOptionsObject(object, keys));
|
|
730
|
+
return !probes.some((probe) => probe.satisfied || probe.opaque);
|
|
731
|
+
}
|
|
732
|
+
return args.every(isUrlLike);
|
|
733
|
+
}
|
|
734
|
+
var requireClientTimeoutRule = createRule({
|
|
735
|
+
name: RULE_NAME5,
|
|
736
|
+
meta: {
|
|
737
|
+
type: "problem",
|
|
738
|
+
docs: {
|
|
739
|
+
description: "A configured network client must be constructed with a timeout option; an unbounded client can hang forever."
|
|
740
|
+
},
|
|
741
|
+
schema: [optionSchema2],
|
|
742
|
+
messages: {
|
|
743
|
+
missingTimeout: "`{{callee}}` is created without a timeout. Set one of {{keys}} in its options so a stalled peer cannot hang the caller indefinitely."
|
|
744
|
+
}
|
|
745
|
+
},
|
|
746
|
+
defaultOptions: [{ clients: [] }],
|
|
747
|
+
create(context, [options]) {
|
|
748
|
+
const clients = (options.clients ?? []).map((client) => ({
|
|
749
|
+
callee: client.callee,
|
|
750
|
+
construct: client.construct ?? false,
|
|
751
|
+
keys: new Set(client.requireAnyOf),
|
|
752
|
+
label: client.requireAnyOf.map((key) => `\`${key}\``).join(", ")
|
|
753
|
+
}));
|
|
754
|
+
if (clients.length === 0) {
|
|
755
|
+
return {};
|
|
756
|
+
}
|
|
757
|
+
const check = (node) => {
|
|
758
|
+
const name = calleeText(node.callee);
|
|
759
|
+
if (name === null) {
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
const construct = node.type === AST_NODE_TYPES6.NewExpression;
|
|
763
|
+
for (const client of clients) {
|
|
764
|
+
if (client.callee !== name || client.construct !== construct) {
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
if (lacksTimeout(node.arguments, client.keys)) {
|
|
768
|
+
context.report({
|
|
769
|
+
node: node.callee,
|
|
770
|
+
messageId: "missingTimeout",
|
|
771
|
+
data: { callee: name, keys: client.label }
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
};
|
|
776
|
+
return { CallExpression: check, NewExpression: check };
|
|
777
|
+
}
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
// src/rules/require-fetch-timeout.ts
|
|
781
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES7 } from "@typescript-eslint/utils";
|
|
782
|
+
var RULE_NAME6 = "require-fetch-timeout";
|
|
783
|
+
var DEFAULT_TIMEOUT_MS = 1e4;
|
|
784
|
+
var SIGNAL_KEYS = /* @__PURE__ */ new Set(["signal", "timeout"]);
|
|
785
|
+
var optionSchema3 = {
|
|
786
|
+
type: "object",
|
|
787
|
+
additionalProperties: false,
|
|
788
|
+
properties: {
|
|
789
|
+
callees: { type: "array", items: { type: "string" }, uniqueItems: true },
|
|
790
|
+
defaultTimeoutMs: { type: "integer", minimum: 1 }
|
|
791
|
+
}
|
|
792
|
+
};
|
|
793
|
+
function isUrlLike2(node) {
|
|
794
|
+
return node.type === AST_NODE_TYPES7.TemplateLiteral || node.type === AST_NODE_TYPES7.Literal && typeof node.value === "string";
|
|
795
|
+
}
|
|
796
|
+
function probeOptionsObject2(object) {
|
|
797
|
+
for (const property of object.properties) {
|
|
798
|
+
if (property.type === AST_NODE_TYPES7.SpreadElement) {
|
|
799
|
+
return { satisfied: false, opaque: true };
|
|
800
|
+
}
|
|
801
|
+
const key = property.key;
|
|
802
|
+
const name = key.type === AST_NODE_TYPES7.Identifier ? key.name : key.type === AST_NODE_TYPES7.Literal && typeof key.value === "string" ? key.value : null;
|
|
701
803
|
if (name !== null && SIGNAL_KEYS.has(name)) {
|
|
702
804
|
return { satisfied: true, opaque: false };
|
|
703
805
|
}
|
|
@@ -705,14 +807,14 @@ function probeOptionsObject(object) {
|
|
|
705
807
|
return { satisfied: false, opaque: false };
|
|
706
808
|
}
|
|
707
809
|
var requireFetchTimeoutRule = createRule({
|
|
708
|
-
name:
|
|
810
|
+
name: RULE_NAME6,
|
|
709
811
|
meta: {
|
|
710
812
|
type: "problem",
|
|
711
813
|
docs: {
|
|
712
814
|
description: "A `fetch` (or configured wrapper) call must carry a `signal`/`timeout` in its options \u2014 an unbounded request can hang forever."
|
|
713
815
|
},
|
|
714
816
|
hasSuggestions: true,
|
|
715
|
-
schema: [
|
|
817
|
+
schema: [optionSchema3],
|
|
716
818
|
messages: {
|
|
717
819
|
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
820
|
addTimeout: "Add `signal: AbortSignal.timeout({{ms}})`."
|
|
@@ -729,14 +831,14 @@ var requireFetchTimeoutRule = createRule({
|
|
|
729
831
|
return;
|
|
730
832
|
}
|
|
731
833
|
const args = node.arguments;
|
|
732
|
-
if (args.some((arg) => arg.type ===
|
|
834
|
+
if (args.some((arg) => arg.type === AST_NODE_TYPES7.SpreadElement)) {
|
|
733
835
|
return;
|
|
734
836
|
}
|
|
735
837
|
const objectArgs = args.filter(
|
|
736
|
-
(arg) => arg.type ===
|
|
838
|
+
(arg) => arg.type === AST_NODE_TYPES7.ObjectExpression
|
|
737
839
|
);
|
|
738
840
|
if (objectArgs.length > 0) {
|
|
739
|
-
const probes = objectArgs.map(
|
|
841
|
+
const probes = objectArgs.map(probeOptionsObject2);
|
|
740
842
|
if (probes.some((probe) => probe.satisfied || probe.opaque)) {
|
|
741
843
|
return;
|
|
742
844
|
}
|
|
@@ -765,7 +867,7 @@ var requireFetchTimeoutRule = createRule({
|
|
|
765
867
|
});
|
|
766
868
|
return;
|
|
767
869
|
}
|
|
768
|
-
if (args.length === 0 || !args.every(
|
|
870
|
+
if (args.length === 0 || !args.every(isUrlLike2)) {
|
|
769
871
|
return;
|
|
770
872
|
}
|
|
771
873
|
const lastArg = args[args.length - 1];
|
|
@@ -792,6 +894,7 @@ var requireFetchTimeoutRule = createRule({
|
|
|
792
894
|
// src/rules/index.ts
|
|
793
895
|
var rules = {
|
|
794
896
|
"require-fetch-timeout": requireFetchTimeoutRule,
|
|
897
|
+
"require-client-timeout": requireClientTimeoutRule,
|
|
795
898
|
"forward-abort-signal": forwardAbortSignalRule,
|
|
796
899
|
"no-shared-mutable-module-state": noSharedMutableModuleStateRule,
|
|
797
900
|
"prefer-parallel-awaits": preferParallelAwaitsRule,
|
|
@@ -800,7 +903,7 @@ var rules = {
|
|
|
800
903
|
|
|
801
904
|
// src/index.ts
|
|
802
905
|
var NAMESPACE = "noctcore-async-safety";
|
|
803
|
-
var VERSION = "0.
|
|
906
|
+
var VERSION = "0.2.0";
|
|
804
907
|
var plugin = {
|
|
805
908
|
meta: { name: "@noctcore/eslint-plugin-async-safety", version: VERSION },
|
|
806
909
|
rules,
|
|
@@ -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.2.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
|
}
|