From 76b6b4c8fdeb356016fb78b8aa2ed280ea220377 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 13 Feb 2026 19:54:43 +0000 Subject: [PATCH 01/13] add eslint rule to prefer disposables over test hooks Co-authored-by: Kent C. Dodds --- eslint-plugin-epic-web.js | 414 +++++++++++++++++++++++++++ eslint.js | 11 + package.json | 1 + tests/eslint-plugin-epic-web.test.js | 165 +++++++++++ 4 files changed, 591 insertions(+) create mode 100644 eslint-plugin-epic-web.js create mode 100644 tests/eslint-plugin-epic-web.test.js diff --git a/eslint-plugin-epic-web.js b/eslint-plugin-epic-web.js new file mode 100644 index 0000000..12a4f16 --- /dev/null +++ b/eslint-plugin-epic-web.js @@ -0,0 +1,414 @@ +const TEST_CALL_ROOTS = new Set(['test', 'it']) +const SUITE_CALL_ROOTS = new Set(['describe', 'suite', 'context']) +const HOOK_NAMES = new Set(['beforeEach', 'afterEach', 'beforeAll', 'afterAll']) +const SUITE_HOOK_NAMES = new Set(['beforeAll', 'afterAll']) + +const KNOWN_FRAMEWORK_HOOK_CALLS = new Set([ + 'vi.useFakeTimers', + 'vi.useRealTimers', + 'vi.clearAllMocks', + 'vi.resetAllMocks', + 'vi.restoreAllMocks', + 'jest.useFakeTimers', + 'jest.useRealTimers', + 'jest.clearAllMocks', + 'jest.resetAllMocks', + 'jest.restoreAllMocks', +]) + +const DEFAULT_OPTIONS = { + allowKnownFrameworkHooks: true, + minimumTestsForSuiteHooks: 2, +} + +function getCallPath(node) { + if (!node) return null + + if (node.type === 'ChainExpression') { + return getCallPath(node.expression) + } + + if (node.type === 'CallExpression') { + return getCallPath(node.callee) + } + + if (node.type === 'Identifier') { + return [node.name] + } + + if (node.type === 'MemberExpression') { + if (node.computed || node.property.type !== 'Identifier') return null + const objectPath = getCallPath(node.object) + if (!objectPath) return null + return [...objectPath, node.property.name] + } + + return null +} + +function isDescribeCallExpression(node) { + if (!node || node.type !== 'CallExpression') return false + const callPath = getCallPath(node) + if (!callPath || callPath.length === 0) return false + + const lastSegment = callPath.at(-1) + + if (SUITE_CALL_ROOTS.has(callPath[0])) { + if (lastSegment === 'each') return node.callee.type === 'CallExpression' + return true + } + + if (callPath.includes('describe')) { + if (lastSegment === 'each') return node.callee.type === 'CallExpression' + return true + } + + return false +} + +function isTestCallExpression(node) { + if (!node || node.type !== 'CallExpression') return false + const callPath = getCallPath(node) + if (!callPath || callPath.length === 0) return false + if (!TEST_CALL_ROOTS.has(callPath[0])) return false + if (callPath.includes('describe')) return false + + const lastSegment = callPath.at(-1) + if (HOOK_NAMES.has(lastSegment)) return false + if (lastSegment === 'step') return false + if (lastSegment === 'each') return node.callee.type === 'CallExpression' + + return true +} + +function getHookName(node) { + if (!node || node.type !== 'CallExpression') return null + const callPath = getCallPath(node) + if (!callPath || callPath.length === 0) return null + const lastSegment = callPath.at(-1) + return HOOK_NAMES.has(lastSegment) ? lastSegment : null +} + +function isFunctionNode(node) { + return ( + node?.type === 'FunctionExpression' || + node?.type === 'ArrowFunctionExpression' + ) +} + +function getHookCallback(node) { + return node.arguments.find((argument) => isFunctionNode(argument)) ?? null +} + +function walk(node, callback) { + const nodesToVisit = [node] + while (nodesToVisit.length > 0) { + const currentNode = nodesToVisit.pop() + if (!currentNode || typeof currentNode.type !== 'string') continue + callback(currentNode) + + for (const [key, value] of Object.entries(currentNode)) { + if (key === 'parent') continue + + if (Array.isArray(value)) { + for (let index = value.length - 1; index >= 0; index -= 1) { + nodesToVisit.push(value[index]) + } + continue + } + + if (value && typeof value.type === 'string') { + nodesToVisit.push(value) + } + } + } +} + +function containsThisExpression(node) { + let foundThisExpression = false + walk(node, (currentNode) => { + if (currentNode.type === 'ThisExpression') { + foundThisExpression = true + } + }) + return foundThisExpression +} + +function isNodeInsideRange(node, containerNode) { + return ( + Array.isArray(node.range) && + Array.isArray(containerNode.range) && + node.range[0] >= containerNode.range[0] && + node.range[1] <= containerNode.range[1] + ) +} + +function isVariableDefinedInNode(variable, containerNode) { + return variable.defs.some((definition) => { + if (!definition.name) return false + return isNodeInsideRange(definition.name, containerNode) + }) +} + +function findVariableInScope(scope, variableName) { + let currentScope = scope + while (currentScope) { + if (currentScope.set?.has(variableName)) { + return currentScope.set.get(variableName) + } + currentScope = currentScope.upper + } + return null +} + +function getRootIdentifier(node) { + if (!node) return null + + if (node.type === 'ChainExpression') { + return getRootIdentifier(node.expression) + } + + if (node.type === 'Identifier') { + return node + } + + if (node.type === 'MemberExpression') { + return getRootIdentifier(node.object) + } + + return null +} + +function writesOuterState(callbackNode, sourceCode) { + let writesOuterValue = false + + walk(callbackNode.body, (currentNode) => { + if (writesOuterValue) return + + let writeTarget = null + if (currentNode.type === 'AssignmentExpression') { + writeTarget = currentNode.left + } else if (currentNode.type === 'UpdateExpression') { + writeTarget = currentNode.argument + } + + if (!writeTarget) return + const rootIdentifier = getRootIdentifier(writeTarget) + if (!rootIdentifier) return + + const identifierScope = sourceCode.getScope(rootIdentifier) + const variable = findVariableInScope(identifierScope, rootIdentifier.name) + + // If this is an unresolved/global write, treat it as shared mutable state. + if (!variable) { + writesOuterValue = true + return + } + + if (!isVariableDefinedInNode(variable, callbackNode)) { + writesOuterValue = true + } + }) + + return writesOuterValue +} + +function findContainingSuiteNode(node) { + let currentNode = node.parent + while (currentNode) { + if (currentNode.type === 'Program') return currentNode + + if ( + isFunctionNode(currentNode) && + currentNode.parent?.type === 'CallExpression' && + currentNode.parent.arguments.includes(currentNode) && + isDescribeCallExpression(currentNode.parent) + ) { + return currentNode.body.type === 'BlockStatement' + ? currentNode.body + : currentNode.body + } + + currentNode = currentNode.parent + } + + return null +} + +function getSuiteStatements(suiteNode) { + if (!suiteNode) return [] + if (suiteNode.type === 'Program') return suiteNode.body + if (suiteNode.type === 'BlockStatement') return suiteNode.body + return [] +} + +function analyzeSuiteNode(suiteNode) { + let testCount = 0 + let hasDirectSuiteHooks = false + + walk(suiteNode, (currentNode) => { + if (currentNode.type === 'CallExpression' && isTestCallExpression(currentNode)) { + testCount += 1 + } + }) + + for (const statement of getSuiteStatements(suiteNode)) { + if (statement.type !== 'ExpressionStatement') continue + if (statement.expression.type !== 'CallExpression') continue + const hookName = getHookName(statement.expression) + if (hookName && SUITE_HOOK_NAMES.has(hookName)) { + hasDirectSuiteHooks = true + break + } + } + + return { testCount, hasDirectSuiteHooks } +} + +function getTopLevelCallNames(callbackNode) { + const statements = + callbackNode.body.type === 'BlockStatement' + ? callbackNode.body.body + : [{ type: 'ExpressionStatement', expression: callbackNode.body }] + + const callNames = [] + + for (const statement of statements) { + if (statement.type !== 'ExpressionStatement') return null + + let expressionNode = statement.expression + + if (expressionNode.type === 'UnaryExpression' && expressionNode.operator === 'void') { + expressionNode = expressionNode.argument + } + + if (expressionNode.type === 'AwaitExpression') { + expressionNode = expressionNode.argument + } + + if (expressionNode.type !== 'CallExpression') return null + const callPath = getCallPath(expressionNode) + if (!callPath) return null + callNames.push(callPath.join('.')) + } + + return callNames +} + +function isKnownFrameworkHookCallback(callbackNode) { + const callNames = getTopLevelCallNames(callbackNode) + if (!callNames || callNames.length === 0) return false + return callNames.every((callName) => KNOWN_FRAMEWORK_HOOK_CALLS.has(callName)) +} + +const preferDisposeInTestsRule = { + meta: { + type: 'suggestion', + docs: { + description: + 'Prefer disposable objects over lifecycle hooks when cleanup can be scoped to a test body', + }, + schema: [ + { + type: 'object', + properties: { + allowKnownFrameworkHooks: { type: 'boolean' }, + minimumTestsForSuiteHooks: { + type: 'integer', + minimum: 1, + }, + }, + additionalProperties: false, + }, + ], + messages: { + preferDisposables: + 'Prefer disposable setup (`using`/`await using` with `dispose`/`disposeAsync`) instead of {{hookName}} when cleanup can live in each test body.', + }, + }, + create(context) { + const sourceCode = context.sourceCode + const options = { + ...DEFAULT_OPTIONS, + ...(context.options[0] ?? {}), + } + const suiteAnalysisCache = new WeakMap() + + function getSuiteAnalysis(suiteNode) { + const existingAnalysis = suiteAnalysisCache.get(suiteNode) + if (existingAnalysis) return existingAnalysis + + const nextAnalysis = analyzeSuiteNode(suiteNode) + suiteAnalysisCache.set(suiteNode, nextAnalysis) + return nextAnalysis + } + + return { + CallExpression(node) { + const hookName = getHookName(node) + if (!hookName) return + + const callbackNode = getHookCallback(node) + if (!callbackNode) return + + const suiteNode = findContainingSuiteNode(node) + if (!suiteNode) return + + const suiteAnalysis = getSuiteAnalysis(suiteNode) + if (suiteAnalysis.testCount === 0) { + // Setup files often have hooks but no colocated tests. + return + } + + // Hooks that rely on runner context, callback completion, or shared state + // are intentionally allowed because disposable refactors are less direct. + if (callbackNode.params.length > 0) return + if (containsThisExpression(callbackNode.body)) return + if (writesOuterState(callbackNode, sourceCode)) return + + const isSuiteHook = SUITE_HOOK_NAMES.has(hookName) + + if ( + isSuiteHook && + suiteAnalysis.testCount >= options.minimumTestsForSuiteHooks + ) { + return + } + + if ( + !isSuiteHook && + suiteAnalysis.hasDirectSuiteHooks && + suiteAnalysis.testCount >= options.minimumTestsForSuiteHooks + ) { + return + } + + if ( + !isSuiteHook && + options.allowKnownFrameworkHooks && + isKnownFrameworkHookCallback(callbackNode) + ) { + return + } + + context.report({ + node, + messageId: 'preferDisposables', + data: { hookName }, + }) + }, + } + }, +} + +const plugin = { + meta: { + name: '@epic-web/eslint-plugin', + }, + rules: { + 'prefer-dispose-in-tests': preferDisposeInTestsRule, + }, +} + +export default plugin +export { preferDisposeInTestsRule } diff --git a/eslint.js b/eslint.js index 2ae1139..ccccbb5 100644 --- a/eslint.js +++ b/eslint.js @@ -1,6 +1,7 @@ import globals from 'globals' import epicWebPlugin from './lint-rules/epic-web-plugin.js' import { has } from './utils.js' +import epicWebEslintPlugin from './eslint-plugin-epic-web.js' const ERROR = 'error' const WARN = 'warn' @@ -251,6 +252,16 @@ export const config = [ }, }, + { + files: testFiles, + plugins: { + 'epic-web': epicWebEslintPlugin, + }, + rules: { + 'epic-web/prefer-dispose-in-tests': WARN, + }, + }, + hasTestingLibrary ? { files: testFiles, diff --git a/package.json b/package.json index d2f1381..fb818d3 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "./typescript": "./typescript.json", "./reset.d.ts": "./reset.d.ts", "./eslint": "./eslint.js", + "./eslint-plugin": "./eslint-plugin-epic-web.js", "./oxlint": "./oxlint-config.json" }, "prettier": "./prettier.js", diff --git a/tests/eslint-plugin-epic-web.test.js b/tests/eslint-plugin-epic-web.test.js new file mode 100644 index 0000000..8f9c0b0 --- /dev/null +++ b/tests/eslint-plugin-epic-web.test.js @@ -0,0 +1,165 @@ +import { ESLint } from 'eslint' +import { describe, expect, test } from 'vitest' + +import epicWebEslintPlugin from '../eslint-plugin-epic-web.js' + +async function lintWithRule(code, { filePath = '/virtual/example.test.ts' } = {}) { + const eslint = new ESLint({ + overrideConfigFile: true, + overrideConfig: [ + { + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + plugins: { + 'epic-web': epicWebEslintPlugin, + }, + rules: { + 'epic-web/prefer-dispose-in-tests': 'error', + }, + }, + ], + }) + + const [result] = await eslint.lintText(code, { filePath }) + return result.messages.filter( + (message) => message.ruleId === 'epic-web/prefer-dispose-in-tests', + ) +} + +describe('epic-web/prefer-dispose-in-tests', () => { + test('reports beforeEach hooks in single-test suites', async () => { + const messages = await lintWithRule(` + describe('user', () => { + beforeEach(() => { + createUser() + }) + + test('renders', () => { + expect(true).toBe(true) + }) + }) + `) + + expect(messages).toHaveLength(1) + expect(messages[0]?.message).toContain('beforeEach') + }) + + test('reports afterEach hooks in multi-test suites without suite hooks', async () => { + const messages = await lintWithRule(` + describe('user', () => { + afterEach(() => { + cleanupUser() + }) + + test('renders', () => { + expect(true).toBe(true) + }) + + test('updates', () => { + expect(true).toBe(true) + }) + }) + `) + + expect(messages).toHaveLength(1) + expect(messages[0]?.message).toContain('afterEach') + }) + + test('reports beforeAll hooks when a suite has only one test', async () => { + const messages = await lintWithRule(` + describe('user', () => { + beforeAll(() => { + server.listen() + }) + + test('renders', () => { + expect(true).toBe(true) + }) + }) + `) + + expect(messages).toHaveLength(1) + expect(messages[0]?.message).toContain('beforeAll') + }) + + test('allows suite hooks for shared setup when suite has many tests', async () => { + const messages = await lintWithRule(` + describe('server', () => { + beforeAll(() => { + server.listen() + }) + + afterEach(() => { + server.resetHandlers() + }) + + afterAll(() => { + server.close() + }) + + test('first', () => { + expect(true).toBe(true) + }) + + test('second', () => { + expect(true).toBe(true) + }) + }) + `) + + expect(messages).toHaveLength(0) + }) + + test('allows hooks that rely on mutable shared outer state', async () => { + const messages = await lintWithRule(` + describe('user', () => { + let user + + beforeEach(() => { + user = createUser() + }) + + test('renders', () => { + expect(user).toBeDefined() + }) + }) + `) + + expect(messages).toHaveLength(0) + }) + + test('allows known framework-wide lifecycle hooks', async () => { + const messages = await lintWithRule(` + describe('timers', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + test('advances time', () => { + expect(true).toBe(true) + }) + }) + `) + + expect(messages).toHaveLength(0) + }) + + test('allows setup files with hooks but no colocated tests', async () => { + const messages = await lintWithRule( + ` + beforeEach(() => { + vi.clearAllMocks() + }) + `, + { filePath: '/virtual/setup-tests.ts' }, + ) + + expect(messages).toHaveLength(0) + }) +}) From 947182d04c4632d8e5321eedf498d118460c35d5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 13 Feb 2026 19:57:03 +0000 Subject: [PATCH 02/13] fix lint order and rule test harness paths Co-authored-by: Kent C. Dodds --- eslint.js | 3 +-- tests/eslint-plugin-epic-web.test.js | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/eslint.js b/eslint.js index ccccbb5..5e550f8 100644 --- a/eslint.js +++ b/eslint.js @@ -1,7 +1,6 @@ import globals from 'globals' import epicWebPlugin from './lint-rules/epic-web-plugin.js' import { has } from './utils.js' -import epicWebEslintPlugin from './eslint-plugin-epic-web.js' const ERROR = 'error' const WARN = 'warn' @@ -255,7 +254,7 @@ export const config = [ { files: testFiles, plugins: { - 'epic-web': epicWebEslintPlugin, + 'epic-web': epicWebPlugin, }, rules: { 'epic-web/prefer-dispose-in-tests': WARN, diff --git a/tests/eslint-plugin-epic-web.test.js b/tests/eslint-plugin-epic-web.test.js index 8f9c0b0..ad74088 100644 --- a/tests/eslint-plugin-epic-web.test.js +++ b/tests/eslint-plugin-epic-web.test.js @@ -3,7 +3,7 @@ import { describe, expect, test } from 'vitest' import epicWebEslintPlugin from '../eslint-plugin-epic-web.js' -async function lintWithRule(code, { filePath = '/virtual/example.test.ts' } = {}) { +async function lintWithRule(code, { filePath = '/workspace/example.test.js' } = {}) { const eslint = new ESLint({ overrideConfigFile: true, overrideConfig: [ @@ -157,7 +157,7 @@ describe('epic-web/prefer-dispose-in-tests', () => { vi.clearAllMocks() }) `, - { filePath: '/virtual/setup-tests.ts' }, + { filePath: '/workspace/setup-tests.js' }, ) expect(messages).toHaveLength(0) From 2532df1e3b8717c1f5f5712a765d9fda7f9ca356 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 13 Feb 2026 20:02:54 +0000 Subject: [PATCH 03/13] Fix hook detection for known test hooks --- eslint-plugin-epic-web.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/eslint-plugin-epic-web.js b/eslint-plugin-epic-web.js index 12a4f16..8a872a9 100644 --- a/eslint-plugin-epic-web.js +++ b/eslint-plugin-epic-web.js @@ -86,7 +86,18 @@ function getHookName(node) { const callPath = getCallPath(node) if (!callPath || callPath.length === 0) return null const lastSegment = callPath.at(-1) - return HOOK_NAMES.has(lastSegment) ? lastSegment : null + if (!HOOK_NAMES.has(lastSegment)) return null + + if (callPath.length === 1) return lastSegment + + if ( + callPath.length === 2 && + (TEST_CALL_ROOTS.has(callPath[0]) || SUITE_CALL_ROOTS.has(callPath[0])) + ) { + return lastSegment + } + + return null } function isFunctionNode(node) { From 7aef2f872d1d4b853d3bd20ed335ce740fd688f5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 13 Feb 2026 20:06:17 +0000 Subject: [PATCH 04/13] Fix outer write detection for destructuring --- eslint-plugin-epic-web.js | 67 +++++++++++++++++++++++++++++---------- 1 file changed, 50 insertions(+), 17 deletions(-) diff --git a/eslint-plugin-epic-web.js b/eslint-plugin-epic-web.js index 8a872a9..7d2d8d7 100644 --- a/eslint-plugin-epic-web.js +++ b/eslint-plugin-epic-web.js @@ -172,22 +172,52 @@ function findVariableInScope(scope, variableName) { return null } -function getRootIdentifier(node) { - if (!node) return null +function getRootIdentifiers(node) { + if (!node) return [] if (node.type === 'ChainExpression') { - return getRootIdentifier(node.expression) + return getRootIdentifiers(node.expression) } if (node.type === 'Identifier') { - return node + return [node] } if (node.type === 'MemberExpression') { - return getRootIdentifier(node.object) + return getRootIdentifiers(node.object) } - return null + if (node.type === 'ObjectPattern') { + let identifiers = [] + for (const property of node.properties) { + if (!property) continue + if (property.type === 'Property') { + identifiers = identifiers.concat(getRootIdentifiers(property.value)) + } else if (property.type === 'RestElement') { + identifiers = identifiers.concat(getRootIdentifiers(property.argument)) + } + } + return identifiers + } + + if (node.type === 'ArrayPattern') { + let identifiers = [] + for (const element of node.elements) { + if (!element) continue + identifiers = identifiers.concat(getRootIdentifiers(element)) + } + return identifiers + } + + if (node.type === 'AssignmentPattern') { + return getRootIdentifiers(node.left) + } + + if (node.type === 'RestElement') { + return getRootIdentifiers(node.argument) + } + + return [] } function writesOuterState(callbackNode, sourceCode) { @@ -204,20 +234,23 @@ function writesOuterState(callbackNode, sourceCode) { } if (!writeTarget) return - const rootIdentifier = getRootIdentifier(writeTarget) - if (!rootIdentifier) return + const rootIdentifiers = getRootIdentifiers(writeTarget) + if (!rootIdentifiers.length) return - const identifierScope = sourceCode.getScope(rootIdentifier) - const variable = findVariableInScope(identifierScope, rootIdentifier.name) + for (const rootIdentifier of rootIdentifiers) { + const identifierScope = sourceCode.getScope(rootIdentifier) + const variable = findVariableInScope(identifierScope, rootIdentifier.name) - // If this is an unresolved/global write, treat it as shared mutable state. - if (!variable) { - writesOuterValue = true - return - } + // If this is an unresolved/global write, treat it as shared mutable state. + if (!variable) { + writesOuterValue = true + return + } - if (!isVariableDefinedInNode(variable, callbackNode)) { - writesOuterValue = true + if (!isVariableDefinedInNode(variable, callbackNode)) { + writesOuterValue = true + return + } } }) From 36d8806981b085af8c91c3f07548fa1561cc689d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 13 Feb 2026 20:09:12 +0000 Subject: [PATCH 05/13] Fix known framework hooks for suite hooks --- eslint-plugin-epic-web.js | 1 - 1 file changed, 1 deletion(-) diff --git a/eslint-plugin-epic-web.js b/eslint-plugin-epic-web.js index 7d2d8d7..1e20c51 100644 --- a/eslint-plugin-epic-web.js +++ b/eslint-plugin-epic-web.js @@ -428,7 +428,6 @@ const preferDisposeInTestsRule = { } if ( - !isSuiteHook && options.allowKnownFrameworkHooks && isKnownFrameworkHookCallback(callbackNode) ) { From 6f771e5f0e98732b652265c99b20933a8b311668 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 13 Feb 2026 20:13:09 +0000 Subject: [PATCH 06/13] document dispose rule and migrate tests to RuleTester Co-authored-by: Kent C. Dodds --- README.md | 1 + tests/eslint-plugin-epic-web.test.js | 389 ++++++++++++++++++--------- 2 files changed, 265 insertions(+), 125 deletions(-) diff --git a/README.md b/README.md index 47fb4ca..f2e3efd 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,7 @@ Oxlint, so they are intentionally omitted: - `jest-dom/*` - `vitest/*` (except `vitest/no-import-node-test`) - `playwright/*` +- `epic-web/prefer-dispose-in-tests` ## License diff --git a/tests/eslint-plugin-epic-web.test.js b/tests/eslint-plugin-epic-web.test.js index ad74088..6efaaaa 100644 --- a/tests/eslint-plugin-epic-web.test.js +++ b/tests/eslint-plugin-epic-web.test.js @@ -1,165 +1,304 @@ -import { ESLint } from 'eslint' -import { describe, expect, test } from 'vitest' - -import epicWebEslintPlugin from '../eslint-plugin-epic-web.js' - -async function lintWithRule(code, { filePath = '/workspace/example.test.js' } = {}) { - const eslint = new ESLint({ - overrideConfigFile: true, - overrideConfig: [ - { - languageOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - }, - plugins: { - 'epic-web': epicWebEslintPlugin, - }, - rules: { - 'epic-web/prefer-dispose-in-tests': 'error', - }, - }, - ], - }) - - const [result] = await eslint.lintText(code, { filePath }) - return result.messages.filter( - (message) => message.ruleId === 'epic-web/prefer-dispose-in-tests', - ) -} - -describe('epic-web/prefer-dispose-in-tests', () => { - test('reports beforeEach hooks in single-test suites', async () => { - const messages = await lintWithRule(` - describe('user', () => { - beforeEach(() => { - createUser() - }) +import { RuleTester } from 'eslint' +import { afterEach, beforeEach, describe, it } from 'vitest' - test('renders', () => { - expect(true).toBe(true) - }) - }) - `) +import { preferDisposeInTestsRule } from '../eslint-plugin-epic-web.js' - expect(messages).toHaveLength(1) - expect(messages[0]?.message).toContain('beforeEach') - }) +RuleTester.describe = describe +RuleTester.it = it +RuleTester.itOnly = it.only +RuleTester.itSkip = it.skip +RuleTester.afterEach = afterEach +RuleTester.beforeEach = beforeEach - test('reports afterEach hooks in multi-test suites without suite hooks', async () => { - const messages = await lintWithRule(` - describe('user', () => { - afterEach(() => { - cleanupUser() - }) +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, +}) - test('renders', () => { - expect(true).toBe(true) - }) +ruleTester.run('prefer-dispose-in-tests', preferDisposeInTestsRule, { + valid: [ + { + name: 'allows shared suite hooks for larger suites', + code: ` + describe('server', () => { + beforeAll(() => { + server.listen() + }) - test('updates', () => { - expect(true).toBe(true) - }) - }) - `) + afterEach(() => { + server.resetHandlers() + }) - expect(messages).toHaveLength(1) - expect(messages[0]?.message).toContain('afterEach') - }) + afterAll(() => { + server.close() + }) - test('reports beforeAll hooks when a suite has only one test', async () => { - const messages = await lintWithRule(` - describe('user', () => { - beforeAll(() => { - server.listen() - }) + test('first', () => { + expect(true).toBe(true) + }) - test('renders', () => { - expect(true).toBe(true) + test('second', () => { + expect(true).toBe(true) + }) }) - }) - `) + `, + }, + { + name: 'allows hooks that mutate outer shared state', + code: ` + describe('user', () => { + let user - expect(messages).toHaveLength(1) - expect(messages[0]?.message).toContain('beforeAll') - }) + beforeEach(() => { + user = createUser() + }) - test('allows suite hooks for shared setup when suite has many tests', async () => { - const messages = await lintWithRule(` - describe('server', () => { - beforeAll(() => { - server.listen() + test('renders', () => { + expect(user).toBeDefined() + }) }) + `, + }, + { + name: 'allows callback parameter hooks', + code: ` + describe('legacy done callback', () => { + beforeEach((done) => { + createUser(() => done()) + }) - afterEach(() => { - server.resetHandlers() + test('renders', () => { + expect(true).toBe(true) + }) }) + `, + }, + { + name: 'allows hooks that use this context', + code: ` + describe('mocha style', function () { + beforeEach(function () { + this.timeout(1000) + }) - afterAll(() => { - server.close() + test('works', function () { + expect(true).toBe(true) + }) }) + `, + }, + { + name: 'allows known framework lifecycle helpers by default', + code: ` + describe('timers', () => { + beforeEach(() => { + vi.useFakeTimers() + }) - test('first', () => { - expect(true).toBe(true) + afterEach(() => { + void vi.useRealTimers() + }) + + test('advances time', () => { + expect(true).toBe(true) + }) }) + `, + }, + { + name: 'allows setup files with hooks and no tests', + filename: '/workspace/setup-tests.js', + code: ` + beforeEach(() => { + vi.clearAllMocks() + }) + `, + }, + { + name: 'allows beforeEach when suite also has shared beforeAll setup', + code: ` + describe('http server', () => { + beforeAll(() => { + server.listen() + }) - test('second', () => { - expect(true).toBe(true) + beforeEach(() => { + server.resetHandlers() + }) + + afterAll(() => { + server.close() + }) + + test('request one', () => { + expect(true).toBe(true) + }) + + test('request two', () => { + expect(true).toBe(true) + }) }) - }) - `) + `, + }, + { + name: 'allows suite hooks exactly at default threshold', + code: ` + describe('two tests is enough for suite hooks', () => { + beforeAll(() => { + connectDb() + }) - expect(messages).toHaveLength(0) - }) + afterAll(() => { + disconnectDb() + }) - test('allows hooks that rely on mutable shared outer state', async () => { - const messages = await lintWithRule(` - describe('user', () => { - let user + test('first', () => { + expect(true).toBe(true) + }) - beforeEach(() => { - user = createUser() + test('second', () => { + expect(true).toBe(true) + }) }) + `, + }, + ], + invalid: [ + { + name: 'reports beforeEach in single-test suite', + code: ` + describe('user', () => { + beforeEach(() => { + createUser() + }) - test('renders', () => { - expect(user).toBeDefined() + test('renders', () => { + expect(true).toBe(true) + }) }) - }) - `) + `, + errors: [{ messageId: 'preferDisposables', data: { hookName: 'beforeEach' } }], + }, + { + name: 'reports afterEach in suite without shared suite hooks', + code: ` + describe('user', () => { + afterEach(() => { + cleanupUser() + }) - expect(messages).toHaveLength(0) - }) + test('renders', () => { + expect(true).toBe(true) + }) - test('allows known framework-wide lifecycle hooks', async () => { - const messages = await lintWithRule(` - describe('timers', () => { - beforeEach(() => { - vi.useFakeTimers() + test('updates', () => { + expect(true).toBe(true) + }) }) + `, + errors: [{ messageId: 'preferDisposables', data: { hookName: 'afterEach' } }], + }, + { + name: 'reports beforeAll in single-test suite', + code: ` + describe('user', () => { + beforeAll(() => { + server.listen() + }) - afterEach(() => { - vi.useRealTimers() + test('renders', () => { + expect(true).toBe(true) + }) }) + `, + errors: [{ messageId: 'preferDisposables', data: { hookName: 'beforeAll' } }], + }, + { + name: 'reports afterAll in single-test suite', + code: ` + describe('user', () => { + afterAll(() => { + server.close() + }) - test('advances time', () => { + test('renders', () => { + expect(true).toBe(true) + }) + }) + `, + errors: [{ messageId: 'preferDisposables', data: { hookName: 'afterAll' } }], + }, + { + name: 'reports top-level beforeEach when tests are colocated', + code: ` + beforeEach(() => { + createUser() + }) + + test('renders', () => { expect(true).toBe(true) }) - }) - `) + `, + errors: [{ messageId: 'preferDisposables', data: { hookName: 'beforeEach' } }], + }, + { + name: 'reports nested suite beforeEach with one local test', + code: ` + describe('outer', () => { + test('outer test', () => { + expect(true).toBe(true) + }) - expect(messages).toHaveLength(0) - }) + describe('inner', () => { + beforeEach(() => { + setupInner() + }) - test('allows setup files with hooks but no colocated tests', async () => { - const messages = await lintWithRule( - ` - beforeEach(() => { - vi.clearAllMocks() + test('inner test', () => { + expect(true).toBe(true) + }) + }) }) `, - { filePath: '/workspace/setup-tests.js' }, - ) + errors: [{ messageId: 'preferDisposables', data: { hookName: 'beforeEach' } }], + }, + { + name: 'reports framework hooks when allowKnownFrameworkHooks is disabled', + options: [{ allowKnownFrameworkHooks: false }], + code: ` + describe('timers', () => { + beforeEach(() => { + vi.useFakeTimers() + }) - expect(messages).toHaveLength(0) - }) + test('advances time', () => { + expect(true).toBe(true) + }) + }) + `, + errors: [{ messageId: 'preferDisposables', data: { hookName: 'beforeEach' } }], + }, + { + name: 'reports suite hooks when minimumTestsForSuiteHooks is higher', + options: [{ minimumTestsForSuiteHooks: 3 }], + code: ` + describe('two tests not enough when threshold is three', () => { + beforeAll(() => { + connectDb() + }) + + test('first', () => { + expect(true).toBe(true) + }) + + test('second', () => { + expect(true).toBe(true) + }) + }) + `, + errors: [{ messageId: 'preferDisposables', data: { hookName: 'beforeAll' } }], + }, + ], }) From e3fabfa9ed0bfeaaa6dec386ee0dd2e18aad4b7a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 13 Feb 2026 20:18:43 +0000 Subject: [PATCH 07/13] organize eslint rule assets under eslint-rules Co-authored-by: Kent C. Dodds --- eslint-plugin-epic-web.js | 459 +----------------- eslint-rules/eslint-plugin-epic-web.js | 13 + eslint-rules/eslint-rules.md | 11 + eslint-rules/prefer-dispose-in-tests.js | 448 +++++++++++++++++ eslint-rules/prefer-dispose-in-tests.md | 70 +++ .../prefer-dispose-in-tests.test.js | 2 +- 6 files changed, 545 insertions(+), 458 deletions(-) create mode 100644 eslint-rules/eslint-plugin-epic-web.js create mode 100644 eslint-rules/eslint-rules.md create mode 100644 eslint-rules/prefer-dispose-in-tests.js create mode 100644 eslint-rules/prefer-dispose-in-tests.md rename tests/eslint-plugin-epic-web.test.js => eslint-rules/prefer-dispose-in-tests.test.js (98%) diff --git a/eslint-plugin-epic-web.js b/eslint-plugin-epic-web.js index 1e20c51..ed2f1b2 100644 --- a/eslint-plugin-epic-web.js +++ b/eslint-plugin-epic-web.js @@ -1,457 +1,2 @@ -const TEST_CALL_ROOTS = new Set(['test', 'it']) -const SUITE_CALL_ROOTS = new Set(['describe', 'suite', 'context']) -const HOOK_NAMES = new Set(['beforeEach', 'afterEach', 'beforeAll', 'afterAll']) -const SUITE_HOOK_NAMES = new Set(['beforeAll', 'afterAll']) - -const KNOWN_FRAMEWORK_HOOK_CALLS = new Set([ - 'vi.useFakeTimers', - 'vi.useRealTimers', - 'vi.clearAllMocks', - 'vi.resetAllMocks', - 'vi.restoreAllMocks', - 'jest.useFakeTimers', - 'jest.useRealTimers', - 'jest.clearAllMocks', - 'jest.resetAllMocks', - 'jest.restoreAllMocks', -]) - -const DEFAULT_OPTIONS = { - allowKnownFrameworkHooks: true, - minimumTestsForSuiteHooks: 2, -} - -function getCallPath(node) { - if (!node) return null - - if (node.type === 'ChainExpression') { - return getCallPath(node.expression) - } - - if (node.type === 'CallExpression') { - return getCallPath(node.callee) - } - - if (node.type === 'Identifier') { - return [node.name] - } - - if (node.type === 'MemberExpression') { - if (node.computed || node.property.type !== 'Identifier') return null - const objectPath = getCallPath(node.object) - if (!objectPath) return null - return [...objectPath, node.property.name] - } - - return null -} - -function isDescribeCallExpression(node) { - if (!node || node.type !== 'CallExpression') return false - const callPath = getCallPath(node) - if (!callPath || callPath.length === 0) return false - - const lastSegment = callPath.at(-1) - - if (SUITE_CALL_ROOTS.has(callPath[0])) { - if (lastSegment === 'each') return node.callee.type === 'CallExpression' - return true - } - - if (callPath.includes('describe')) { - if (lastSegment === 'each') return node.callee.type === 'CallExpression' - return true - } - - return false -} - -function isTestCallExpression(node) { - if (!node || node.type !== 'CallExpression') return false - const callPath = getCallPath(node) - if (!callPath || callPath.length === 0) return false - if (!TEST_CALL_ROOTS.has(callPath[0])) return false - if (callPath.includes('describe')) return false - - const lastSegment = callPath.at(-1) - if (HOOK_NAMES.has(lastSegment)) return false - if (lastSegment === 'step') return false - if (lastSegment === 'each') return node.callee.type === 'CallExpression' - - return true -} - -function getHookName(node) { - if (!node || node.type !== 'CallExpression') return null - const callPath = getCallPath(node) - if (!callPath || callPath.length === 0) return null - const lastSegment = callPath.at(-1) - if (!HOOK_NAMES.has(lastSegment)) return null - - if (callPath.length === 1) return lastSegment - - if ( - callPath.length === 2 && - (TEST_CALL_ROOTS.has(callPath[0]) || SUITE_CALL_ROOTS.has(callPath[0])) - ) { - return lastSegment - } - - return null -} - -function isFunctionNode(node) { - return ( - node?.type === 'FunctionExpression' || - node?.type === 'ArrowFunctionExpression' - ) -} - -function getHookCallback(node) { - return node.arguments.find((argument) => isFunctionNode(argument)) ?? null -} - -function walk(node, callback) { - const nodesToVisit = [node] - while (nodesToVisit.length > 0) { - const currentNode = nodesToVisit.pop() - if (!currentNode || typeof currentNode.type !== 'string') continue - callback(currentNode) - - for (const [key, value] of Object.entries(currentNode)) { - if (key === 'parent') continue - - if (Array.isArray(value)) { - for (let index = value.length - 1; index >= 0; index -= 1) { - nodesToVisit.push(value[index]) - } - continue - } - - if (value && typeof value.type === 'string') { - nodesToVisit.push(value) - } - } - } -} - -function containsThisExpression(node) { - let foundThisExpression = false - walk(node, (currentNode) => { - if (currentNode.type === 'ThisExpression') { - foundThisExpression = true - } - }) - return foundThisExpression -} - -function isNodeInsideRange(node, containerNode) { - return ( - Array.isArray(node.range) && - Array.isArray(containerNode.range) && - node.range[0] >= containerNode.range[0] && - node.range[1] <= containerNode.range[1] - ) -} - -function isVariableDefinedInNode(variable, containerNode) { - return variable.defs.some((definition) => { - if (!definition.name) return false - return isNodeInsideRange(definition.name, containerNode) - }) -} - -function findVariableInScope(scope, variableName) { - let currentScope = scope - while (currentScope) { - if (currentScope.set?.has(variableName)) { - return currentScope.set.get(variableName) - } - currentScope = currentScope.upper - } - return null -} - -function getRootIdentifiers(node) { - if (!node) return [] - - if (node.type === 'ChainExpression') { - return getRootIdentifiers(node.expression) - } - - if (node.type === 'Identifier') { - return [node] - } - - if (node.type === 'MemberExpression') { - return getRootIdentifiers(node.object) - } - - if (node.type === 'ObjectPattern') { - let identifiers = [] - for (const property of node.properties) { - if (!property) continue - if (property.type === 'Property') { - identifiers = identifiers.concat(getRootIdentifiers(property.value)) - } else if (property.type === 'RestElement') { - identifiers = identifiers.concat(getRootIdentifiers(property.argument)) - } - } - return identifiers - } - - if (node.type === 'ArrayPattern') { - let identifiers = [] - for (const element of node.elements) { - if (!element) continue - identifiers = identifiers.concat(getRootIdentifiers(element)) - } - return identifiers - } - - if (node.type === 'AssignmentPattern') { - return getRootIdentifiers(node.left) - } - - if (node.type === 'RestElement') { - return getRootIdentifiers(node.argument) - } - - return [] -} - -function writesOuterState(callbackNode, sourceCode) { - let writesOuterValue = false - - walk(callbackNode.body, (currentNode) => { - if (writesOuterValue) return - - let writeTarget = null - if (currentNode.type === 'AssignmentExpression') { - writeTarget = currentNode.left - } else if (currentNode.type === 'UpdateExpression') { - writeTarget = currentNode.argument - } - - if (!writeTarget) return - const rootIdentifiers = getRootIdentifiers(writeTarget) - if (!rootIdentifiers.length) return - - for (const rootIdentifier of rootIdentifiers) { - const identifierScope = sourceCode.getScope(rootIdentifier) - const variable = findVariableInScope(identifierScope, rootIdentifier.name) - - // If this is an unresolved/global write, treat it as shared mutable state. - if (!variable) { - writesOuterValue = true - return - } - - if (!isVariableDefinedInNode(variable, callbackNode)) { - writesOuterValue = true - return - } - } - }) - - return writesOuterValue -} - -function findContainingSuiteNode(node) { - let currentNode = node.parent - while (currentNode) { - if (currentNode.type === 'Program') return currentNode - - if ( - isFunctionNode(currentNode) && - currentNode.parent?.type === 'CallExpression' && - currentNode.parent.arguments.includes(currentNode) && - isDescribeCallExpression(currentNode.parent) - ) { - return currentNode.body.type === 'BlockStatement' - ? currentNode.body - : currentNode.body - } - - currentNode = currentNode.parent - } - - return null -} - -function getSuiteStatements(suiteNode) { - if (!suiteNode) return [] - if (suiteNode.type === 'Program') return suiteNode.body - if (suiteNode.type === 'BlockStatement') return suiteNode.body - return [] -} - -function analyzeSuiteNode(suiteNode) { - let testCount = 0 - let hasDirectSuiteHooks = false - - walk(suiteNode, (currentNode) => { - if (currentNode.type === 'CallExpression' && isTestCallExpression(currentNode)) { - testCount += 1 - } - }) - - for (const statement of getSuiteStatements(suiteNode)) { - if (statement.type !== 'ExpressionStatement') continue - if (statement.expression.type !== 'CallExpression') continue - const hookName = getHookName(statement.expression) - if (hookName && SUITE_HOOK_NAMES.has(hookName)) { - hasDirectSuiteHooks = true - break - } - } - - return { testCount, hasDirectSuiteHooks } -} - -function getTopLevelCallNames(callbackNode) { - const statements = - callbackNode.body.type === 'BlockStatement' - ? callbackNode.body.body - : [{ type: 'ExpressionStatement', expression: callbackNode.body }] - - const callNames = [] - - for (const statement of statements) { - if (statement.type !== 'ExpressionStatement') return null - - let expressionNode = statement.expression - - if (expressionNode.type === 'UnaryExpression' && expressionNode.operator === 'void') { - expressionNode = expressionNode.argument - } - - if (expressionNode.type === 'AwaitExpression') { - expressionNode = expressionNode.argument - } - - if (expressionNode.type !== 'CallExpression') return null - const callPath = getCallPath(expressionNode) - if (!callPath) return null - callNames.push(callPath.join('.')) - } - - return callNames -} - -function isKnownFrameworkHookCallback(callbackNode) { - const callNames = getTopLevelCallNames(callbackNode) - if (!callNames || callNames.length === 0) return false - return callNames.every((callName) => KNOWN_FRAMEWORK_HOOK_CALLS.has(callName)) -} - -const preferDisposeInTestsRule = { - meta: { - type: 'suggestion', - docs: { - description: - 'Prefer disposable objects over lifecycle hooks when cleanup can be scoped to a test body', - }, - schema: [ - { - type: 'object', - properties: { - allowKnownFrameworkHooks: { type: 'boolean' }, - minimumTestsForSuiteHooks: { - type: 'integer', - minimum: 1, - }, - }, - additionalProperties: false, - }, - ], - messages: { - preferDisposables: - 'Prefer disposable setup (`using`/`await using` with `dispose`/`disposeAsync`) instead of {{hookName}} when cleanup can live in each test body.', - }, - }, - create(context) { - const sourceCode = context.sourceCode - const options = { - ...DEFAULT_OPTIONS, - ...(context.options[0] ?? {}), - } - const suiteAnalysisCache = new WeakMap() - - function getSuiteAnalysis(suiteNode) { - const existingAnalysis = suiteAnalysisCache.get(suiteNode) - if (existingAnalysis) return existingAnalysis - - const nextAnalysis = analyzeSuiteNode(suiteNode) - suiteAnalysisCache.set(suiteNode, nextAnalysis) - return nextAnalysis - } - - return { - CallExpression(node) { - const hookName = getHookName(node) - if (!hookName) return - - const callbackNode = getHookCallback(node) - if (!callbackNode) return - - const suiteNode = findContainingSuiteNode(node) - if (!suiteNode) return - - const suiteAnalysis = getSuiteAnalysis(suiteNode) - if (suiteAnalysis.testCount === 0) { - // Setup files often have hooks but no colocated tests. - return - } - - // Hooks that rely on runner context, callback completion, or shared state - // are intentionally allowed because disposable refactors are less direct. - if (callbackNode.params.length > 0) return - if (containsThisExpression(callbackNode.body)) return - if (writesOuterState(callbackNode, sourceCode)) return - - const isSuiteHook = SUITE_HOOK_NAMES.has(hookName) - - if ( - isSuiteHook && - suiteAnalysis.testCount >= options.minimumTestsForSuiteHooks - ) { - return - } - - if ( - !isSuiteHook && - suiteAnalysis.hasDirectSuiteHooks && - suiteAnalysis.testCount >= options.minimumTestsForSuiteHooks - ) { - return - } - - if ( - options.allowKnownFrameworkHooks && - isKnownFrameworkHookCallback(callbackNode) - ) { - return - } - - context.report({ - node, - messageId: 'preferDisposables', - data: { hookName }, - }) - }, - } - }, -} - -const plugin = { - meta: { - name: '@epic-web/eslint-plugin', - }, - rules: { - 'prefer-dispose-in-tests': preferDisposeInTestsRule, - }, -} - -export default plugin -export { preferDisposeInTestsRule } +export { default } from './eslint-rules/eslint-plugin-epic-web.js' +export { preferDisposeInTestsRule } from './eslint-rules/eslint-plugin-epic-web.js' diff --git a/eslint-rules/eslint-plugin-epic-web.js b/eslint-rules/eslint-plugin-epic-web.js new file mode 100644 index 0000000..5b4d1ba --- /dev/null +++ b/eslint-rules/eslint-plugin-epic-web.js @@ -0,0 +1,13 @@ +import preferDisposeInTestsRule from './prefer-dispose-in-tests.js' + +const plugin = { + meta: { + name: '@epic-web/eslint-plugin', + }, + rules: { + 'prefer-dispose-in-tests': preferDisposeInTestsRule, + }, +} + +export default plugin +export { preferDisposeInTestsRule } diff --git a/eslint-rules/eslint-rules.md b/eslint-rules/eslint-rules.md new file mode 100644 index 0000000..0c37caa --- /dev/null +++ b/eslint-rules/eslint-rules.md @@ -0,0 +1,11 @@ +# ESLint rules + +Custom ESLint rule assets live in this directory: + +- implementation files (`*.js`) +- rule docs (`*.md`) +- rule tests (`*.test.js`) + +## Available rules + +- [`epic-web/prefer-dispose-in-tests`](./prefer-dispose-in-tests.md) diff --git a/eslint-rules/prefer-dispose-in-tests.js b/eslint-rules/prefer-dispose-in-tests.js new file mode 100644 index 0000000..30819de --- /dev/null +++ b/eslint-rules/prefer-dispose-in-tests.js @@ -0,0 +1,448 @@ +const TEST_CALL_ROOTS = new Set(['test', 'it']) +const SUITE_CALL_ROOTS = new Set(['describe', 'suite', 'context']) +const HOOK_NAMES = new Set(['beforeEach', 'afterEach', 'beforeAll', 'afterAll']) +const SUITE_HOOK_NAMES = new Set(['beforeAll', 'afterAll']) + +const KNOWN_FRAMEWORK_HOOK_CALLS = new Set([ + 'vi.useFakeTimers', + 'vi.useRealTimers', + 'vi.clearAllMocks', + 'vi.resetAllMocks', + 'vi.restoreAllMocks', + 'jest.useFakeTimers', + 'jest.useRealTimers', + 'jest.clearAllMocks', + 'jest.resetAllMocks', + 'jest.restoreAllMocks', +]) + +const DEFAULT_OPTIONS = { + allowKnownFrameworkHooks: true, + minimumTestsForSuiteHooks: 2, +} + +function getCallPath(node) { + if (!node) return null + + if (node.type === 'ChainExpression') { + return getCallPath(node.expression) + } + + if (node.type === 'CallExpression') { + return getCallPath(node.callee) + } + + if (node.type === 'Identifier') { + return [node.name] + } + + if (node.type === 'MemberExpression') { + if (node.computed || node.property.type !== 'Identifier') return null + const objectPath = getCallPath(node.object) + if (!objectPath) return null + return [...objectPath, node.property.name] + } + + return null +} + +function isDescribeCallExpression(node) { + if (!node || node.type !== 'CallExpression') return false + const callPath = getCallPath(node) + if (!callPath || callPath.length === 0) return false + + const lastSegment = callPath.at(-1) + + if (SUITE_CALL_ROOTS.has(callPath[0])) { + if (lastSegment === 'each') return node.callee.type === 'CallExpression' + return true + } + + if (callPath.includes('describe')) { + if (lastSegment === 'each') return node.callee.type === 'CallExpression' + return true + } + + return false +} + +function isTestCallExpression(node) { + if (!node || node.type !== 'CallExpression') return false + const callPath = getCallPath(node) + if (!callPath || callPath.length === 0) return false + if (!TEST_CALL_ROOTS.has(callPath[0])) return false + if (callPath.includes('describe')) return false + + const lastSegment = callPath.at(-1) + if (HOOK_NAMES.has(lastSegment)) return false + if (lastSegment === 'step') return false + if (lastSegment === 'each') return node.callee.type === 'CallExpression' + + return true +} + +function getHookName(node) { + if (!node || node.type !== 'CallExpression') return null + const callPath = getCallPath(node) + if (!callPath || callPath.length === 0) return null + const lastSegment = callPath.at(-1) + if (!HOOK_NAMES.has(lastSegment)) return null + + if (callPath.length === 1) return lastSegment + + if ( + callPath.length === 2 && + (TEST_CALL_ROOTS.has(callPath[0]) || SUITE_CALL_ROOTS.has(callPath[0])) + ) { + return lastSegment + } + + return null +} + +function isFunctionNode(node) { + return ( + node?.type === 'FunctionExpression' || + node?.type === 'ArrowFunctionExpression' + ) +} + +function getHookCallback(node) { + return node.arguments.find((argument) => isFunctionNode(argument)) ?? null +} + +function walk(node, callback) { + const nodesToVisit = [node] + while (nodesToVisit.length > 0) { + const currentNode = nodesToVisit.pop() + if (!currentNode || typeof currentNode.type !== 'string') continue + callback(currentNode) + + for (const [key, value] of Object.entries(currentNode)) { + if (key === 'parent') continue + + if (Array.isArray(value)) { + for (let index = value.length - 1; index >= 0; index -= 1) { + nodesToVisit.push(value[index]) + } + continue + } + + if (value && typeof value.type === 'string') { + nodesToVisit.push(value) + } + } + } +} + +function containsThisExpression(node) { + let foundThisExpression = false + walk(node, (currentNode) => { + if (currentNode.type === 'ThisExpression') { + foundThisExpression = true + } + }) + return foundThisExpression +} + +function isNodeInsideRange(node, containerNode) { + return ( + Array.isArray(node.range) && + Array.isArray(containerNode.range) && + node.range[0] >= containerNode.range[0] && + node.range[1] <= containerNode.range[1] + ) +} + +function isVariableDefinedInNode(variable, containerNode) { + return variable.defs.some((definition) => { + if (!definition.name) return false + return isNodeInsideRange(definition.name, containerNode) + }) +} + +function findVariableInScope(scope, variableName) { + let currentScope = scope + while (currentScope) { + if (currentScope.set?.has(variableName)) { + return currentScope.set.get(variableName) + } + currentScope = currentScope.upper + } + return null +} + +function getRootIdentifiers(node) { + if (!node) return [] + + if (node.type === 'ChainExpression') { + return getRootIdentifiers(node.expression) + } + + if (node.type === 'Identifier') { + return [node] + } + + if (node.type === 'MemberExpression') { + return getRootIdentifiers(node.object) + } + + if (node.type === 'ObjectPattern') { + let identifiers = [] + for (const property of node.properties) { + if (!property) continue + if (property.type === 'Property') { + identifiers = identifiers.concat(getRootIdentifiers(property.value)) + } else if (property.type === 'RestElement') { + identifiers = identifiers.concat(getRootIdentifiers(property.argument)) + } + } + return identifiers + } + + if (node.type === 'ArrayPattern') { + let identifiers = [] + for (const element of node.elements) { + if (!element) continue + identifiers = identifiers.concat(getRootIdentifiers(element)) + } + return identifiers + } + + if (node.type === 'AssignmentPattern') { + return getRootIdentifiers(node.left) + } + + if (node.type === 'RestElement') { + return getRootIdentifiers(node.argument) + } + + return [] +} + +function writesOuterState(callbackNode, sourceCode) { + let writesOuterValue = false + + walk(callbackNode.body, (currentNode) => { + if (writesOuterValue) return + + let writeTarget = null + if (currentNode.type === 'AssignmentExpression') { + writeTarget = currentNode.left + } else if (currentNode.type === 'UpdateExpression') { + writeTarget = currentNode.argument + } + + if (!writeTarget) return + const rootIdentifiers = getRootIdentifiers(writeTarget) + if (!rootIdentifiers.length) return + + for (const rootIdentifier of rootIdentifiers) { + const identifierScope = sourceCode.getScope(rootIdentifier) + const variable = findVariableInScope(identifierScope, rootIdentifier.name) + + // If this is an unresolved/global write, treat it as shared mutable state. + if (!variable) { + writesOuterValue = true + return + } + + if (!isVariableDefinedInNode(variable, callbackNode)) { + writesOuterValue = true + return + } + } + }) + + return writesOuterValue +} + +function findContainingSuiteNode(node) { + let currentNode = node.parent + while (currentNode) { + if (currentNode.type === 'Program') return currentNode + + if ( + isFunctionNode(currentNode) && + currentNode.parent?.type === 'CallExpression' && + currentNode.parent.arguments.includes(currentNode) && + isDescribeCallExpression(currentNode.parent) + ) { + return currentNode.body.type === 'BlockStatement' + ? currentNode.body + : currentNode.body + } + + currentNode = currentNode.parent + } + + return null +} + +function getSuiteStatements(suiteNode) { + if (!suiteNode) return [] + if (suiteNode.type === 'Program') return suiteNode.body + if (suiteNode.type === 'BlockStatement') return suiteNode.body + return [] +} + +function analyzeSuiteNode(suiteNode) { + let testCount = 0 + let hasDirectSuiteHooks = false + + walk(suiteNode, (currentNode) => { + if (currentNode.type === 'CallExpression' && isTestCallExpression(currentNode)) { + testCount += 1 + } + }) + + for (const statement of getSuiteStatements(suiteNode)) { + if (statement.type !== 'ExpressionStatement') continue + if (statement.expression.type !== 'CallExpression') continue + const hookName = getHookName(statement.expression) + if (hookName && SUITE_HOOK_NAMES.has(hookName)) { + hasDirectSuiteHooks = true + break + } + } + + return { testCount, hasDirectSuiteHooks } +} + +function getTopLevelCallNames(callbackNode) { + const statements = + callbackNode.body.type === 'BlockStatement' + ? callbackNode.body.body + : [{ type: 'ExpressionStatement', expression: callbackNode.body }] + + const callNames = [] + + for (const statement of statements) { + if (statement.type !== 'ExpressionStatement') return null + + let expressionNode = statement.expression + + if (expressionNode.type === 'UnaryExpression' && expressionNode.operator === 'void') { + expressionNode = expressionNode.argument + } + + if (expressionNode.type === 'AwaitExpression') { + expressionNode = expressionNode.argument + } + + if (expressionNode.type !== 'CallExpression') return null + const callPath = getCallPath(expressionNode) + if (!callPath) return null + callNames.push(callPath.join('.')) + } + + return callNames +} + +function isKnownFrameworkHookCallback(callbackNode) { + const callNames = getTopLevelCallNames(callbackNode) + if (!callNames || callNames.length === 0) return false + return callNames.every((callName) => KNOWN_FRAMEWORK_HOOK_CALLS.has(callName)) +} + +const preferDisposeInTestsRule = { + meta: { + type: 'suggestion', + docs: { + description: + 'Prefer disposable objects over lifecycle hooks when cleanup can be scoped to a test body', + }, + schema: [ + { + type: 'object', + properties: { + allowKnownFrameworkHooks: { type: 'boolean' }, + minimumTestsForSuiteHooks: { + type: 'integer', + minimum: 1, + }, + }, + additionalProperties: false, + }, + ], + messages: { + preferDisposables: + 'Prefer disposable setup (`using`/`await using` with `dispose`/`disposeAsync`) instead of {{hookName}} when cleanup can live in each test body.', + }, + }, + create(context) { + const sourceCode = context.sourceCode + const options = { + ...DEFAULT_OPTIONS, + ...(context.options[0] ?? {}), + } + const suiteAnalysisCache = new WeakMap() + + function getSuiteAnalysis(suiteNode) { + const existingAnalysis = suiteAnalysisCache.get(suiteNode) + if (existingAnalysis) return existingAnalysis + + const nextAnalysis = analyzeSuiteNode(suiteNode) + suiteAnalysisCache.set(suiteNode, nextAnalysis) + return nextAnalysis + } + + return { + CallExpression(node) { + const hookName = getHookName(node) + if (!hookName) return + + const callbackNode = getHookCallback(node) + if (!callbackNode) return + + const suiteNode = findContainingSuiteNode(node) + if (!suiteNode) return + + const suiteAnalysis = getSuiteAnalysis(suiteNode) + if (suiteAnalysis.testCount === 0) { + // Setup files often have hooks but no colocated tests. + return + } + + // Hooks that rely on runner context, callback completion, or shared state + // are intentionally allowed because disposable refactors are less direct. + if (callbackNode.params.length > 0) return + if (containsThisExpression(callbackNode.body)) return + if (writesOuterState(callbackNode, sourceCode)) return + + const isSuiteHook = SUITE_HOOK_NAMES.has(hookName) + + if ( + isSuiteHook && + suiteAnalysis.testCount >= options.minimumTestsForSuiteHooks + ) { + return + } + + if ( + !isSuiteHook && + suiteAnalysis.hasDirectSuiteHooks && + suiteAnalysis.testCount >= options.minimumTestsForSuiteHooks + ) { + return + } + + if ( + options.allowKnownFrameworkHooks && + isKnownFrameworkHookCallback(callbackNode) + ) { + return + } + + context.report({ + node, + messageId: 'preferDisposables', + data: { hookName }, + }) + }, + } + }, +} + +export default preferDisposeInTestsRule +export { preferDisposeInTestsRule } diff --git a/eslint-rules/prefer-dispose-in-tests.md b/eslint-rules/prefer-dispose-in-tests.md new file mode 100644 index 0000000..6f61c88 --- /dev/null +++ b/eslint-rules/prefer-dispose-in-tests.md @@ -0,0 +1,70 @@ +# `epic-web/prefer-dispose-in-tests` + +Prefer disposable setup (`using`/`await using` with `dispose`/`disposeAsync`) +instead of `beforeEach`/`afterEach`/`beforeAll`/`afterAll` when cleanup can +reasonably live in each test body. + +This rule is enabled as `warn` in test files by the shared config. + +## Why + +Disposable test setup keeps setup and cleanup in the same lexical scope and +reduces hidden global lifecycle behavior in flat tests. + +## What it reports + +The rule reports lifecycle hooks that can usually be moved to disposable setup +inside each test. + +## What it intentionally allows + +To avoid noisy false positives, the rule skips reporting when disposable +refactors are often not straightforward: + +- setup files with hooks but no colocated tests +- hooks that use callback params (for example, `done`) or `this` +- hooks that mutate shared outer state +- common framework-level timer/mock lifecycle hooks +- suite-level shared setup in larger suites + +## Options + +```js +{ + allowKnownFrameworkHooks: true, + minimumTestsForSuiteHooks: 2, +} +``` + +- `allowKnownFrameworkHooks` (boolean, default `true`) + - Allows known framework lifecycle calls like `vi.useFakeTimers()`. +- `minimumTestsForSuiteHooks` (integer, default `2`) + - Minimum test count before suite-level shared hooks are considered + reasonable. + +## Example override + +```js +import { config as defaultConfig } from '@epic-web/config/eslint' +import epicWebPlugin from '@epic-web/config/eslint-plugin' + +/** @type {import("eslint").Linter.Config[]} */ +export default [ + ...defaultConfig, + { + files: ['**/*.test.*', '**/*.spec.*'], + plugins: { 'epic-web': epicWebPlugin }, + rules: { + 'epic-web/prefer-dispose-in-tests': [ + 'warn', + { minimumTestsForSuiteHooks: 3 }, + ], + }, + }, +] +``` + +## Source files + +- implementation: [`prefer-dispose-in-tests.js`](./prefer-dispose-in-tests.js) +- tests: [`prefer-dispose-in-tests.test.js`](./prefer-dispose-in-tests.test.js) diff --git a/tests/eslint-plugin-epic-web.test.js b/eslint-rules/prefer-dispose-in-tests.test.js similarity index 98% rename from tests/eslint-plugin-epic-web.test.js rename to eslint-rules/prefer-dispose-in-tests.test.js index 6efaaaa..60c19ac 100644 --- a/tests/eslint-plugin-epic-web.test.js +++ b/eslint-rules/prefer-dispose-in-tests.test.js @@ -1,7 +1,7 @@ import { RuleTester } from 'eslint' import { afterEach, beforeEach, describe, it } from 'vitest' -import { preferDisposeInTestsRule } from '../eslint-plugin-epic-web.js' +import { preferDisposeInTestsRule } from './prefer-dispose-in-tests.js' RuleTester.describe = describe RuleTester.it = it From a3ed9461cabebc18e6f151882ed0dacd03361c9d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 13 Feb 2026 20:22:04 +0000 Subject: [PATCH 08/13] optimize custom rule for oxlint js plugins Co-authored-by: Kent C. Dodds --- eslint-rules/eslint-plugin-epic-web.js | 5 +- eslint-rules/prefer-dispose-in-tests.js | 136 ++++++++++--------- eslint-rules/prefer-dispose-in-tests.md | 10 ++ eslint-rules/prefer-dispose-in-tests.test.js | 12 +- 4 files changed, 97 insertions(+), 66 deletions(-) diff --git a/eslint-rules/eslint-plugin-epic-web.js b/eslint-rules/eslint-plugin-epic-web.js index 5b4d1ba..3a56562 100644 --- a/eslint-rules/eslint-plugin-epic-web.js +++ b/eslint-rules/eslint-plugin-epic-web.js @@ -1,13 +1,14 @@ +import { eslintCompatPlugin } from '@oxlint/plugins' import preferDisposeInTestsRule from './prefer-dispose-in-tests.js' -const plugin = { +const plugin = eslintCompatPlugin({ meta: { name: '@epic-web/eslint-plugin', }, rules: { 'prefer-dispose-in-tests': preferDisposeInTestsRule, }, -} +}) export default plugin export { preferDisposeInTestsRule } diff --git a/eslint-rules/prefer-dispose-in-tests.js b/eslint-rules/prefer-dispose-in-tests.js index 30819de..31eafde 100644 --- a/eslint-rules/prefer-dispose-in-tests.js +++ b/eslint-rules/prefer-dispose-in-tests.js @@ -345,6 +345,72 @@ function isKnownFrameworkHookCallback(callbackNode) { return callNames.every((callName) => KNOWN_FRAMEWORK_HOOK_CALLS.has(callName)) } +function createRuleVisitors(context, options, state) { + function getSuiteAnalysis(suiteNode) { + const existingAnalysis = state.suiteAnalysisCache.get(suiteNode) + if (existingAnalysis) return existingAnalysis + + const nextAnalysis = analyzeSuiteNode(suiteNode) + state.suiteAnalysisCache.set(suiteNode, nextAnalysis) + return nextAnalysis + } + + return { + CallExpression(node) { + const hookName = getHookName(node) + if (!hookName) return + + const callbackNode = getHookCallback(node) + if (!callbackNode) return + + const suiteNode = findContainingSuiteNode(node) + if (!suiteNode) return + + const suiteAnalysis = getSuiteAnalysis(suiteNode) + if (suiteAnalysis.testCount === 0) { + // Setup files often have hooks but no colocated tests. + return + } + + // Hooks that rely on runner context, callback completion, or shared state + // are intentionally allowed because disposable refactors are less direct. + if (callbackNode.params.length > 0) return + if (containsThisExpression(callbackNode.body)) return + if (writesOuterState(callbackNode, state.sourceCode)) return + + const isSuiteHook = SUITE_HOOK_NAMES.has(hookName) + + if ( + isSuiteHook && + suiteAnalysis.testCount >= options.minimumTestsForSuiteHooks + ) { + return + } + + if ( + !isSuiteHook && + suiteAnalysis.hasDirectSuiteHooks && + suiteAnalysis.testCount >= options.minimumTestsForSuiteHooks + ) { + return + } + + if ( + options.allowKnownFrameworkHooks && + isKnownFrameworkHookCallback(callbackNode) + ) { + return + } + + context.report({ + node, + messageId: 'preferDisposables', + data: { hookName }, + }) + }, + } +} + const preferDisposeInTestsRule = { meta: { type: 'suggestion', @@ -370,76 +436,22 @@ const preferDisposeInTestsRule = { 'Prefer disposable setup (`using`/`await using` with `dispose`/`disposeAsync`) instead of {{hookName}} when cleanup can live in each test body.', }, }, - create(context) { - const sourceCode = context.sourceCode + createOnce(context) { const options = { ...DEFAULT_OPTIONS, ...(context.options[0] ?? {}), } - const suiteAnalysisCache = new WeakMap() - - function getSuiteAnalysis(suiteNode) { - const existingAnalysis = suiteAnalysisCache.get(suiteNode) - if (existingAnalysis) return existingAnalysis - - const nextAnalysis = analyzeSuiteNode(suiteNode) - suiteAnalysisCache.set(suiteNode, nextAnalysis) - return nextAnalysis + const state = { + sourceCode: context.sourceCode, + suiteAnalysisCache: new WeakMap(), } return { - CallExpression(node) { - const hookName = getHookName(node) - if (!hookName) return - - const callbackNode = getHookCallback(node) - if (!callbackNode) return - - const suiteNode = findContainingSuiteNode(node) - if (!suiteNode) return - - const suiteAnalysis = getSuiteAnalysis(suiteNode) - if (suiteAnalysis.testCount === 0) { - // Setup files often have hooks but no colocated tests. - return - } - - // Hooks that rely on runner context, callback completion, or shared state - // are intentionally allowed because disposable refactors are less direct. - if (callbackNode.params.length > 0) return - if (containsThisExpression(callbackNode.body)) return - if (writesOuterState(callbackNode, sourceCode)) return - - const isSuiteHook = SUITE_HOOK_NAMES.has(hookName) - - if ( - isSuiteHook && - suiteAnalysis.testCount >= options.minimumTestsForSuiteHooks - ) { - return - } - - if ( - !isSuiteHook && - suiteAnalysis.hasDirectSuiteHooks && - suiteAnalysis.testCount >= options.minimumTestsForSuiteHooks - ) { - return - } - - if ( - options.allowKnownFrameworkHooks && - isKnownFrameworkHookCallback(callbackNode) - ) { - return - } - - context.report({ - node, - messageId: 'preferDisposables', - data: { hookName }, - }) + before() { + state.sourceCode = context.sourceCode + state.suiteAnalysisCache = new WeakMap() }, + ...createRuleVisitors(context, options, state), } }, } diff --git a/eslint-rules/prefer-dispose-in-tests.md b/eslint-rules/prefer-dispose-in-tests.md index 6f61c88..efb1a74 100644 --- a/eslint-rules/prefer-dispose-in-tests.md +++ b/eslint-rules/prefer-dispose-in-tests.md @@ -6,6 +6,16 @@ reasonably live in each test body. This rule is enabled as `warn` in test files by the shared config. +## Runtime compatibility + +This rule is authored in Oxlint's optimized style (`createOnce` + `before`), +then exposed to ESLint via `eslintCompatPlugin` from `@oxlint/plugins`. + +That gives: + +- faster execution path in Oxlint JS plugins +- unchanged behavior in ESLint + ## Why Disposable test setup keeps setup and cleanup in the same lexical scope and diff --git a/eslint-rules/prefer-dispose-in-tests.test.js b/eslint-rules/prefer-dispose-in-tests.test.js index 60c19ac..1da2d01 100644 --- a/eslint-rules/prefer-dispose-in-tests.test.js +++ b/eslint-rules/prefer-dispose-in-tests.test.js @@ -1,7 +1,10 @@ import { RuleTester } from 'eslint' -import { afterEach, beforeEach, describe, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, test } from 'vitest' -import { preferDisposeInTestsRule } from './prefer-dispose-in-tests.js' +import epicWebEslintPlugin from './eslint-plugin-epic-web.js' + +const preferDisposeInTestsRule = + epicWebEslintPlugin.rules['prefer-dispose-in-tests'] RuleTester.describe = describe RuleTester.it = it @@ -17,6 +20,11 @@ const ruleTester = new RuleTester({ }, }) +test('is oxlint optimized and eslint compatible', () => { + expect(typeof preferDisposeInTestsRule.createOnce).toBe('function') + expect(typeof preferDisposeInTestsRule.create).toBe('function') +}) + ruleTester.run('prefer-dispose-in-tests', preferDisposeInTestsRule, { valid: [ { From 8c5078e177902ec74b807d07499d682dfa5f6ed5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 13 Feb 2026 20:22:33 +0000 Subject: [PATCH 09/13] handle null context options in oxlint compat path Co-authored-by: Kent C. Dodds --- eslint-rules/prefer-dispose-in-tests.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/eslint-rules/prefer-dispose-in-tests.js b/eslint-rules/prefer-dispose-in-tests.js index 31eafde..b89fa71 100644 --- a/eslint-rules/prefer-dispose-in-tests.js +++ b/eslint-rules/prefer-dispose-in-tests.js @@ -437,9 +437,12 @@ const preferDisposeInTestsRule = { }, }, createOnce(context) { + const userOptions = Array.isArray(context.options) + ? (context.options[0] ?? {}) + : (context.options ?? {}) const options = { ...DEFAULT_OPTIONS, - ...(context.options[0] ?? {}), + ...userOptions, } const state = { sourceCode: context.sourceCode, From c53dd082cae913dc6435f4da080209e19d6aa2e0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 13 Feb 2026 20:23:22 +0000 Subject: [PATCH 10/13] compute source and options in before hook for compat Co-authored-by: Kent C. Dodds --- eslint-rules/prefer-dispose-in-tests.js | 27 +++++++++++++------------ 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/eslint-rules/prefer-dispose-in-tests.js b/eslint-rules/prefer-dispose-in-tests.js index b89fa71..f1cc96c 100644 --- a/eslint-rules/prefer-dispose-in-tests.js +++ b/eslint-rules/prefer-dispose-in-tests.js @@ -345,7 +345,7 @@ function isKnownFrameworkHookCallback(callbackNode) { return callNames.every((callName) => KNOWN_FRAMEWORK_HOOK_CALLS.has(callName)) } -function createRuleVisitors(context, options, state) { +function createRuleVisitors(context, state) { function getSuiteAnalysis(suiteNode) { const existingAnalysis = state.suiteAnalysisCache.get(suiteNode) if (existingAnalysis) return existingAnalysis @@ -382,7 +382,7 @@ function createRuleVisitors(context, options, state) { if ( isSuiteHook && - suiteAnalysis.testCount >= options.minimumTestsForSuiteHooks + suiteAnalysis.testCount >= state.options.minimumTestsForSuiteHooks ) { return } @@ -390,13 +390,13 @@ function createRuleVisitors(context, options, state) { if ( !isSuiteHook && suiteAnalysis.hasDirectSuiteHooks && - suiteAnalysis.testCount >= options.minimumTestsForSuiteHooks + suiteAnalysis.testCount >= state.options.minimumTestsForSuiteHooks ) { return } if ( - options.allowKnownFrameworkHooks && + state.options.allowKnownFrameworkHooks && isKnownFrameworkHookCallback(callbackNode) ) { return @@ -437,24 +437,25 @@ const preferDisposeInTestsRule = { }, }, createOnce(context) { - const userOptions = Array.isArray(context.options) - ? (context.options[0] ?? {}) - : (context.options ?? {}) - const options = { - ...DEFAULT_OPTIONS, - ...userOptions, - } const state = { - sourceCode: context.sourceCode, + sourceCode: null, suiteAnalysisCache: new WeakMap(), + options: DEFAULT_OPTIONS, } return { before() { state.sourceCode = context.sourceCode state.suiteAnalysisCache = new WeakMap() + const userOptions = Array.isArray(context.options) + ? (context.options[0] ?? {}) + : (context.options ?? {}) + state.options = { + ...DEFAULT_OPTIONS, + ...userOptions, + } }, - ...createRuleVisitors(context, options, state), + ...createRuleVisitors(context, state), } }, } From f30df66ce7094511c70e890c9b78c807791128c8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 13 Feb 2026 20:25:33 +0000 Subject: [PATCH 11/13] rename eslint-rules directory to lint-rules Co-authored-by: Kent C. Dodds --- eslint-plugin-epic-web.js | 4 ++-- {eslint-rules => lint-rules}/eslint-plugin-epic-web.js | 0 eslint-rules/eslint-rules.md => lint-rules/lint-rules.md | 4 ++-- {eslint-rules => lint-rules}/prefer-dispose-in-tests.js | 0 {eslint-rules => lint-rules}/prefer-dispose-in-tests.md | 0 {eslint-rules => lint-rules}/prefer-dispose-in-tests.test.js | 0 6 files changed, 4 insertions(+), 4 deletions(-) rename {eslint-rules => lint-rules}/eslint-plugin-epic-web.js (100%) rename eslint-rules/eslint-rules.md => lint-rules/lint-rules.md (72%) rename {eslint-rules => lint-rules}/prefer-dispose-in-tests.js (100%) rename {eslint-rules => lint-rules}/prefer-dispose-in-tests.md (100%) rename {eslint-rules => lint-rules}/prefer-dispose-in-tests.test.js (100%) diff --git a/eslint-plugin-epic-web.js b/eslint-plugin-epic-web.js index ed2f1b2..f8202bb 100644 --- a/eslint-plugin-epic-web.js +++ b/eslint-plugin-epic-web.js @@ -1,2 +1,2 @@ -export { default } from './eslint-rules/eslint-plugin-epic-web.js' -export { preferDisposeInTestsRule } from './eslint-rules/eslint-plugin-epic-web.js' +export { default } from './lint-rules/eslint-plugin-epic-web.js' +export { preferDisposeInTestsRule } from './lint-rules/eslint-plugin-epic-web.js' diff --git a/eslint-rules/eslint-plugin-epic-web.js b/lint-rules/eslint-plugin-epic-web.js similarity index 100% rename from eslint-rules/eslint-plugin-epic-web.js rename to lint-rules/eslint-plugin-epic-web.js diff --git a/eslint-rules/eslint-rules.md b/lint-rules/lint-rules.md similarity index 72% rename from eslint-rules/eslint-rules.md rename to lint-rules/lint-rules.md index 0c37caa..38f7f12 100644 --- a/eslint-rules/eslint-rules.md +++ b/lint-rules/lint-rules.md @@ -1,6 +1,6 @@ -# ESLint rules +# Lint rules -Custom ESLint rule assets live in this directory: +Custom lint rule assets live in this directory: - implementation files (`*.js`) - rule docs (`*.md`) diff --git a/eslint-rules/prefer-dispose-in-tests.js b/lint-rules/prefer-dispose-in-tests.js similarity index 100% rename from eslint-rules/prefer-dispose-in-tests.js rename to lint-rules/prefer-dispose-in-tests.js diff --git a/eslint-rules/prefer-dispose-in-tests.md b/lint-rules/prefer-dispose-in-tests.md similarity index 100% rename from eslint-rules/prefer-dispose-in-tests.md rename to lint-rules/prefer-dispose-in-tests.md diff --git a/eslint-rules/prefer-dispose-in-tests.test.js b/lint-rules/prefer-dispose-in-tests.test.js similarity index 100% rename from eslint-rules/prefer-dispose-in-tests.test.js rename to lint-rules/prefer-dispose-in-tests.test.js From 61f9607553b6bec057b3e17bf5d1941c71b212db Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 13 Feb 2026 20:37:30 +0000 Subject: [PATCH 12/13] align prefer-dispose docs and plugin with main lint-rules layout Co-authored-by: Kent C. Dodds --- eslint-plugin-epic-web.js | 5 +++-- eslint.js | 3 --- lint-rules/epic-web-plugin.js | 3 +++ lint-rules/eslint-plugin-epic-web.js | 14 -------------- lint-rules/index.md | 1 + lint-rules/lint-rules.md | 11 ----------- lint-rules/prefer-dispose-in-tests.test.js | 4 ++-- 7 files changed, 9 insertions(+), 32 deletions(-) delete mode 100644 lint-rules/eslint-plugin-epic-web.js delete mode 100644 lint-rules/lint-rules.md diff --git a/eslint-plugin-epic-web.js b/eslint-plugin-epic-web.js index f8202bb..f5da306 100644 --- a/eslint-plugin-epic-web.js +++ b/eslint-plugin-epic-web.js @@ -1,2 +1,3 @@ -export { default } from './lint-rules/eslint-plugin-epic-web.js' -export { preferDisposeInTestsRule } from './lint-rules/eslint-plugin-epic-web.js' +export { default } from './lint-rules/epic-web-plugin.js' +export { default as noManualDisposeRule } from './lint-rules/no-manual-dispose.js' +export { default as preferDisposeInTestsRule } from './lint-rules/prefer-dispose-in-tests.js' diff --git a/eslint.js b/eslint.js index 5e550f8..9c97508 100644 --- a/eslint.js +++ b/eslint.js @@ -253,9 +253,6 @@ export const config = [ { files: testFiles, - plugins: { - 'epic-web': epicWebPlugin, - }, rules: { 'epic-web/prefer-dispose-in-tests': WARN, }, diff --git a/lint-rules/epic-web-plugin.js b/lint-rules/epic-web-plugin.js index edb26fb..98ab18c 100644 --- a/lint-rules/epic-web-plugin.js +++ b/lint-rules/epic-web-plugin.js @@ -1,5 +1,6 @@ import { eslintCompatPlugin } from '@oxlint/plugins' import noManualDispose from './no-manual-dispose.js' +import preferDisposeInTests from './prefer-dispose-in-tests.js' const plugin = eslintCompatPlugin({ meta: { @@ -7,7 +8,9 @@ const plugin = eslintCompatPlugin({ }, rules: { 'no-manual-dispose': noManualDispose, + 'prefer-dispose-in-tests': preferDisposeInTests, }, }) export default plugin +export { noManualDispose, preferDisposeInTests } diff --git a/lint-rules/eslint-plugin-epic-web.js b/lint-rules/eslint-plugin-epic-web.js deleted file mode 100644 index 3a56562..0000000 --- a/lint-rules/eslint-plugin-epic-web.js +++ /dev/null @@ -1,14 +0,0 @@ -import { eslintCompatPlugin } from '@oxlint/plugins' -import preferDisposeInTestsRule from './prefer-dispose-in-tests.js' - -const plugin = eslintCompatPlugin({ - meta: { - name: '@epic-web/eslint-plugin', - }, - rules: { - 'prefer-dispose-in-tests': preferDisposeInTestsRule, - }, -}) - -export default plugin -export { preferDisposeInTestsRule } diff --git a/lint-rules/index.md b/lint-rules/index.md index a80baa9..8043d29 100644 --- a/lint-rules/index.md +++ b/lint-rules/index.md @@ -15,3 +15,4 @@ remaining ESLint-compatible. ## Rules - [`epic-web/no-manual-dispose`](./no-manual-dispose.md) +- [`epic-web/prefer-dispose-in-tests`](./prefer-dispose-in-tests.md) diff --git a/lint-rules/lint-rules.md b/lint-rules/lint-rules.md deleted file mode 100644 index 38f7f12..0000000 --- a/lint-rules/lint-rules.md +++ /dev/null @@ -1,11 +0,0 @@ -# Lint rules - -Custom lint rule assets live in this directory: - -- implementation files (`*.js`) -- rule docs (`*.md`) -- rule tests (`*.test.js`) - -## Available rules - -- [`epic-web/prefer-dispose-in-tests`](./prefer-dispose-in-tests.md) diff --git a/lint-rules/prefer-dispose-in-tests.test.js b/lint-rules/prefer-dispose-in-tests.test.js index 1da2d01..db23ac3 100644 --- a/lint-rules/prefer-dispose-in-tests.test.js +++ b/lint-rules/prefer-dispose-in-tests.test.js @@ -1,10 +1,10 @@ import { RuleTester } from 'eslint' import { afterEach, beforeEach, describe, expect, it, test } from 'vitest' -import epicWebEslintPlugin from './eslint-plugin-epic-web.js' +import plugin from './epic-web-plugin.js' const preferDisposeInTestsRule = - epicWebEslintPlugin.rules['prefer-dispose-in-tests'] + plugin.rules['prefer-dispose-in-tests'] RuleTester.describe = describe RuleTester.it = it From 1ca487053f1c72f61d6c34664d5bc17f469a65a7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 13 Feb 2026 20:42:19 +0000 Subject: [PATCH 13/13] enable prefer-dispose rule in oxlint config Co-authored-by: Kent C. Dodds --- README.md | 1 - oxlint-config.json | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f2e3efd..47fb4ca 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,6 @@ Oxlint, so they are intentionally omitted: - `jest-dom/*` - `vitest/*` (except `vitest/no-import-node-test`) - `playwright/*` -- `epic-web/prefer-dispose-in-tests` ## License diff --git a/oxlint-config.json b/oxlint-config.json index 5ff1196..3fb4779 100644 --- a/oxlint-config.json +++ b/oxlint-config.json @@ -113,6 +113,7 @@ ], "rules": { "eslint/no-restricted-imports": "off", + "epic-web/prefer-dispose-in-tests": "warn", "vitest/no-import-node-test": "error" } },