eslint-plugin-test-governance 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,43 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.12.0] - 2026-11-04
10
+
11
+ ### Added
12
+
13
+ - `annotations: { only: true }` for Playwright and Vitest: a test or describe must carry its ticket in the
14
+ annotation, which reaches the test report, and a marker comment above one is reported with a suggestion that moves
15
+ its tickets into `annotation` or `meta`. Runtime skips and steps, which can't carry an annotation, keep their
16
+ comments. With any other framework, `only` is a configuration error.
17
+ - A new rule, `require-ticket-for-commented-out-tests`, set to warn in every config: a test commented out by hand,
18
+ such as `// test('places an order', ...)`, needs the `// SKIP: <ticket>` marker a skipped test needs, anywhere in
19
+ its block of comments. It reads comment text, so the configs warn rather than fail. A marker in such a block
20
+ belongs to it, not to the test below the block.
21
+ - A `retry` state, off by default: with `states: { retry: true }`, retries need a `// RETRY: <ticket>` marker, as a
22
+ skip needs `// SKIP:`. It reads Playwright's `test.describe.configure({ retries })`, Jest's `jest.retryTimes()`,
23
+ Vitest's `{ retry }`, Mocha's `this.retries()` in a describe, test or hook written as a `function`, and Cypress's
24
+ `{ retries }`. Any count but `0` counts, including one known only when the tests run, such as
25
+ `process.env.CI ? 2 : 0`, since retries on CI are where flaky tests hide. node:test and Jasmine have no retries.
26
+ - A marker can be the description of an ESLint directive, as in
27
+ `// eslint-disable-next-line jest/no-disabled-tests -- SKIP: TRADE-123`, so a skip that also turns off a rule
28
+ banning skipped tests needs one comment, not two. It works in `eslint-disable-next-line` above the test,
29
+ `eslint-disable-line` on its first line, and an `eslint-disable` block comment, which covers the tests up to the
30
+ `eslint-enable` of one of its rules or of every rule. Removing such a marker keeps the directive, and a marker
31
+ added next to it goes above it.
32
+
33
+ ### Changed
34
+
35
+ - `require-ticket-in-comments` now reads the description of an ESLint directive, the text after `--`, as it
36
+ reads any comment, so `// eslint-disable-next-line rule -- TODO: migrate this` in a test file needs a ticket:
37
+ `-- TODO: TRADE-123`. Directives weren't read before, so a test file with such descriptions can get new reports on
38
+ upgrade. Add the tickets, or set `comments: false` to turn the check off.
39
+
40
+ ### Fixed
41
+
42
+ - The `clickup` preset no longer takes digits alone as a task in `#4821` or `https://app.clickup.com/t/9015210959`:
43
+ the first is an issue number in GitHub, GitLab or Redmine, and the second is a workspace link. Write an
44
+ all-digit task ID as `CU-4821` or `/t/<workspace>/4821`.
45
+
9
46
  ## [0.11.1] - 2026-11-03
10
47
 
11
48
  ### Fixed
@@ -447,7 +484,8 @@ First release.
447
484
  - Support for ESLint 9 and 10 flat config, JavaScript and TypeScript test files, `.extend()`
448
485
  fixtures, describe blocks, runtime skips and tags in titles or the `tag` option.
449
486
 
450
- [Unreleased]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.11.1...HEAD
487
+ [Unreleased]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.12.0...HEAD
488
+ [0.12.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.11.1...v0.12.0
451
489
  [0.11.1]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.11.0...v0.11.1
452
490
  [0.11.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.10.1...v0.11.0
453
491
  [0.10.1]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.10.0...v0.10.1
package/README.md CHANGED
@@ -194,13 +194,14 @@ condition. A function or variable of your own called `pending` is not Jasmine's.
194
194
  🔧 Automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix).\
195
195
  💡 Manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).
196
196
 
