diff --git a/.changeset/many-needles-brake.md b/.changeset/many-needles-brake.md
new file mode 100644
index 0000000000..b1867d51bc
--- /dev/null
+++ b/.changeset/many-needles-brake.md
@@ -0,0 +1,6 @@
+---
+"@flint.fyi/rule-data": patch
+"@flint.fyi/vitest": patch
+---
+
+[titleValidity] add rule.
diff --git a/packages/rule-data/src/data.json b/packages/rule-data/src/data.json
index c83f28f16e..963f502565 100644
--- a/packages/rule-data/src/data.json
+++ b/packages/rule-data/src/data.json
@@ -31557,7 +31557,8 @@
"flint": {
"name": "titleValidity",
"plugin": "vitest",
- "preset": "logical"
+ "preset": "logical",
+ "status": "implemented"
},
"oxlint": [
{
diff --git a/packages/site/src/content/docs/rules/vitest/titleValidity.mdx b/packages/site/src/content/docs/rules/vitest/titleValidity.mdx
new file mode 100644
index 0000000000..ae13ab89f9
--- /dev/null
+++ b/packages/site/src/content/docs/rules/vitest/titleValidity.mdx
@@ -0,0 +1,217 @@
+---
+description: "This rule aims to enforce valid titles for `describe()`, `it()` and `test()` titles."
+title: "titleValidity"
+topic: "rules"
+---
+
+import { TabItem, Tabs } from "@astrojs/starlight/components";
+
+import { RuleEquivalents } from "~/components/RuleEquivalents";
+import RuleSummary from "~/components/RuleSummary.astro";
+
+
+
+Reports `describe()`, `it()`, and `test()` calls with invalid titles.
+
+## Examples
+
+
+
+
+```ts
+describe(1, () => {
+ // ...
+});
+```
+
+```ts
+it("", () => {
+ // ...
+});
+```
+
+```ts
+it("it returns a number", () => {
+ // ...
+});
+```
+
+```ts
+it(" returns a number ", () => {
+ // ...
+});
+```
+
+
+
+
+```ts
+describe("1", () => {
+ // ...
+});
+```
+
+```ts
+it("returns a number", () => {
+ // ...
+});
+```
+
+```ts
+declare function getValue(): number;
+
+describe(getValue, () => {
+ // ...
+});
+```
+
+
+
+
+## Options
+
+### `allowArguments`
+
+Whether to allow identifiers as titles.
+Defaults to `false`.
+
+Only identifiers are skipped by this option.
+Function calls such as `getTitle()` and property accesses such as `config.title` are not skipped, and are still reported.
+
+Examples of **incorrect** code for this rule with the `{ "allowArguments": true }` option:
+
+```ts
+declare function getTitle(): unknown;
+
+it(getTitle(), () => {
+ // ...
+});
+```
+
+```ts
+declare const config: { title: unknown };
+
+it(config.title, () => {
+ // ...
+});
+```
+
+Examples of **correct** code for this rule with the `{ "allowArguments": true }` option:
+
+```ts
+declare const title: unknown;
+
+it(title, () => {
+ // ...
+});
+```
+
+### `disallowedWords`
+
+Words that are not allowed to appear in titles.
+Defaults to `[]`.
+
+Words are matched case-insensitively on word boundaries, against the title string only.
+Because of the word boundaries, `"skip"` does not match a title containing `skipped`.
+
+Examples of **incorrect** code for this rule with the `{ "disallowedWords": ["skips"] }` option:
+
+```ts
+it("skips the empty case", () => {
+ // ...
+});
+```
+
+Examples of **correct** code for this rule with the `{ "disallowedWords": ["skips"] }` option:
+
+```ts
+it("ignores the empty case", () => {
+ // ...
+});
+```
+
+### `ignoreTypeOfDescribeName`
+
+Whether to skip checking the type of `describe()` titles.
+Defaults to `false`.
+
+Examples of **correct** code for this rule with the `{ "ignoreTypeOfDescribeName": true }` option:
+
+```ts
+declare const value: unknown;
+
+describe(typeof value, () => {
+ // ...
+});
+```
+
+### `mustMatch`
+
+Regular expressions that titles must match, optionally with a custom message.
+Not set by default.
+
+A string or a `[pattern, message]` pair applies to `describe`, `it`, and `test` alike.
+An object applies a separate pattern per function:
+
+```json
+{
+ "mustMatch": {
+ "it": ["^should ", "Start test titles with \"should\"."]
+ }
+}
+```
+
+Aliases map onto those three keys: `xdescribe` uses `describe`, `fit` and `xit` use `it`, and `xtest` uses `test`.
+
+Examples of **incorrect** code for this rule with the `{ "mustMatch": "^should " }` option:
+
+```ts
+it("returns a number", () => {
+ // ...
+});
+```
+
+Examples of **correct** code for this rule with the `{ "mustMatch": "^should " }` option:
+
+```ts
+it("should return a number", () => {
+ // ...
+});
+```
+
+### `mustNotMatch`
+
+Regular expressions that titles must not match, optionally with a custom message.
+Not set by default.
+
+It takes the same shapes as `mustMatch`.
+When a title matches `mustNotMatch`, that is reported and `mustMatch` is not checked.
+
+Examples of **incorrect** code for this rule with the `{ "mustNotMatch": "^should " }` option:
+
+```ts
+it("should return a number", () => {
+ // ...
+});
+```
+
+Examples of **correct** code for this rule with the `{ "mustNotMatch": "^should " }` option:
+
+```ts
+it("returns a number", () => {
+ // ...
+});
+```
+
+## When Not To Use It
+
+Projects that lean on generated titles, or that have a large existing suite whose titles would be disruptive to rename, might prefer to enable only the options they need rather than the whole rule.
+
+## Further Reading
+
+- [Vitest API: Describe](https://vitest.dev/api/describe)
+- [Vitest API: Test](https://vitest.dev/api/test)
+
+## Equivalents in Other Linters
+
+
diff --git a/packages/vitest/package.json b/packages/vitest/package.json
index e224174bfd..2ab2535588 100644
--- a/packages/vitest/package.json
+++ b/packages/vitest/package.json
@@ -27,7 +27,8 @@
"dependencies": {
"@flint.fyi/core": "workspace:^",
"@flint.fyi/typescript-language": "workspace:^",
- "typescript": "^6.0.0"
+ "typescript": "^6.0.0",
+ "zod": "^4.3.6"
},
"devDependencies": {
"@flint.fyi/build": "workspace:^",
diff --git a/packages/vitest/src/plugin.ts b/packages/vitest/src/plugin.ts
index ff79d3bb6c..466bcc8bad 100644
--- a/packages/vitest/src/plugin.ts
+++ b/packages/vitest/src/plugin.ts
@@ -12,6 +12,7 @@ import expectGroupPaddingLines from "./rules/expectGroupPaddingLines.ts";
import nodeTestImports from "./rules/nodeTestImports.ts";
import testCasePaddingLines from "./rules/testCasePaddingLines.ts";
import testCasesWithinDescribes from "./rules/testCasesWithinDescribes.ts";
+import titleValidity from "./rules/titleValidity.ts";
export const vitest = createPlugin({
files: {
@@ -29,5 +30,6 @@ export const vitest = createPlugin({
nodeTestImports,
testCasePaddingLines,
testCasesWithinDescribes,
+ titleValidity,
],
});
diff --git a/packages/vitest/src/rules/titleValidity.test.ts b/packages/vitest/src/rules/titleValidity.test.ts
new file mode 100644
index 0000000000..18a441913c
--- /dev/null
+++ b/packages/vitest/src/rules/titleValidity.test.ts
@@ -0,0 +1,734 @@
+import { ruleTester } from "../ruleTester.ts";
+import rule from "./titleValidity.ts";
+
+ruleTester.describe(rule, {
+ invalid: [
+ {
+ code: `
+test("the correct way to properly handle all things", () => {});
+`,
+ options: { disallowedWords: ["correct", "properly", "all"] },
+ snapshot: `
+test("the correct way to properly handle all things", () => {});
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ \`correct\` is not allowed in test title.
+`,
+ },
+ {
+ code: `
+describe("the correct way to do things", function () {})
+`,
+ options: { disallowedWords: ["correct"] },
+ snapshot: `
+describe("the correct way to do things", function () {})
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ \`correct\` is not allowed in test title.
+`,
+ },
+ {
+ code: `
+it("has ALL the things", () => {})
+`,
+ options: { disallowedWords: ["all"] },
+ snapshot: `
+it("has ALL the things", () => {})
+ ~~~~~~~~~~~~~~~~~~~~
+ \`ALL\` is not allowed in test title.
+`,
+ },
+ {
+ code: `
+xdescribe("every single one of them", function () {})
+`,
+ options: { disallowedWords: ["every"] },
+ snapshot: `
+xdescribe("every single one of them", function () {})
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~
+ \`every\` is not allowed in test title.
+`,
+ },
+ {
+ code: `
+describe('Very Descriptive Title Goes Here', function () {})
+`,
+ options: { disallowedWords: ["descriptive"] },
+ snapshot: `
+describe('Very Descriptive Title Goes Here', function () {})
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ \`Descriptive\` is not allowed in test title.
+`,
+ },
+ {
+ code: `
+test(\`that the value is set properly\`, function () {})
+`,
+ options: { disallowedWords: ["properly"] },
+ snapshot: `
+test(\`that the value is set properly\`, function () {})
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ \`properly\` is not allowed in test title.
+`,
+ },
+ {
+ code: `
+import { test } from './test-extend'
+test('the correct way to properly handle all things', () => {})
+`,
+ options: { disallowedWords: ["correct"] },
+ snapshot: `
+import { test } from './test-extend'
+test('the correct way to properly handle all things', () => {})
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ \`correct\` is not allowed in test title.
+`,
+ },
+ {
+ code: `
+import { test } from '@/tests/fixtures'
+test('the correct way to properly handle all things', () => {})
+`,
+ options: { disallowedWords: ["correct"] },
+ snapshot: `
+import { test } from '@/tests/fixtures'
+test('the correct way to properly handle all things', () => {})
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ \`correct\` is not allowed in test title.
+`,
+ },
+ ],
+ valid: [
+ `describe("the correct way to properly handle all the things", () => {});`,
+ `test("that all is as it should be", () => {});`,
+ {
+ code: `it("correctly sets the value", () => {});`,
+ options: {
+ disallowedWords: ["incorrectly"],
+ ignoreTypeOfDescribeName: false,
+ },
+ },
+ {
+ code: `it("correctly sets the value", () => {});`,
+ options: { disallowedWords: undefined },
+ },
+ `
+ function foo(){}
+ describe(foo, () => {
+ test('item', () => {
+ expect(0).toBe(0)
+ })
+ })
+ `,
+ `
+ declare const outerName: string;
+ describe(outerName, () => {
+ test('item', () => {
+ expect(0).toBe(0)
+ })
+ })
+ `,
+ `
+ declare const outerName: 'a';
+ describe(outerName, () => {
+ test('item', () => {
+ expect(0).toBe(0)
+ })
+ })
+ `,
+ `
+ declare const outerName: \`\${'a'}\`;
+ describe(outerName, () => {
+ test('item', () => {
+ expect(0).toBe(0)
+ })
+ })
+ `,
+ `
+ class foo{}
+ describe(foo, () => {
+ test('item', () => {
+ expect(0).toBe(0)
+ })
+ })
+ `,
+ `
+ type Func = (params: object) => void
+ const func: Func = (params) => console.log(params)
+ describe(func, () => {
+ test('item', () => {
+ expect(0).toBe(0)
+ })
+ });`,
+ `
+ interface Func {
+ (params: object): void
+ }
+ const func: Func = (params) => console.log(params)
+ describe(func, () => {
+ test('item', () => {
+ expect(0).toBe(0)
+ })
+ });`,
+ {
+ code: `
+ import { validatorFunction } from "./myFunction"
+ describe(validatorFunction, () => {
+ test('item', () => {
+ expect(0).toBe(0)
+ })
+ })
+ `,
+ fileName: "myFunction.test.ts",
+ files: { "myFunction.ts": `export function validatorFunction() {}` },
+ },
+ ],
+});
+
+ruleTester.describe(rule, {
+ invalid: [
+ {
+ code: `
+test(bar, () => {});
+`,
+ options: { allowArguments: false },
+ snapshot: `
+test(bar, () => {});
+ ~~~
+ Test title must be a string, a function or class name.
+`,
+ },
+ ],
+ valid: [
+ {
+ code: `it(foo, () => {});`,
+ options: { allowArguments: true },
+ },
+ {
+ code: `describe(bar, () => {});`,
+ options: { allowArguments: true },
+ },
+ {
+ code: `test(baz, () => {});`,
+ options: { allowArguments: true },
+ },
+ ],
+});
+
+ruleTester.describe(rule, {
+ invalid: [
+ {
+ code: `
+ describe('things to test', () => {
+ describe('unit tests #unit', () => {
+ it('is true', () => {
+ expect(true).toBe(true);
+ });
+ });
+
+ describe('e2e tests #e4e', () => {
+ it('is another test #e2e #vitest4life', () => {});
+ });
+ });
+`,
+ options: {
+ mustMatch: "^[^#]+$|(?:#(?:unit|e2e))",
+ mustNotMatch: "(?:#(?!unit|e2e))\\w+",
+ },
+ snapshot: `
+ describe('things to test', () => {
+ describe('unit tests #unit', () => {
+ it('is true', () => {
+ expect(true).toBe(true);
+ });
+ });
+
+ describe('e2e tests #e4e', () => {
+ ~~~~~~~~~~~~~~~~
+ \`describe\` should not match /(?:#(?!unit|e2e))\\w+/u.
+ it('is another test #e2e #vitest4life', () => {});
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ \`it\` should not match /(?:#(?!unit|e2e))\\w+/u.
+ });
+ });
+`,
+ },
+ {
+ code: `
+ describe('things to test', () => {
+ describe('unit tests #unit', () => {
+ it('is true', () => {
+ expect(true).toBe(true);
+ });
+ });
+
+ describe('e2e tests #e4e', () => {
+ it('is another test #e2e #vitest4life', () => {});
+ });
+ });
+`,
+ options: {
+ mustMatch: [
+ "^[^#]+$|(?:#(?:unit|e2e))",
+ 'Please include "#unit" or "#e2e" in titles',
+ ],
+ mustNotMatch: [
+ "(?:#(?!unit|e2e))\\w+",
+ 'Please include "#unit" or "#e2e" in titles',
+ ],
+ },
+ snapshot: `
+ describe('things to test', () => {
+ describe('unit tests #unit', () => {
+ it('is true', () => {
+ expect(true).toBe(true);
+ });
+ });
+
+ describe('e2e tests #e4e', () => {
+ ~~~~~~~~~~~~~~~~
+ \`describe\` should not match /(?:#(?!unit|e2e))\\w+/u. Please include "#unit" or "#e2e" in titles
+ it('is another test #e2e #vitest4life', () => {});
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ \`it\` should not match /(?:#(?!unit|e2e))\\w+/u. Please include "#unit" or "#e2e" in titles
+ });
+ });
+`,
+ },
+ {
+ code: `
+test("the correct way to properly handle all things", () => {});
+`,
+ options: { mustMatch: "#(?:unit|integration|e2e)" },
+ snapshot: `
+test("the correct way to properly handle all things", () => {});
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ \`test\` should match /#(?:unit|integration|e2e)/u.
+`,
+ },
+ {
+ code: `
+describe.skip("the test", () => {});
+`,
+ options: { mustMatch: { describe: "#(?:unit|integration|e2e)" } },
+ snapshot: `
+describe.skip("the test", () => {});
+ ~~~~~~~~~~
+ \`describe\` should match /#(?:unit|integration|e2e)/u.
+`,
+ },
+ ],
+ valid: [
+ `describe("the correct way to properly handle all the things", () => {});`,
+ `test("that all is as it should be", () => {});`,
+ {
+ code: `it("correctly sets the value", () => {});`,
+ options: { mustMatch: {} },
+ },
+ {
+ code: `it("correctly sets the value", () => {});`,
+ options: { mustMatch: " " },
+ },
+ {
+ code: `it("correctly sets the value", () => {});`,
+ options: { mustMatch: [" "] },
+ },
+ {
+ code: `it("correctly sets the value #unit", () => {});`,
+ options: { mustMatch: "#(?:unit|integration|e2e)" },
+ },
+ {
+ code: `it("correctly sets the value", () => {});`,
+ options: { mustMatch: "^[^#]+$|(?:#(?:unit|e2e))" },
+ },
+ {
+ code: `it("correctly sets the value", () => {});`,
+ options: { mustMatch: { test: "#(?:unit|integration|e2e)" } },
+ },
+ {
+ code: `describe('things to test', () => {
+ describe('unit tests #unit', () => {
+ it('is true', () => {
+ expect(true).toBe(true);
+ });
+ });
+
+ describe('e2e tests #e2e', () => {
+ it('is another test #jest4life', () => {});
+ });
+ });`,
+ options: { mustMatch: { test: "^[^#]+$|(?:#(?:unit|e2e))" } },
+ },
+ ],
+});
+
+ruleTester.describe(rule, {
+ invalid: [
+ {
+ code: `
+it.each([])(1, () => {});
+`,
+ snapshot: `
+it.each([])(1, () => {});
+ ~
+ Test title must be a string, a function or class name.
+`,
+ },
+ {
+ code: `
+it.skip.each([])(1, () => {});
+`,
+ snapshot: `
+it.skip.each([])(1, () => {});
+ ~
+ Test title must be a string, a function or class name.
+`,
+ },
+ {
+ code: `
+it.skip.each\`\`(1, () => {});
+`,
+ snapshot: `
+it.skip.each\`\`(1, () => {});
+ ~
+ Test title must be a string, a function or class name.
+`,
+ },
+ {
+ code: `
+it(123, () => {});
+`,
+ snapshot: `
+it(123, () => {});
+ ~~~
+ Test title must be a string, a function or class name.
+`,
+ },
+ {
+ code: `
+it.concurrent(123, () => {});
+`,
+ snapshot: `
+it.concurrent(123, () => {});
+ ~~~
+ Test title must be a string, a function or class name.
+`,
+ },
+ {
+ code: `
+it(1 + 2 + 3, () => {});
+`,
+ snapshot: `
+it(1 + 2 + 3, () => {});
+ ~~~~~~~~~
+ Test title must be a string, a function or class name.
+`,
+ },
+ ],
+ valid: [
+ `it("is a string", () => {});`,
+ `it("is" + " a " + " string", () => {});`,
+ `it(1 + " + " + 1, () => {});`,
+ `test("is a string", () => {});`,
+ `xtest("is a string", () => {});`,
+ `xtest(\`\${myFunc} is a string\`, () => {});`,
+ `describe("is a string", () => {});`,
+ `describe.skip("is a string", () => {});`,
+ `describe.skip(\`\${myFunc} is a string\`, () => {});`,
+ `fdescribe("is a string", () => {});`,
+ {
+ code: `describe(String(/.+/), () => {});`,
+ options: { ignoreTypeOfDescribeName: true },
+ },
+ {
+ code: `describe(myFunction, () => {});`,
+ options: { ignoreTypeOfDescribeName: true },
+ },
+ {
+ code: `xdescribe(skipFunction, () => {});`,
+ options: { disallowedWords: [], ignoreTypeOfDescribeName: true },
+ },
+ ],
+});
+
+ruleTester.describe(rule, {
+ invalid: [
+ {
+ code: `
+describe("", function () {})
+`,
+ snapshot: `
+describe("", function () {})
+ ~~
+ \`describe\` should not have an empty title.
+`,
+ },
+ {
+ code: `
+ describe('foo', () => {
+ it('', () => {});
+ });
+`,
+ snapshot: `
+ describe('foo', () => {
+ it('', () => {});
+ ~~
+ \`it\` should not have an empty title.
+ });
+`,
+ },
+ {
+ code: `
+ describe('foo', () => {
+ test('', () => {});
+ });
+`,
+ snapshot: `
+ describe('foo', () => {
+ test('', () => {});
+ ~~
+ \`test\` should not have an empty title.
+ });
+`,
+ },
+ {
+ code: `
+it("", function () {})
+`,
+ snapshot: `
+it("", function () {})
+ ~~
+ \`it\` should not have an empty title.
+`,
+ },
+ {
+ code: `
+it.concurrent("", function () {})
+`,
+ snapshot: `
+it.concurrent("", function () {})
+ ~~
+ \`it\` should not have an empty title.
+`,
+ },
+ {
+ code: `
+test("", function () {})
+`,
+ snapshot: `
+test("", function () {})
+ ~~
+ \`test\` should not have an empty title.
+`,
+ },
+ {
+ code: `
+test.concurrent("", function () {})
+`,
+ snapshot: `
+test.concurrent("", function () {})
+ ~~
+ \`test\` should not have an empty title.
+`,
+ },
+ {
+ code: `
+test.concurrent(\`\`, function () {})
+`,
+ snapshot: `
+test.concurrent(\`\`, function () {})
+ ~~
+ \`test\` should not have an empty title.
+`,
+ },
+ {
+ code: `
+xdescribe('', () => {})
+`,
+ snapshot: `
+xdescribe('', () => {})
+ ~~
+ \`describe\` should not have an empty title.
+`,
+ },
+ ],
+ valid: [
+ `describe()`,
+ `someFn("", function () {})`,
+ `describe("foo", function () {})`,
+ `describe("foo", function () { it("bar", function () {}) })`,
+ `test("foo", function () {})`,
+ `test.concurrent("foo", function () {})`,
+ `test(\`foo\`, function () {})`,
+ `test.concurrent(\`foo\`, function () {})`,
+ `test(\`\${foo}\`, function () {})`,
+ `test.concurrent(\`\${foo}\`, function () {})`,
+ `test.scoped({})`,
+ `it.scoped({})`,
+ `it('foo', function () {})`,
+ `it.each([])()`,
+ `it.concurrent('foo', function () {})`,
+ `xdescribe('foo', function () {})`,
+ `xit('foo', function () {})`,
+ `xtest('foo', function () {})`,
+ ],
+});
+
+ruleTester.describe(rule, {
+ invalid: [
+ {
+ code: `
+describe(" foo", function () {})
+`,
+ output: `
+describe("foo", function () {})
+`,
+ snapshot: `
+describe(" foo", function () {})
+ ~~~~~~
+ Should not have leading or trailing spaces
+`,
+ },
+ {
+ code: `
+describe.each()(" foo", function () {})
+`,
+ output: `
+describe.each()("foo", function () {})
+`,
+ snapshot: `
+describe.each()(" foo", function () {})
+ ~~~~~~
+ Should not have leading or trailing spaces
+`,
+ },
+ {
+ code: `
+describe.only.each()(" foo", function () {})
+`,
+ output: `
+describe.only.each()("foo", function () {})
+`,
+ snapshot: `
+describe.only.each()(" foo", function () {})
+ ~~~~~~
+ Should not have leading or trailing spaces
+`,
+ },
+ {
+ code: `
+describe(" foo foe fum", function () {})
+`,
+ output: `
+describe("foo foe fum", function () {})
+`,
+ snapshot: `
+describe(" foo foe fum", function () {})
+ ~~~~~~~~~~~~~~
+ Should not have leading or trailing spaces
+`,
+ },
+ {
+ code: `
+describe("foo foe fum ", function () {})
+`,
+ output: `
+describe("foo foe fum", function () {})
+`,
+ snapshot: `
+describe("foo foe fum ", function () {})
+ ~~~~~~~~~~~~~~
+ Should not have leading or trailing spaces
+`,
+ },
+ {
+ code: `
+it.skip(" foo", function () {})
+`,
+ output: `
+it.skip("foo", function () {})
+`,
+ snapshot: `
+it.skip(" foo", function () {})
+ ~~~~~~
+ Should not have leading or trailing spaces
+`,
+ },
+ {
+ code: `
+fit("foo ", function () {})
+`,
+ output: `
+fit("foo", function () {})
+`,
+ snapshot: `
+fit("foo ", function () {})
+ ~~~~~~
+ Should not have leading or trailing spaces
+`,
+ },
+ {
+ code: `
+it.skip("foo ", function () {})
+`,
+ output: `
+it.skip("foo", function () {})
+`,
+ snapshot: `
+it.skip("foo ", function () {})
+ ~~~~~~
+ Should not have leading or trailing spaces
+`,
+ },
+ ],
+ valid: [
+ `it()`,
+ `it.concurrent()`,
+ `describe()`,
+ `it.each()()`,
+ `describe("foo", function () {})`,
+ `fdescribe("foo", function () {})`,
+ `xdescribe("foo", function () {})`,
+ `it("foo", function () {})`,
+ `it.concurrent("foo", function () {})`,
+ `fit("foo", function () {})`,
+ `fit.concurrent("foo", function () {})`,
+ `xit("foo", function () {})`,
+ `test("foo", function () {})`,
+ `test.concurrent("foo", function () {})`,
+ `xtest("foo", function () {})`,
+ `xtest(\`foo\`, function () {})`,
+ `someFn("foo", function () {})`,
+ `
+ import { test } from 'vitest';
+
+ export const myTest = test.extend({
+ archive: []
+ })`,
+ {
+ code: `
+ import { test } from 'vitest';
+
+ const it = test.extend({})
+ it('passes', () => {})
+ `,
+ name: "does not error when using test.extend",
+ },
+ {
+ code: `import { it } from 'vitest'
+
+ const test = it.extend({
+ fixture: [
+ async ({}, use) => {
+ setup()
+ await use()
+ teardown()
+ },
+ { auto: true }
+ ],
+ })
+
+ test('passes', () => {})
+ `,
+ name: "does not error when using it.extend",
+ },
+ ],
+});
diff --git a/packages/vitest/src/rules/titleValidity.ts b/packages/vitest/src/rules/titleValidity.ts
new file mode 100644
index 0000000000..30435e3eba
--- /dev/null
+++ b/packages/vitest/src/rules/titleValidity.ts
@@ -0,0 +1,344 @@
+import type ts from "typescript";
+import { SyntaxKind, TypeFlags } from "typescript";
+import { z } from "zod/v4";
+
+import {
+ getTSNodeRange,
+ isStaticString,
+ typescriptLanguage,
+ type AST,
+ type StaticString,
+} from "@flint.fyi/typescript-language";
+
+import { ruleCreator } from "../ruleCreator.ts";
+import { parseVitestFunctionCall } from "../utils/parseVitestFunctionCall.ts";
+
+const matcherSchema = z.union([
+ z.string(),
+ z.tuple([z.string()]),
+ z.tuple([z.string(), z.string()]),
+]);
+
+const matchersSchema = z.union([
+ matcherSchema,
+ z.object({
+ describe: matcherSchema.optional(),
+ it: matcherSchema.optional(),
+ test: matcherSchema.optional(),
+ }),
+]);
+
+const options = {
+ allowArguments: z
+ .boolean()
+ .default(false)
+ .describe(
+ "Whether to allow identifiers and other dynamic values as title.",
+ ),
+ disallowedWords: z
+ .array(z.string())
+ .default([])
+ .describe("Words that are not allowed to appear in title."),
+ ignoreTypeOfDescribeName: z
+ .boolean()
+ .default(false)
+ .describe("Whether to skip checking the type of `describe()` title."),
+ mustMatch: matchersSchema
+ .optional()
+ .describe(
+ "Regular expressions that title must match, optionally with a custom message.",
+ ),
+ mustNotMatch: matchersSchema
+ .optional()
+ .describe(
+ "Regular expressions that title must not match, optionally with a custom message.",
+ ),
+};
+
+type Matchers = z.infer;
+type Options = z.infer>;
+type TitleGroup = "describe" | "it" | "test";
+
+function containsStaticString(node: AST.BinaryExpression): boolean {
+ if (isStaticString(node.right)) {
+ return true;
+ }
+
+ if (node.left.kind === SyntaxKind.BinaryExpression) {
+ return containsStaticString(node.left);
+ }
+
+ return isStaticString(node.left);
+}
+
+function findMatcher(matchers: Matchers | undefined, group: TitleGroup) {
+ if (matchers === undefined) {
+ return undefined;
+ }
+
+ const matcher =
+ typeof matchers === "string" || Array.isArray(matchers)
+ ? matchers
+ : matchers[group];
+ if (matcher === undefined) {
+ return undefined;
+ }
+
+ const [pattern, message] =
+ typeof matcher === "string" ? ([matcher] as const) : matcher;
+
+ return { message, pattern: new RegExp(pattern, "u") };
+}
+
+function getTitleGroup(name: string | undefined) {
+ switch (name) {
+ case "describe":
+ case "xdescribe":
+ return "describe";
+
+ case "fit":
+ case "it":
+ case "xit":
+ return "it";
+
+ case "test":
+ case "xtest":
+ return "test";
+ }
+}
+
+function isClassOrFunctionType(type: ts.Type) {
+ return (
+ !!type.getCallSignatures().length || !!type.getConstructSignatures().length
+ );
+}
+
+export default ruleCreator.createRule(typescriptLanguage, {
+ about: {
+ description:
+ "Reports `describe()`, `it()`, and `test()` calls with invalid titles.",
+ id: "titleValidity",
+ presets: ["logical", "logicalStrict"],
+ },
+ messages: {
+ accidentalSpace: {
+ primary: "Should not have leading or trailing spaces",
+ secondary: [
+ "Vitest joins nested titles with spaces and matches `--testNamePattern` against the joined name.",
+ "Whitespace at the edges of a title is invisible in reports but still changes what those patterns match.",
+ ],
+ suggestions: [
+ "Remove the whitespace at the start and end of this title.",
+ ],
+ },
+ disallowedWord: {
+ primary: "`{{ word }}` is not allowed in test title.",
+ secondary: [
+ "This word is listed in the `disallowedWords` option for this rule.",
+ ],
+ suggestions: ["Rewrite this title without the word `{{ word }}`."],
+ },
+ duplicatePrefix: {
+ primary: "Should not have duplicate prefix",
+ secondary: [
+ "The `{{ functionName }}()` call already indicates what kind of block this is.",
+ "Repeating it at the start of the title makes reported names longer without adding information.",
+ ],
+ suggestions: ["Remove the `{{ functionName }}` prefix from this title."],
+ },
+ emptyTitle: {
+ primary: "`{{ functionName }}` should not have an empty title.",
+ secondary: [
+ "Vitest identifies blocks by their titles in reports and in `--testNamePattern` matching.",
+ "An empty title leaves this block identifiable only by its position in the file.",
+ ],
+ suggestions: [
+ "Add a title describing what this `{{ functionName }}()` covers.",
+ ],
+ },
+ mustMatch: {
+ primary: "`{{ functionName }}` should match {{ pattern }}.",
+ secondary: [
+ "Titles for `{{ functionName }}` are required to match the `mustMatch` option for this rule.",
+ ],
+ suggestions: ["Rewrite this title so it matches {{ pattern }}."],
+ },
+ mustMatchCustom: {
+ primary: "`{{ functionName }}` should match {{ pattern }}. {{ message }}",
+ secondary: [
+ "Titles for `{{ functionName }}` are required to match the `mustMatch` option for this rule.",
+ ],
+ suggestions: ["Rewrite this title so it matches {{ pattern }}."],
+ },
+ mustNotMatch: {
+ primary: "`{{ functionName }}` should not match {{ pattern }}.",
+ secondary: [
+ "Titles for `{{ functionName }}` are required not to match the `mustNotMatch` option for this rule.",
+ ],
+ suggestions: ["Rewrite this title so it does not match {{ pattern }}."],
+ },
+ mustNotMatchCustom: {
+ primary:
+ "`{{ functionName }}` should not match {{ pattern }}. {{ message }}",
+ secondary: [
+ "Titles for `{{ functionName }}` are required not to match the `mustNotMatch` option for this rule.",
+ ],
+ suggestions: ["Rewrite this title so it does not match {{ pattern }}."],
+ },
+ titleMustBeString: {
+ primary: "Test title must be a string, a function or class name.",
+ secondary: [
+ "Vitest converts other values to strings, turning objects into titles such as `[object Object]`.",
+ "Titles that don't read as text make reports and `--testNamePattern` filters harder to use.",
+ ],
+ suggestions: [
+ "Change this title to a string.",
+ "Pass a function or class whose name describes what's under test.",
+ ],
+ },
+ },
+ options,
+ setup(context) {
+ function checkMatchers(
+ options: Options,
+ group: TitleGroup,
+ title: string,
+ range: ReturnType,
+ ) {
+ const mustNotMatch = findMatcher(options.mustNotMatch, group);
+ if (mustNotMatch?.pattern.test(title)) {
+ context.report({
+ data: {
+ functionName: group,
+ message: mustNotMatch.message ?? "",
+ pattern: String(mustNotMatch.pattern),
+ },
+ message: mustNotMatch.message ? "mustNotMatchCustom" : "mustNotMatch",
+ range,
+ });
+ return;
+ }
+
+ const mustMatch = findMatcher(options.mustMatch, group);
+ if (mustMatch && !mustMatch.pattern.test(title)) {
+ context.report({
+ data: {
+ functionName: group,
+ message: mustMatch.message ?? "",
+ pattern: String(mustMatch.pattern),
+ },
+ message: mustMatch.message ? "mustMatchCustom" : "mustMatch",
+ range,
+ });
+ }
+ }
+
+ function checkTitle(
+ argument: StaticString,
+ group: TitleGroup,
+ options: Options,
+ sourceFile: AST.SourceFile,
+ ) {
+ const range = getTSNodeRange(argument, sourceFile);
+ const title = argument.text;
+
+ if (!title) {
+ context.report({
+ data: { functionName: group },
+ message: "emptyTitle",
+ range,
+ });
+ return;
+ }
+
+ if (options.disallowedWords.length) {
+ const disallowedMatch = new RegExp(
+ `\\b(${options.disallowedWords.join("|")})\\b`,
+ "iu",
+ ).exec(title);
+
+ if (disallowedMatch) {
+ context.report({
+ data: { word: disallowedMatch[0] },
+ message: "disallowedWord",
+ range,
+ });
+ return;
+ }
+ }
+
+ const text = argument.getText(sourceFile);
+
+ if (title.trim() !== title) {
+ const trimmed = text
+ .replace(/^(['"`])\s+/u, "$1")
+ .replace(/\s+(['"`])$/u, "$1");
+
+ context.report({
+ fix: trimmed === text ? undefined : [{ range, text: trimmed }],
+ message: "accidentalSpace",
+ range,
+ });
+ }
+
+ if (title.split(" ")[0]?.toLowerCase() === group) {
+ context.report({
+ data: { functionName: group },
+ fix: [{ range, text: text.replace(/^(['"`]).+? /u, "$1") }],
+ message: "duplicatePrefix",
+ range,
+ });
+ }
+
+ checkMatchers(options, group, title, range);
+ }
+
+ return {
+ visitors: {
+ CallExpression: (node, { options, sourceFile, typeChecker }) => {
+ const group = getTitleGroup(parseVitestFunctionCall(node)?.name);
+ if (!group) {
+ return;
+ }
+
+ const [argument] = node.arguments;
+ if (!argument) {
+ return;
+ }
+
+ const type = typeChecker.getTypeAtLocation(argument);
+ if (isClassOrFunctionType(type)) {
+ return;
+ }
+
+ if (
+ options.allowArguments &&
+ argument.kind === SyntaxKind.Identifier
+ ) {
+ return;
+ }
+
+ if (isStaticString(argument)) {
+ checkTitle(argument, group, options, sourceFile);
+ return;
+ }
+
+ if (
+ (argument.kind === SyntaxKind.BinaryExpression &&
+ containsStaticString(argument)) ||
+ (type.flags & TypeFlags.StringLike) !== 0
+ ) {
+ return;
+ }
+
+ if (!(options.ignoreTypeOfDescribeName && group === "describe")) {
+ context.report({
+ message: "titleMustBeString",
+ range: getTSNodeRange(argument, sourceFile),
+ });
+ }
+ },
+ },
+ };
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 391eb99e59..6e75be0470 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1735,6 +1735,9 @@ importers:
typescript:
specifier: ^6.0.0
version: 6.0.3
+ zod:
+ specifier: ^4.3.6
+ version: 4.4.3
devDependencies:
'@flint.fyi/build':
specifier: workspace:^