eslint-plugin-test-governance 0.12.1 → 0.13.1

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,86 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.13.1] - 2026-11-06
10
+
11
+ ### Fixed
12
+
13
+ - The `rally` preset no longer takes a prefix with no number, such as `US` or `TC`, as a ticket, so
14
+ `// SKIP: TC` and, with `allowNotes`, a note that starts with "US" or "DE" need a real ticket.
15
+ - A second `configure()` for some of the same files, such as `{ preset: 'github' }` for a legacy folder after
16
+ `{ preset: 'jira', projects: ['TRADE'] }` for all files, replaces the earlier ticket format. ESLint merged the
17
+ two key by key, so linting failed with "ticket.projects is not an option of the github preset".
18
+ - The `github` preset reads Gitea's, Forgejo's and Codeberg's pull request links, `/<owner>/<repo>/pulls/12`.
19
+ - A `pattern` with an unmatched `)`, such as `TRADE-\d+)|(.*`, is a configuration error. It used to close the
20
+ group that anchors the pattern, so every ticket passed.
21
+ - `plane` checks its `projects` as `jira` does: a key must start with a letter and have two or more characters,
22
+ since no other key can match.
23
+ - An option named like a built-in object member, such as `customStates: { constructor: ... }`, is checked like
24
+ any other.
25
+ - A marker in an `eslint-disable` comment, as in `/* eslint-disable jest/no-disabled-tests -- SKIP: TRADE-1 */`,
26
+ covers every test up to the `eslint-enable`, also when the comment sits directly above a describe or a test that
27
+ isn't skipped. `no-orphaned-marker` called it left over there, and its suggestion removed the marker the
28
+ skipped tests below relied on. Such a marker is now reported only when none of the tests it covers is in its
29
+ state, and `require-ticket-in-comments` no longer reports its note a second time.
30
+ - `require-ticket-for-commented-out-tests` counts only a call that declares a test, describe or step. Everyday
31
+ lines such as `// test.slow();`, `// test.use({ ... })` and `// test.describe.configure({ ... })` were reported,
32
+ and in a comment block above a skipped test they took that test's marker, so `require-ticket` reported it too,
33
+ even with the commented-out rule off.
34
+ - A test function kept in a constant is read when a condition picks it, as
35
+ `const itUnlessWindows = isWindows ? it.skip : it`, when a call builds it, as `describe.runIf(hasDocker)`,
36
+ `it.skipIf(true)` or `it.skip.each(table)`, and when the constant is declared inside a describe. A marker above
37
+ such a test was reported as not directly above a test.
38
+ - With `requireReasonForConditional`, the description of an `eslint-disable` comment that covers a stretch of the
39
+ file no longer counts as the reason for every conditional skip in it. A reason written as a template literal
40
+ counts in Vitest's `context.skip(...)` and in node:test's `skip` option, and so does a comment at the end of a
41
+ one-line `test.skipIf(isMobile)(...)` or after an `if`'s opening line, as `if (isMobile) { // why`.
42
+ - Suggestions edit only the marker's own line: removing a leftover marker from a block comment that says more,
43
+ such as a doc comment, another marker or an ESLint directive's description, keeps the rest of the comment, and
44
+ renaming a marker no longer renames the same word in prose above it.
45
+ - With `annotations: { only: true }`, there is no suggestion to move a ticket into the details when it would break
46
+ the call: a Vitest test with a timeout or options after its function, or details with a computed key such as
47
+ `['annotation']`.
48
+ - The suggestion to add a marker uses the file's own line breaks, so it no longer leaves a bare line feed in a
49
+ file with CRLF line endings.
50
+ - Playwright's `test.describe.configure({ retries })` is read, with `states: { retry: true }`, when the options
51
+ sit in a constant or a spread, and when `describe` comes from `const { describe } = test`.
52
+ - A marker above `test.describe('orders', () => test.skip(...))`, whose arrow body is the test, belongs to the test
53
+ as well, so `no-orphaned-marker` no longer calls it left over on the describe.
54
+ - node:test's `t.skip(message)` and `t.todo(message)` and Jasmine's `pending(message)` take no condition, so they
55
+ need a ticket whatever the message is, also one held in a name or built in a template. A message in a name was
56
+ read as a condition.
57
+ - A tag constant used in several titles, as `` `buys ${TAG}` ``, is reported once, where it is written.
58
+ - The example in the `missingReason` message is written on one line, also when the call is split over lines.
59
+ - With `requireReasonForConditional`, a comment above the declaration of a constant that picks a test function,
60
+ as `const posixTest = isWindows ? test.skip : test`, or at the end of its line, is the reason for every test
61
+ that uses it.
62
+ - A link whose `id` query is zero, such as `https://www.wrike.com/open.htm?id=0`, is a placeholder, as any ticket
63
+ numbered 0 is. Other zero queries, such as `?page=0`, are left alone.
64
+ - A placeholder written with a leading `#`, such as `#TBD`, also matches `TBD`: a leading `#` is ignored on both
65
+ the ticket and the placeholder.
66
+ - A link whose scheme isn't in lowercase, such as `HTTPS://...`, is read as a link.
67
+ - Linear, Trello, Shortcut and YouTrack links that end in a slash after the title, as
68
+ `.../ENG-1/fix-rounding/`, are accepted.
69
+ - A lowercase Jira or Plane project in `projects` is reported once instead of twice.
70
+ - `numeric` with a `minLength` above its `maxLength`, and a `pattern` or `flags` that isn't a valid regex, are
71
+ reported in the list of invalid options. Without `maxLength`, the message names its default of 20.
72
+ - The ticket format of a state that is off, or that the framework doesn't have, such as `states.fixme.ticket`
73
+ with Jest, is checked too, so a mistake in it shows up at once.
74
+
75
+ ## [0.13.0] - 2026-11-05
76
+
77
+ ### Added
78
+
79
+ - `requireReasonForConditional`, off by default: a conditional skip, such as `test.skip(isMobile)` or
80
+ `if (isWeekend) this.skip()`, needs a reason but no ticket. A comment above it or at the end of its line, above
81
+ the `if` around it, or above its hook, step or test counts, and so does the reason the call takes where the runner
82
+ has one, as Playwright's `test.skip(condition, 'why')`, Vitest's `context.skip(condition, 'why')`, node:test's
83
+ `t.skip('why')` and `{ skip: isWindows && 'why' }`, and Jasmine's `pending('why')`. `require-ticket` reports the
84
+ skips that give none.
85
+ - `knownTags`, such as `['@smoke', '@regression']`: `no-conflicting-states` gives these tags the case and typo
86
+ checks state tags get, so `@Smoke` is fixed to `@smoke` and `@smok` is reported. A listed tag is never reported as
87
+ a typo of a state tag.
88
+
9
89
  ## [0.12.1] - 2026-11-04
