@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 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-fetch-timeout.ts
708
+ // src/rules/require-client-timeout.ts
707
709
  var import_utils10 = require("@typescript-eslint/utils");
708
- var RULE_NAME5 = "require-fetch-timeout";
709
- var DEFAULT_TIMEOUT_MS = 1e4;
710
- var SIGNAL_KEYS = /* @__PURE__ */ new Set(["signal", "timeout"]);
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
- callees: { type: "array", items: { type: "string" }, uniqueItems: true },
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: RULE_NAME5,
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: [optionSchema2],
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 === import_utils10.AST_NODE_TYPES.SpreadElement)) {
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 === import_utils10.AST_NODE_TYPES.ObjectExpression
866
+ (arg) => arg.type === import_utils12.AST_NODE_TYPES.ObjectExpression
765
867
  );
766
868
  if (objectArgs.length > 0) {
767
- const probes = objectArgs.map(probeOptionsObject);
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(isUrlLike)) {
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.1.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-fetch-timeout.ts
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-fetch-timeout";
681
- var DEFAULT_TIMEOUT_MS = 1e4;
682
- var SIGNAL_KEYS = /* @__PURE__ */ new Set(["signal", "timeout"]);
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
- callees: { type: "array", items: { type: "string" }, uniqueItems: true },
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: RULE_NAME5,
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: [optionSchema2],
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 === AST_NODE_TYPES6.SpreadElement)) {
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 === AST_NODE_TYPES6.ObjectExpression
838
+ (arg) => arg.type === AST_NODE_TYPES7.ObjectExpression
737
839
  );
738
840
  if (objectArgs.length > 0) {
739
- const probes = objectArgs.map(probeOptionsObject);
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(isUrlLike)) {
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.1.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.1.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": "^3"
65
+ "vitest": "^4"
66
66
  }
67
67
  }