197
- | Name                       | Description | 💼 | ⚠️ | 🔧 | 💡 |
198
- | :--------------------------------------------------------------------- | :-------------------------------------------------------------------------------- | :---------------------- | :---------------------- | :-- | :-- |
199
- | [marker-matches-state](docs/rules/marker-matches-state.md) | Require the marker keyword to match the test's state | 🌲 🌼 🃏 ☕ 🟢 🎭 ⚡ 🤖 | | | 💡 |
200
- | [no-conflicting-states](docs/rules/no-conflicting-states.md) | Disallow state tags that contradict each other, differ in case or look like typos | | 🌲 🌼 🃏 ☕ 🟢 🎭 ⚡ 🤖 | 🔧 | |
201
- | [no-orphaned-marker](docs/rules/no-orphaned-marker.md) | Disallow marker comments that no longer match a test state | 🌲 🌼 🃏 ☕ 🟢 🎭 ⚡ 🤖 | | | 💡 |
202
- | [require-ticket](docs/rules/require-ticket.md) | Require a ticket marker comment above skipped, fixme and tagged tests | 🌲 🌼 🃏 ☕ 🟢 🎭 ⚡ 🤖 | | | 💡 |
203
- | [require-ticket-in-comments](docs/rules/require-ticket-in-comments.md) | Require FIXME and TODO comments in test files to start with a ticket | 🌲 🌼 🃏 ☕ 🟢 🎭 ⚡ 🤖 | | | 💡 |
197
+ | Name                                   | Description | 💼 | ⚠️ | 🔧 | 💡 |
198
+ | :--------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------- | :---------------------- | :---------------------- | :-- | :-- |
199
+ | [marker-matches-state](docs/rules/marker-matches-state.md) | Require the marker keyword to match the test's state | 🌲 🌼 🃏 ☕ 🟢 🎭 ⚡ 🤖 | | | 💡 |
200
+ | [no-conflicting-states](docs/rules/no-conflicting-states.md) | Disallow state tags that contradict each other, differ in case or look like typos | | 🌲 🌼 🃏 ☕ 🟢 🎭 ⚡ 🤖 | 🔧 | |
201
+ | [no-orphaned-marker](docs/rules/no-orphaned-marker.md) | Disallow marker comments that no longer match a test state | 🌲 🌼 🃏 ☕ 🟢 🎭 ⚡ 🤖 | | | 💡 |
202
+ | [require-ticket](docs/rules/require-ticket.md) | Require a ticket marker comment above skipped, fixme and tagged tests | 🌲 🌼 🃏 ☕ 🟢 🎭 ⚡ 🤖 | | | 💡 |
203
+ | [require-ticket-for-commented-out-tests](docs/rules/require-ticket-for-commented-out-tests.md) | Require a ticket marker on commented-out tests | | 🌲 🌼 🃏 ☕ 🟢 🎭 ⚡ 🤖 | | 💡 |
204
+ | [require-ticket-in-comments](docs/rules/require-ticket-in-comments.md) | Require FIXME and TODO comments in test files to start with a ticket | 🌲 🌼 🃏 ☕ 🟢 🎭 ⚡ 🤖 | | | 💡 |
204
205
 
205
206
  <!-- end auto-generated rules list -->
206
207
 
@@ -208,20 +209,21 @@ condition. A function or variable of your own called `pending` is not Jasmine's.
208
209
 
209
210
  The defaults follow how most teams already work, so each framework config is useful with no options.
210
211
 
