Skip to content

Commit e664169

Browse files
feat: Disposable object linting (#48)
* feat(eslint): warn on manual disposable cleanup patterns Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * chore(eslint): fix import ordering for custom rule Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Fix finally detection across function boundaries * docs(eslint): add no-manual-dispose rule documentation Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * docs(eslint): move rule docs into eslint-rules directory Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * feat(eslint): make no-manual-dispose oxlint-optimized Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * refactor(lint): rename eslint-rules directory to lint-rules Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 5894ffe commit e664169

10 files changed

Lines changed: 6633 additions & 6289 deletions

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ configurations minimal and only enable rules that catch real problems (the kind
139139
that are likely to happen). This keeps our linting faster and reduces the number
140140
of false positives.
141141

142+
Custom rule documentation lives in [`lint-rules/index.md`](./lint-rules/index.md).
143+
142144
### Oxlint
143145

144146
Create a `.oxlintrc.json` file in your project root with the following content:

eslint.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import globals from 'globals'
2+
import epicWebPlugin from './lint-rules/epic-web-plugin.js'
23
import { has } from './utils.js'
34

45
const ERROR = 'error'
@@ -38,6 +39,7 @@ export const config = [
3839
{
3940
plugins: {
4041
import: (await import('eslint-plugin-import-x')).default,
42+
'epic-web': epicWebPlugin,
4143
},
4244
languageOptions: {
4345
globals: {
@@ -51,6 +53,7 @@ export const config = [
5153
ERROR,
5254
{ terms: ['FIXME'], location: 'anywhere' },
5355
],
56+
'epic-web/no-manual-dispose': WARN,
5457
'import/no-duplicates': [WARN, { 'prefer-inline': true }],
5558
'import/order': [
5659
WARN,

lint-rules/epic-web-plugin.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { eslintCompatPlugin } from '@oxlint/plugins'
2+
import noManualDispose from './no-manual-dispose.js'
3+
4+
const plugin = eslintCompatPlugin({
5+
meta: {
6+
name: 'epic-web',
7+
},
8+
rules: {
9+
'no-manual-dispose': noManualDispose,
10+
},
11+
})
12+
13+
export default plugin

lint-rules/index.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Lint rules
2+
3+
Custom lint rules for this package live in this directory.
4+
5+
Each rule should have:
6+
7+
- implementation: `*.js`
8+
- tests: `*.test.js`
9+
- documentation: `*.md`
10+
11+
Rules are registered through [`epic-web-plugin.js`](./epic-web-plugin.js), which
12+
uses `eslintCompatPlugin(...)` so rules can use Oxlint's `createOnce` API while
13+
remaining ESLint-compatible.
14+
15+
## Rules
16+
17+
- [`epic-web/no-manual-dispose`](./no-manual-dispose.md)

lint-rules/no-manual-dispose.js

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
const SYMBOL_DISPOSE_PROPERTY_NAMES = new Set([
2+
'dispose',
3+
'asyncDispose',
4+
'disposeAsync',
5+
])
6+
7+
function unwrapChainExpression(node) {
8+
if (node?.type === 'ChainExpression') {
9+
return node.expression
10+
}
11+
return node
12+
}
13+
14+
function isSymbolDisposeProperty(node) {
15+
const candidate = unwrapChainExpression(node)
16+
17+
if (candidate?.type !== 'MemberExpression') return false
18+
if (candidate.computed || candidate.optional) return false
19+
if (candidate.object.type !== 'Identifier') return false
20+
if (candidate.object.name !== 'Symbol') return false
21+
if (candidate.property.type !== 'Identifier') return false
22+
23+
return SYMBOL_DISPOSE_PROPERTY_NAMES.has(candidate.property.name)
24+
}
25+
26+
function getManualDisposeCallKind(node) {
27+
const callee = unwrapChainExpression(node.callee)
28+
29+
if (callee?.type !== 'MemberExpression') return null
30+
if (isSymbolDisposeProperty(callee.property)) return 'symbol'
31+
32+
if (
33+
!callee.computed &&
34+
callee.property.type === 'Identifier' &&
35+
callee.property.name === 'dispose'
36+
) {
37+
return 'method'
38+
}
39+
40+
if (
41+
callee.computed &&
42+
callee.property.type === 'Literal' &&
43+
callee.property.value === 'dispose'
44+
) {
45+
return 'method'
46+
}
47+
48+
return null
49+
}
50+
51+
function isFunctionBoundary(node) {
52+
return (
53+
node?.type === 'FunctionDeclaration' ||
54+
node?.type === 'FunctionExpression' ||
55+
node?.type === 'ArrowFunctionExpression'
56+
)
57+
}
58+
59+
function isInFinallyBlock(node) {
60+
let current = node
61+
62+
while (current?.parent) {
63+
if (isFunctionBoundary(current.parent)) {
64+
return false
65+
}
66+
67+
if (
68+
current.parent.type === 'TryStatement' &&
69+
current.parent.finalizer === current
70+
) {
71+
return true
72+
}
73+
74+
current = current.parent
75+
}
76+
77+
return false
78+
}
79+
80+
const rule = {
81+
meta: {
82+
type: 'suggestion',
83+
docs: {
84+
description:
85+
'Prefer `using`/`await using` over manual disposable cleanup patterns',
86+
},
87+
schema: [],
88+
messages: {
89+
preferUsingInFinally:
90+
'Avoid manual disposal in `finally`; prefer `using` or `await using`.',
91+
avoidManualSymbolDispose:
92+
'Do not call `[Symbol.dispose]`/`[Symbol.asyncDispose]` directly; prefer `using` or `await using`.',
93+
},
94+
},
95+
createOnce(context) {
96+
return {
97+
CallExpression(node) {
98+
const callKind = getManualDisposeCallKind(node)
99+
if (callKind === null) return
100+
101+
if (callKind === 'symbol') {
102+
context.report({
103+
node,
104+
messageId: 'avoidManualSymbolDispose',
105+
})
106+
return
107+
}
108+
109+
if (isInFinallyBlock(node)) {
110+
context.report({
111+
node,
112+
messageId: 'preferUsingInFinally',
113+
})
114+
}
115+
},
116+
}
117+
},
118+
}
119+
120+
export default rule

lint-rules/no-manual-dispose.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# `epic-web/no-manual-dispose`
2+
3+
Warns when disposable resources are cleaned up manually in patterns that should
4+
use `using` or `await using`.
5+
6+
## Why
7+
8+
Manual cleanup with `try/finally` and disposal calls is easier to get wrong and
9+
less readable than language-level disposables.
10+
11+
This rule is implemented with Oxlint's `createOnce` API and wrapped with
12+
`eslintCompatPlugin(...)`, so it is optimized for Oxlint and still works in
13+
ESLint.
14+
15+
## What it warns on
16+
17+
- direct calls to `[Symbol.dispose]`
18+
- direct calls to `[Symbol.asyncDispose]`
19+
- direct calls to `[Symbol.disposeAsync]`
20+
- `.dispose()` and `['dispose']()` calls inside `finally` blocks
21+
22+
## Examples
23+
24+
### Invalid
25+
26+
```js
27+
let tempFile
28+
try {
29+
tempFile = createTempFile()
30+
} finally {
31+
tempFile?.[Symbol.dispose]()
32+
}
33+
```
34+
35+
```js
36+
let tempFile
37+
try {
38+
tempFile = createTempFile()
39+
} finally {
40+
tempFile?.dispose()
41+
}
42+
```
43+
44+
### Valid
45+
46+
```js
47+
using tempFile = createTempFile()
48+
```
49+
50+
```js
51+
await using db = await createDisposableDatabase()
52+
```
53+
54+
```js
55+
function cleanup(resource) {
56+
resource.dispose()
57+
}
58+
```
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { RuleTester } from 'eslint'
2+
import plugin from './epic-web-plugin.js'
3+
4+
const rule = plugin.rules['no-manual-dispose']
5+
6+
const tester = new RuleTester({
7+
languageOptions: {
8+
ecmaVersion: 'latest',
9+
sourceType: 'module',
10+
},
11+
})
12+
13+
tester.run('no-manual-dispose', rule, {
14+
valid: [
15+
`
16+
test('reads a temp file', () => {
17+
using tempFile = createTempFile()
18+
return Bun.file(tempFile.path).text()
19+
})
20+
`,
21+
`
22+
async function setup() {
23+
await using db = await createDisposableDatabase()
24+
return db
25+
}
26+
`,
27+
`
28+
function cleanup(resource) {
29+
resource.dispose()
30+
}
31+
`,
32+
`
33+
class TempFile {
34+
[Symbol.dispose]() {
35+
closeFileHandle()
36+
}
37+
async [Symbol.asyncDispose]() {
38+
await closeFileHandle()
39+
}
40+
}
41+
`,
42+
`
43+
let tempFile
44+
try {
45+
tempFile = createTempFile()
46+
} finally {
47+
logCleanup(tempFile)
48+
}
49+
`,
50+
],
51+
invalid: [
52+
{
53+
code: `
54+
let tempFile
55+
try {
56+
tempFile = createTempFile()
57+
} finally {
58+
tempFile?.[Symbol.dispose]()
59+
}
60+
`,
61+
errors: [{ messageId: 'avoidManualSymbolDispose' }],
62+
},
63+
{
64+
code: `
65+
let tempFile
66+
try {
67+
tempFile = createTempFile()
68+
} finally {
69+
if (tempFile) {
70+
tempFile['dispose']()
71+
}
72+
}
73+
`,
74+
errors: [{ messageId: 'preferUsingInFinally' }],
75+
},
76+
{
77+
code: `
78+
let tempFile
79+
try {
80+
tempFile = createTempFile()
81+
} finally {
82+
tempFile?.dispose()
83+
}
84+
`,
85+
errors: [{ messageId: 'preferUsingInFinally' }],
86+
},
87+
{
88+
code: `
89+
async function closeResource() {
90+
let tempFile
91+
try {
92+
tempFile = await createTempFile()
93+
} finally {
94+
await tempFile?.[Symbol.asyncDispose]()
95+
}
96+
}
97+
`,
98+
errors: [{ messageId: 'avoidManualSymbolDispose' }],
99+
},
100+
{
101+
code: `
102+
const tempFile = createTempFile()
103+
tempFile[Symbol.dispose]()
104+
`,
105+
errors: [{ messageId: 'avoidManualSymbolDispose' }],
106+
},
107+
{
108+
code: `
109+
const tempFile = createTempFile()
110+
tempFile?.[Symbol.disposeAsync]()
111+
`,
112+
errors: [{ messageId: 'avoidManualSymbolDispose' }],
113+
},
114+
],
115+
})

oxlint-config.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"node": true
66
},
77
"plugins": ["import", "react", "typescript", "vitest"],
8+
"jsPlugins": ["./lint-rules/epic-web-plugin.js"],
89
"ignorePatterns": [
910
"**/.cache/**",
1011
"**/node_modules/**",
@@ -29,6 +30,7 @@
2930
"location": "anywhere"
3031
}
3132
],
33+
"epic-web/no-manual-dispose": "warn",
3234
"import/no-duplicates": [
3335
"warn",
3436
{

0 commit comments

Comments
 (0)