10
90
 
11
91
  ### Fixed
@@ -493,7 +573,9 @@ First release.
493
573
  - Support for ESLint 9 and 10 flat config, JavaScript and TypeScript test files, `.extend()`
494
574
  fixtures, describe blocks, runtime skips and tags in titles or the `tag` option.
495
575
 
496
- [Unreleased]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.12.1...HEAD
576
+ [Unreleased]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.13.1...HEAD
577
+ [0.13.1]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.13.0...v0.13.1
578
+ [0.13.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.12.1...v0.13.0
497
579
  [0.12.1]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.12.0...v0.12.1
498
580
  [0.12.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.11.1...v0.12.0
499
581
  [0.11.1]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.11.0...v0.11.1
package/README.md CHANGED
@@ -20,6 +20,10 @@ Without the comment, `test-governance/require-ticket` fails the lint. When the t
20
20
  skip removed, `no-orphaned-marker` flags the leftover comment. The markers are plain
21
21
  comments, so `grep -rn "SKIP:" tests/` lists every disabled test and its ticket.
22
22
 
23
+ The same check covers tests commented out by hand, `FIXME` and `TODO` comments in test files, and, once
24
+ turned on, retried tests. The ticket can also go in the description of an `eslint-disable` comment, or in
25
+ a Playwright annotation or Vitest `meta`, which reach the test report.
26
+
23
27
  > **Status:** early release (0.x). Options may still change before 1.0. Coming from 0.5?
24
28
  > [Upgrading to 0.6](docs/upgrading.md#upgrading-to-06) shows what to change.
25
29
 
@@ -148,8 +152,9 @@ turn on the same rules. What puts a test in a state in each framework:
148
152
  | `fixme` (`// FIXME:`) | `test.fixme(...)`, `test.describe.fixme`, `testInfo.fixme()` | | | | | | | |
149
153
  | `fail` (`// FAIL:`), off by default | `test.fail(...)`, `testInfo.fail()` | `test.failing` | `test.fails`, `{ fails: true }` | | | | `test.expectFailure`, `expectFailure()`, `{ expectFailure }` | |
150
154
  | `slow` (`// SLOW:`), off by default | `test.slow()`, `testInfo.slow()` | | | | | | | |
155
+ | `retry` (`// RETRY:`), off by default | `test.describe.configure({ retries })` | `jest.retryTimes()` | `{ retry }` | `this.retries()` in a describe, test or hook written as a `function` | as Mocha, and `{ retries }` | as Mocha | | |
151
156
  | conditional skip, no ticket by default | `test.skip(condition)`, `if (...) test.skip()`, `(condition ? test.skip : test)(...)` | `(condition ? it.skip : it)(...)` | `skipIf(condition)`, `runIf(condition)`, `{ skip: condition }`, `context.skip(condition)`, `if (...) context.skip()`, `(condition ? it.skip : it)(...)` | `if (...) this.skip()`, `(condition ? it.skip : it)(...)` | as Mocha, and `{ browser: '!webkit' }` | as Mocha | `{ skip: condition }`, `{ todo: condition }`, `if (...) t.skip()`, `(condition ? it.skip : it)(...)` | `if (...) pending()`, `(condition ? xit : it)(...)` |
152
- | tags such as `@new` (`// NEW:`) | the title, `{ tag }` | the title | the title, `{ tags }`, where `'new'` and `'@new'` both mean `@new` | the title | the title, `{ tags }` from @cypress/grep | the title | the title | the title |
157
+ | tags such as `@new` (`// NEW:`) | the title, `{ tag }` | the title | the title, `{ tags }`, where `'new'` and `'@new'` both mean `@new` | the title | the title, `{ tags }` from @cypress/grep | the title | the title, `{ tags }`, as in Vitest | the title |
153
158
 
154
159
  Table-driven tests count too: `test.skip.each(table)(...)`, `describe.skip.each` and Vitest's `.for`.
155
160
  A test or describe whose function a condition picks, as in `(isMobile ? test.skip : test)('title', fn)`,
@@ -200,7 +205,7 @@ condition. A function or variable of your own called `pending` is not Jasmine's.
200
205
  | [marker-matches-state](docs/rules/marker-matches-state.md) | Require the marker keyword to match the test's state | 🌲 🌼 🃏 ☕ 🟢 🎭 ⚡ 🤖 | | | 💡 |
201
206
  | [no-conflicting-states](docs/rules/no-conflicting-states.md) | Disallow state tags that contradict each other, differ in case or look like typos | | 🌲 🌼 🃏 ☕ 🟢 🎭 ⚡ 🤖 | 🔧 | |
202
207
  | [no-orphaned-marker](docs/rules/no-orphaned-marker.md) | Disallow marker comments that no longer match a test state | 🌲 🌼 🃏 ☕ 🟢 🎭 ⚡ 🤖 | | | 💡 |
203
- | [require-ticket](docs/rules/require-ticket.md) | Require a ticket marker comment above skipped, fixme and tagged tests | 🌲 🌼 🃏 ☕ 🟢 🎭 ⚡ 🤖 | | | 💡 |
208
+ | [require-ticket](docs/rules/require-ticket.md) | Require a ticket for skipped, fixme, todo, retried and tagged tests | 🌲 🌼 🃏 ☕ 🟢 🎭 ⚡ 🤖 | | | 💡 |
204
209
  | [require-ticket-for-commented-out-tests](docs/rules/require-ticket-for-commented-out-tests.md) | Require a ticket marker on commented-out tests | | 🌲 🌼 🃏 ☕ 🟢 🎭 ⚡ 🤖 | | 💡 |
205
210
  | [require-ticket-in-comments](docs/rules/require-ticket-in-comments.md) | Require FIXME and TODO comments in test files to start with a ticket | 🌲 🌼 🃏 ☕ 🟢 🎭 ⚡ 🤖 | | | 💡 |
206
211
 
@@ -214,7 +219,7 @@ The defaults follow how most teams already work, so each framework config is use
214
219
  | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
215
220
  | 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. |
216
221
  | `@new`, `@quarantined` | off; `lifecycleTags: true` turns both on | A promotion workflow is not universal. Teams that say `@flaky` instead [rename the tag](#tag-names). |
217
- | 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. |
222
+ | 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` asks for a ticket, `requireReasonForConditional: true` for a reason. |
218
223
  | `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 }`. |
219
224
  | 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. |
220
225
  | Marker format | `// SKIP: TRADE-123` or `// SKIP: TRADE-123, TRADE-124` | Uppercase keyword and colon, like `TODO:`. Easy to grep. |
@@ -237,16 +242,18 @@ and markers. To turn a rule off, set it to `'off'` as usual.
237
242
  | ----------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
238
243
  | `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). |
239
244
  | `ticket` | `TicketSpec \| TicketSpec[]` | `{ preset: 'any' }` | Accepted ticket formats. An array accepts a ticket that matches any entry. See [Ticket presets](#ticket-presets). |
240
- | `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. |
245
+ | `placeholders` | `string[]` | see above | Tickets rejected as placeholders. `*` matches any characters. Case-insensitive, and a leading `#` on the ticket or the placeholder is ignored. Your list replaces the default one, so repeat the defaults you want to keep. Tickets numbered 0 are always rejected. |
241
246
  | `lifecycleTags` | `boolean` | `false` | Turns on the `new` (`@new`) and `quarantined` (`@quarantined`) states. |
242
247
  | `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, Vitest and node:test, and `retry` in every framework but node:test and Jasmine, so turning one on elsewhere has no effect. |
243
248
  | `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. |
249
+ | `knownTags` | `string[]` | `[]` | Your team's other tags, such as `['@smoke', '@regression']`, written with the `@`. `no-conflicting-states` reports them in the wrong case or misspelled, as it does state tags, and never reports a listed tag as a typo of a state tag. |
244
250
  | `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`. |
251
+ | `requireReasonForConditional` | `boolean` | `false` | Require a reason, but no ticket, for conditional skips: a comment above the skip or at the end of its line, above the `if` around it or above its hook, step or test, or the reason the call takes, as `test.skip(condition, 'why')`. See [require-ticket](docs/rules/require-ticket.md#reasons-for-conditional-skips). |
245
252
  | `allowBlankLine` | `boolean` | `false` | Let blank lines separate a marker from its test. |
246
253
  | `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. |
247
254
  | `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. |
248
255
  | `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). |
249
- | `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. |
256
+ | `testFunctions` | `string[]` | `['test']` | Names treated as the framework's `test`. Jest, Vitest and node:test 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. |
250
257
  | `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. |
251
258
 
252
259
  ### Tag names
@@ -323,8 +330,9 @@ run on their own servers.
323
330
 
324
331
  For a tracker without a preset, use `url` with its host, for example
325
332
  `{ preset: 'url', hosts: ['www.wrike.com'] }` for Wrike links such as
326
- `https://www.wrike.com/open.htm?id=4821`. It works the same for Microsoft Planner (`tasks.office.com`),
327
- Airtable (`airtable.com`), Smartsheet (`app.smartsheet.com`) and trackers of your own. For another
333
+ `https://www.wrike.com/open.htm?id=4821`; a link with `?id=0`, or a path that ends in `/0`, is a
334
+ placeholder. It works the same for Microsoft Planner (`tasks.office.com`), Airtable (`airtable.com`),
335
+ Smartsheet (`app.smartsheet.com`) and trackers of your own. For another
328
336
  tracker's keys, such as `WEB-12`, `any` or `pattern` works. Gitea, Forgejo and Codeberg use GitHub's link layout,
329
337
  so the `github` preset with their `host` covers them. Bitbucket has no issue tracker since August 2026,
330
338
  so it has no preset.
@@ -471,10 +479,9 @@ The [Frameworks](#frameworks) table lists what each framework's tests can do. In
471
479
  Not covered in this version: tests generated in loops with dynamic titles, and tags or details imported
472
480
  from other modules (see `reportUnreadableTags`), fixtures imported from other modules under a name not in
473
481
  `testFunctions`, a Vitest test with only a title, which Vitest runs as a todo but which looks the same as a
474
- helper called `test`, Playwright annotations as markers, Cypress tests skipped with @cypress/skip-test's
475
- `skipOn()` and `onlyOn()`, `node:test`'s experimental `tags` option and `getTestContext()`, Jasmine's
476
- `pending()` in a helper function that tests call, Jasmine 5 and 6's `require('jasmine-core').noGlobals()`,
477
- and other runners such as Bun.
482
+ helper called `test`, Cypress tests skipped with @cypress/skip-test's `skipOn()` and `onlyOn()`, `node:test`'s
483
+ `getTestContext()`, Jasmine's `pending()` in a helper function that tests call, Jasmine 5 and 6's
484
+ `require('jasmine-core').noGlobals()`, and other runners such as Bun.
478
485
 
479
486
  ## TypeScript
480
487
 
package/dist/index.js CHANGED
@@ -32,6 +32,28 @@ const plugin = {
32
32
  configs: {},
33
33
  configure,
34
34
  };
35
+ /** `ticket` as a list, so ESLint replaces it whole when it merges two configs' settings. */
36
+ function ticketAsList(options) {
37
+ const { ticket } = options;
38
+ return ticket === undefined || Array.isArray(ticket) ? options : { ...options, ticket: [ticket] };
39
+ }
40
+ /**
41
+ * ESLint merges the settings of configs that apply to the same file key by key and replaces arrays. Merged
42
+ * key by key, a later config's `{ preset: 'github' }` would keep an earlier `projects` that github doesn't take,
43
+ * so every ticket format goes in a list.
44
+ */
45
+ function ticketsAsLists(options) {
46
+ const { states, customStates } = options;
47
+ return {
48
+ ...ticketAsList(options),
49
+ ...(states && {
50
+ states: Object.fromEntries(Object.entries(states).map(([name, state]) => [name, typeof state === 'object' ? ticketAsList(state) : state])),
51
+ }),
52
+ ...(customStates && {
53
+ customStates: Object.fromEntries(Object.entries(customStates).map(([name, state]) => [name, ticketAsList(state)])),
54
+ }),
55
+ };
56
+ }
35
57
  /**
36
58
  * Returns the config of `options.framework` with shared options in `settings['test-governance']`,
37
59
  * so all rules read the same ticket formats and states.
@@ -42,7 +64,7 @@ export function configure(options) {
42
64
  name: `${PLUGIN_NAME}/${options.framework}`,
43
65
  plugins: { [PLUGIN_NAME]: plugin },
44
66
  rules: { ...configRules },
45
- settings: { [SETTINGS_KEY]: options },
67
+ settings: { [SETTINGS_KEY]: ticketsAsLists(options) },
46
68
  };
47
69
  }
48
70
  // Each sets its framework, so it wins over a `configure()` that applies to all files when listed after it.
@@ -1,6 +1,6 @@
1
1
  import { appliesTo, describeSubject, directiveOf, isStateMarker, missingStates, strayMarkers, } from "../utils/analyze.js";
2
2
  import { createRule } from "../utils/create-rule.js";
3
- import { indentOf, keywordRange } from "../utils/fix.js";
3
+ import { indentOf, keywordRange, lineBreakAfter } from "../utils/fix.js";
4
4
  export default createRule({
5
5
  meta: {
6
6
  type: 'problem',
@@ -39,7 +39,7 @@ export default createRule({
39
39
  suggest: missing.map((state) => ({
40
40
  messageId: 'renameMarker',
41
41
  data: { expected: state.marker },
42
- fix: (fixer) => fixer.replaceTextRange(keywordRange(sourceCode, marker.comment, marker.keyword), state.marker),
42
+ fix: (fixer) => fixer.replaceTextRange(keywordRange(marker), state.marker),
43
43
  })),
44
44
  });
45
45
  }
@@ -75,9 +75,12 @@ export default createRule({
75
75
  {
76
76
  messageId: 'addMarker',
77
77
  data: { expected: 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}`),
78
+ fix: (fixer) => {
79
+ const lineBreak = lineBreakAfter(sourceCode, present.comment);
80
+ return directive
81
+ ? fixer.insertTextBeforeRange(present.comment.range, `${added}${lineBreak}${indent}`)
82
+ : fixer.insertTextAfterRange(present.comment.range, `${lineBreak}${indent}${added}`);
83
+ },
81
84
  },
82
85
  ]
83
86
  : [],
@@ -20,9 +20,11 @@ export default createRule({
20
20
  },
21
21
  },
22
22
  create(context, analysis, options) {
23
- const tagStates = options.states.filter(isTagState);
23
+ // State tags first, so a tag near both a state tag and a known tag is read as the state's.
24
+ const checkedTags = [...options.states.filter(isTagState).map((s) => s.tag), ...options.knownTags];
24
25
  const newTag = options.states.find((s) => s.name === 'new')?.tag;
25
26
  const quarantinedTag = options.states.find((s) => s.name === 'quarantined')?.tag;
27
+ const spelled = new Set();
26
28
  for (const subject of analysis.subjects) {
27
29
  for (const occurrence of subject.tags)
28
30
  checkSpelling(occurrence);
@@ -51,25 +53,30 @@ export default createRule({
51
53
  }
52
54
  function checkSpelling(occurrence) {
53
55
  const { tag, range } = occurrence;
56
+ // A constant used in several titles, as `` `buys ${TAG}` ``, is one place to fix, so one report.
57
+ const key = `${occurrence.node.range[0]}:${range?.[0]}:${tag}`;
58
+ if (spelled.has(key))
59
+ return;
60
+ spelled.add(key);
54
61
  // A Vitest tag name is written without its `@`, so show it, and fix it, the same way.
55
62
  const written = (name) => (occurrence.plain ? name.slice(1) : name);
56
- if (tagStates.some((s) => s.tag === tag))
63
+ if (checkedTags.includes(tag))
57
64
  return;
58
65
  const lower = tag.toLowerCase();
59
- const sameLetters = tagStates.find((s) => s.tag.toLowerCase() === lower);
66
+ const sameLetters = checkedTags.find((checked) => checked.toLowerCase() === lower);
60
67
  if (sameLetters) {
61
68
  context.report({
62
69
  node: occurrence.node,
63
70
  messageId: 'tagCase',
64
- data: { found: written(tag), expected: written(sameLetters.tag) },
65
- fix: range && ((fixer) => fixer.replaceTextRange(range, written(sameLetters.tag))),
71
+ data: { found: written(tag), expected: written(sameLetters) },
72
+ fix: range && ((fixer) => fixer.replaceTextRange(range, written(sameLetters))),
66
73
  });
67
74
  return;
68
75
  }
69
76
  // A typo changes, adds, drops or swaps a letter or two. A tag that is two or more letters
70
77
  // shorter or longer, like `@unquarantined` next to `@quarantined`, is a different word.
71
- const near = tagStates.find((s) => {
72
- const expected = s.tag.toLowerCase();
78
+ const near = checkedTags.find((checked) => {
79
+ const expected = checked.toLowerCase();
73
80
  return (Math.abs(lower.length - expected.length) < 2 &&
74
81
  editDistance(lower, expected) <= typoThreshold(expected.length - 1));
75
82
  });
@@ -77,7 +84,7 @@ export default createRule({
77
84
  context.report({
78
85
  node: occurrence.node,
79
86
  messageId: 'tagTypo',
80
- data: { found: written(tag), expected: written(near.tag) },
87
+ data: { found: written(tag), expected: written(near) },
81
88
  });
82
89
  }
83
90
  }
@@ -1,2 +1,2 @@
1
- declare const _default: import("../utils/create-rule.ts").Rule<"orphaned" | "detached" | "removeMarker">;
1
+ declare const _default: import("../utils/create-rule.ts").Rule<"orphaned" | "orphanedRegion" | "detached" | "removeMarker">;
2
2
  export default _default;
@@ -13,6 +13,7 @@ export default createRule({
13
13
  schema: [],
14
14
  messages: {
15
15
  orphaned: "`{{marker}}:` is left over: {{subject}} is not in the '{{state}}' state.",
16
+ orphanedRegion: "`{{marker}}:` is left over: no test up to the `eslint-enable` of this comment is in the '{{state}}' state.",
16
17
  detached: '`{{marker}}:` is not directly above a test, describe or skip call, so it has no effect.',
17
18
  removeMarker: 'Remove this comment.',
18
19
  },
@@ -29,17 +30,21 @@ export default createRule({
29
30
  continue;
30
31
  markers.push({ marker, messageId: 'detached', subject: '' });
31
32
  }
33
+ // TODO is the everyday work-comment keyword, so `// TODO: TRADE-12` above a test that isn't a todo
34
+ // is a tracked note about it far more often than a todo test's forgotten marker.
35
+ const trackedNote = (marker) => marker.state.name === 'todo' && options.workCommentKeywords.has(marker.keyword.toUpperCase());
36
+ for (const marker of analysis.idleRegionMarkers) {
37
+ if (!trackedNote(marker))
38
+ markers.push({ marker, messageId: 'orphanedRegion', subject: '' });
39
+ }
32
40
  for (const subject of analysis.subjects) {
33
41
  // A stray marker next to a missing one is a mismatch, reported by marker-matches-state.
34
42
  if (missingStates(subject, options).length > 0)
35
43
  continue;
36
44
  const name = describeSubject(subject).replace(/^This/, 'this');
37
45
  for (const marker of strayMarkers(subject, options)) {
38
- // TODO is the everyday work-comment keyword, so `// TODO: TRADE-12` above a test that isn't a todo
39
- // is a tracked note about it far more often than a todo test's forgotten marker.
40
- if (marker.state.name === 'todo' && options.workCommentKeywords.has(marker.keyword.toUpperCase()))
41
- continue;
42
- markers.push({ marker, messageId: 'orphaned', subject: name });
46
+ if (!trackedNote(marker))
47
+ markers.push({ marker, messageId: 'orphaned', subject: name });
43
48
  }
44
49
  }
45
50
  for (const { marker, messageId, subject } of markers) {
@@ -50,7 +55,7 @@ export default createRule({
50
55
  loc: marker.comment.loc,
51
56
  messageId,
52
57
  data: { marker: marker.keyword, state: marker.state.name, subject },
53
- suggest: [{ messageId: 'removeMarker', fix: () => removeMarkerComment(sourceCode, marker.comment) }],
58
+ suggest: [{ messageId: 'removeMarker', fix: () => removeMarkerComment(sourceCode, marker) }],
54
59
  });
55
60
  }
56
61
  },
@@ -34,7 +34,7 @@ export default createRule({
34
34
  // in the wrong case.
35
35
  const owned = new Set();
36
36
  for (const subject of analysis.subjects) {
37
- for (const marker of subject.markers) {
37
+ for (const marker of [...subject.markers, ...subject.regionMarkers]) {
38
38
  const state = marker.state ?? marker.caseOf;
39
39
  if (state && appliesTo(subject, state.name))
40
40
  owned.add(marker.comment);
@@ -1,2 +1,2 @@
1
- declare const _default: import("../utils/create-rule.ts").Rule<"missingMarker" | "missingTicket" | "invalidTicket" | "placeholderTicket" | "extraText" | "removeExtraText" | "markerCase" | "dynamicTitle" | "dynamicTags" | "invalidAnnotation" | "useAnnotation" | "missingAnnotation" | "moveToAnnotation" | "unreadAnnotation">;
1
+ declare const _default: import("../utils/create-rule.ts").Rule<"missingMarker" | "missingTicket" | "invalidTicket" | "placeholderTicket" | "extraText" | "removeExtraText" | "missingReason" | "markerCase" | "dynamicTitle" | "dynamicTags" | "invalidAnnotation" | "useAnnotation" | "missingAnnotation" | "moveToAnnotation" | "unreadAnnotation">;
2
2
  export default _default;
@@ -5,7 +5,7 @@ export default createRule({
5
5
  meta: {
6
6
  type: 'problem',
7
7
  docs: {
8
- description: 'Require a ticket marker comment above skipped, fixme and tagged tests',
8
+ description: 'Require a ticket for skipped, fixme, todo, retried and tagged tests',
9
9
  recommended: 'error',
10
10
  url: 'https://github.com/Huldoser/eslint-plugin-test-governance/blob/main/docs/rules/require-ticket.md',
11
11
  },
@@ -13,6 +13,7 @@ export default createRule({
13
13
  schema: [],
14
14
  messages: {
15
15
  missingMarker: "{{subject}} is in the '{{state}}' state but has no `// {{marker}}: <ticket>` comment directly above it, e.g. `// {{marker}}: {{example}}`.",
16
+ missingReason: "{{subject}} is in the '{{state}}' state only under a condition and doesn't say why. Write the reason in a comment above it{{argument}}.",
16
17
  missingTicket: '`{{marker}}:` needs a ticket right after the colon, e.g. `// {{marker}}: {{example}}`.',
17
18
  invalidTicket: "'{{ticket}}' is not a valid ticket for the '{{state}}' state. Expected {{expected}}.",
18
19
  placeholderTicket: "'{{ticket}}' is a placeholder, not a real ticket. Link the ticket that tracks this test.",
@@ -65,6 +66,24 @@ export default createRule({
65
66
  data: { subject: describeSubject(subject), state: state.name, type, example },
66
67
  });
67
68
  };
69
+ /** Reports a conditional state that gives no reason and has no ticket, which would say why as well. */
70
+ const reportReason = (subject, state) => {
71
+ if (evaluate(subject, state).kind === 'ok' || hasAnnotationTicket(subject, state, options))
72
+ return;
73
+ const callee = context.sourceCode.getText(subject.node.callee).replace(/\s+/g, '');
74
+ const first = subject.node.arguments.at(0);
75
+ const where = options.framework.reasonArgument;
76
+ // Vitest's `context.skip()` takes either a reason alone or a condition and then the reason.
77
+ const reasonOnly = where === 'only' ||
78
+ (where === 'either' && (first === undefined || first.type === 'Literal' || first.type === 'TemplateLiteral'));
79
+ const example = reasonOnly ? `${callee}('why')` : `${callee}(condition, 'why')`;
80
+ const argument = subject.kind === 'runtime' && where !== undefined ? `, or pass it to the call: \`${example}\`` : '';
81
+ context.report({
82
+ loc: headLoc(subject),
83
+ messageId: 'missingReason',
84
+ data: { subject: describeSubject(subject), state: state.name, argument },
85
+ });
86
+ };
68
87
  for (const subject of analysis.subjects) {
69
88
  const needsTicket = subject.states.some((s) => s.required);
70
89
  if (options.reportUnreadableTags && hasTagStates) {
@@ -82,9 +101,12 @@ export default createRule({
82
101
  }
83
102
  }
84
103
  const annotationOnly = only && (subject.kind === 'test' || subject.kind === 'describe') ? only : undefined;
85
- for (const { state, required } of subject.states) {
86
- if (!required)
104
+ for (const { state, required, reasoned } of subject.states) {
105
+ if (!required) {
106
+ if (options.requireReasonForConditional && reasoned === false)
107
+ reportReason(subject, state);
87
108
  continue;
109
+ }
88
110
  if (annotationOnly) {
89
111
  reportAnnotationOnly(subject, state, annotationOnly);
90
112
  continue;
@@ -22,6 +22,10 @@ export interface Marker {
22
22
  text: string;
23
23
  range: TSESTree.Range;
24
24
  };
25
+ /** The index of the marker's line in `commentLines(comment)`. */
26
+ line: number;
27
+ /** Where the keyword starts in the source. */
28
+ keywordStart: number;
25
29
  }
26
30
  /** A marker whose keyword is exactly a state's marker. */
27
31
  export type StateMarker = Marker & {
@@ -56,6 +60,11 @@ interface StateSource {
56
60
  state: StateDef;
57
61
  /** False for conditional skips unless `requireTicketForConditional` is set. */
58
62
  required: boolean;
63
+ /**
64
+ * Set for a conditional state: whether something says why, a comment above it or its test, the reason its
65
+ * call takes, or one its option gives.
66
+ */
67
+ reasoned?: boolean;
59
68
  }
60
69
  export interface Subject {
61
70
  kind: 'test' | 'describe' | 'step' | 'runtime';
@@ -69,6 +78,8 @@ export interface Subject {
69
78
  inherited: Set<string>;
70
79
  /** Names of states added by runtime calls directly inside this test or describe. */
71
80
  runtimeStates: Set<string>;
81
+ /** Whether the comment block above this node holds any text, an `eslint-disable` description included. */
82
+ commented: boolean;
72
83
  /** Every tag written on this node, known or not. */
73
84
  tags: TagOccurrence[];
74
85
  /** Tags this node inherits from enclosing describes. */
@@ -80,6 +91,11 @@ export interface Subject {
80
91
  * Vitest's option states when its options object can't. Includes those of enclosing describes.
81
92
  */
82
93
  unknownStates: Set<string>;
94
+ /**
95
+ * States of other subjects whose comment block is this one's, as a test that is the arrow body of a
96
+ * describe, `describe('d', () => test.skip(...))`, so a marker for them isn't left over here.
97
+ */
98
+ sharedStates: Set<string>;
83
99
  /** True when this node is skipped or fixme'd by any means, whether or not those states are on. */
84
100
  skipped: boolean;
85
101
  ownSkip: boolean;
@@ -108,6 +124,8 @@ export interface Analysis {
108
124
  subjects: Subject[];
109
125
  /** Markers that are not in the comment block of any test, describe or runtime call. */
110
126
  detachedMarkers: StateMarker[];
127
+ /** Markers of `eslint-disable` regions that cover tests, none of them in the marker's state. */
128
+ idleRegionMarkers: StateMarker[];
111
129
  /** Commented-out tests; their comments are left out of the comment blocks of the tests around them. */
112
130
  commentedOutTests: CommentedOutTest[];
113
131
  /** Whether the file uses the test framework at all: an import from it or a call to a test function. */