@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 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` | `warn` | Heuristic advisory. |
57
- | `prefer-parallel-awaits` | `warn` | Heuristic advisory suggestion. |
58
- | `no-concurrent-shared-mutation` | `warn` | Heuristic advisory. |
58
+ | `forward-abort-signal` | `error` | A dead `signal` is a real bug; any forwarding shape counts as a pass. |
59
+ | `no-concurrent-shared-mutation` | `error` | A lost update is a real bug; order-tolerant writes are skipped. |
60
+ | `prefer-parallel-awaits` | `off` | A latency hint, not a bug. Sequential awaits are often deliberate. Opt in where you want it. |
61
+
62
+ Every rule is `error` or `off`, never `warn`: a warning is a rule nobody obeys.
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
- // Heuristic advisory. Warns rather than blocking.
36
- "noctcore-async-safety/forward-abort-signal": "warn",
37
- "noctcore-async-safety/prefer-parallel-awaits": "warn",
38
- "noctcore-async-safety/no-concurrent-shared-mutation": "warn"
37
+ // A dead `signal` parameter is a real bug (the cancel never reaches the I/O), and
38
+ // the rule counts any forwarding shape as a pass, so it errs toward silence.
39
+ "noctcore-async-safety/forward-abort-signal": "error",
40
+ // A lost update is a real bug, and the rule skips order-tolerant writes
41
+ // (`push`, `set`, distinct-index) and plain overwrites.
42
+ "noctcore-async-safety/no-concurrent-shared-mutation": "error",
43
+ // Ships OFF: a latency hint, not a correctness bug. Sequential awaits are often
44
+ // deliberate (one transaction client, rate limits, deterministic test setup), and
45
+ // the rule cannot see that. Enable it where you want the nudge.
46
+ "noctcore-async-safety/prefer-parallel-awaits": "off"
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-fetch-timeout.ts
714
+ // src/rules/require-client-timeout.ts
707
715
  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"]);
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
- callees: { type: "array", items: { type: "string" }, uniqueItems: true },
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: RULE_NAME5,
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: [optionSchema2],
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 === import_utils10.AST_NODE_TYPES.SpreadElement)) {
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 === import_utils10.AST_NODE_TYPES.ObjectExpression
872
+ (arg) => arg.type === import_utils12.AST_NODE_TYPES.ObjectExpression
765
873
  );
766
874
  if (objectArgs.length > 0) {
767
- const probes = objectArgs.map(probeOptionsObject);
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(isUrlLike)) {
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.1.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
- // Heuristic advisory. Warns rather than blocking.
8
- "noctcore-async-safety/forward-abort-signal": "warn",
9
- "noctcore-async-safety/prefer-parallel-awaits": "warn",
10
- "noctcore-async-safety/no-concurrent-shared-mutation": "warn"
9
+ // A dead `signal` parameter is a real bug (the cancel never reaches the I/O), and
10
+ // the rule counts any forwarding shape as a pass, so it errs toward silence.
11
+ "noctcore-async-safety/forward-abort-signal": "error",
12
+ // A lost update is a real bug, and the rule skips order-tolerant writes
13
+ // (`push`, `set`, distinct-index) and plain overwrites.
14
+ "noctcore-async-safety/no-concurrent-shared-mutation": "error",
15
+ // Ships OFF: a latency hint, not a correctness bug. Sequential awaits are often
16
+ // deliberate (one transaction client, rate limits, deterministic test setup), and
17
+ // the rule cannot see that. Enable it where you want the nudge.
18
+ "noctcore-async-safety/prefer-parallel-awaits": "off"
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-fetch-timeout.ts
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-fetch-timeout";
681
- var DEFAULT_TIMEOUT_MS = 1e4;
682
- var SIGNAL_KEYS = /* @__PURE__ */ new Set(["signal", "timeout"]);
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
- callees: { type: "array", items: { type: "string" }, uniqueItems: true },
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: RULE_NAME5,
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: [optionSchema2],
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 === AST_NODE_TYPES6.SpreadElement)) {
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 === AST_NODE_TYPES6.ObjectExpression
844
+ (arg) => arg.type === AST_NODE_TYPES7.ObjectExpression
737
845
  );
738
846
  if (objectArgs.length > 0) {
739
- const probes = objectArgs.map(probeOptionsObject);
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(isUrlLike)) {
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.1.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 warning is a false positive disable it inline for that block.
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.1.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": "^3"
65
+ "vitest": "^4"
66
66
  }
67
67
  }