@epic-web/config
Version:
Reasonable Oxlint, Oxfmt, and TypeScript configs for epic web devs
471 lines (390 loc) • 11.7 kB
JavaScript
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))
}
function createRuleVisitors(context, 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 >= state.options.minimumTestsForSuiteHooks
) {
return
}
if (
!isSuiteHook &&
suiteAnalysis.hasDirectSuiteHooks &&
suiteAnalysis.testCount >= state.options.minimumTestsForSuiteHooks
) {
return
}
if (
state.options.allowKnownFrameworkHooks &&
isKnownFrameworkHookCallback(callbackNode)
) {
return
}
context.report({
node,
messageId: 'preferDisposables',
data: { hookName },
})
},
}
}
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.',
},
},
createOnce(context) {
const state = {
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, state),
}
},
}
export default preferDisposeInTestsRule
export { preferDisposeInTestsRule }