eslint-plugin-use-disposables 0.1.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/dist/index.d.ts +7 -0
- package/dist/index.js +18 -0
- package/dist/rules/use-disposables.d.ts +11 -0
- package/dist/rules/use-disposables.js +166 -0
- package/package.json +43 -0
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import useDisposables from './rules/use-disposables.js';
|
|
2
|
+
// See eslint-plugin-imports-regulation's index.ts: ts-eslint's `RuleContext` carries ten members
|
|
3
|
+
// ESLint's does not, and `create` compares its parameter in the opposite direction. The rule touches
|
|
4
|
+
// only `sourceCode`, `options` and `report`, so the cast is carried here instead of by consumers.
|
|
5
|
+
const rule = useDisposables;
|
|
6
|
+
const plugin = {
|
|
7
|
+
meta: { name: 'eslint-plugin-use-disposables' },
|
|
8
|
+
rules: { 'use-disposables': rule },
|
|
9
|
+
};
|
|
10
|
+
const recommended = {
|
|
11
|
+
plugins: { 'use-disposables': plugin },
|
|
12
|
+
rules: { 'use-disposables/use-disposables': 'error' },
|
|
13
|
+
};
|
|
14
|
+
const exported = {
|
|
15
|
+
...plugin,
|
|
16
|
+
configs: { recommended },
|
|
17
|
+
};
|
|
18
|
+
export default exported;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { TSESLint } from '@typescript-eslint/utils';
|
|
2
|
+
type MessageIds = 'using' | 'awaitUsing' | 'suggestUsing' | 'suggestAwaitUsing';
|
|
3
|
+
/**
|
|
4
|
+
* A disposable created into a plain variable, which nothing will ever dispose.
|
|
5
|
+
*
|
|
6
|
+
* Only values this code creates count — a call, a `new`, or an `await` of one. `const r = this.conn`
|
|
7
|
+
* borrows a resource someone else owns, and `using` there would dispose it out from under them.
|
|
8
|
+
* A variable that is returned, passed, yielded, stored or exported has handed ownership on.
|
|
9
|
+
*/
|
|
10
|
+
declare const rule: TSESLint.RuleModule<MessageIds, []>;
|
|
11
|
+
export default rule;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A disposable created into a plain variable, which nothing will ever dispose.
|
|
3
|
+
*
|
|
4
|
+
* Only values this code creates count — a call, a `new`, or an `await` of one. `const r = this.conn`
|
|
5
|
+
* borrows a resource someone else owns, and `using` there would dispose it out from under them.
|
|
6
|
+
* A variable that is returned, passed, yielded, stored or exported has handed ownership on.
|
|
7
|
+
*/
|
|
8
|
+
const rule = {
|
|
9
|
+
defaultOptions: [],
|
|
10
|
+
meta: {
|
|
11
|
+
type: 'problem',
|
|
12
|
+
hasSuggestions: true,
|
|
13
|
+
docs: {
|
|
14
|
+
description: 'Require a disposable held in a variable to be declared with `using`, or handed on',
|
|
15
|
+
},
|
|
16
|
+
schema: [],
|
|
17
|
+
messages: {
|
|
18
|
+
using: 'Disposable is never disposed: declare it with `using`, or return or pass it on.',
|
|
19
|
+
awaitUsing: 'Disposable is never disposed: declare it with `await using`, or return or pass it on.',
|
|
20
|
+
suggestUsing: 'Declare it with `using`.',
|
|
21
|
+
suggestAwaitUsing: 'Declare it with `await using`.',
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
create(context) {
|
|
25
|
+
const services = context.sourceCode.parserServices;
|
|
26
|
+
// Type-aware only; see no-floating-promises for why this returns nothing rather than throwing.
|
|
27
|
+
if (!services?.program || !services.esTreeNodeToTSNodeMap)
|
|
28
|
+
return {};
|
|
29
|
+
const { esTreeNodeToTSNodeMap } = services;
|
|
30
|
+
const checker = services.program.getTypeChecker();
|
|
31
|
+
// TypeScript keys `[Symbol.dispose]` as `__@dispose@<id>`.
|
|
32
|
+
const hasProperty = (type, prefix) => type.getProperties().some(property => String(property.escapedName).startsWith(prefix));
|
|
33
|
+
/** How the value can be disposed, when every branch that is not nullish can be. */
|
|
34
|
+
const disposal = (type) => {
|
|
35
|
+
const present = checker.getNonNullableType(type);
|
|
36
|
+
const parts = present.isUnion() ? present.types : [present];
|
|
37
|
+
const sync = parts.map(part => hasProperty(part, '__@dispose@'));
|
|
38
|
+
const async = parts.map(part => hasProperty(part, '__@asyncDispose@'));
|
|
39
|
+
if (sync.every(Boolean))
|
|
40
|
+
return 'sync';
|
|
41
|
+
if (parts.every((_, i) => sync[i] || async[i]))
|
|
42
|
+
return 'async';
|
|
43
|
+
return undefined;
|
|
44
|
+
};
|
|
45
|
+
const isStack = (node) => {
|
|
46
|
+
const name = checker.getTypeAtLocation(esTreeNodeToTSNodeMap.get(node)).getSymbol()?.getName();
|
|
47
|
+
return name === 'DisposableStack' || name === 'AsyncDisposableStack';
|
|
48
|
+
};
|
|
49
|
+
/** `stack.use(x)` and `stack.adopt(x)` hand back a value the stack already owns. */
|
|
50
|
+
const isStackHandOver = (node) => node.callee.type === 'MemberExpression'
|
|
51
|
+
&& !node.callee.computed
|
|
52
|
+
&& node.callee.property.type === 'Identifier'
|
|
53
|
+
&& (node.callee.property.name === 'use' || node.callee.property.name === 'adopt')
|
|
54
|
+
&& isStack(node.callee.object);
|
|
55
|
+
const isNullish = (node) => (node.type === 'Literal' && node.value === null)
|
|
56
|
+
|| (node.type === 'Identifier' && node.name === 'undefined');
|
|
57
|
+
/** Whether the value is made here, rather than borrowed from somewhere that owns it. */
|
|
58
|
+
const creates = (node) => {
|
|
59
|
+
switch (node.type) {
|
|
60
|
+
case 'CallExpression': return !isStackHandOver(node);
|
|
61
|
+
case 'NewExpression': return true;
|
|
62
|
+
case 'AwaitExpression': return creates(node.argument);
|
|
63
|
+
case 'ChainExpression':
|
|
64
|
+
case 'TSAsExpression':
|
|
65
|
+
case 'TSNonNullExpression':
|
|
66
|
+
case 'TSSatisfiesExpression':
|
|
67
|
+
case 'TSTypeAssertion':
|
|
68
|
+
return creates(node.expression);
|
|
69
|
+
case 'ConditionalExpression':
|
|
70
|
+
return [node.consequent, node.alternate].every(branch => creates(branch) || isNullish(branch));
|
|
71
|
+
case 'LogicalExpression':
|
|
72
|
+
return creates(node.left) && creates(node.right);
|
|
73
|
+
default: return false;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
/** Whether this read of the variable passes the value on to something else. */
|
|
77
|
+
const handsOn = (node) => {
|
|
78
|
+
const parent = node.parent;
|
|
79
|
+
if (!parent)
|
|
80
|
+
return false;
|
|
81
|
+
switch (parent.type) {
|
|
82
|
+
case 'TSAsExpression':
|
|
83
|
+
case 'TSNonNullExpression':
|
|
84
|
+
case 'TSSatisfiesExpression':
|
|
85
|
+
case 'TSTypeAssertion':
|
|
86
|
+
case 'LogicalExpression':
|
|
87
|
+
return handsOn(parent);
|
|
88
|
+
case 'ConditionalExpression': return parent.test !== node && handsOn(parent);
|
|
89
|
+
case 'SequenceExpression': return parent.expressions.at(-1) === node && handsOn(parent);
|
|
90
|
+
case 'ReturnStatement':
|
|
91
|
+
case 'YieldExpression':
|
|
92
|
+
case 'ArrayExpression':
|
|
93
|
+
case 'SpreadElement':
|
|
94
|
+
case 'JSXExpressionContainer':
|
|
95
|
+
case 'ExportSpecifier':
|
|
96
|
+
return true;
|
|
97
|
+
case 'ArrowFunctionExpression': return parent.body === node;
|
|
98
|
+
case 'CallExpression':
|
|
99
|
+
case 'NewExpression':
|
|
100
|
+
return parent.callee !== node;
|
|
101
|
+
case 'AssignmentExpression': return parent.right === node;
|
|
102
|
+
case 'VariableDeclarator': return parent.init === node;
|
|
103
|
+
case 'Property':
|
|
104
|
+
case 'PropertyDefinition':
|
|
105
|
+
return parent.value === node;
|
|
106
|
+
default: return false;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
const enclosingFunction = (node) => {
|
|
110
|
+
for (let at = node.parent; at; at = at.parent) {
|
|
111
|
+
if (at.type === 'FunctionDeclaration'
|
|
112
|
+
|| at.type === 'FunctionExpression'
|
|
113
|
+
|| at.type === 'ArrowFunctionExpression') {
|
|
114
|
+
return at;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return undefined;
|
|
118
|
+
};
|
|
119
|
+
/** Whether swapping the keyword for `using` keeps the code compiling and meaning the same. */
|
|
120
|
+
const canSuggest = (declaration, declarator, variables, kind) => {
|
|
121
|
+
if (declaration.kind !== 'const' && declaration.kind !== 'let')
|
|
122
|
+
return false;
|
|
123
|
+
if (declaration.declarations.length !== 1 || declarator.id.type !== 'Identifier')
|
|
124
|
+
return false;
|
|
125
|
+
if (declaration.parent.type === 'SwitchCase')
|
|
126
|
+
return false;
|
|
127
|
+
if (variables.some(variable => variable.references.filter(ref => ref.isWrite()).length > 1))
|
|
128
|
+
return false;
|
|
129
|
+
// At the top of a module `await` is allowed, and these are type-aware ESM sources.
|
|
130
|
+
return kind === 'sync' || (enclosingFunction(declaration)?.async ?? true);
|
|
131
|
+
};
|
|
132
|
+
return {
|
|
133
|
+
VariableDeclaration(declaration) {
|
|
134
|
+
if (declaration.kind === 'using' || declaration.kind === 'await using')
|
|
135
|
+
return;
|
|
136
|
+
if (declaration.parent.type === 'ExportNamedDeclaration')
|
|
137
|
+
return;
|
|
138
|
+
for (const declarator of declaration.declarations) {
|
|
139
|
+
const init = declarator.init;
|
|
140
|
+
if (!init || !creates(init))
|
|
141
|
+
continue;
|
|
142
|
+
const kind = disposal(checker.getTypeAtLocation(esTreeNodeToTSNodeMap.get(init)));
|
|
143
|
+
if (!kind)
|
|
144
|
+
continue;
|
|
145
|
+
const variables = context.sourceCode.getDeclaredVariables(declarator);
|
|
146
|
+
const handedOn = variables.some(variable => variable.references.some(ref => ref.isRead() && handsOn(ref.identifier)));
|
|
147
|
+
if (handedOn)
|
|
148
|
+
continue;
|
|
149
|
+
const keyword = context.sourceCode.getFirstToken(declaration);
|
|
150
|
+
const replacement = kind === 'sync' ? 'using' : 'await using';
|
|
151
|
+
context.report({
|
|
152
|
+
node: declarator.id,
|
|
153
|
+
messageId: kind === 'sync' ? 'using' : 'awaitUsing',
|
|
154
|
+
suggest: keyword && canSuggest(declaration, declarator, variables, kind)
|
|
155
|
+
? [{
|
|
156
|
+
messageId: kind === 'sync' ? 'suggestUsing' : 'suggestAwaitUsing',
|
|
157
|
+
fix: fixer => fixer.replaceText(keyword, replacement),
|
|
158
|
+
}]
|
|
159
|
+
: [],
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
export default rule;
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "eslint-plugin-use-disposables",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Require a disposable held in a variable to be declared with `using`, or handed on.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"!dist/**/*.test.*"
|
|
14
|
+
],
|
|
15
|
+
"keywords": [
|
|
16
|
+
"eslint",
|
|
17
|
+
"eslintplugin",
|
|
18
|
+
"eslint-plugin",
|
|
19
|
+
"typescript",
|
|
20
|
+
"using",
|
|
21
|
+
"disposable",
|
|
22
|
+
"explicit-resource-management"
|
|
23
|
+
],
|
|
24
|
+
"license": "ISC",
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"eslint": "^9.39.4 || ^10.0.0",
|
|
27
|
+
"typescript": ">=5.2"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^22.19.15",
|
|
31
|
+
"@typescript-eslint/utils": "^8.70.0",
|
|
32
|
+
"eslint": "^10.8.1",
|
|
33
|
+
"typescript-eslint": "^8.70.0"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"compile": "tsc",
|
|
37
|
+
"build": "tsc --watch",
|
|
38
|
+
"test": "node --test --watch \"dist/**/*.test.js\"",
|
|
39
|
+
"test-once": "tsc && node --test \"dist/**/*.test.js\"",
|
|
40
|
+
"preview": "tsc && eslint --no-config-lookup -c preview.config.mjs fixtures",
|
|
41
|
+
"release": "node ../../scripts/release-package.ts"
|
|
42
|
+
}
|
|
43
|
+
}
|