211
- | Setting | Default | Why |
212
- | --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
213
- | States that need a ticket | `skip`, `fixme`; in the other frameworks `skip`, `todo` | Every team skips tests. Chromium DevTools, GitLab and Slack all require a bug link on disabled tests. |
214
- | `@new`, `@quarantined` | off; `lifecycleTags: true` turns both on | A promotion workflow is not universal. Teams that say `@flaky` instead [rename the tag](#tag-names). |
215
- | Conditional skips (`test.skip(browserName === 'webkit', ...)`, `if (...) test.skip()`, a skip in `catch`) | no ticket needed | These are usually permanent platform limits, not bugs. `requireTicketForConditional: true` changes this. |
216
- | `test.fail`, `test.slow`, Jest's `test.failing`, Vitest's `test.fails` | off | They don't remove coverage. Turn them on with `states: { fail: true, slow: true }`. |
217
- | Marker format | `// SKIP: TRADE-123` or `// SKIP: TRADE-123, TRADE-124` | Uppercase keyword and colon, like `TODO:`. Easy to grep. |
218
- | Notes after the ticket | not allowed; `allowNotes: true` allows them | The ticket is the source of truth. A note in a comment goes stale while the ticket stays current. |
219
- | `FIXME` / `TODO` comments | must start with a ticket, e.g. `// TODO: TRADE-123` or `// TODO(TRADE-123)` | The ticket is the source of truth; an untracked `TODO` is never done. `comments: false` turns this off. |
220
- | Placement | comment block directly above; other comments allowed; a blank line breaks it | Survives `eslint-disable-next-line` and Prettier. |
221
- | Ticket format | `any`: `PROJ-123`, `#123`, `owner/repo#123`, or an http(s) URL | Works for Jira, Linear, GitHub and GitLab out of the box. Narrow it with a preset. |
222
- | Placeholder tickets | rejected: `TODO`, `TBD`, `XXX-*`, and any ticket numbered 0 (`PROJ-0`, `#0`) | A fake ticket is worse than none, and no tracker issues number 0. |
223
- | Describe blocks | a marker on a skipped or tagged describe covers its tests | One ticket per skipped suite. |
224
- | Severity | `no-conflicting-states`: warn; every other rule: error | A marker must always match a real state, so a missing, wrong or leftover marker fails the lint. Tag spelling is cleanup. |
212
+ | Setting | Default | Why |
213
+ | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
214
+ | States that need a ticket | `skip`, `fixme`; in the other frameworks `skip`, `todo` | Every team skips tests. Chromium DevTools, GitLab and Slack all require a bug link on disabled tests. |
215
+ | `@new`, `@quarantined` | off; `lifecycleTags: true` turns both on | A promotion workflow is not universal. Teams that say `@flaky` instead [rename the tag](#tag-names). |
216
+ | Conditional skips (`test.skip(browserName === 'webkit', ...)`, `if (...) test.skip()`, a skip in `catch`) | no ticket needed | These are usually permanent platform limits, not bugs. `requireTicketForConditional: true` changes this. |
217
+ | `test.fail`, `test.slow`, Jest's `test.failing`, Vitest's `test.fails` | off | They don't remove coverage. Turn them on with `states: { fail: true, slow: true }`. |
218
+ | Retries: `describe.configure({ retries })`, `jest.retryTimes()`, `retry`, `this.retries()` | off | Retries hide flaky tests. `states: { retry: true }` needs a ticket wherever tests retry, even only on CI. |
219
+ | Marker format | `// SKIP: TRADE-123` or `// SKIP: TRADE-123, TRADE-124` | Uppercase keyword and colon, like `TODO:`. Easy to grep. |
220
+ | Notes after the ticket | not allowed; `allowNotes: true` allows them | The ticket is the source of truth. A note in a comment goes stale while the ticket stays current. |
221
+ | `FIXME` / `TODO` comments | must start with a ticket, e.g. `// TODO: TRADE-123` or `// TODO(TRADE-123)` | The ticket is the source of truth; an untracked `TODO` is never done. `comments: false` turns this off. |
222
+ | Placement | comment block directly above; other comments allowed; a blank line breaks it | Survives `eslint-disable-next-line` and Prettier. |
223
+ | Ticket format | `any`: `PROJ-123`, `#123`, `owner/repo#123`, or an http(s) URL | Works for Jira, Linear, GitHub and GitLab out of the box. Narrow it with a preset. |
224
+ | Placeholder tickets | rejected: `TODO`, `TBD`, `XXX-*`, and any ticket numbered 0 (`PROJ-0`, `#0`) | A fake ticket is worse than none, and no tracker issues number 0. |
225
+ | Describe blocks | a marker on a skipped or tagged describe covers its tests | One ticket per skipped suite. |
226
+ | Severity | `no-conflicting-states` and `require-ticket-for-commented-out-tests`: warn; every other rule: error | A marker must always match a real state, so a missing, wrong or leftover marker fails the lint. Tag spelling is cleanup, and commented-out tests are read from comment text. |
225
227
 
226
228
  ## Options
227
229
 
@@ -230,21 +232,21 @@ yourself. `framework` is required, and every other option has a default. The rul
230
232
  of their own: they all read these shared settings, so they always agree on ticket formats, states
231
233
  and markers. To turn a rule off, set it to `'off'` as usual.
232
234
 
233
- | Option | Type | Default | Description |
234
- | ----------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
235
- | `framework` | `'playwright' \| 'jest' \| 'vitest' \| 'mocha' \| 'cypress' \| 'webdriverio' \| 'node-test' \| 'jasmine'` | required | The test framework of the files the config applies to. Each framework's config, such as `configs.playwright`, sets it, and `configure()` needs it. See [Frameworks](#frameworks). |
236
- | `ticket` | `TicketSpec \| TicketSpec[]` | `{ preset: 'any' }` | Accepted ticket formats. An array accepts a ticket that matches any entry. See [Ticket presets](#ticket-presets). |
237
- | `placeholders` | `string[]` | see above | Tickets rejected as placeholders. `*` matches any characters. Case-insensitive; a leading `#` is ignored. Your list replaces the default one, so repeat the defaults you want to keep. Tickets numbered 0 are always rejected. |
238
- | `lifecycleTags` | `boolean` | `false` | Turns on the `new` (`@new`) and `quarantined` (`@quarantined`) states. |
239
- | `states` | `{ [state]: boolean \| { enabled?, tag?, marker?, ticket? } }` | `skip`, `fixme` and `todo` on | Turns built-in states (`skip`, `fixme`, `fail`, `slow`, `todo`, `new`, `quarantined`) on or off, renames their tag or marker, or gives them their own ticket format. `fail` and `slow` are off by default; a setting for `new` or `quarantined` here wins over `lifecycleTags`. Only `new` and `quarantined` have a tag to rename; see [Tag names](#tag-names). `fixme` and `slow` exist only in Playwright, `todo` in every framework but Playwright, and `fail` in Playwright, Jest and Vitest, so turning one on elsewhere has no effect. |
240
- | `customStates` | `{ [name]: { when, marker, ticket? } }` | `{}` | Adds a state for a tag, e.g. `{ 'needs-data': { when: '@needs-data', marker: 'NEEDS-DATA' } }`. `when` is a tag and `marker` an uppercase keyword; neither can be shared with another state. |
241
- | `requireTicketForConditional` | `boolean` | `false` | Require a ticket for conditional skips too: `test.skip(condition, ...)`, `if (...) test.skip()`, `(condition ? test.skip : test)(...)`, a skip in a `catch` block, after other code in a `try` block with a `catch`, or after an early `return` or `throw`. |
242
- | `allowBlankLine` | `boolean` | `false` | Let blank lines separate a marker from its test. |
243
- | `allowNotes` | `boolean` | `false` | Allow free text after the tickets, e.g. `// SKIP: TRADE-123 flaky on CI`. Applies to markers and to `FIXME` / `TODO` comments. |
244
- | `comments` | `false \| { keywords?: string[] }` | `{ keywords: ['FIXME', 'TODO'] }` | Comment keywords that must start with a ticket anywhere in a test file, e.g. `// TODO: TRADE-123`. Your keywords replace the default ones. `false` allows free-form comments. |
245
- | `annotations` | `false \| { type?: string }` | `{ type: 'issue' }` | Accept the ticket from a test annotation as well: Playwright's `annotation` of the given type or Vitest's `meta` key of that name. One valid annotation covers every state the test is in, and a describe's covers its tests. See [Tickets from annotations](#tickets-from-annotations). |
246
- | `testFunctions` | `string[]` | `['test']` | Names treated as the framework's `test`. Jest and Vitest default to `['test', 'it']`, Mocha, Cypress and WebdriverIO to `['it', 'specify', 'test']`, and Jasmine to `['it']`. Your list replaces the default one, so include those names if you still need them. See below. |
247
- | `reportUnreadableTags` | `boolean` | `false` | When a tag state is on, report titles that aren't static text, and tags or details that can't be read in the file, such as ones imported from another module, since their tags can't be checked. |
235
+ | Option | Type | Default | Description |
236
+ | ----------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
237
+ | `framework` | `'playwright' \| 'jest' \| 'vitest' \| 'mocha' \| 'cypress' \| 'webdriverio' \| 'node-test' \| 'jasmine'` | required | The test framework of the files the config applies to. Each framework's config, such as `configs.playwright`, sets it, and `configure()` needs it. See [Frameworks](#frameworks). |
238
+ | `ticket` | `TicketSpec \| TicketSpec[]` | `{ preset: 'any' }` | Accepted ticket formats. An array accepts a ticket that matches any entry. See [Ticket presets](#ticket-presets). |
239
+ | `placeholders` | `string[]` | see above | Tickets rejected as placeholders. `*` matches any characters. Case-insensitive; a leading `#` is ignored. Your list replaces the default one, so repeat the defaults you want to keep. Tickets numbered 0 are always rejected. |
240
+ | `lifecycleTags` | `boolean` | `false` | Turns on the `new` (`@new`) and `quarantined` (`@quarantined`) states. |
241
+ | `states` | `{ [state]: boolean \| { enabled?, tag?, marker?, ticket? } }` | `skip`, `fixme` and `todo` on | Turns built-in states (`skip`, `fixme`, `fail`, `slow`, `todo`, `retry`, `new`, `quarantined`) on or off, renames their tag or marker, or gives them their own ticket format. `fail`, `slow` and `retry` are off by default; a setting for `new` or `quarantined` here wins over `lifecycleTags`. Only `new` and `quarantined` have a tag to rename; see [Tag names](#tag-names). `fixme` and `slow` exist only in Playwright, `todo` in every framework but Playwright, `fail` in Playwright, Jest and Vitest, and `retry` in every framework but node:test and Jasmine, so turning one on elsewhere has no effect. |
242
+ | `customStates` | `{ [name]: { when, marker, ticket? } }` | `{}` | Adds a state for a tag, e.g. `{ 'needs-data': { when: '@needs-data', marker: 'NEEDS-DATA' } }`. `when` is a tag and `marker` an uppercase keyword; neither can be shared with another state. |
243
+ | `requireTicketForConditional` | `boolean` | `false` | Require a ticket for conditional skips too: `test.skip(condition, ...)`, `if (...) test.skip()`, `(condition ? test.skip : test)(...)`, a skip in a `catch` block, after other code in a `try` block with a `catch`, or after an early `return` or `throw`. |
244
+ | `allowBlankLine` | `boolean` | `false` | Let blank lines separate a marker from its test. |
245
+ | `allowNotes` | `boolean` | `false` | Allow free text after the tickets, e.g. `// SKIP: TRADE-123 flaky on CI`. Applies to markers and to `FIXME` / `TODO` comments. |
246
+ | `comments` | `false \| { keywords?: string[] }` | `{ keywords: ['FIXME', 'TODO'] }` | Comment keywords that must start with a ticket anywhere in a test file, e.g. `// TODO: TRADE-123`. Your keywords replace the default ones. `false` allows free-form comments. |
247
+ | `annotations` | `false \| { type?: string, only?: boolean }` | `{ type: 'issue' }` | Accept the ticket from a test annotation as well: Playwright's `annotation` of the given type or Vitest's `meta` key of that name. One valid annotation covers every state the test is in, and a describe's covers its tests. `only: true` makes it the only place for a test's or describe's ticket. See [Tickets from annotations](#tickets-from-annotations). |
248
+ | `testFunctions` | `string[]` | `['test']` | Names treated as the framework's `test`. Jest and Vitest default to `['test', 'it']`, Mocha, Cypress and WebdriverIO to `['it', 'specify', 'test']`, and Jasmine to `['it']`. Your list replaces the default one, so include those names if you still need them. See below. |
249
+ | `reportUnreadableTags` | `boolean` | `false` | When a tag state is on, report titles that aren't static text, and tags or details that can't be read in the file, such as ones imported from another module, since their tags can't be checked. |
248
250
 
249
251
  ### Tag names
250
252
 
@@ -286,7 +288,7 @@ a `customStates` entry.
286
288
  | `trello` | card links, `https://trello.com/c/nqPiDKmw` with or without the `/9-title` after it | |
287
289
  | `notion` | Unique IDs such as `TASK-123`, `https://www.notion.so/TASK-123`, page links that end in the page ID (also `https://app.notion.com/p/...`), database links with `?p=<page ID>` | `prefixes`; `host` for a custom domain (`notion.so`, `www.notion.so`, `app.notion.com` and `*.notion.site` by default) |
288
290
  | `asana` | task links, `https://app.asana.com/0/<project>/<task>` and the newer `https://app.asana.com/1/<workspace>/.../task/<task>` | |
289
- | `clickup` | `CU-86abc123`, `#86abc123`, custom IDs such as `DEV-123`, `https://app.clickup.com/t/86abc123` or `/t/<workspace>/DEV-123`, view links with `?p=<task>` | `prefixes` for custom IDs |
291
+ | `clickup` | `CU-86abc123`, `#86abc123` (not `#123`), custom IDs like `DEV-123`, `https://app.clickup.com/t/86abc123` or `/t/<workspace>/DEV-123`, view links `?p=<task>` | `prefixes` for custom IDs |
290
292
  | `redmine` | `#123`, `#123-4` and `#123#note-4` (a note), `##123`; with `host`, `https://<host>/issues/123` (also under a path) | `host`, needed for links |
291
293
  | `monday` | item links, `https://<account>.monday.com/boards/<board>/pulses/<item>`, and custom keys such as `TIX-001` | `prefixes` for custom keys |
292
294
  | `youtrack` | `KT-123`, `https://<host>/issue/KT-123/...` or `/projects/KT/issues/KT-123` (also under a path, as in `/youtrack/issue/KT-123`) | `projects`; `host` for Server or a custom domain (`youtrack.jetbrains.com`, `*.youtrack.cloud` and `*.myjetbrains.com` by default) |
@@ -369,6 +371,26 @@ left alone, and an annotation whose ticket isn't static text is reported with `r
369
371
  both must be valid. Jest, Mocha, Jasmine, node:test and Cypress have no annotations, so for them it is
370
372
  comments only.
371
373
 
374
+ With `annotations: { only: true }`, a test or describe in Playwright or Vitest must carry its ticket in the
375
+ annotation, and a marker comment above it is reported, with a suggestion that moves its tickets into
376
+ `annotation` or `meta`. A runtime skip such as `test.skip()` in a body, and a step, can't carry an
377
+ annotation, so their comments still count. `only` with another framework is a configuration error.
378
+
379
+ ### Markers in `eslint-disable` comments
380
+
381
+ If you also run a rule that bans skipped tests, such as `jest/no-disabled-tests`, the comment that turns it off
382
+ can hold the marker in its description, after `--`:
383
+
384
+ ```js
385
+ // eslint-disable-next-line jest/no-disabled-tests -- SKIP: TRADE-123
386
+ test.skip('rejects a margin order above the buying power', () => {});
387
+ ```
388
+
389
+ The description is read as a marker comment, with the same tickets and notes rules. It works in an
390
+ `eslint-disable-next-line` above the test, an `eslint-disable-line` on the line the test starts on, and an
391
+ `eslint-disable` block comment, whose marker covers every test up to the `eslint-enable` of one of its rules, or of
392
+ every rule. A suggestion that removes or adds a marker keeps the directive working.
393
+
372
394
  ### Which functions count as `test`
373
395
 
374
396
  In Playwright files:
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ import { type GovernanceOptions } from './utils/options.ts';
4
4
  export type { FrameworkName } from './utils/constants.ts';
5
5
  export type { CustomState, GovernanceOptions, StateOverride } from './utils/options.ts';
6
6
  export type { TicketSpec } from './utils/tickets.ts';
7
- export type RuleName = 'require-ticket' | 'marker-matches-state' | 'no-orphaned-marker' | 'no-conflicting-states' | 'require-ticket-in-comments';
7
+ export type RuleName = 'require-ticket' | 'marker-matches-state' | 'no-orphaned-marker' | 'no-conflicting-states' | 'require-ticket-in-comments' | 'require-ticket-for-commented-out-tests';
8
8
  export declare const rules: Record<RuleName, Rule.RuleModule>;
9
9
  export interface FlatConfig {
10
10
  name: string;
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@ import { createRequire } from 'node:module';
2
2
  import markerMatchesState from "./rules/marker-matches-state.js";
3
3
  import noConflictingStates from "./rules/no-conflicting-states.js";
4
4
  import noOrphanedMarker from "./rules/no-orphaned-marker.js";
5
+ import requireTicketForCommentedOutTests from "./rules/require-ticket-for-commented-out-tests.js";
5
6
  import requireTicketInComments from "./rules/require-ticket-in-comments.js";
6
7
  import requireTicket from "./rules/require-ticket.js";
7
8
  import { FRAMEWORK_NAMES } from "./utils/constants.js";
@@ -16,6 +17,7 @@ const ruleModules = {
16
17
  'no-orphaned-marker': noOrphanedMarker,
17
18
  'no-conflicting-states': noConflictingStates,
18
19
  'require-ticket-in-comments': requireTicketInComments,
20
+ 'require-ticket-for-commented-out-tests': requireTicketForCommentedOutTests,
19
21
  };
20
22
  // Public types come from `eslint` itself, so users don't need typescript-eslint installed.
21
23
  export const rules = ruleModules;
@@ -1,4 +1,4 @@
1
- import { appliesTo, describeSubject, isStateMarker, missingStates, strayMarkers } from "../utils/analyze.js";
1
+ import { appliesTo, describeSubject, directiveOf, isStateMarker, missingStates, strayMarkers, } from "../utils/analyze.js";
2
2
  import { createRule } from "../utils/create-rule.js";
3
3
  import { indentOf, keywordRange } from "../utils/fix.js";
4
4
  export default createRule({
@@ -53,8 +53,13 @@ export default createRule({
53
53
  .find((m) => m.state !== missing[0] && appliesTo(subject, m.state.name));
54
54
  if (!present)
55
55
  continue;
56
+ // A marker added after an `eslint-disable-next-line` would take the line it disables, so it goes above.
57
+ // One on the test's own line, in an `eslint-disable-line`, has no line of its own to go next to.
58
+ const directive = directiveOf(present.comment);
59
+ const indent = indentOf(sourceCode, present.comment);
56
60
  for (const state of missing) {
57
61
  const tickets = present.tickets.map((t) => t.text).join(', ');
62
+ const added = `// ${state.marker}: ${tickets}`;
58
63
  context.report({
59
64
  loc: present.comment.loc,
60
65
  messageId: 'sharedMarker',
@@ -65,12 +70,14 @@ export default createRule({
65
70
  state: state.name,
66
71
  subject: subjectName,
67
72
  },
68
- suggest: present.comment.type === 'Line' && tickets !== ''
73
+ suggest: present.comment.type === 'Line' && tickets !== '' && directive?.kind !== 'eslint-disable-line'
69
74
  ? [
70
75
  {
71
76
  messageId: 'addMarker',
72
77
  data: { expected: state.marker, tickets },
73
- fix: (fixer) => fixer.insertTextAfterRange(present.comment.range, `\n${indentOf(sourceCode, present.comment)}// ${state.marker}: ${tickets}`),
78
+ fix: (fixer) => directive
79
+ ? fixer.insertTextBeforeRange(present.comment.range, `${added}\n${indent}`)
80
+ : fixer.insertTextAfterRange(present.comment.range, `\n${indent}${added}`),
74
81
  },
75
82
  ]
76
83
  : [],
@@ -1,6 +1,6 @@
1
1
  import { describeSubject, isProse, missingStates, strayMarkers } from "../utils/analyze.js";
2
2
  import { createRule } from "../utils/create-rule.js";
3
- import { removeComment } from "../utils/fix.js";
3
+ import { removeMarkerComment } from "../utils/fix.js";
4
4
  export default createRule({
5
5
  meta: {
6
6
  type: 'problem',
@@ -50,7 +50,7 @@ export default createRule({
50
50
  loc: marker.comment.loc,
51
51
  messageId,
52
52
  data: { marker: marker.keyword, state: marker.state.name, subject },
53
- suggest: [{ messageId: 'removeMarker', fix: () => removeComment(sourceCode, marker.comment) }],
53
+ suggest: [{ messageId: 'removeMarker', fix: () => removeMarkerComment(sourceCode, marker.comment) }],
54
54
  });
55
55
  }
56
56
  },
@@ -0,0 +1,2 @@
1
+ declare const _default: import("../utils/create-rule.ts").Rule<"missingMarker" | "missingTicket" | "invalidTicket" | "placeholderTicket" | "extraText" | "removeExtraText">;
2
+ export default _default;
@@ -0,0 +1,67 @@
1
+ import { createRule } from "../utils/create-rule.js";
2
+ export default createRule({
3
+ meta: {
4
+ type: 'suggestion',
5
+ docs: {
6
+ description: 'Require a ticket marker on commented-out tests',
7
+ recommended: 'warn',
8
+ url: 'https://github.com/Huldoser/eslint-plugin-test-governance/blob/main/docs/rules/require-ticket-for-commented-out-tests.md',
9
+ },
10
+ hasSuggestions: true,
11
+ schema: [],
12
+ messages: {
13
+ missingMarker: 'This commented-out test has no `// {{marker}}: <ticket>` comment, e.g. `// {{marker}}: {{example}}`. Track it like a skipped test, or delete it.',
14
+ missingTicket: '`{{marker}}:` needs a ticket right after the colon, e.g. `// {{marker}}: {{example}}`.',
15
+ invalidTicket: "'{{ticket}}' is not a valid ticket for the 'skip' state. Expected {{expected}}.",
16
+ placeholderTicket: "'{{ticket}}' is a placeholder, not a real ticket. Link the ticket that tracks this test.",
17
+ extraText: 'Only ticket IDs are allowed after `{{marker}}:`, separated by commas. Put details in the ticket instead of `{{text}}`.',
18
+ removeExtraText: 'Remove `{{text}}`.',
19
+ },
20
+ },
21
+ create(context, analysis, options) {
22
+ // Commenting a test out skips it by hand, so it takes the skip state's marker and ticket format.
23
+ const skip = options.states.find((state) => state.name === 'skip');
24
+ if (!analysis.isTestFile || skip === undefined)
25
+ return;
26
+ const { sourceCode } = context;
27
+ for (const { range, markers } of analysis.commentedOutTests) {
28
+ const own = markers.filter((marker) => marker.state === skip);
29
+ const data = { marker: skip.marker, example: skip.ticket.example };
30
+ if (own.length === 0) {
31
+ const loc = { start: sourceCode.getLocFromIndex(range[0]), end: sourceCode.getLocFromIndex(range[1]) };
32
+ context.report({ loc, messageId: 'missingMarker', data });
33
+ continue;
34
+ }
35
+ const valid = own.find((m) => m.tickets.length > 0 && m.tickets.every((t) => t.result === 'ok'));
36
+ if (valid) {
37
+ const { extra } = valid;
38
+ if (extra === undefined || options.allowNotes)
39
+ continue;
40
+ context.report({
41
+ loc: { start: sourceCode.getLocFromIndex(extra.range[0]), end: sourceCode.getLocFromIndex(extra.range[1]) },
42
+ messageId: 'extraText',
43
+ data: { marker: skip.marker, text: extra.text },
44
+ suggest: [
45
+ {
46
+ messageId: 'removeExtraText',
47
+ data: { text: extra.text },
48
+ fix: (fixer) => fixer.removeRange(extra.range),
49
+ },
50
+ ],
51
+ });
52
+ continue;
53
+ }
54
+ const [marker] = own;
55
+ const bad = marker.tickets.find((t) => t.result !== 'ok');
56
+ if (bad === undefined) {
57
+ context.report({ loc: marker.comment.loc, messageId: 'missingTicket', data });
58
+ continue;
59
+ }
60
+ context.report({
61
+ loc: marker.comment.loc,
62
+ messageId: bad.result === 'placeholder' ? 'placeholderTicket' : 'invalidTicket',
63
+ data: { ticket: bad.text, expected: skip.ticket.expected },
64
+ });
65
+ }
66
+ },
67
+ });
@@ -1,2 +1,2 @@
1
- declare const _default: import("../utils/create-rule.ts").Rule<"missingTicket" | "invalidTicket" | "placeholderTicket" | "extraText" | "removeExtraText" | "missingMarker" | "markerCase" | "dynamicTitle" | "dynamicTags" | "invalidAnnotation" | "unreadAnnotation">;
1
+ declare const _default: import("../utils/create-rule.ts").Rule<"missingMarker" | "missingTicket" | "invalidTicket" | "placeholderTicket" | "extraText" | "removeExtraText" | "markerCase" | "dynamicTitle" | "dynamicTags" | "invalidAnnotation" | "useAnnotation" | "missingAnnotation" | "moveToAnnotation" | "unreadAnnotation">;
2
2
  export default _default;
@@ -1,5 +1,6 @@
1
1
  import { annotationTicketsOf, appliesTo, describeSubject, evaluate, hasAnnotationTicket, headLoc, unreadAnnotationsOf, } from "../utils/analyze.js";
2
2
  import { createRule } from "../utils/create-rule.js";
3
+ import { moveToAnnotation } from "../utils/fix.js";
3
4
  export default createRule({
4
5
  meta: {
5
6
  type: 'problem',
@@ -21,6 +22,9 @@ export default createRule({
21
22
  dynamicTitle: "This title isn't static text, so its tags can't be checked. Use a string or template literal.",
22
23
  dynamicTags: "These tags can't be read in this file, so they can't be checked. Use strings, or constants declared in this file.",
23
24
  invalidAnnotation: "'{{ticket}}' is not a valid ticket for the '{{state}}' state. Expected {{expected}}.",
25
+ useAnnotation: 'Put the ticket in an `{{type}}` annotation instead of a comment, so it reaches the test report, e.g. `{{example}}`.',
26
+ missingAnnotation: "{{subject}} is in the '{{state}}' state but has no `{{type}}` annotation with a ticket, e.g. `{{example}}`.",
27
+ moveToAnnotation: 'Move the ticket into an `{{type}}` annotation.',
24
28
  unreadAnnotation: "This annotation's ticket isn't static text, so it can't be checked. Use a string, or a constant declared in this file.",
25
29
  },
26
30
  },
@@ -29,6 +33,38 @@ export default createRule({
29
33
  // A broken marker on a describe can be reached from several tests; report it once.
30
34
  const reported = new Set();
31
35
  const reportOnce = (key) => !reported.has(key) && Boolean(reported.add(key));
36
+ // With `annotations.only`, a test or describe takes its ticket from an annotation only; runtime skips
37
+ // and steps can't carry one, so their comments still count.
38
+ const only = options.annotations?.only === true && options.framework.annotations !== undefined
39
+ ? { source: options.framework.annotations, type: options.annotations.type }
40
+ : undefined;
41
+ /** Reports the comment markers that should be annotations, or the missing annotation when there is none. */
42
+ const reportAnnotationOnly = (subject, state, { source, type }) => {
43
+ const example = source.shape === 'meta'
44
+ ? `meta: { ${type}: '${state.ticket.example}' }`
45
+ : `annotation: { type: '${type}', description: '${state.ticket.example}' }`;
46
+ const markers = [...subject.markers, ...subject.regionMarkers].filter((m) => m.state === state);
47
+ for (const marker of markers) {
48
+ if (!reportOnce(`only:${marker.comment.range[0]}`))
49
+ continue;
50
+ const fix = subject.markers.includes(marker)
51
+ ? moveToAnnotation(context.sourceCode, subject.node, marker, source, type)
52
+ : undefined;
53
+ context.report({
54
+ loc: marker.comment.loc,
55
+ messageId: 'useAnnotation',
56
+ data: { type, example },
57
+ suggest: fix ? [{ messageId: 'moveToAnnotation', data: { type }, fix }] : [],
58
+ });
59
+ }
60
+ if (markers.length > 0 || hasAnnotationTicket(subject, state, options))
61
+ return;
62
+ context.report({
63
+ loc: headLoc(subject),
64
+ messageId: 'missingAnnotation',
65
+ data: { subject: describeSubject(subject), state: state.name, type, example },
66
+ });
67
+ };
32
68
  for (const subject of analysis.subjects) {
33
69
  const needsTicket = subject.states.some((s) => s.required);
34
70
  if (options.reportUnreadableTags && hasTagStates) {
@@ -45,9 +81,14 @@ export default createRule({
45
81
  }
46
82
  }
47
83
  }
84
+ const annotationOnly = only && (subject.kind === 'test' || subject.kind === 'describe') ? only : undefined;
48
85
  for (const { state, required } of subject.states) {
49
86
  if (!required)
50
87
  continue;
88
+ if (annotationOnly) {
89
+ reportAnnotationOnly(subject, state, annotationOnly);
90
+ continue;
91
+ }
51
92
  const result = evaluate(subject, state);
52
93
  // Annotations are checked whenever a ticket is needed, whatever the markers say.
53
94
  for (const ticket of annotationTicketsOf(subject)) {
@@ -106,9 +147,9 @@ export default createRule({
106
147
  }
107
148
  // Markers are ticket-only unless notes are allowed. A marker with a broken ticket is
108
149
  // already reported above, so its extra text waits until the ticket is fixed.
109
- if (options.allowNotes)
150
+ if (options.allowNotes || annotationOnly)
110
151
  continue;
111
- for (const marker of subject.markers) {
152
+ for (const marker of [...subject.markers, ...subject.regionMarkers]) {
112
153
  const { extra, state } = marker;
113
154
  if (!extra || !state || !appliesTo(subject, state.name))
114
155
  continue;
@@ -28,6 +28,18 @@ export type StateMarker = Marker & {
28
28
  state: StateDef;
29
29
  };
30
30
  export declare function isStateMarker(marker: Marker): marker is StateMarker;
31
+ export interface Directive {
32
+ kind: 'eslint-disable-next-line' | 'eslint-disable-line' | 'eslint-disable' | 'eslint-enable';
33
+ /** The rules it names; none means every rule. */
34
+ rules: string[];
35
+ /** Where ` -- ` starts in the comment's value and where the description after it starts. */
36
+ description?: {
37
+ separator: number;
38
+ start: number;
39
+ };
40
+ }
41
+ /** The ESLint directive a comment holds, read the way ESLint reads it, or undefined for any other comment. */
42
+ export declare function directiveOf(comment: Comment): Directive | undefined;
31
43
  export interface TagOccurrence {
32
44
  tag: string;
33
45
  /** Where to report the tag: the text it is written in, or the name it comes from, as `Tags.NEW`. */
@@ -72,6 +84,8 @@ export interface Subject {
72
84
  skipped: boolean;
73
85
  ownSkip: boolean;
74
86
  markers: Marker[];
87
+ /** Markers of the `eslint-disable` regions this node is in, other than those in its own block. */
88
+ regionMarkers: Marker[];
75
89
  /** Tickets read from annotations of the configured type on this call, with the node to report at. */
76
90
  annotationTickets: {
77
91
  text: string;
@@ -83,10 +97,19 @@ export interface Subject {
83
97
  /** For a runtime function such as Jasmine's `pending()`, its name; messages call the others by their state. */
84
98
  functionName?: string;
85
99
  }
100
+ /** A block of comments holding a test or describe written out as code, as `// test('places an order', ...)`. */
101
+ export interface CommentedOutTest {
102
+ /** The first line that is a commented-out test or describe, without the comment's leading space or `*`. */
103
+ range: TSESTree.Range;
104
+ /** The markers anywhere in the block. */
105
+ markers: Marker[];
106
+ }
86
107
  export interface Analysis {
87
108
  subjects: Subject[];
88
109
  /** Markers that are not in the comment block of any test, describe or runtime call. */
89
110
  detachedMarkers: StateMarker[];
111
+ /** Commented-out tests; their comments are left out of the comment blocks of the tests around them. */
112
+ commentedOutTests: CommentedOutTest[];
90
113
  /** Whether the file uses the test framework at all: an import from it or a call to a test function. */
91
114
  isTestFile: boolean;
92
115
  }
@@ -105,12 +128,16 @@ export type Evaluation = {
105
128
  marker: Marker;
106
129
  ticket: MarkerTicket;
107
130
  };
131
+ export declare function isFunction(node: Node | undefined): node is TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression;
108
132
  /**
109
133
  * Parses `TICKET[, TICKET...]` from the text after a marker's colon. `offset` is the source position
110
134
  * of `text[0]`. Anything after the last ticket, including a trailing comma, is returned as `extra`.
111
135
  */
112
136
  export declare function parseTickets(text: string, offset: number, matcher: TicketMatcher): Pick<Marker, 'tickets' | 'extra'>;
113
- /** The lines of a comment and where each starts in the source, split at every line break JavaScript has. */
137
+ /**
138
+ * The lines of a comment and where each starts in the source, split at every line break JavaScript has.
139
+ * In an ESLint directive, only the description after ` -- ` is comment text.
140
+ */
114
141
  export declare function commentLines(comment: Comment): {
115
142
  text: string;
116
143
  start: number;