Skip to content

Commit 7d9d12c

Browse files
feat: add prefer-describe-function-title rule (#690)
* feat: add prefer-describe-function-title rule * chore: fix peer deps --------- Co-authored-by: Verite Mugabo <[email protected]>
1 parent eee6aae commit 7d9d12c

14 files changed

+340
-36
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ export default [
187187
| [padding-around-expect-groups](docs/rules/padding-around-expect-groups.md) | enforce padding around `expect` groups | | 🌐 | 🔧 | | |
188188
| [prefer-called-with](docs/rules/prefer-called-with.md) | enforce using `toBeCalledWith()` or `toHaveBeenCalledWith()` | | 🌐 | 🔧 | | |
189189
| [prefer-comparison-matcher](docs/rules/prefer-comparison-matcher.md) | enforce using the built-in comparison matchers | | 🌐 | 🔧 | | |
190+
| [prefer-describe-function-title](docs/rules/prefer-describe-function-title.md) | prefer using a function as a describe name over an equivalent string | | 🌐 | 🔧 | | |
190191
| [prefer-each](docs/rules/prefer-each.md) | enforce using `each` rather than manual loops | | 🌐 | | | |
191192
| [prefer-equality-matcher](docs/rules/prefer-equality-matcher.md) | enforce using the built-in quality matchers | | 🌐 | | 💡 | |
192193
| [prefer-expect-assertions](docs/rules/prefer-expect-assertions.md) | enforce using expect assertions instead of callbacks | | 🌐 | | 💡 | |
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Enforce using a function as a describe title over an equivalent string (`vitest/prefer-describe-function-title`)
2+
3+
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
4+
5+
<!-- end auto-generated rule header -->
6+
7+
## Rule Details
8+
9+
This rule aims to enforce passing a named function to `describe()` instead of an equivalent hardcoded string.
10+
11+
Passing named functions means the correct title will be used even if the function is renamed.
12+
This rule will report if a string is passed to a `describe()` block if:
13+
14+
* The string matches a function imported into the file
15+
* That function's name also matches the test file's name
16+
17+
Examples of **incorrect** code for this rule:
18+
19+
```ts
20+
// myFunction.test.js
21+
import { myFunction } from "./myFunction"
22+
23+
describe("myFunction", () => {
24+
// ...
25+
})
26+
```
27+
28+
Examples of **correct** code for this rule:
29+
30+
```ts
31+
// myFunction.test.js
32+
import { myFunction } from "./myFunction"
33+
34+
describe(myFunction, () => {
35+
// ...
36+
})
37+
```

package.json

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,16 +73,14 @@
7373
"vitest": "^3.0.5"
7474
},
7575
"peerDependencies": {
76+
"@typescript-eslint/utils": ">= 8.24.0",
7677
"eslint": ">= 8.57.0",
7778
"typescript": ">= 5.0.0",
7879
"vitest": "*"
7980
},
8081
"peerDependenciesMeta": {
8182
"typescript": {
8283
"optional": true
83-
},
84-
"vitest": {
85-
"optional": true
8684
}
8785
},
8886
"packageManager": "[email protected]"

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import requireLocalTestContextForConcurrentSnapshots, { RULE_NAME as requireLoca
5252
import preferTodo, { RULE_NAME as preferTodoName } from './rules/prefer-todo'
5353
import preferSpyOn, { RULE_NAME as preferSpyOnName } from './rules/prefer-spy-on'
5454
import preferComparisonMatcher, { RULE_NAME as preferComparisonMatcherName } from './rules/prefer-comparison-matcher'
55+
import preferDescribeFunctionTitle, { RULE_NAME as preferDescribeFunctionTitleName } from './rules/prefer-describe-function-title'
5556
import preferToContain, { RULE_NAME as preferToContainName } from './rules/prefer-to-contain'
5657
import preferExpectAssertions, { RULE_NAME as preferExpectAssertionsName } from './rules/prefer-expect-assertions'
5758
import paddingAroundAfterAllBlocks, { RULE_NAME as paddingAroundAfterAllBlocksName } from './rules/padding-around-after-all-blocks'
@@ -129,6 +130,7 @@ const allRules = {
129130
[preferTodoName]: 'warn',
130131
[preferSpyOnName]: 'warn',
131132
[preferComparisonMatcherName]: 'warn',
133+
[preferDescribeFunctionTitleName]: 'warn',
132134
[preferToContainName]: 'warn',
133135
[preferExpectAssertionsName]: 'warn',
134136
[usePreferToBe]: 'warn',
@@ -221,6 +223,7 @@ const plugin = {
221223
[preferTodoName]: preferTodo,
222224
[preferSpyOnName]: preferSpyOn,
223225
[preferComparisonMatcherName]: preferComparisonMatcher,
226+
[preferDescribeFunctionTitleName]: preferDescribeFunctionTitle,
224227
[preferToContainName]: preferToContain,
225228
[preferExpectAssertionsName]: preferExpectAssertions,
226229
[paddingAroundAfterAllBlocksName]: paddingAroundAfterAllBlocks,

src/rules/no-disabled-tests.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createEslintRule, getAccessorValue } from '../utils'
22
import { parseVitestFnCall, resolveScope } from '../utils/parse-vitest-fn-call'
3+
import { getScope } from '../utils/scope'
34

45
export const RULE_NAME = 'no-disabled-tests'
56
export type MESSAGE_ID = 'missingFunction' | 'pending' | 'pendingSuite' | 'pendingTest' | 'disabledSuite' | 'disabledTest'
@@ -69,9 +70,7 @@ export default createEslintRule<Options, MESSAGE_ID>({
6970
testDepth--
7071
},
7172
'CallExpression[callee.name="pending"]'(node) {
72-
const scope = context.sourceCode.getScope
73-
? context.sourceCode.getScope(node)
74-
: context.getScope()
73+
const scope = getScope(context, node)
7574

7675
if (resolveScope(scope, 'pending'))
7776
return
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import {
2+
AST_NODE_TYPES,
3+
ESLintUtils,
4+
TSESLint
5+
} from '@typescript-eslint/utils'
6+
import { createEslintRule } from '../utils'
7+
import { parsePluginSettings } from '../utils/parse-plugin-settings'
8+
import { parseVitestFnCall } from '../utils/parse-vitest-fn-call'
9+
import { getModuleScope } from '../utils/scope'
10+
import { isClassOrFunctionType } from '../utils/types'
11+
12+
export const RULE_NAME = 'prefer-describe-function-title'
13+
export type MESSAGE_IDS = 'preferFunction'
14+
export type Options = []
15+
16+
export default createEslintRule<Options, MESSAGE_IDS>({
17+
name: RULE_NAME,
18+
meta: {
19+
type: 'problem',
20+
docs: {
21+
description:
22+
'enforce using a function as a describe title over an equivalent string',
23+
recommended: false
24+
},
25+
fixable: 'code',
26+
schema: [],
27+
messages: {
28+
preferFunction: 'Enforce using a function over an equivalent string'
29+
}
30+
},
31+
defaultOptions: [],
32+
create(context) {
33+
return {
34+
CallExpression(node) {
35+
if (node.arguments.length < 2) {
36+
return
37+
}
38+
39+
const [argument] = node.arguments
40+
if (
41+
argument.type !== AST_NODE_TYPES.Literal
42+
|| typeof argument.value !== 'string'
43+
) {
44+
return
45+
}
46+
47+
const describedTitle = argument.value
48+
if (!context.filename.includes(`${describedTitle}.`)) {
49+
return
50+
}
51+
52+
const vitestFnCall = parseVitestFnCall(node, context)
53+
if (vitestFnCall?.type !== 'describe') {
54+
return
55+
}
56+
57+
const scope = getModuleScope(context, node)
58+
const scopedFunction = scope?.set.get(describedTitle)?.defs[0]
59+
if (scopedFunction?.type !== 'ImportBinding') {
60+
return
61+
}
62+
63+
const settings = parsePluginSettings(context.settings)
64+
if (settings.typecheck) {
65+
const services = ESLintUtils.getParserServices(context)
66+
const type = services.getTypeAtLocation(scopedFunction.node)
67+
68+
if (!isClassOrFunctionType(type)) {
69+
return
70+
}
71+
}
72+
73+
context.report({
74+
node: argument,
75+
messageId: 'preferFunction',
76+
fix(fixer) {
77+
return fixer.replaceText(argument, describedTitle)
78+
}
79+
})
80+
}
81+
}
82+
}
83+
})

src/rules/valid-title.ts

Lines changed: 2 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { AST_NODE_TYPES, ESLintUtils, JSONSchema, TSESTree } from '@typescript-eslint/utils'
22
import { createEslintRule, getStringValue, isStringNode, StringNode } from '../utils'
33
import { parseVitestFnCall } from '../utils/parse-vitest-fn-call'
4-
import { DescribeAlias, TestCaseName } from '../utils/types'
4+
import { DescribeAlias, isClassOrFunctionType, TestCaseName } from '../utils/types'
55
import ts from 'typescript'
66
import { parsePluginSettings } from '../utils/parse-plugin-settings'
77

@@ -62,30 +62,6 @@ const compileMatcherPattern = (matcherMaybeWithMessage: MatcherAndMessage | stri
6262
return [new RegExp(matcher, 'u'), message]
6363
}
6464

65-
function isFunctionType(type: ts.Type): boolean {
66-
const symbol = type.getSymbol()
67-
68-
if (!symbol) {
69-
return false
70-
}
71-
72-
return symbol.getDeclarations()?.some(declaration =>
73-
ts.isFunctionDeclaration(declaration)
74-
|| ts.isMethodDeclaration(declaration)
75-
|| ts.isFunctionExpression(declaration)
76-
|| ts.isArrowFunction(declaration)) ?? false
77-
}
78-
79-
function isClassType(type: ts.Type): boolean {
80-
const symbol = type.getSymbol()
81-
82-
if (!symbol) return false
83-
84-
return symbol.getDeclarations()?.some(declaration =>
85-
ts.isClassDeclaration(declaration)
86-
|| ts.isClassExpression(declaration)) ?? false
87-
}
88-
8965
function isStringLikeType(type: ts.Type): boolean {
9066
return !!(type.flags & (ts.TypeFlags.StringLike))
9167
}
@@ -229,7 +205,7 @@ export default createEslintRule<Options, MESSAGE_IDS>({
229205

230206
const type = services.getTypeAtLocation(argument)
231207

232-
if (isFunctionType(type) || isClassType(type)) return
208+
if (isClassOrFunctionType(type)) return
233209

234210
if (isStringLikeType(type)) {
235211
if (isStringNode(argument) && !getStringValue(argument)) {

src/utils/parse-vitest-fn-call.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/utils'
22
import { DescribeAlias, HookName, ModifierName, TestCaseName } from './types'
33
import { ValidVitestFnCallChains } from './valid-vitest-fn-call-chains'
44
import { AccessorNode, getAccessorValue, getStringValue, isFunction, isIdentifier, isStringNode, isSupportedAccessor } from '.'
5+
import { getScope } from './scope'
56

67
export type VitestFnType =
78
| 'test'
@@ -313,9 +314,7 @@ const resolveVitestFn = (
313314
node: TSESTree.CallExpression,
314315
identifier: string
315316
): ResolvedVitestFn | null => {
316-
const scope = context.sourceCode.getScope
317-
? context.sourceCode.getScope(node)
318-
: context.getScope()
317+
const scope = getScope(context, node)
319318
const maybeImport = resolveScope(scope, identifier)
320319

321320
if (maybeImport === 'local')

src/utils/scope.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { TSESLint, TSESTree } from '@typescript-eslint/utils'
2+
import { Scope } from '@typescript-eslint/utils/ts-eslint'
3+
4+
export function getScope(
5+
context: TSESLint.RuleContext<string, unknown[]>,
6+
node: TSESTree.Node
7+
): Scope.Scope {
8+
return context.sourceCode.getScope
9+
? context.sourceCode.getScope(node)
10+
: context.getScope()
11+
}
12+
13+
export function getModuleScope(
14+
context: TSESLint.RuleContext<string, unknown[]>,
15+
node: TSESTree.Node
16+
): Scope.Scope | null {
17+
let scope: Scope.Scope | null = getScope(context, node)
18+
19+
while (scope) {
20+
if (scope.type === 'module') {
21+
return scope
22+
}
23+
scope = scope.upper
24+
}
25+
26+
return scope
27+
}

src/utils/types.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { TSESTree } from '@typescript-eslint/utils'
2+
import ts from 'typescript'
23

34
export enum DescribeAlias {
45
describe = 'describe',
@@ -73,3 +74,18 @@ interface TypeAssertionChain<
7374
> extends TSESTree.TSTypeAssertion {
7475
expression: TypeAssertionChain<Expression> | Expression
7576
}
77+
78+
export function isClassOrFunctionType(type: ts.Type): boolean {
79+
return type
80+
.getSymbol()
81+
?.getDeclarations()
82+
?.some(
83+
declaration =>
84+
ts.isArrowFunction(declaration)
85+
|| ts.isClassDeclaration(declaration)
86+
|| ts.isClassExpression(declaration)
87+
|| ts.isFunctionDeclaration(declaration)
88+
|| ts.isFunctionExpression(declaration)
89+
|| ts.isMethodDeclaration(declaration)
90+
) ?? false
91+
}

tests/fixture/myFunction.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export function myFunction() {}

tests/fixture/myFunction.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export function myFunction() {}

tests/fixture/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22
"compilerOptions": {
33
"strict": true
44
},
5-
"include": [ "file.ts", "react.tsx" ]
5+
"include": [ "*.ts", "*.tsx" ]
66
}

0 commit comments

Comments
 (0)