Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ configurations minimal and only enable rules that catch real problems (the kind
that are likely to happen). This keeps our linting faster and reduces the number
of false positives.

Custom rule documentation lives in [`lint-rules/index.md`](./lint-rules/index.md).

### Oxlint

Create a `.oxlintrc.json` file in your project root with the following content:
Expand Down
3 changes: 3 additions & 0 deletions eslint.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import globals from 'globals'
import epicWebPlugin from './lint-rules/epic-web-plugin.js'
import { has } from './utils.js'

const ERROR = 'error'
Expand Down Expand Up @@ -38,6 +39,7 @@ export const config = [
{
plugins: {
import: (await import('eslint-plugin-import-x')).default,
'epic-web': epicWebPlugin,
},
languageOptions: {
globals: {
Expand All @@ -51,6 +53,7 @@ export const config = [
ERROR,
{ terms: ['FIXME'], location: 'anywhere' },
],
'epic-web/no-manual-dispose': WARN,
'import/no-duplicates': [WARN, { 'prefer-inline': true }],
'import/order': [
WARN,
Expand Down
13 changes: 13 additions & 0 deletions lint-rules/epic-web-plugin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { eslintCompatPlugin } from '@oxlint/plugins'
import noManualDispose from './no-manual-dispose.js'

const plugin = eslintCompatPlugin({
meta: {
name: 'epic-web',
},
rules: {
'no-manual-dispose': noManualDispose,
},
})

export default plugin
17 changes: 17 additions & 0 deletions lint-rules/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Lint rules

Custom lint rules for this package live in this directory.

Each rule should have:

- implementation: `*.js`
- tests: `*.test.js`
- documentation: `*.md`

Rules are registered through [`epic-web-plugin.js`](./epic-web-plugin.js), which
uses `eslintCompatPlugin(...)` so rules can use Oxlint's `createOnce` API while
remaining ESLint-compatible.

## Rules

- [`epic-web/no-manual-dispose`](./no-manual-dispose.md)
120 changes: 120 additions & 0 deletions lint-rules/no-manual-dispose.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
const SYMBOL_DISPOSE_PROPERTY_NAMES = new Set([
'dispose',
'asyncDispose',
'disposeAsync',
])

function unwrapChainExpression(node) {
if (node?.type === 'ChainExpression') {
return node.expression
}
return node
}

function isSymbolDisposeProperty(node) {
const candidate = unwrapChainExpression(node)

if (candidate?.type !== 'MemberExpression') return false
if (candidate.computed || candidate.optional) return false
if (candidate.object.type !== 'Identifier') return false
if (candidate.object.name !== 'Symbol') return false
if (candidate.property.type !== 'Identifier') return false

return SYMBOL_DISPOSE_PROPERTY_NAMES.has(candidate.property.name)
}

function getManualDisposeCallKind(node) {
const callee = unwrapChainExpression(node.callee)

if (callee?.type !== 'MemberExpression') return null
if (isSymbolDisposeProperty(callee.property)) return 'symbol'

if (
!callee.computed &&
callee.property.type === 'Identifier' &&
callee.property.name === 'dispose'
) {
return 'method'
}

if (
callee.computed &&
callee.property.type === 'Literal' &&
callee.property.value === 'dispose'
) {
return 'method'
}

return null
}

function isFunctionBoundary(node) {
return (
node?.type === 'FunctionDeclaration' ||
node?.type === 'FunctionExpression' ||
node?.type === 'ArrowFunctionExpression'
)
}

function isInFinallyBlock(node) {
let current = node

while (current?.parent) {
if (isFunctionBoundary(current.parent)) {
return false
}

if (
current.parent.type === 'TryStatement' &&
current.parent.finalizer === current
) {
return true
}

current = current.parent
}
Comment thread
cursor[bot] marked this conversation as resolved.

return false
}

const rule = {
meta: {
type: 'suggestion',
docs: {
description:
'Prefer `using`/`await using` over manual disposable cleanup patterns',
},
schema: [],
messages: {
preferUsingInFinally:
'Avoid manual disposal in `finally`; prefer `using` or `await using`.',
avoidManualSymbolDispose:
'Do not call `[Symbol.dispose]`/`[Symbol.asyncDispose]` directly; prefer `using` or `await using`.',
},
},
createOnce(context) {
return {
CallExpression(node) {
const callKind = getManualDisposeCallKind(node)
if (callKind === null) return

if (callKind === 'symbol') {
context.report({
node,
messageId: 'avoidManualSymbolDispose',
})
return
}

if (isInFinallyBlock(node)) {
context.report({
node,
messageId: 'preferUsingInFinally',
})
}
},
}
},
}

export default rule
58 changes: 58 additions & 0 deletions lint-rules/no-manual-dispose.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# `epic-web/no-manual-dispose`

Warns when disposable resources are cleaned up manually in patterns that should
use `using` or `await using`.

## Why

Manual cleanup with `try/finally` and disposal calls is easier to get wrong and
less readable than language-level disposables.

This rule is implemented with Oxlint's `createOnce` API and wrapped with
`eslintCompatPlugin(...)`, so it is optimized for Oxlint and still works in
ESLint.

## What it warns on

- direct calls to `[Symbol.dispose]`
- direct calls to `[Symbol.asyncDispose]`
- direct calls to `[Symbol.disposeAsync]`
- `.dispose()` and `['dispose']()` calls inside `finally` blocks

## Examples

### Invalid

```js
let tempFile
try {
tempFile = createTempFile()
} finally {
tempFile?.[Symbol.dispose]()
}
```

```js
let tempFile
try {
tempFile = createTempFile()
} finally {
tempFile?.dispose()
}
```

### Valid

```js
using tempFile = createTempFile()
```

```js
await using db = await createDisposableDatabase()
```

```js
function cleanup(resource) {
resource.dispose()
}
```
115 changes: 115 additions & 0 deletions lint-rules/no-manual-dispose.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { RuleTester } from 'eslint'
import plugin from './epic-web-plugin.js'

const rule = plugin.rules['no-manual-dispose']

const tester = new RuleTester({
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
})

tester.run('no-manual-dispose', rule, {
valid: [
`
test('reads a temp file', () => {
using tempFile = createTempFile()
return Bun.file(tempFile.path).text()
})
`,
`
async function setup() {
await using db = await createDisposableDatabase()
return db
}
`,
`
function cleanup(resource) {
resource.dispose()
}
`,
`
class TempFile {
[Symbol.dispose]() {
closeFileHandle()
}
async [Symbol.asyncDispose]() {
await closeFileHandle()
}
}
`,
`
let tempFile
try {
tempFile = createTempFile()
} finally {
logCleanup(tempFile)
}
`,
],
invalid: [
{
code: `
let tempFile
try {
tempFile = createTempFile()
} finally {
tempFile?.[Symbol.dispose]()
}
`,
errors: [{ messageId: 'avoidManualSymbolDispose' }],
},
{
code: `
let tempFile
try {
tempFile = createTempFile()
} finally {
if (tempFile) {
tempFile['dispose']()
}
}
`,
errors: [{ messageId: 'preferUsingInFinally' }],
},
{
code: `
let tempFile
try {
tempFile = createTempFile()
} finally {
tempFile?.dispose()
}
`,
errors: [{ messageId: 'preferUsingInFinally' }],
},
{
code: `
async function closeResource() {
let tempFile
try {
tempFile = await createTempFile()
} finally {
await tempFile?.[Symbol.asyncDispose]()
}
}
`,
errors: [{ messageId: 'avoidManualSymbolDispose' }],
},
{
code: `
const tempFile = createTempFile()
tempFile[Symbol.dispose]()
`,
errors: [{ messageId: 'avoidManualSymbolDispose' }],
},
{
code: `
const tempFile = createTempFile()
tempFile?.[Symbol.disposeAsync]()
`,
errors: [{ messageId: 'avoidManualSymbolDispose' }],
},
],
})
2 changes: 2 additions & 0 deletions oxlint-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"node": true
},
"plugins": ["import", "react", "typescript", "vitest"],
"jsPlugins": ["./lint-rules/epic-web-plugin.js"],
"ignorePatterns": [
"**/.cache/**",
"**/node_modules/**",
Expand All @@ -29,6 +30,7 @@
"location": "anywhere"
}
],
"epic-web/no-manual-dispose": "warn",
"import/no-duplicates": [
"warn",
{
Expand Down
Loading