@fjall/eslint-plugin 2.30.3 → 2.31.1
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/index.js +2 -0
- package/package.json +2 -2
- package/require-abort-composition-on-sdk-send.js +142 -0
package/index.js
CHANGED
|
@@ -23,6 +23,7 @@ import noL2AsgLifecycleHook from "./no-l2-asg-lifecycle-hook.js";
|
|
|
23
23
|
import noTierStageConflation from "./no-tier-stage-conflation.js";
|
|
24
24
|
import noBareSdkAbortTimeout from "./no-bare-sdk-abort-timeout.js";
|
|
25
25
|
import requireAbortPrecheckInSdkLoop from "./require-abort-precheck-in-sdk-loop.js";
|
|
26
|
+
import requireAbortCompositionOnSdkSend from "./require-abort-composition-on-sdk-send.js";
|
|
26
27
|
import noClassicConnectedAccountAssume from "./no-classic-connected-account-assume.js";
|
|
27
28
|
import noRawDbTransaction from "./no-raw-db-transaction.js";
|
|
28
29
|
|
|
@@ -55,6 +56,7 @@ export default {
|
|
|
55
56
|
"no-tier-stage-conflation": noTierStageConflation,
|
|
56
57
|
"no-bare-sdk-abort-timeout": noBareSdkAbortTimeout,
|
|
57
58
|
"require-abort-precheck-in-sdk-loop": requireAbortPrecheckInSdkLoop,
|
|
59
|
+
"require-abort-composition-on-sdk-send": requireAbortCompositionOnSdkSend,
|
|
58
60
|
"no-classic-connected-account-assume": noClassicConnectedAccountAssume,
|
|
59
61
|
"no-raw-db-transaction": noRawDbTransaction
|
|
60
62
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fjall/eslint-plugin",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.31.1",
|
|
4
4
|
"description": "ESLint plugin with Fjall-specific coding standard rules",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -32,5 +32,5 @@
|
|
|
32
32
|
"vitest": "^4.1.5"
|
|
33
33
|
},
|
|
34
34
|
"license": "SEE LICENSE IN LICENSE",
|
|
35
|
-
"gitHead": "
|
|
35
|
+
"gitHead": "a48cfaf6e050ce1736837802dfcaf517ba39f199"
|
|
36
36
|
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint Rule: require-abort-composition-on-sdk-send
|
|
3
|
+
*
|
|
4
|
+
* The presence half of the three-rule deploy-core abort contract.
|
|
5
|
+
* no-bare-sdk-abort-timeout enforces the composition QUALITY half (no bare
|
|
6
|
+
* AbortSignal.timeout where an abort option exists) and
|
|
7
|
+
* require-abort-precheck-in-sdk-loop the pagination PRE-CHECK half. THIS rule
|
|
8
|
+
* enforces PRESENCE: an AWS SDK `.send()` carrying a Command argument MUST
|
|
9
|
+
* pass an options argument with an `abortSignal` property. A zero-option
|
|
10
|
+
* `client.send(command)` composes nothing — it can neither time out nor abort
|
|
11
|
+
* on caller shutdown, so it can hang forever (worse than the bare-timeout
|
|
12
|
+
* class the sibling rule catches). Pass
|
|
13
|
+
* `{ abortSignal: composeSdkAbortSignal(abortSignal) }` from
|
|
14
|
+
* `deploy-core/src/aws/organisations/types.ts` and thread the caller's
|
|
15
|
+
* signal; `composeSdkAbortSignal(undefined)` still bounds the call with the
|
|
16
|
+
* default timeout when no caller signal exists.
|
|
17
|
+
*
|
|
18
|
+
* SDK-send predicate (syntactic, no type info): the first argument is
|
|
19
|
+
* "commandish" — a `new XxxCommand(...)` expression, or an identifier whose
|
|
20
|
+
* declaration init is one (resolved via a scope-chain walk). Non-SDK sends
|
|
21
|
+
* (`res.send(html)`, `process.send(msg)`, EventEmitter/WebSocket
|
|
22
|
+
* `.send(payload)`) never take a Command-constructed first argument, so the
|
|
23
|
+
* predicate keeps them out of scope.
|
|
24
|
+
*
|
|
25
|
+
* Exempt (deliberate false-negative bias, mirroring the leniency rationale in
|
|
26
|
+
* require-abort-precheck-in-sdk-loop):
|
|
27
|
+
* - Second argument that is an identifier/call/conditional — opaque without
|
|
28
|
+
* data-flow analysis; options built elsewhere may well carry the signal.
|
|
29
|
+
* - Options object containing a spread — the abortSignal may ride in on it.
|
|
30
|
+
*
|
|
31
|
+
* No fixer — the correct fix is a signature-threading judgement (which caller
|
|
32
|
+
* signal to thread, through how many layers), like both siblings.
|
|
33
|
+
*
|
|
34
|
+
* Per .claude/rules/robustness-standards.md § "SDK probe helpers take optional
|
|
35
|
+
* abortSignal and compose with the timeout".
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const COMMAND_NAME_RE = /Command$/;
|
|
39
|
+
|
|
40
|
+
function isCommandNew(node) {
|
|
41
|
+
return (
|
|
42
|
+
node !== null &&
|
|
43
|
+
node !== undefined &&
|
|
44
|
+
node.type === "NewExpression" &&
|
|
45
|
+
node.callee.type === "Identifier" &&
|
|
46
|
+
COMMAND_NAME_RE.test(node.callee.name)
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function findVariableInScopeChain(scope, name) {
|
|
51
|
+
let s = scope;
|
|
52
|
+
while (s) {
|
|
53
|
+
const v = s.variables.find((v) => v.name === name);
|
|
54
|
+
if (v) return v;
|
|
55
|
+
s = s.upper;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* True when the first `.send()` argument is "commandish": a
|
|
62
|
+
* `new XxxCommand(...)` expression directly, or an identifier whose
|
|
63
|
+
* declaration init is one.
|
|
64
|
+
*/
|
|
65
|
+
function isCommandish(node, sourceCode) {
|
|
66
|
+
if (isCommandNew(node)) return true;
|
|
67
|
+
if (node.type !== "Identifier") return false;
|
|
68
|
+
const scope = sourceCode.getScope(node);
|
|
69
|
+
const variable = findVariableInScopeChain(scope, node.name);
|
|
70
|
+
if (!variable) return false;
|
|
71
|
+
return variable.defs.some(
|
|
72
|
+
(def) =>
|
|
73
|
+
def.type === "Variable" &&
|
|
74
|
+
def.node.type === "VariableDeclarator" &&
|
|
75
|
+
isCommandNew(def.node.init)
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* True when the options ObjectExpression carries an `abortSignal` property or
|
|
81
|
+
* any spread (a spread may carry the signal in — exempt, per the
|
|
82
|
+
* false-negative bias above).
|
|
83
|
+
*/
|
|
84
|
+
function hasAbortSignalOrSpread(objectExpression) {
|
|
85
|
+
return objectExpression.properties.some(
|
|
86
|
+
(prop) =>
|
|
87
|
+
prop.type === "SpreadElement" ||
|
|
88
|
+
(prop.type === "Property" &&
|
|
89
|
+
!prop.computed &&
|
|
90
|
+
((prop.key.type === "Identifier" && prop.key.name === "abortSignal") ||
|
|
91
|
+
(prop.key.type === "Literal" && prop.key.value === "abortSignal")))
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
96
|
+
export default {
|
|
97
|
+
meta: {
|
|
98
|
+
type: "problem",
|
|
99
|
+
docs: {
|
|
100
|
+
description:
|
|
101
|
+
"Require an abortSignal option on every Command-carrying SDK .send() — the presence half of the abort contract.",
|
|
102
|
+
category: "Possible Errors",
|
|
103
|
+
recommended: true
|
|
104
|
+
},
|
|
105
|
+
messages: {
|
|
106
|
+
missingSendOptions:
|
|
107
|
+
"This SDK .send() passes a Command but no options argument — nothing composes an abort signal, so the call can neither time out nor abort on caller shutdown (it can hang forever). Pass { abortSignal: composeSdkAbortSignal(abortSignal) } from organisations/types.js and thread the caller's abortSignal; composeSdkAbortSignal(undefined) still bounds the call when no caller signal exists.",
|
|
108
|
+
optionsMissingAbortSignal:
|
|
109
|
+
"This SDK .send() options object carries no abortSignal — nothing composes an abort signal, so the call can neither time out nor abort on caller shutdown. Add abortSignal: composeSdkAbortSignal(abortSignal) from organisations/types.js and thread the caller's abortSignal. If this send is genuinely signal-free, add an eslint-disable with a reason."
|
|
110
|
+
},
|
|
111
|
+
schema: []
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
create(context) {
|
|
115
|
+
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
CallExpression(node) {
|
|
119
|
+
if (
|
|
120
|
+
node.callee.type !== "MemberExpression" ||
|
|
121
|
+
node.callee.computed ||
|
|
122
|
+
node.callee.property.type !== "Identifier" ||
|
|
123
|
+
node.callee.property.name !== "send"
|
|
124
|
+
) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const commandArg = node.arguments[0];
|
|
128
|
+
if (commandArg === undefined) return;
|
|
129
|
+
if (!isCommandish(commandArg, sourceCode)) return;
|
|
130
|
+
|
|
131
|
+
if (node.arguments.length === 1) {
|
|
132
|
+
context.report({ node, messageId: "missingSendOptions" });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const options = node.arguments[1];
|
|
136
|
+
if (options.type !== "ObjectExpression") return;
|
|
137
|
+
if (hasAbortSignalOrSpread(options)) return;
|
|
138
|
+
context.report({ node, messageId: "optionsMissingAbortSignal" });
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
};
|