eslint-plugin-test-governance 0.13.0 → 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,72 @@ 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
+
9
75
  ## [0.13.0] - 2026-11-05
10
76
 
11
77
  ### Added
@@ -507,7 +573,8 @@ First release.
507
573
  - Support for ESLint 9 and 10 flat config, JavaScript and TypeScript test files, `.extend()`
508
574
  fixtures, describe blocks, runtime skips and tags in titles or the `tag` option.
509
575
 
510
- [Unreleased]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.13.0...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
511
578
  [0.13.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.12.1...v0.13.0
512
579
  [0.12.1]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.12.0...v0.12.1
513
580
  [0.12.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.11.1...v0.12.0
package/README.md CHANGED
@@ -242,7 +242,7 @@ and markers. To turn a rule off, set it to `'off'` as usual.
242
242
  | ----------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
243
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). |
244
244
  | `ticket` | `TicketSpec \| TicketSpec[]` | `{ preset: 'any' }` | Accepted ticket formats. An array accepts a ticket that matches any entry. See [Ticket presets](#ticket-presets). |
245
- | `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. |
246
246
  | `lifecycleTags` | `boolean` | `false` | Turns on the `new` (`@new`) and `quarantined` (`@quarantined`) states. |
247
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. |
248
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. |
@@ -253,7 +253,7 @@ and markers. To turn a rule off, set it to `'off'` as usual.
253
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. |
254
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. |
255
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). |
256
- | `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. |
257
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. |
258
258
 
259
259
  ### Tag names
@@ -330,8 +330,9 @@ run on their own servers.
330
330
 
331
331
  For a tracker without a preset, use `url` with its host, for example
332
332
  `{ preset: 'url', hosts: ['www.wrike.com'] }` for Wrike links such as
333
- `https://www.wrike.com/open.htm?id=4821`. It works the same for Microsoft Planner (`tasks.office.com`),
334
- 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
335
336
  tracker's keys, such as `WEB-12`, `any` or `pattern` works. Gitea, Forgejo and Codeberg use GitHub's link layout,
336
337
  so the `github` preset with their `host` covers them. Bitbucket has no issue tracker since August 2026,
337
338
  so it has no preset.
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
  : [],
@@ -24,6 +24,7 @@ export default createRule({
24
24
  const checkedTags = [...options.states.filter(isTagState).map((s) => s.tag), ...options.knownTags];
25
25
  const newTag = options.states.find((s) => s.name === 'new')?.tag;
26
26
  const quarantinedTag = options.states.find((s) => s.name === 'quarantined')?.tag;
27
+ const spelled = new Set();
27
28
  for (const subject of analysis.subjects) {
28
29
  for (const occurrence of subject.tags)
29
30
  checkSpelling(occurrence);
@@ -52,6 +53,11 @@ export default createRule({
52
53
  }
53
54
  function checkSpelling(occurrence) {
54
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);
55
61
  // A Vitest tag name is written without its `@`, so show it, and fix it, the same way.
56
62
  const written = (name) => (occurrence.plain ? name.slice(1) : name);
57
63
  if (checkedTags.includes(tag))
@@ -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);
@@ -70,7 +70,7 @@ export default createRule({
70
70
  const reportReason = (subject, state) => {
71
71
  if (evaluate(subject, state).kind === 'ok' || hasAnnotationTicket(subject, state, options))
72
72
  return;
73
- const callee = context.sourceCode.getText(subject.node.callee);
73
+ const callee = context.sourceCode.getText(subject.node.callee).replace(/\s+/g, '');
74
74
  const first = subject.node.arguments.at(0);
75
75
  const where = options.framework.reasonArgument;
76
76
  // Vitest's `context.skip()` takes either a reason alone or a condition and then the reason.
@@ -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 & {
@@ -87,6 +91,11 @@ export interface Subject {
87
91
  * Vitest's option states when its options object can't. Includes those of enclosing describes.
88
92
  */
89
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>;
90
99
  /** True when this node is skipped or fixme'd by any means, whether or not those states are on. */
91
100
  skipped: boolean;
92
101
  ownSkip: boolean;
@@ -115,6 +124,8 @@ export interface Analysis {
115
124
  subjects: Subject[];
116
125
  /** Markers that are not in the comment block of any test, describe or runtime call. */
117
126
  detachedMarkers: StateMarker[];
127
+ /** Markers of `eslint-disable` regions that cover tests, none of them in the marker's state. */
128
+ idleRegionMarkers: StateMarker[];
118
129
  /** Commented-out tests; their comments are left out of the comment blocks of the tests around them. */
119
130
  commentedOutTests: CommentedOutTest[];
120
131
  /** Whether the file uses the test framework at all: an import from it or a call to a test function. */
@@ -32,14 +32,16 @@ export function directiveOf(comment) {
32
32
  return { kind, rules, description: { separator: separator.index, start: separator.index + separator[0].length } };
33
33
  }
34
34
  // A call at the start of a comment line, as `test('...'`, `test.describe.skip(` or `await test.step(`. Code has
35
- // no space before a call's `(`, and a sentence such as `// it (see above) ...` has one.
36
- const COMMENTED_CALL_RE = /^(\s*\*?\s*)(?:await\s+)?([A-Za-z_$][\w$]*)(?:\.[A-Za-z_$][\w$]*)*\(/;
35
+ // no space before a call's `(`, and a sentence such as `// it (see above) ...` has one. An empty call, as
36
+ // `test.skip()`, changes a test at runtime and declares none.
37
+ const COMMENTED_CALL_RE = /^(\s*\*?\s*)(?:await\s+)?([A-Za-z_$][\w$]*)((?:\.[A-Za-z_$][\w$]*)*)\((?!\))/;
37
38
  /**
38
39
  * Blocks of comments that hold a test or describe written out as code: comments on consecutive lines with
39
- * no code before them, one of whose lines starts with a call of `names`. A JSDoc comment is documentation,
40
- * so a test in its example doesn't count.
40
+ * no code before them, one of whose lines starts with a call of `names` whose members all declare tests,
41
+ * as `test.describe.skip(`, unlike `test.use(`. A JSDoc comment is documentation, so a test in its example
42
+ * doesn't count.
41
43
  */
42
- function findCommentedOutTests(sourceCode, comments, names, states) {
44
+ function findCommentedOutTests(sourceCode, comments, names, members, states) {
43
45
  const blocks = [];
44
46
  for (const comment of comments) {
45
47
  if (sourceCode.getTokenBefore(comment)?.loc.end.line === comment.loc.start.line)
@@ -59,7 +61,13 @@ function findCommentedOutTests(sourceCode, comments, names, states) {
59
61
  .flatMap(commentLines)
60
62
  .flatMap(({ text, start }) => {
61
63
  const match = COMMENTED_CALL_RE.exec(text);
62
- return match && names.has(match[2]) ? [[start + match[1].length, start + text.trimEnd().length]] : [];
64
+ const declares = match !== null &&
65
+ names.has(match[2]) &&
66
+ match[3]
67
+ .split('.')
68
+ .slice(1)
69
+ .every((member) => members.has(member));
70
+ return declares ? [[start + match[1].length, start + text.trimEnd().length]] : [];
63
71
  })
64
72
  .at(0);
65
73
  if (range === undefined)
@@ -389,8 +397,15 @@ function isLocalVariable(sourceCode, id) {
389
397
  }
390
398
  return false;
391
399
  }
400
+ /** Values a constant can hold a test function in: `it.skip`, `it.skipIf(...)`, `` it.each`...` `` or `a ? b : c`. */
401
+ const ALIAS_VALUES = new Set([
402
+ 'MemberExpression',
403
+ 'CallExpression',
404
+ 'TaggedTemplateExpression',
405
+ 'ConditionalExpression',
406
+ ]);
392
407
  const STATEMENT_CONTAINERS = new Set(['Program', 'BlockStatement', 'StaticBlock', 'SwitchCase', 'TSModuleBlock']);
393
- /** The statement a call belongs to; its marker block sits above that statement. */
408
+ /** The statement a call, or a value, belongs to; its marker block sits above that statement. */
394
409
  /**
395
410
  * The declaration a call makes through `Function.prototype.call`, `apply` or `bind`, read as if it were
396
411
  * written plainly: `it.skip.call(null, 'title', fn)`, `it.skip.apply(null, ['title', fn])` and
@@ -516,20 +531,21 @@ export function commentLines(comment) {
516
531
  }
517
532
  function parseMarkers(comment, states) {
518
533
  const markers = [];
519
- for (const { text, start } of commentLines(comment)) {
534
+ for (const [line, { text, start }] of commentLines(comment).entries()) {
520
535
  const match = MARKER_LINE_RE.exec(text);
521
536
  if (!match)
522
537
  continue;
523
538
  const [, keyword, rest] = match;
524
539
  const restOffset = start + match[0].length - rest.length;
540
+ const at = { comment, keyword, line, keywordStart: start + text.indexOf(keyword) };
525
541
  const state = states.find((s) => s.marker === keyword);
526
542
  if (state) {
527
- markers.push({ comment, keyword, state, ...parseTickets(rest, restOffset, state.ticket) });
543
+ markers.push({ ...at, state, ...parseTickets(rest, restOffset, state.ticket) });
528
544
  continue;
529
545
  }
530
546
  const caseOf = states.find((s) => s.marker === keyword.toUpperCase());
531
547
  if (caseOf)
532
- markers.push({ comment, keyword, caseOf, ...parseTickets(rest, restOffset, caseOf.ticket) });
548
+ markers.push({ ...at, caseOf, ...parseTickets(rest, restOffset, caseOf.ticket) });
533
549
  }
534
550
  return markers;
535
551
  }
@@ -983,9 +999,13 @@ const OPTION_VALUES = new Set([
983
999
  * Whether a runtime call always applies: it has no condition, or `true`, or with `notes` only a note, as
984
1000
  * Vitest's `context.skip('Broker sandbox is down')`, and nothing around it makes it conditional.
985
1001
  */
986
- function isUnconditional(call, notes) {
1002
+ function isUnconditional(call, framework) {
987
1003
  const first = call.arguments.at(0);
988
- const always = first === undefined || booleanOf(first) === true || (notes && isStaticText(first));
1004
+ const always = first === undefined ||
1005
+ booleanOf(first) === true ||
1006
+ // node:test's `t.skip(message)` and Jasmine's `pending(message)` take no condition, so any argument is a note.
1007
+ framework.reasonArgument === 'only' ||
1008
+ (framework.skipNotes && isStaticText(first));
989
1009
  return always && !isGuarded(call);
990
1010
  }
991
1011
  /**
@@ -1006,9 +1026,13 @@ function saysWhy(sourceCode, node) {
1006
1026
  const value = unwrap(node);
1007
1027
  return value.type !== 'Literal' && !(value.type === 'Identifier' && value.name === 'undefined');
1008
1028
  }
1009
- /** Whether `node` is text that isn't blank. */
1029
+ /**
1030
+ * Whether `node` is text that isn't blank, or a template with values in it, as `` `${browserName} has no
1031
+ * clipboard` ``, which is taken on trust.
1032
+ */
1010
1033
  function isReasonText(sourceCode, node) {
1011
- return Boolean(staticText(sourceCode, node)?.trim());
1034
+ const text = staticText(sourceCode, node);
1035
+ return text === undefined ? unwrap(node).type === 'TemplateLiteral' : text.trim() !== '';
1012
1036
  }
1013
1037
  /** Whether a runtime call gives the reason for its state where its framework takes one (see `reasonArgument`). */
1014
1038
  function callReason(sourceCode, call, where) {
@@ -1017,7 +1041,8 @@ function callReason(sourceCode, call, where) {
1017
1041
  if (where === 'only')
1018
1042
  return first !== undefined && saysWhy(sourceCode, first);
1019
1043
  // Vitest's `context.skip(isMobile)` takes a condition, so only text first is a reason.
1020
- if (where === 'either' && first !== undefined && staticText(sourceCode, first) !== undefined) {
1044
+ const text = first !== undefined && (staticText(sourceCode, first) !== undefined || unwrap(first).type === 'TemplateLiteral');
1045
+ if (where === 'either' && text) {
1021
1046
  return isReasonText(sourceCode, first);
1022
1047
  }
1023
1048
  return second !== undefined && saysWhy(sourceCode, second);
@@ -1140,9 +1165,11 @@ function runAnalysis(sourceCode, options) {
1140
1165
  const commentedOut = findCommentedOutTests(sourceCode, allComments, new Set([
1141
1166
  ...options.testFunctions,
1142
1167
  ...[...framework.exports, ...framework.globals].flatMap(([name, root]) => (root.kind === 'hook' ? [] : [name])),
1143
- ]), states);
1168
+ ]), new Set([...framework.testMembers, ...framework.describeMembers, 'describe', 'step'].map((m) => m.replace('()', ''))), states);
1144
1169
  const comments = allComments.filter((comment) => !commentedOut.comments.has(comment));
1145
1170
  const regions = disableRegions(comments, states);
1171
+ // A region's marker covers every test up to its `eslint-enable`, so it is never the own marker of the first.
1172
+ const regionComments = new Set(regions.map((r) => r.comment));
1146
1173
  // `test.skip(...); // eslint-disable-line rule -- SKIP: TRADE-1` holds its marker on the test's own line.
1147
1174
  const lineDirectives = new Map();
1148
1175
  for (const comment of comments) {
@@ -1162,12 +1189,12 @@ function runAnalysis(sourceCode, options) {
1162
1189
  /** Describe bodies written as a `function`, whose `this` is Mocha's suite, which `this.retries()` acts on. */
1163
1190
  const suiteBodies = new Set();
1164
1191
  /**
1165
- * Whether a statement around `anchor` has a comment that says something above it, as `// why` above an `if`,
1166
- * up to `owner`, the test or describe the call acts on. The walk goes on through a callback passed to another
1192
+ * Whether `anchor` or a statement around it has a comment that says something above it or at the end of its
1193
+ * first line, as `// why` above an `if` or after its `{`, up to `owner`, the test or describe the call acts on. The walk goes on through a callback passed to another
1167
1194
  * call, such as a hook's or a step's, but stops at a function declared some other way.
1168
1195
  */
1169
1196
  const commentedAround = (anchor, owner) => {
1170
- for (let node = anchor.parent; node; node = node.parent) {
1197
+ for (let node = anchor; node; node = node.parent) {
1171
1198
  if (FUNCTIONS.has(node.type)) {
1172
1199
  const call = node.parent;
1173
1200
  if (call?.type !== 'CallExpression' || call === owner || !call.arguments.includes(node)) {
@@ -1179,7 +1206,11 @@ function runAnalysis(sourceCode, options) {
1179
1206
  if (!container || !STATEMENT_CONTAINERS.has(container.type))
1180
1207
  continue;
1181
1208
  const block = commentBlock(sourceCode, node, options.allowBlankLine);
1182
- if (block.some((comment) => !commentedOut.comments.has(comment) && hasText(comment)))
1209
+ if (block.some((c) => !commentedOut.comments.has(c) && !regionComments.has(c) && hasText(c)))
1210
+ return true;
1211
+ // `if (isMobile) { // why`: a comment at the end of the line the statement starts on.
1212
+ const first = sourceCode.getCommentsInside(node).at(0);
1213
+ if (first?.loc.start.line === node.loc.start.line && hasText(first))
1183
1214
  return true;
1184
1215
  }
1185
1216
  return false;
@@ -1200,17 +1231,18 @@ function runAnalysis(sourceCode, options) {
1200
1231
  block.push(...commentBlock(sourceCode, call, options.allowBlankLine).filter((c) => !commentedOut.comments.has(c)));
1201
1232
  }
1202
1233
  block.push(...(lineDirectives.get(call.loc.start.line) ?? []));
1203
- const inRegions = regions.filter((r) => r.start <= call.range[0] && call.range[0] < r.end && !block.includes(r.comment));
1234
+ const inRegions = regions.filter((r) => r.start <= call.range[0] && call.range[0] < r.end);
1204
1235
  for (const comment of [...block, ...inRegions.map((r) => r.comment)])
1205
1236
  claimed.add(comment);
1206
- const commented = [...block, ...inRegions.map((r) => r.comment)].some(hasText);
1207
- // A runtime call can also say why above the statements around it in its function, above its test, or in
1208
- // its arguments.
1209
- const reasonedCall = () => kind === 'runtime' &&
1210
- ((framework.reasonArgument !== undefined && callReason(sourceCode, call, framework.reasonArgument)) ||
1211
- parent?.commented === true ||
1212
- trailingText(anchor) ||
1213
- commentedAround(anchor, parent?.node));
1237
+ // A region's description says why a rule is off, not why a test is skipped; its marker's ticket counts.
1238
+ const commented = block.some((comment) => !regionComments.has(comment) && hasText(comment));
1239
+ // A comment at the end of the line also says why. A runtime call can also say why above the statements
1240
+ // around it in its function, above its test, or in its arguments.
1241
+ const reasonedCall = () => trailingText(anchor) ||
1242
+ (kind === 'runtime' &&
1243
+ ((framework.reasonArgument !== undefined && callReason(sourceCode, call, framework.reasonArgument)) ||
1244
+ parent?.commented === true ||
1245
+ commentedAround(anchor, parent?.node)));
1214
1246
  const ownTags = new Set(tagInfo.tags.map((t) => t.tag));
1215
1247
  const sources = [];
1216
1248
  for (const state of states) {
@@ -1266,9 +1298,10 @@ function runAnalysis(sourceCode, options) {
1266
1298
  inheritedTags,
1267
1299
  unreadTags: tagInfo.unread,
1268
1300
  unknownStates,
1301
+ sharedStates: new Set(),
1269
1302
  skipped: ownSkip || (kind !== 'runtime' && parent?.skipped === true),
1270
1303
  ownSkip,
1271
- markers: block.flatMap((comment) => parseMarkers(comment, states)),
1304
+ markers: block.flatMap((comment) => (regionComments.has(comment) ? [] : parseMarkers(comment, states))),
1272
1305
  regionMarkers: inRegions.flatMap((r) => r.markers),
1273
1306
  annotationTickets: tagInfo.annotations,
1274
1307
  unreadTickets: tagInfo.unreadAnnotations,
@@ -1386,8 +1419,8 @@ function runAnalysis(sourceCode, options) {
1386
1419
  * `(isCI ? describe.skip : describe)('order entry', ...)`: a test or describe whose function is picked by
1387
1420
  * a condition. A state that only one side has depends on the condition, like `test.skip(isCI)`.
1388
1421
  */
1389
- const pickedDeclaration = (call) => {
1390
- const callee = unwrap(call.callee);
1422
+ const pickedDeclaration = (call, picker) => {
1423
+ const callee = unwrap(picker);
1391
1424
  if (callee.type !== 'ConditionalExpression')
1392
1425
  return undefined;
1393
1426
  const [yes, no] = [callee.consequent, callee.alternate].map((side) => {
@@ -1456,22 +1489,52 @@ function runAnalysis(sourceCode, options) {
1456
1489
  ? declare(call, declaration, bodyInfos(call.arguments, []))
1457
1490
  : undefined;
1458
1491
  };
1492
+ /**
1493
+ * The value of a constant that holds a test function the collected names can't: one a condition picks, as
1494
+ * `const itIf = isCI ? it.skip : it`, one a factory builds, as `const d = describe.runIf(hasDocker)`, or any
1495
+ * declared inside a function.
1496
+ */
1497
+ const aliasValue = (callee) => {
1498
+ if (callee.type !== 'Identifier' || testNames.roots.has(callee.name))
1499
+ return undefined;
1500
+ const value = declaredValue(sourceCode, callee);
1501
+ return value && ALIAS_VALUES.has(unwrap(value).type) ? value : undefined;
1502
+ };
1503
+ /**
1504
+ * Whether the declaration of a constant that holds a test function says why above it or at the end of its
1505
+ * line, as `// no symlinks on Windows` above `const posixTest = isWindows ? test.skip : test`.
1506
+ */
1507
+ const aliasCommented = (value) => {
1508
+ const statement = anchorOf(value);
1509
+ const block = commentBlock(sourceCode, statement, options.allowBlankLine);
1510
+ return (block.some((c) => !commentedOut.comments.has(c) && !regionComments.has(c) && hasText(c)) ||
1511
+ trailingText(statement));
1512
+ };
1459
1513
  const visitCall = (original) => {
1514
+ const alias = aliasValue(throughFunctionMethods(original).callee);
1515
+ const visited = visitDeclaredCall(original, alias);
1516
+ if (alias && visited.subject && aliasCommented(alias)) {
1517
+ for (const source of visited.subject.states)
1518
+ if (source.reasoned === false)
1519
+ source.reasoned = true;
1520
+ }
1521
+ return visited;
1522
+ };
1523
+ const visitDeclaredCall = (original, alias) => {
1460
1524
  const call = throughFunctionMethods(original);
1461
- const callee = target(chainOf(call.callee, framework.factories));
1525
+ const written = alias ?? call.callee;
1526
+ const callee = target(chainOf(written, framework.factories));
1462
1527
  if (callee === undefined) {
1463
- return subtestOf(call) ?? pickedDeclaration(call) ?? { subject: runtimeCall(call) ?? retryCall(call) };
1528
+ return subtestOf(call) ?? pickedDeclaration(call, written) ?? { subject: runtimeCall(call) ?? retryCall(call) };
1464
1529
  }
1465
1530
  usesTest = true;
1466
1531
  const args = call.arguments;
1467
1532
  const { path } = callee;
1468
- if (framework.retryCall === 'describe.configure' && path.join('.') === 'describe.configure') {
1469
- const options = args.at(0);
1470
- const object = options && unwrap(options);
1471
- const count = object?.type === 'ObjectExpression'
1472
- ? object.properties.find((p) => p.type === 'Property' && propertyName(p) === 'retries')
1473
- : undefined;
1474
- return { subject: retrySubject(call, count?.type === 'Property' ? count.value : undefined, 'configure') };
1533
+ // `test.describe.configure(...)`, or `describe.configure(...)` with `const { describe } = test`.
1534
+ const configure = path.join('.') === 'describe.configure' || (callee.root === DESCRIBE && path.join('.') === 'configure');
1535
+ if (framework.retryCall === 'describe.configure' && configure) {
1536
+ const count = detailsOf(sourceCode, call, true).entries.findLast(([key]) => key === 'retries');
1537
+ return { subject: retrySubject(call, count?.[1], 'configure') };
1475
1538
  }
1476
1539
  const declaration = declarationOf(callee);
1477
1540
  // Vitest's `beforeEach((context) => context.skip())` and Mocha's `before(function () { this.skip(); })`
@@ -1635,7 +1698,7 @@ function runAnalysis(sourceCode, options) {
1635
1698
  return subject;
1636
1699
  };
1637
1700
  /** `step` is set for `step.skip()`, which skips only its step; the other calls act on the whole test. */
1638
- const runtimeSubject = (call, modifier, step, always = isUnconditional(call, framework.skipNotes)) => {
1701
+ const runtimeSubject = (call, modifier, step, always = isUnconditional(call, framework)) => {
1639
1702
  if (!states.some((s) => s.modifier === modifier))
1640
1703
  return undefined;
1641
1704
  const subject = makeSubject('runtime', call, new Map([[modifier, always]]), NO_TAGS, step ?? stack.findLast((s) => s.kind !== 'step'));
@@ -1676,7 +1739,36 @@ function runAnalysis(sourceCode, options) {
1676
1739
  .filter((comment) => !claimed.has(comment))
1677
1740
  .flatMap((comment) => parseMarkers(comment, states))
1678
1741
  .filter(isStateMarker);
1679
- return { subjects, detachedMarkers, commentedOutTests: commentedOut.found, isTestFile: usesTest };
1742
+ const byAnchor = new Map();
1743
+ for (const subject of subjects)
1744
+ byAnchor.set(subject.anchor, [...(byAnchor.get(subject.anchor) ?? []), subject]);
1745
+ for (const subject of subjects) {
1746
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- every subject's anchor is a key
1747
+ for (const other of byAnchor.get(subject.anchor)) {
1748
+ if (other === subject)
1749
+ continue;
1750
+ for (const marker of subject.markers) {
1751
+ if (marker.state && appliesTo(other, marker.state.name))
1752
+ subject.sharedStates.add(marker.state.name);
1753
+ }
1754
+ }
1755
+ }
1756
+ const idleRegionMarkers = regions
1757
+ .flatMap((region) => region.markers)
1758
+ .filter(isStateMarker)
1759
+ .filter((marker) => {
1760
+ const covered = subjects.filter((subject) => subject.regionMarkers.includes(marker));
1761
+ return (covered.length > 0 &&
1762
+ !isWorkComment(marker, options) &&
1763
+ !covered.some((s) => appliesTo(s, marker.state.name) || s.unknownStates.has(marker.state.name)));
1764
+ });
1765
+ return {
1766
+ subjects,
1767
+ detachedMarkers,
1768
+ idleRegionMarkers,
1769
+ commentedOutTests: commentedOut.found,
1770
+ isTestFile: usesTest,
1771
+ };
1680
1772
  }
1681
1773
  /** Whether `subject` is in `stateName` at all, required or not, including inherited and runtime states. */
1682
1774
  export function appliesTo(subject, stateName) {
@@ -1701,7 +1793,7 @@ function candidateMarkers(subject, state) {
1701
1793
  export function evaluate(subject, state) {
1702
1794
  const markers = candidateMarkers(subject, state);
1703
1795
  if (markers.length === 0) {
1704
- const marker = subject.markers.find((m) => m.caseOf === state);
1796
+ const marker = [...subject.markers, ...subject.regionMarkers].find((m) => m.caseOf === state);
1705
1797
  return marker ? { kind: 'case', marker } : { kind: 'missing' };
1706
1798
  }
1707
1799
  const isValid = (m) => m.tickets.length > 0 && m.tickets.every((t) => t.result === 'ok');
@@ -1775,8 +1867,12 @@ function isWorkComment(marker, options) {
1775
1867
  export function strayMarkers(subject, options) {
1776
1868
  return subject.markers.flatMap((marker) => {
1777
1869
  const state = marker.state ?? (marker.tickets.at(0)?.result === 'ok' ? marker.caseOf : undefined);
1778
- if (state === undefined || appliesTo(subject, state.name) || subject.unknownStates.has(state.name))
1870
+ if (state === undefined ||
1871
+ appliesTo(subject, state.name) ||
1872
+ subject.unknownStates.has(state.name) ||
1873
+ subject.sharedStates.has(state.name)) {
1779
1874
  return [];
1875
+ }
1780
1876
  return isWorkComment(marker, options) ? [] : [{ ...marker, state }];
1781
1877
  });
1782
1878
  }
@@ -7,7 +7,6 @@ import { resolveOptions } from "./options.js";
7
7
  export function createRule(definition) {
8
8
  return {
9
9
  meta: definition.meta,
10
- defaultOptions: [],
11
10
  create(context) {
12
11
  const options = resolveOptions(context.settings);
13
12
  return {
@@ -1,13 +1,16 @@
1
1
  import type { TSESLint, TSESTree } from '@typescript-eslint/utils';
2
2
  import { type Marker } from './analyze.ts';
3
3
  type SourceCode = Readonly<TSESLint.SourceCode>;
4
- /** Range of `keyword` where it is used as a marker (`KEYWORD:`) inside `comment`. */
5
- export declare function keywordRange(sourceCode: SourceCode, comment: TSESTree.Comment, keyword: string): TSESTree.Range;
4
+ /** Range of a marker's keyword on the marker's own line. */
5
+ export declare function keywordRange(marker: Marker): TSESTree.Range;
6
6
  /**
7
- * Removes a marker comment, and its whole line when nothing else is on it. From an ESLint directive it
8
- * removes only the description, so the directive keeps working.
7
+ * Removes a marker. From a comment that says more, it removes only the marker's line. Otherwise it removes
8
+ * the comment, and its whole line when nothing else is on it, or from an ESLint directive only the
9
+ * description, so the directive keeps working.
9
10
  */
10
- export declare function removeMarkerComment(sourceCode: SourceCode, comment: TSESTree.Comment): TSESLint.RuleFix;
11
+ export declare function removeMarkerComment(sourceCode: SourceCode, marker: Marker): TSESLint.RuleFix;
12
+ /** The line break that ends the line `comment` ends on. */
13
+ export declare function lineBreakAfter(sourceCode: SourceCode, comment: TSESTree.Comment): string;
11
14
  /** Indentation of the line `comment` starts on. */
12
15
  export declare function indentOf(sourceCode: SourceCode, comment: TSESTree.Comment): string;
13
16
  /**
package/dist/utils/fix.js CHANGED
@@ -1,21 +1,26 @@
1
- import { directiveOf, isFunction } from "./analyze.js";
2
- function escapeRegExp(text) {
3
- return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
4
- }
5
- /** Range of `keyword` where it is used as a marker (`KEYWORD:`) inside `comment`. */
6
- export function keywordRange(sourceCode, comment, keyword) {
7
- const text = sourceCode.getText(comment);
8
- // Only called with a keyword that was parsed from this comment, so it always matches.
9
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- see above
10
- const match = new RegExp(`(^|[^\\w-])(${escapeRegExp(keyword)})\\s*:`).exec(text);
11
- const start = comment.range[0] + match.index + match[1].length;
12
- return [start, start + keyword.length];
1
+ import { commentLines, directiveOf, isFunction } from "./analyze.js";
2
+ /** Range of a marker's keyword on the marker's own line. */
3
+ export function keywordRange(marker) {
4
+ return [marker.keywordStart, marker.keywordStart + marker.keyword.length];
13
5
  }
14
6
  /**
15
- * Removes a marker comment, and its whole line when nothing else is on it. From an ESLint directive it
16
- * removes only the description, so the directive keeps working.
7
+ * Removes a marker. From a comment that says more, it removes only the marker's line. Otherwise it removes
8
+ * the comment, and its whole line when nothing else is on it, or from an ESLint directive only the
9
+ * description, so the directive keeps working.
17
10
  */
18
- export function removeMarkerComment(sourceCode, comment) {
11
+ export function removeMarkerComment(sourceCode, marker) {
12
+ const { comment, line: index } = marker;
13
+ const lines = commentLines(comment);
14
+ if (lines.some(({ text }, i) => i !== index && /[^\s*]/.test(text))) {
15
+ const { start, text } = lines[index];
16
+ if (index === 0) {
17
+ // A JSDoc comment keeps the `*` of its `/**`.
18
+ return { range: [start + text.search(/[^*]/), start + text.length], text: '' };
19
+ }
20
+ const previous = lines[index - 1];
21
+ const end = index === lines.length - 1 ? text.trimEnd().length : text.length;
22
+ return { range: [previous.start + previous.text.length, start + end], text: '' };
23
+ }
19
24
  const description = directiveOf(comment)?.description;
20
25
  if (description) {
21
26
  // `//` and `/*` are both two characters; a block comment keeps the space before its `*/`.
@@ -29,6 +34,12 @@ export function removeMarkerComment(sourceCode, comment) {
29
34
  const aloneOnLine = text.slice(lineStart, comment.range[0]).trim() === '' && text.slice(comment.range[1], lineEnd).trim() === '';
30
35
  return { range: aloneOnLine ? [lineStart, lineEnd] : comment.range, text: '' };
31
36
  }
37
+ /** The line break that ends the line `comment` ends on. */
38
+ export function lineBreakAfter(sourceCode, comment) {
39
+ // A line comment above a test always ends at a line break.
40
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- see above
41
+ return /^(?:\r\n|[\r\n\u2028\u2029])/.exec(sourceCode.text.slice(comment.range[1]))[0];
42
+ }
32
43
  /** Indentation of the line `comment` starts on. */
33
44
  export function indentOf(sourceCode, comment) {
34
45
  const line = sourceCode.lines[comment.loc.start.line - 1];
@@ -63,14 +74,18 @@ export function moveToAnnotation(sourceCode, call, marker, source, type) {
63
74
  const details = call.arguments.at(1);
64
75
  if (isFunction(title))
65
76
  return undefined;
66
- const remove = removeMarkerComment(sourceCode, comment);
77
+ const remove = removeMarkerComment(sourceCode, marker);
67
78
  if (isFunction(details)) {
79
+ // Vitest reads a timeout or options after the function only as the third argument.
80
+ if (call.arguments.length > 2)
81
+ return undefined;
68
82
  return (fixer) => [remove, fixer.insertTextAfter(title, `, { ${property} }`)];
69
83
  }
70
84
  if (details?.type !== 'ObjectExpression')
71
85
  return undefined;
72
86
  const taken = details.properties.some((prop) => prop.type === 'SpreadElement' ||
73
- (!prop.computed && (prop.key.type === 'Identifier' ? prop.key.name : String(prop.key.value)) === source.key));
87
+ prop.computed ||
88
+ (prop.key.type === 'Identifier' ? prop.key.name : String(prop.key.value)) === source.key);
74
89
  if (taken)
75
90
  return undefined;
76
91
  const last = details.properties.at(-1);
@@ -26,15 +26,17 @@ export interface GovernanceOptions {
26
26
  framework: FrameworkName;
27
27
  /**
28
28
  * Names that are treated as the framework's `test` function. Defaults to `['test']` for Playwright,
29
- * `['test', 'it']` for Jest and Vitest, and `['it', 'specify', 'test']` for Mocha, Cypress and
30
- * WebdriverIO. Your list replaces the default one, so include those names if you still need them.
29
+ * `['test', 'it']` for Jest, Vitest and node:test, `['it', 'specify', 'test']` for Mocha, Cypress and
30
+ * WebdriverIO, and `['it']` for Jasmine. Your list replaces the default one, so include those names if you
31
+ * still need them.
31
32
  */
32
33
  testFunctions?: string[];
33
34
  /** Accepted ticket formats. Defaults to `{ preset: 'any' }`. */
34
35
  ticket?: TicketSpec | TicketSpec[];
35
36
  /**
36
- * Tickets that are rejected as placeholders. `*` matches any characters. Your list replaces the default
37
- * one (`TODO`, `TBD`, `XXX-*`, ...); tickets numbered 0 are always rejected.
37
+ * Tickets that are rejected as placeholders. `*` matches any characters. Case-insensitive, and a leading `#`
38
+ * on the ticket or the placeholder is ignored. Your list replaces the default one (`TODO`, `TBD`, `XXX-*`,
39
+ * ...); tickets numbered 0 are always rejected.
38
40
  */
39
41
  placeholders?: string[];
40
42
  /** Turns on the `@new` and `@quarantined` states. */
@@ -1,8 +1,8 @@
1
1
  import { BUILTIN_STATE_NAMES, DEFAULT_COMMENT_KEYWORDS, HOST_ENTRY_RE, } from "./constants.js";
2
2
  import { ConfigError } from "./errors.js";
3
3
  import { FRAMEWORKS } from "./frameworks.js";
4
- import { optionsSchema } from "./schema.js";
5
- import { compileTicketSpec, DEFAULT_PLACEHOLDERS } from "./tickets.js";
4
+ import { optionsSchema, PROJECT_PATTERN } from "./schema.js";
5
+ import { compileTicketSpec, DEFAULT_PLACEHOLDERS, NUMERIC_MAX_LENGTH, ticketPattern, } from "./tickets.js";
6
6
  import { validate } from "./validate.js";
7
7
  export const SETTINGS_KEY = 'test-governance';
8
8
  export function isTagState(state) {
@@ -83,13 +83,17 @@ const STRICT_HOST_PRESETS = new Set([
83
83
  'phabricator',
84
84
  'plane',
85
85
  ]);
86
+ const PROJECT_RE = new RegExp(PROJECT_PATTERN);
86
87
  /** Option values the schema allows for every preset but this one can't use. */
87
88
  function ownOptionProblems(spec, at) {
88
89
  const problems = [];
89
- if (spec.preset === 'jira' && Array.isArray(spec.projects)) {
90
+ // The schema lets YouTrack's `4DM` through; Jira and Plane keys start with a letter and match nothing else.
91
+ // A key the schema rejects, such as a lowercase one, is reported there.
92
+ if ((spec.preset === 'jira' || spec.preset === 'plane') && Array.isArray(spec.projects)) {
93
+ const tracker = spec.preset === 'jira' ? 'Jira' : 'Plane';
90
94
  for (const [i, key] of spec.projects.entries()) {
91
- if (typeof key !== 'string' || !/^[A-Z][A-Z0-9_]+$/.test(key)) {
92
- problems.push(`${at}.projects[${i}] must start with a letter and have at least two characters, as Jira keys do (got "${key}")`);
95
+ if (typeof key === 'string' && PROJECT_RE.test(key) && !/^[A-Z][A-Z0-9_]+$/.test(key)) {
96
+ problems.push(`${at}.projects[${i}] must start with a letter and have at least two characters, as ${tracker} keys do (got "${key}")`);
93
97
  }
94
98
  }
95
99
  }
@@ -103,6 +107,25 @@ function ownOptionProblems(spec, at) {
103
107
  if (spec.preset === 'servicenow' && spec.prefixes === undefined) {
104
108
  problems.push(`${at}.prefixes is required by the "servicenow" preset: the record-number prefixes it accepts, such as STRY`);
105
109
  }
110
+ if (spec.preset === 'numeric') {
111
+ const { minLength, maxLength = NUMERIC_MAX_LENGTH } = spec;
112
+ if (typeof minLength === 'number' && typeof maxLength === 'number' && minLength > maxLength) {
113
+ problems.push(spec.maxLength === undefined
114
+ ? `${at}.minLength (${minLength}) is greater than the default maxLength (${maxLength}); set maxLength as well`
115
+ : `${at}.minLength (${minLength}) is greater than maxLength (${maxLength})`);
116
+ }
117
+ }
118
+ if (spec.preset === 'pattern') {
119
+ const { pattern, flags } = spec;
120
+ if (typeof pattern === 'string' && (flags === undefined || typeof flags === 'string')) {
121
+ try {
122
+ ticketPattern(pattern, flags);
123
+ }
124
+ catch (error) {
125
+ problems.push(`${at}.pattern is not a valid regex: ${error.message}`);
126
+ }
127
+ }
128
+ }
106
129
  return problems;
107
130
  }
108
131
  /** A spec the cross-field checks can read: an object whose `preset` names one of the presets. */
@@ -182,14 +205,12 @@ export function compileOptions(options) {
182
205
  const raw = overrides[name];
183
206
  const override = typeof raw === 'boolean' ? { enabled: raw } : (raw ?? {});
184
207
  const enabled = override.enabled ?? (def.lifecycle ? (options.lifecycleTags ?? def.enabled) : def.enabled);
208
+ // Compiled even for a state that is off, so a mistake in its ticket format shows before it is turned on.
209
+ const ticket = override.ticket === undefined ? defaultMatcher : compile(override.ticket);
185
210
  // A state the framework has no way to enter, such as `fixme` in Jest, stays off.
186
211
  if (!enabled || !framework.states.has(name))
187
212
  continue;
188
- const base = {
189
- name,
190
- marker: override.marker ?? def.marker,
191
- ticket: override.ticket === undefined ? defaultMatcher : compile(override.ticket),
192
- };
213
+ const base = { name, marker: override.marker ?? def.marker, ticket };
193
214
  states.push(def.modifier === undefined ? { ...base, tag: override.tag ?? def.tag } : { ...base, modifier: def.modifier });
194
215
  }
195
216
  for (const [name, custom] of Object.entries(options.customStates ?? {})) {
@@ -1,3 +1,5 @@
1
1
  import type { Schema } from './validate.ts';
2
+ /** YouTrack project IDs may also start with a digit, as `4DM` in `4DM-12`; `jira` checks its own in options.ts. */
3
+ export declare const PROJECT_PATTERN = "^(?=[A-Z0-9_]*[A-Z])[A-Z0-9_]+$";
2
4
  /** The shape of `settings['test-governance']`, the options of `configure()`. */
3
5
  export declare const optionsSchema: Schema;
@@ -7,7 +7,7 @@ const KEY_HINT = 'an uppercase key as it appears in tickets, such as WEB for WEB
7
7
  /** Linear teams and the prefixes of Notion, ClickUp and monday.com keys have at least two characters. */
8
8
  const LONG_KEY_HINT = 'an uppercase key of two or more characters as it appears in tickets, such as WEB for WEB-123';
9
9
  /** YouTrack project IDs may also start with a digit, as `4DM` in `4DM-12`; `jira` checks its own in options.ts. */
10
- const PROJECT_PATTERN = '^(?=[A-Z0-9_]*[A-Z])[A-Z0-9_]+$';
10
+ export const PROJECT_PATTERN = '^(?=[A-Z0-9_]*[A-Z])[A-Z0-9_]+$';
11
11
  /** `['"a"', '"b"', '"c"']` as `"a", "b" or "c"`. */
12
12
  const orList = (items) => `${items.slice(0, -1).join(', ')} or ${items.slice(-1).join('')}`;
13
13
  /** How code reads a framework's config: `configs.mocha`, or `configs['node-test']` for a name with a dash. */
@@ -80,4 +80,8 @@ export interface TicketMatcher {
80
80
  example: string;
81
81
  }
82
82
  export declare const DEFAULT_PLACEHOLDERS: string[];
83
+ /** The `numeric` preset's `maxLength` when it isn't set. */
84
+ export declare const NUMERIC_MAX_LENGTH = 20;
85
+ /** The regex a `pattern` spec matches whole tickets with. Throws a SyntaxError when the pattern or flags are invalid. */
86
+ export declare function ticketPattern(pattern: string, flags: string | undefined): RegExp;
83
87
  export declare function compileTicketSpec(specs: TicketSpec[], placeholders: string[]): TicketMatcher;
@@ -1,5 +1,7 @@
1
1
  import { ConfigError } from "./errors.js";
2
2
  export const DEFAULT_PLACEHOLDERS = ['TODO', 'TBD', 'XXX-*', '0'];
3
+ /** The `numeric` preset's `maxLength` when it isn't set. */
4
+ export const NUMERIC_MAX_LENGTH = 20;
3
5
  // Every pattern below is anchored and has no nested or overlapping quantifiers, so matching is
4
6
  // linear in the token length. URLs are parsed with `new URL` rather than a regex.
5
7
  const KEY_RE = /^([A-Z][A-Z0-9_]+)-\d+$/;
@@ -10,11 +12,12 @@ const AZURE_REF_RE = /^AB#\d+$/;
10
12
  const DIGITS_RE = /^\d+$/;
11
13
  // Jira Server and Data Center can run under a path, as in https://issues.example.com/jira/browse/TRADE-1.
12
14
  const JIRA_PATH_RE = /\/browse\/([A-Z][A-Z0-9_]+-\d+)\/?$/;
13
- const GITHUB_PATH_RE = /^\/[^/]+\/[^/]+\/(?:issues|pull)\/(\d+)\/?$/;
15
+ // Gitea, Forgejo and Codeberg write a pull request as `/pulls/N`, GitHub as `/pull/N`.
16
+ const GITHUB_PATH_RE = /^\/[^/]+\/[^/]+\/(?:issues|pulls?)\/(\d+)\/?$/;
14
17
  const GITLAB_PATH_RE = /\/-\/(?:issues|merge_requests|work_items)\/(\d+)\/?$/;
15
- const LINEAR_PATH_RE = /^\/[^/]+\/issue\/([A-Z][A-Z0-9]*-\d+)(?:\/[^/]*)?$/;
18
+ const LINEAR_PATH_RE = /^\/[^/]+\/issue\/([A-Z][A-Z0-9]*-\d+)(?:\/[^/]+)?\/?$/;
16
19
  const AZURE_PATH_RE = /\/_workitems\/edit\/(\d+)\/?$/;
17
- const TRELLO_PATH_RE = /^\/c\/([A-Za-z0-9]{8})(?:\/[^/]*)?$/;
20
+ const TRELLO_PATH_RE = /^\/c\/([A-Za-z0-9]{8})(?:\/[^/]+)?\/?$/;
18
21
  // A Notion page ID is 32 hex digits, plain or dashed as a UUID, at the end of the page's last path segment.
19
22
  const NOTION_ID = '[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
20
23
  const NOTION_PAGE_RE = new RegExp(`(?:^|-)(${NOTION_ID})$`);
@@ -33,10 +36,10 @@ const MONDAY_PATH_RE = /^\/boards\/\d+\/pulses\/(\d+)(?:\/.*)?$/;
33
36
  // YouTrack project IDs may start with a digit (4DM-12) but can't be digits only.
34
37
  const YOUTRACK_KEY_RE = /^([A-Z0-9_]+)-\d+$/;
35
38
  // `/issue/KT-1` and the newer `/projects/KT/issues/KT-1`, with an optional title after the key.
36
- const YOUTRACK_PATH_RE = /\/(?:issue|projects\/[^/]+\/issues)\/([A-Z0-9_]+-\d+)(?:\/[^/]*)?$/;
39
+ const YOUTRACK_PATH_RE = /\/(?:issue|projects\/[^/]+\/issues)\/([A-Z0-9_]+-\d+)(?:\/[^/]+)?\/?$/;
37
40
  const BASECAMP_PATH_RE = /^\/\d+\/buckets\/\d+\/(?:todos|card_tables\/cards)\/(\d+)\/?$/;
38
41
  const SHORTCUT_REF_RE = /^(?:sc-(\d+)|\[sc-(\d+)\])$/i;
39
- const SHORTCUT_PATH_RE = /^\/[^/]+\/story\/(\d+)(?:\/[^/]*)?$/;
42
+ const SHORTCUT_PATH_RE = /^\/[^/]+\/story\/(\d+)(?:\/[^/]+)?\/?$/;
40
43
  // OpenProject work packages: the canonical `/projects/<identifier>/work_packages/<id or AGILE-1>` with any
41
44
  // tab path after it, and the `/wp/<id>` short form its changelog links use.
42
45
  const OPENPROJECT_PATH_RE = /\/projects\/[^/]+\/work_packages\/([A-Z][A-Z0-9_]+-\d+|\d+)(?:\/.*)?\/?$/;
@@ -50,7 +53,7 @@ const PHABRICATOR_PATH_RE = /^\/T(\d+)\/?$/;
50
53
  // Rally numbers its work items per workspace from 1 without padding (US42, US41048), so a zero-padded ID is
51
54
  // no Rally ticket: it is how MITRE ATT&CK writes its technique IDs (TA0001 to TA0043), which security suites
52
55
  // put above tests. An all-zero ID such as TA0000 is a placeholder, as in every preset.
53
- const RALLY_REF_RE = /^(?:US|DE|TA|TC)([1-9]\d*|0*)$/;
56
+ const RALLY_REF_RE = /^(?:US|DE|TA|TC)([1-9]\d*|0+)$/;
54
57
  // Plane work items: `/<workspace>/<project>/issues/<WEB-142 or uuid>`, unanchored for a self-hosted path.
55
58
  const PLANE_PATH_RE = /\/[^/]+\/[^/]+\/issues\/([A-Z][A-Z0-9_]+-\d+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/?$/;
56
59
  // No tracker issues number 0. A preset hands back the ID it finds, and an ID of zeros (0, 000, or a
@@ -61,7 +64,7 @@ const ZERO_ID_RE = /(?:^|[-#/])0+[/\]]?$/;
61
64
  // The number at the end of a link to an unknown tracker, as in `/issues/123`.
62
65
  const LINK_NUMBER_RE = /\/(\d+)\/?$/;
63
66
  function parseUrl(ticket) {
64
- if (!/^https?:\/\//.test(ticket))
67
+ if (!/^https?:\/\//i.test(ticket))
65
68
  return undefined;
66
69
  try {
67
70
  return new URL(ticket);
@@ -107,15 +110,18 @@ function keyId(key, ok) {
107
110
  }
108
111
  /**
109
112
  * A link's ID when the preset doesn't know the tracker: the keys in its path, `#/route` or query (Jira's
110
- * `?selectedIssue=TRADE-1`) and the number its path or route ends in. A zero among them makes the link a
111
- * placeholder, as in 0.9.0; with none, the link itself stands for the ID.
113
+ * `?selectedIssue=TRADE-1`), the number its path or route ends in, and its `id` query (Wrike's
114
+ * `?id=4821`). A zero among them makes the link a placeholder, as in 0.9.0; with none, the link itself
115
+ * stands for the ID.
112
116
  */
113
117
  function linkId(url) {
114
118
  const route = /^#!?\//.test(url.hash) ? url.hash.replace(/^#!?/, '') : '';
115
- const parts = [...url.pathname.split('/'), ...route.split('/'), ...[...url.searchParams.values()].map(trimSlash)];
119
+ const query = [...url.searchParams].map(([key, value]) => [key, trimSlash(value)]);
120
+ const parts = [...url.pathname.split('/'), ...route.split('/'), ...query.map(([, value]) => value)];
116
121
  const keys = parts.filter((part) => KEY_RE.test(part));
117
122
  const ends = [url.pathname, route].flatMap((path) => LINK_NUMBER_RE.exec(path)?.[1] ?? []);
118
- const numbers = [...keys, ...ends].map(numberOf);
123
+ const ids = query.flatMap(([key, value]) => (key.toLowerCase() === 'id' && DIGITS_RE.test(value) ? [value] : []));
124
+ const numbers = [...keys, ...ends, ...ids].map(numberOf);
119
125
  return numbers.find((id) => ZERO_RE.test(id)) ?? numbers.at(0) ?? url.href;
120
126
  }
121
127
  /** `TRADE, RISK or OPS` for `['TRADE', 'RISK', 'OPS']`. */
@@ -126,6 +132,15 @@ function orList(list) {
126
132
  function describeList(words, list) {
127
133
  return list === undefined ? '' : ` ${words} ${orList(list)}`;
128
134
  }
135
+ /** The regex a `pattern` spec matches whole tickets with. Throws a SyntaxError when the pattern or flags are invalid. */
136
+ export function ticketPattern(pattern, flags) {
137
+ // `g` and `y` make `test()` resume from the last match, so the same ticket would pass and fail
138
+ // in turn. A ticket is matched whole, so they never change what matches; drop them.
139
+ const kept = flags?.replace(/[gy]/g, '');
140
+ // Compiled alone first: a stray `)` in it would otherwise close the anchoring group and match anything.
141
+ new RegExp(pattern, kept);
142
+ return new RegExp(`^(?:${pattern})$`, kept);
143
+ }
129
144
  function compilePreset(spec) {
130
145
  switch (spec.preset) {
131
146
  case 'any':
@@ -476,10 +491,7 @@ function compilePreset(spec) {
476
491
  }
477
492
  case 'numeric': {
478
493
  const min = spec.minLength ?? 1;
479
- const max = spec.maxLength ?? 20;
480
- if (min > max) {
481
- throw new ConfigError(`numeric ticket minLength (${min}) is greater than maxLength (${max}).`);
482
- }
494
+ const max = spec.maxLength ?? NUMERIC_MAX_LENGTH;
483
495
  return {
484
496
  find: (t) => (DIGITS_RE.test(t) && t.length >= min && t.length <= max ? t : undefined),
485
497
  expected: min === max ? `a ${min}-digit number` : `a number with ${min} to ${max} digits`,
@@ -493,9 +505,7 @@ function compilePreset(spec) {
493
505
  }
494
506
  let re;
495
507
  try {
496
- // `g` and `y` make `test()` resume from the last match, so the same ticket would pass and fail
497
- // in turn. A ticket is matched whole, so they never change what matches; drop them.
498
- re = new RegExp(`^(?:${spec.pattern})$`, spec.flags?.replace(/[gy]/g, ''));
508
+ re = ticketPattern(spec.pattern, spec.flags);
499
509
  }
500
510
  catch (error) {
501
511
  throw new ConfigError(`invalid ticket pattern "${spec.pattern}": ${error.message}`);
@@ -546,10 +556,11 @@ function matchesGlob(glob, value) {
546
556
  }
547
557
  export function compileTicketSpec(specs, placeholders) {
548
558
  const presets = specs.map(compilePreset);
559
+ const globs = placeholders.map((glob) => glob.replace(/^#/, ''));
549
560
  return {
550
561
  check(ticket) {
551
562
  const bare = ticket.replace(/^#/, '');
552
- if (placeholders.some((glob) => matchesGlob(glob, ticket) || matchesGlob(glob, bare)))
563
+ if (globs.some((glob) => matchesGlob(glob, ticket) || matchesGlob(glob, bare)))
553
564
  return 'placeholder';
554
565
  for (const generic of [false, true]) {
555
566
  const ids = presets.flatMap((preset) => Boolean(preset.generic) === generic ? (preset.find(ticket) ?? []) : []);
@@ -109,7 +109,7 @@ export function validate(value, schema, path = '') {
109
109
  for (const [key, item] of Object.entries(object)) {
110
110
  if (item === undefined)
111
111
  continue;
112
- const propertySchema = properties[key] ?? schema.additionalProperties;
112
+ const propertySchema = Object.hasOwn(properties, key) ? properties[key] : schema.additionalProperties;
113
113
  if (typeof propertySchema === 'object') {
114
114
  problems.push(...validate(item, propertySchema, join(path, key)));
115
115
  }
@@ -11,7 +11,9 @@
11
11
  When a test is promoted from `@new`, or a skipped test is fixed, its marker is often left behind.
12
12
  A leftover marker makes searches for open tickets lie. This rule reports:
13
13
 
14
- - a marker above a test, describe or skip call that is not in that marker's state, and
14
+ - a marker above a test, describe or skip call that is not in that marker's state,
15
+ - a marker in the description of an `eslint-disable` block comment, which covers the tests up to the
16
+ `eslint-enable`, when none of those tests is in that marker's state, and
15
17
  - a marker that is not directly above a test, describe or skip call at all, so it has no effect.
16
18
 
17
19
  `FIXME:` and `TODO:` are also everyday work comments, so this rule leaves them to
@@ -45,7 +47,8 @@ and checked as usual.
45
47
 
46
48
  Files without tests, such as application code, are not checked.
47
49
 
48
- The rule offers a suggestion to delete the comment.
50
+ The rule offers a suggestion to delete the comment. From a block comment that says more, such as a doc
51
+ comment above the test, it deletes only the marker's line.
49
52
 
50
53
  ## Examples
51
54
 
@@ -23,6 +23,8 @@ How comments are read:
23
23
 
24
24
  - A commented-out test is a comment line that starts with a call of a test or describe function of the
25
25
  framework, such as `test(`, `test.describe(`, `it.skip(` or `xit(`, after any `*` of a block comment.
26
+ A call that declares no test, such as `test.use({ ... })`, `test.describe.configure(...)` or an empty
27
+ `test.slow()`, doesn't count.
26
28
  - Comments on consecutive lines, with no code before them on their line, form one block. The block is
27
29
  reported once, at its first test line, and a `SKIP:` marker anywhere in it covers it. A blank line starts a
28
30
  new block.
@@ -114,14 +114,19 @@ If one test or describe has several markers for the same state, each must be val
114
114
  With `requireReasonForConditional: true`, a conditional skip needs a reason but no ticket, so nobody has
115
115
  to guess later why it doesn't run. Any of these counts:
116
116
 
117
- - a comment above it or at the end of its line, above a statement around it, such as the `if`, or above
118
- the hook, step or test it is in. Commented-out code, a marker keyword alone such as `// SKIP:`, and an
119
- `eslint-disable` comment without a description after `--` say nothing, so they don't count;
117
+ - a comment above it or at the end of its line, above a statement around it, such as the `if`, or at the
118
+ end of that statement's first line, as `if (isMobile) { // why`, or above the hook, step or test it is in.
119
+ Commented-out code, a marker keyword alone such as `// SKIP:`, and an `eslint-disable` comment without a
120
+ description after `--` say nothing, so they don't count. The description of an `eslint-disable` block
121
+ comment that turns a rule off for a stretch of the file says why the rule is off, so it doesn't count either;
122
+ - for a test or describe whose function a constant picks, as `const posixTest = isWindows ? test.skip : test`,
123
+ a comment above that declaration or at the end of its line, which says why once for every test that uses it;
120
124
  - the reason its call takes, where the runner has one: Playwright's `test.skip(condition, 'why')` and
121
125
  `testInfo.skip(condition, 'why')`, Vitest's `context.skip(condition, 'why')` or `context.skip('why')`,
122
126
  node:test's `t.skip('why')` and Jasmine's `pending('why')`. A reason held in a name or built in a
123
127
  template is taken on trust;
124
- - in node:test, text in the option, as `{ skip: isWindows && 'No symlinks' }`, which Node reports;
128
+ - in node:test, text or a template in the option, as `{ skip: isWindows && 'No symlinks' }`, which Node
129
+ reports;
125
130
  - a ticket, from a marker or an annotation.
126
131
 
127
132
  The same goes for the other states a condition can set, such as `test.fixme(condition)`. With
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-test-governance",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
4
4
  "description": "ESLint rules that require a ticket for skipped, fixme, todo, retried, tagged and commented-out Playwright, Jest, Vitest, Mocha, Cypress, WebdriverIO, node:test and Jasmine tests.",
5
5
  "keywords": [
6
6
  "eslint",