eslint-plugin-test-governance 0.9.0 → 0.10.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,53 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.10.1] - 2026-10-31
10
+
11
+ ### Fixed
12
+
13
+ - Options that belong to another preset, such as `prefixes` on `trello`, are reported even when a value next
14
+ to them is malformed, so one run shows both the misplaced option and the bad value.
15
+ - A placeholder with several `*` characters, such as `*a*a*a*a*b`, matches in one pass instead of
16
+ backtracking on a long ticket.
17
+ - A details object written as a literal with a spread or a computed key, `{ ...base, tag: '@new' }` or
18
+ `{ ['tag']: '@new' }`, keeps its tags and options; a key or spread that can't be read, such as an object
19
+ imported from another file, is reported with `reportUnreadableTags` instead of being ignored.
20
+ - A tag read from an array element, `TAGS[1]`, or from a constant inside a template, `` `${TAG}` ``, is
21
+ checked like the constant itself.
22
+ - A runtime skip method kept in a local variable, `const { skip } = t`, `const s = testInfo.skip` or
23
+ `const p = pending`, is checked like the method called on the context, in every framework.
24
+ - `node:test`'s `test.describe` and `test.it` are read after `import { test } from 'node:test'`, as they were
25
+ after `require()` or a default import. A state member kept in a variable, `const s = it.skip` or
26
+ `const { skip } = it`, is checked like `it.skip(...)` in every framework. The same holds for a skip called
27
+ on a required module used inline, `require('node:test').test.skip(...)`.
28
+
29
+ ## [0.10.0] - 2026-10-30
30
+
31
+ ### Added
32
+
33
+ - Ticket presets for more trackers: `trello`, `notion`, `asana`, `clickup`, `redmine`, `monday`,
34
+ `youtrack`, `basecamp` and `shortcut`. Each accepts its tracker's ticket keys and links to one ticket,
35
+ and rejects links to boards, projects, views and other sites. `prefixes` limits the keys Notion,
36
+ ClickUp and monday.com accept, and `projects` the YouTrack projects; `host` sets a custom domain for
37
+ Notion, a server for YouTrack, and the server whose links Redmine accepts.
38
+ - A `url` preset for any other tracker: it accepts links to the `hosts` you list, and `*.example.com`
39
+ matches subdomains.
40
+ - The `azure-devops` preset accepts `#123`, which Azure Repos links to work item 123 in commit messages
41
+ and pull requests.
42
+
43
+ ### Fixed
44
+
45
+ - A link whose title ends in `-0`, as in `https://linear.app/acme/issue/ENG-1/upgrade-to-node-0`, was
46
+ reported as a placeholder ticket. Only the ticket's own ID makes it a placeholder now, which also
47
+ catches links where a title, suffix or query follows a zero ID, as in
48
+ `https://linear.app/acme/issue/ENG-0/fix-rounding`.
49
+ - `host` takes `*.example.com` to match every subdomain, as in `{ preset: 'jira', host: '*.atlassian.net' }`.
50
+ Before, such a host matched no link.
51
+ - `projects: []` (Jira) and `teams: []` (Linear) are config errors. An empty list rejected every key, and
52
+ the messages read "in project ,".
53
+ - Links whose host ends in a dot, as in `https://dev.azure.com./acme/web/_workitems/edit/1`, are read like
54
+ the same link without it.
55
+
9
56
  ## [0.9.0] - 2026-10-27
10
57
 
11
58
  ### Added
@@ -369,7 +416,9 @@ First release.
369
416
  - Support for ESLint 9 and 10 flat config, JavaScript and TypeScript test files, `.extend()`
370
417
  fixtures, describe blocks, runtime skips and tags in titles or the `tag` option.
371
418
 
372
- [Unreleased]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.9.0...HEAD
419
+ [Unreleased]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.10.1...HEAD
420
+ [0.10.1]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.10.0...v0.10.1
421
+ [0.10.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.9.0...v0.10.0
373
422
  [0.9.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.8.1...v0.9.0
374
423
  [0.8.1]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.8.0...v0.8.1
375
424
  [0.8.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.7.0...v0.8.0
package/README.md CHANGED
@@ -230,20 +230,20 @@ yourself. `framework` is required, and every other option has a default. The rul
230
230
  of their own: they all read these shared settings, so they always agree on ticket formats, states
231
231
  and markers. To turn a rule off, set it to `'off'` as usual.
232
232
 
233
- | Option | Type | Default | Description |
234
- | ----------------------------- | ----------------------------------------------------------------------------- | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
235
- | `framework` | `'playwright' \| 'jest' \| 'vitest' \| 'mocha' \| 'cypress' \| 'webdriverio'` | required | The test framework of the files the config applies to. Each framework's config, such as `configs.playwright`, sets it, and `configure()` needs it. See [Frameworks](#frameworks). |
236
- | `ticket` | `TicketSpec \| TicketSpec[]` | `{ preset: 'any' }` | Accepted ticket formats. An array accepts a ticket that matches any entry. See [Ticket presets](#ticket-presets). |
237
- | `placeholders` | `string[]` | see above | Tickets rejected as placeholders. `*` matches any characters. Case-insensitive; a leading `#` is ignored. Your list replaces the default one, so repeat the defaults you want to keep. Tickets numbered 0 are always rejected. |
238
- | `lifecycleTags` | `boolean` | `false` | Turns on the `new` (`@new`) and `quarantined` (`@quarantined`) states. |
239
- | `states` | `{ [state]: boolean \| { enabled?, tag?, marker?, ticket? } }` | `skip`, `fixme` and `todo` on | Turns built-in states (`skip`, `fixme`, `fail`, `slow`, `todo`, `new`, `quarantined`) on or off, renames their tag or marker, or gives them their own ticket format. `fail` and `slow` are off by default; a setting for `new` or `quarantined` here wins over `lifecycleTags`. Only `new` and `quarantined` have a tag to rename; see [Tag names](#tag-names). `fixme` and `slow` exist only in Playwright, `todo` in every framework but Playwright, and `fail` in Playwright, Jest and Vitest, so turning one on elsewhere has no effect. |
240
- | `customStates` | `{ [name]: { when, marker, ticket? } }` | `{}` | Adds a state for a tag, e.g. `{ 'needs-data': { when: '@needs-data', marker: 'NEEDS-DATA' } }`. `when` is a tag and `marker` an uppercase keyword; neither can be shared with another state. |
241
- | `requireTicketForConditional` | `boolean` | `false` | Require a ticket for conditional skips too: `test.skip(condition, ...)`, `if (...) test.skip()`, `(condition ? test.skip : test)(...)`, a skip in a `catch` block, after other code in a `try` block with a `catch`, or after an early `return` or `throw`. |
242
- | `allowBlankLine` | `boolean` | `false` | Let blank lines separate a marker from its test. |
243
- | `allowNotes` | `boolean` | `false` | Allow free text after the tickets, e.g. `// SKIP: TRADE-123 flaky on CI`. Applies to markers and to `FIXME` / `TODO` comments. |
244
- | `comments` | `false \| { keywords?: string[] }` | `{ keywords: ['FIXME', 'TODO'] }` | Comment keywords that must start with a ticket anywhere in a test file, e.g. `// TODO: TRADE-123`. Your keywords replace the default ones. `false` allows free-form comments. |
245
- | `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. |
246
- | `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. |
233
+ | Option | Type | Default | Description |
234
+ | ----------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
235
+ | `framework` | `'playwright' \| 'jest' \| 'vitest' \| 'mocha' \| 'cypress' \| 'webdriverio' \| 'node-test' \| 'jasmine'` | required | The test framework of the files the config applies to. Each framework's config, such as `configs.playwright`, sets it, and `configure()` needs it. See [Frameworks](#frameworks). |
236
+ | `ticket` | `TicketSpec \| TicketSpec[]` | `{ preset: 'any' }` | Accepted ticket formats. An array accepts a ticket that matches any entry. See [Ticket presets](#ticket-presets). |
237
+ | `placeholders` | `string[]` | see above | Tickets rejected as placeholders. `*` matches any characters. Case-insensitive; a leading `#` is ignored. Your list replaces the default one, so repeat the defaults you want to keep. Tickets numbered 0 are always rejected. |
238
+ | `lifecycleTags` | `boolean` | `false` | Turns on the `new` (`@new`) and `quarantined` (`@quarantined`) states. |
239
+ | `states` | `{ [state]: boolean \| { enabled?, tag?, marker?, ticket? } }` | `skip`, `fixme` and `todo` on | Turns built-in states (`skip`, `fixme`, `fail`, `slow`, `todo`, `new`, `quarantined`) on or off, renames their tag or marker, or gives them their own ticket format. `fail` and `slow` are off by default; a setting for `new` or `quarantined` here wins over `lifecycleTags`. Only `new` and `quarantined` have a tag to rename; see [Tag names](#tag-names). `fixme` and `slow` exist only in Playwright, `todo` in every framework but Playwright, and `fail` in Playwright, Jest and Vitest, so turning one on elsewhere has no effect. |
240
+ | `customStates` | `{ [name]: { when, marker, ticket? } }` | `{}` | Adds a state for a tag, e.g. `{ 'needs-data': { when: '@needs-data', marker: 'NEEDS-DATA' } }`. `when` is a tag and `marker` an uppercase keyword; neither can be shared with another state. |
241
+ | `requireTicketForConditional` | `boolean` | `false` | Require a ticket for conditional skips too: `test.skip(condition, ...)`, `if (...) test.skip()`, `(condition ? test.skip : test)(...)`, a skip in a `catch` block, after other code in a `try` block with a `catch`, or after an early `return` or `throw`. |
242
+ | `allowBlankLine` | `boolean` | `false` | Let blank lines separate a marker from its test. |
243
+ | `allowNotes` | `boolean` | `false` | Allow free text after the tickets, e.g. `// SKIP: TRADE-123 flaky on CI`. Applies to markers and to `FIXME` / `TODO` comments. |
244
+ | `comments` | `false \| { keywords?: string[] }` | `{ keywords: ['FIXME', 'TODO'] }` | Comment keywords that must start with a ticket anywhere in a test file, e.g. `// TODO: TRADE-123`. Your keywords replace the default ones. `false` allows free-form comments. |
245
+ | `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. |
246
+ | `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. |
247
247
 
248
248
  ### Tag names
249
249
 
@@ -274,21 +274,45 @@ a `customStates` entry.
274
274
 
275
275
  ### Ticket presets
276
276
 
277
- | Preset | Accepts | Options |
278
- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
279
- | `any` | `PROJ-123`, `#123`, `owner/repo#123`, any http(s) URL | |
280
- | `jira` | `TRADE-123`, `https://<host>/browse/TRADE-123` (also under a path, as in `/jira/browse/TRADE-123`), board URLs with `selectedIssue=TRADE-123` | `projects`; `host` (any host by default) |
281
- | `github` | `#123`, `owner/repo#123`, `https://github.com/owner/repo/issues/123` (or `/pull/`) | `host` for GitHub Enterprise (`github.com` by default) |
282
- | `gitlab` | `#123`, `group/project#123`, `https://gitlab.com/group/project/-/issues/123` | `host` for self-managed (`gitlab.com` by default) |
283
- | `linear` | `RISK-123`, `https://linear.app/<workspace>/issue/RISK-123/...` | `teams` |
284
- | `azure-devops` | `AB#123`, `https://dev.azure.com/<org>/<project>/_workitems/edit/123` | `host` for Azure DevOps Server (`dev.azure.com` and `*.visualstudio.com` by default) |
285
- | `numeric` | `4821` | `minLength` (1), `maxLength` (20) |
286
- | `pattern` | anything matching your regex, anchored at both ends | `pattern`, `flags` |
287
-
288
- `projects` (Jira) and `teams` (Linear) are keys as they appear in tickets, in uppercase: `TRADE` for
289
- `TRADE-123`. Each preset takes only its own options, so `host` on the `any` preset fails with a clear
290
- message instead of being ignored. A `pattern` always matches the whole ticket, so the `g` and `y`
291
- flags make no difference and are ignored.
277
+ | Preset | Accepts | Options |
278
+ | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
279
+ | `any` | `PROJ-123`, `#123`, `owner/repo#123`, any http(s) URL | |
280
+ | `jira` | `TRADE-123`, `https://<host>/browse/TRADE-123` (also under a path, as in `/jira/browse/TRADE-123`), board URLs with `selectedIssue=TRADE-123` | `projects`; `host` (any host by default) |
281
+ | `github` | `#123`, `owner/repo#123`, `https://github.com/owner/repo/issues/123` (or `/pull/`) | `host` for GitHub Enterprise (`github.com` by default) |
282
+ | `gitlab` | `#123`, `group/project#123`, `https://gitlab.com/group/project/-/issues/123` | `host` for self-managed (`gitlab.com` by default) |
283
+ | `linear` | `RISK-123`, `https://linear.app/<workspace>/issue/RISK-123/...` | `teams` |
284
+ | `azure-devops` | `AB#123`, `#123`, `https://dev.azure.com/<org>/<project>/_workitems/edit/123` | `host` for Azure DevOps Server (`dev.azure.com` and `*.visualstudio.com` by default) |
285
+ | `trello` | card links, `https://trello.com/c/nqPiDKmw` with or without the `/9-title` after it | |
286
+ | `notion` | Unique IDs such as `TASK-123`, `https://www.notion.so/TASK-123`, page links that end in the page ID (also `https://app.notion.com/p/...`), database links with `?p=<page ID>` | `prefixes`; `host` for a custom domain (`notion.so`, `www.notion.so`, `app.notion.com` and `*.notion.site` by default) |
287
+ | `asana` | task links, `https://app.asana.com/0/<project>/<task>` and the newer `https://app.asana.com/1/<workspace>/.../task/<task>` | |
288
+ | `clickup` | `CU-86abc123`, `#86abc123`, custom IDs such as `DEV-123`, `https://app.clickup.com/t/86abc123` or `/t/<workspace>/DEV-123`, view links with `?p=<task>` | `prefixes` for custom IDs |
289
+ | `redmine` | `#123`, `#123-4` and `#123#note-4` (a note), `##123`; with `host`, `https://<host>/issues/123` (also under a path) | `host`, needed for links |
290
+ | `monday` | item links, `https://<account>.monday.com/boards/<board>/pulses/<item>`, and custom keys such as `TIX-001` | `prefixes` for custom keys |
291
+ | `youtrack` | `KT-123`, `https://<host>/issue/KT-123/...` or `/projects/KT/issues/KT-123` (also under a path, as in `/youtrack/issue/KT-123`) | `projects`; `host` for Server or a custom domain (`youtrack.jetbrains.com`, `*.youtrack.cloud` and `*.myjetbrains.com` by default) |
292
+ | `basecamp` | to-do and card links, `https://3.basecamp.com/<account>/buckets/<project>/todos/<id>` or `/card_tables/cards/<id>` | |
293
+ | `shortcut` | `sc-123`, `[sc-123]`, `https://app.shortcut.com/<workspace>/story/123/...` | |
294
+ | `url` | links to the hosts you list, and nothing else | `hosts`, required: host names such as `tasks.example.com`, with no scheme, port or path; `*.example.com` matches its subdomains |
295
+ | `numeric` | `4821` | `minLength` (1), `maxLength` (20) |
296
+ | `pattern` | anything matching your regex, anchored at both ends | `pattern`, `flags` |
297
+
298
+ `projects` (Jira, YouTrack), `teams` (Linear) and `prefixes` (Notion, ClickUp, monday.com) are keys as
299
+ they appear in tickets, in uppercase: `TRADE` for `TRADE-123`. YouTrack project IDs may also start with a digit, as `4DM`. Without them, any key passes. `host` is a host name such as `jira.example.com`, or
300
+ `*.example.com` for every subdomain, as in `{ preset: 'jira', host: '*.atlassian.net' }`. Each preset
301
+ takes only its own options, so `host` on the `any` preset fails with a clear message instead of being
302
+ ignored. A `pattern` always matches the whole ticket, so the `g` and `y` flags make no difference and are
303
+ ignored.
304
+
305
+ A preset rejects links that aren't one ticket in its tracker, such as a Trello board, an Asana project or
306
+ a Notion database view. Redmine runs on your own server, and `/issues/123` is also GitHub's path, so its
307
+ links pass only once `host` is set.
308
+
309
+ For a tracker without a preset, use `url` with its host, for example
310
+ `{ preset: 'url', hosts: ['www.wrike.com'] }` for Wrike links such as
311
+ `https://www.wrike.com/open.htm?id=4821`. It works the same for Microsoft Planner (`tasks.office.com`),
312
+ Airtable (`airtable.com`), Smartsheet (`app.smartsheet.com`) and trackers of your own. For another
313
+ tracker's keys, such as `WEB-12`, `any` or `pattern` works. Gitea, Forgejo and Codeberg use GitHub's link layout,
314
+ so the `github` preset with their `host` covers them. Bitbucket has no issue tracker since August 2026,
315
+ so it has no preset.
292
316
 
293
317
  Presets combine, and each state can have its own format. For example, bugs in Jira and flaky-test
294
318
  reports in GitHub:
@@ -30,6 +30,12 @@ function memberName(node) {
30
30
  return node.property.value;
31
31
  return undefined;
32
32
  }
33
+ /** The key a member expression reads, including an array index as in `TAGS[1]`. */
34
+ function memberKey(node) {
35
+ if (node.property.type === 'Literal' && typeof node.property.value === 'number')
36
+ return String(node.property.value);
37
+ return memberName(node);
38
+ }
33
39
  /**
34
40
  * The name a callee starts from and the members after it: `['describe', 'skip']` for
35
41
  * `test.describe.skip`. A member called to build the function, as `each` in `test.each(table)` or
@@ -63,7 +69,7 @@ function chainOf(node, factories) {
63
69
  args.set(`${name}()`, current.arguments.at(0));
64
70
  current = factory.object;
65
71
  }
66
- return current.type === 'Identifier' ? { root: current, path, args } : undefined;
72
+ return current.type === 'Identifier' || current.type === 'CallExpression' ? { root: current, path, args } : undefined;
67
73
  }
68
74
  function unwrap(node) {
69
75
  let current = node;
@@ -79,9 +85,11 @@ function isFunction(node) {
79
85
  }
80
86
  /** Collects the top-level names that refer to the framework's test functions or to its module. */
81
87
  function collectTestNames(program, framework, configured) {
88
+ /** The framework's `test` function, which is also its module when `moduleIsDefault`. */
89
+ const testRoot = framework.defaultExport ?? TEST;
82
90
  const roots = new Map(framework.globals);
83
91
  for (const name of configured)
84
- roots.set(name, TEST);
92
+ roots.set(name, testRoot);
85
93
  const runtime = new Map();
86
94
  const modules = new Set();
87
95
  const mergeTests = new Set();
@@ -91,7 +99,23 @@ function collectTestNames(program, framework, configured) {
91
99
  const moduleMember = (node) => node.type === 'MemberExpression' && node.object.type === 'Identifier' && modules.has(node.object.name)
92
100
  ? memberName(node)
93
101
  : undefined;
94
- /** The test function an expression is, such as `base.extend({...})` or `mergeTests(a, b)`. */
102
+ /**
103
+ * What a member of a test function is: `it.skip` is `it` in the skip state, and node:test's
104
+ * `test.describe` is the `describe` export, since its module is also its `test`.
105
+ */
106
+ const memberRoot = (object, name) => {
107
+ if (framework.defaultExport !== undefined && object === framework.defaultExport) {
108
+ const exported = framework.exports.get(name);
109
+ if (exported !== undefined)
110
+ return exported;
111
+ }
112
+ const effect = framework.effects.get(name);
113
+ if (effect !== undefined && effect.when === undefined) {
114
+ return { kind: object.kind, modifiers: [...object.modifiers, effect.modifier] };
115
+ }
116
+ return undefined;
117
+ };
118
+ /** The test function an expression is, such as `base.extend({...})`, `mergeTests(a, b)` or `it.skip`. */
95
119
  const resolve = (node) => {
96
120
  const target = unwrap(node);
97
121
  if (target.type === 'Identifier')
@@ -99,15 +123,20 @@ function collectTestNames(program, framework, configured) {
99
123
  const member = moduleMember(target);
100
124
  if (member !== undefined)
101
125
  return framework.exports.get(member);
126
+ if (target.type === 'MemberExpression') {
127
+ const object = resolve(target.object);
128
+ const name = memberName(target);
129
+ return object === undefined || name === undefined ? undefined : memberRoot(object, name);
130
+ }
102
131
  if (target.type !== 'CallExpression')
103
132
  return undefined;
104
133
  // mergeTests(dbTest, a11yTest) or pw.mergeTests(dbTest, a11yTest)
105
134
  if (target.callee.type === 'Identifier')
106
- return mergeTests.has(target.callee.name) ? TEST : undefined;
135
+ return mergeTests.has(target.callee.name) ? testRoot : undefined;
107
136
  if (target.callee.type !== 'MemberExpression')
108
137
  return undefined;
109
138
  if (moduleMember(target.callee) === 'mergeTests')
110
- return TEST;
139
+ return testRoot;
111
140
  return memberName(target.callee) === 'extend' ? resolve(target.callee.object) : undefined;
112
141
  };
113
142
  // require('node:test'), ava, ava.serial or `ava as TestFn<Context>`
@@ -189,12 +218,18 @@ function collectTestNames(program, framework, configured) {
189
218
  }
190
219
  }
191
220
  else if (declarator.id.type === 'ObjectPattern') {
192
- // Playwright's `const { describe } = test`, used as `describe.skip(...)`.
193
- if (framework.playwright && resolve(init)) {
221
+ // `const { describe } = test` and `const { skip } = it`, used as `describe.skip(...)`.
222
+ const base = resolve(init);
223
+ if (base !== undefined) {
194
224
  for (const prop of declarator.id.properties) {
195
- if (prop.type === 'Property' && prop.value.type === 'Identifier' && propertyName(prop) === 'describe') {
196
- roots.set(prop.value.name, DESCRIBE);
197
- }
225
+ if (prop.type !== 'Property' || prop.value.type !== 'Identifier')
226
+ continue;
227
+ const name = propertyName(prop);
228
+ if (name === undefined)
229
+ continue;
230
+ const root = name === 'describe' && framework.playwright ? DESCRIBE : memberRoot(base, name);
231
+ if (root)
232
+ roots.set(prop.value.name, root);
198
233
  }
199
234
  continue;
200
235
  }
@@ -249,6 +284,15 @@ function declaredWhere(sourceCode, id) {
249
284
  }
250
285
  return 'global';
251
286
  }
287
+ /** The variable a name resolves to, walking out through the enclosing scopes. */
288
+ function declaredVariable(sourceCode, id) {
289
+ for (let scope = sourceCode.getScope(id); scope; scope = scope.upper) {
290
+ const variable = scope.set.get(id.name);
291
+ if (variable)
292
+ return variable;
293
+ }
294
+ return undefined;
295
+ }
252
296
  /**
253
297
  * Whether `id` refers to a variable or function declared inside a function or block, such as a local
254
298
  * helper that happens to be called `test`. Top-level variables, imports, parameters and globals still count.
@@ -434,13 +478,20 @@ function constantValue(sourceCode, node) {
434
478
  return declaredValue(sourceCode, node);
435
479
  if (node.type !== 'MemberExpression')
436
480
  return undefined;
437
- const key = memberName(node);
481
+ const key = memberKey(node);
438
482
  const value = constantValue(sourceCode, unwrap(node.object));
439
483
  const owner = value && unwrap(value);
440
484
  if (key === undefined || owner === undefined)
441
485
  return undefined;
442
486
  if (owner.type === 'ObjectExpression')
443
487
  return propertyValue(owner, key);
488
+ if (owner.type === 'ArrayExpression') {
489
+ const index = Number(key);
490
+ if (!Number.isInteger(index))
491
+ return undefined;
492
+ const element = owner.elements[index];
493
+ return element !== null && element.type !== 'SpreadElement' ? element : undefined;
494
+ }
444
495
  if (owner.type !== 'TSEnumDeclaration')
445
496
  return undefined;
446
497
  const member = owner.body.members.find((m) => (m.id.type === 'Identifier' ? m.id.name : m.id.value) === key);
@@ -455,6 +506,15 @@ function readTexts(sourceCode, node, out, via, path = []) {
455
506
  const target = unwrap(node);
456
507
  if (isStaticText(target)) {
457
508
  out.texts.push({ text: target, via });
509
+ // A template that holds a constant, as in `` `${TAG}` ``, reads the constant's text too.
510
+ if (target.type === 'TemplateLiteral') {
511
+ for (const expression of target.expressions) {
512
+ const value = constantValue(sourceCode, unwrap(expression));
513
+ const resolved = value && unwrap(value);
514
+ if (resolved !== undefined && isStaticText(resolved))
515
+ out.texts.push({ text: resolved, via });
516
+ }
517
+ }
458
518
  return;
459
519
  }
460
520
  if (target.type === 'ArrayExpression') {
@@ -471,6 +531,45 @@ function readTexts(sourceCode, node, out, via, path = []) {
471
531
  else
472
532
  readTexts(sourceCode, value, out, via ?? target, [...path, value]);
473
533
  }
534
+ /**
535
+ * The key of a details property, following a computed string key declared in this file. A computed key that
536
+ * can't be read, as in `{ [key]: '@new' }`, goes in `unread`, since it may be the tag or an option.
537
+ */
538
+ function detailKey(sourceCode, prop, unread) {
539
+ const key = unwrap(prop.key);
540
+ if (!prop.computed) {
541
+ return key.type === 'Identifier' ? key.name : String(key.value);
542
+ }
543
+ if (key.type === 'Literal' && typeof key.value === 'string')
544
+ return key.value;
545
+ if (key.type === 'TemplateLiteral' && key.expressions.length === 0)
546
+ return key.quasis[0]?.value.cooked ?? undefined;
547
+ const value = constantValue(sourceCode, key);
548
+ const resolved = value && unwrap(value);
549
+ if (resolved?.type === 'Literal' && typeof resolved.value === 'string')
550
+ return resolved.value;
551
+ unread.push(prop.key);
552
+ return undefined;
553
+ }
554
+ /**
555
+ * The plain-keyed properties of a details object, following spread objects declared in this file and
556
+ * collecting the ones that can't be read.
557
+ */
558
+ function objectDetails(sourceCode, object, unread) {
559
+ return object.properties.flatMap((prop) => {
560
+ if (prop.type === 'SpreadElement') {
561
+ const argument = unwrap(prop.argument);
562
+ const value = argument.type === 'ObjectExpression' ? argument : constantValue(sourceCode, argument);
563
+ const spread = value && unwrap(value);
564
+ if (spread?.type === 'ObjectExpression')
565
+ return objectDetails(sourceCode, spread, unread);
566
+ unread.push(prop.argument);
567
+ return [];
568
+ }
569
+ const key = detailKey(sourceCode, prop, unread);
570
+ return key === undefined ? [] : [[key, prop.value]];
571
+ });
572
+ }
474
573
  /**
475
574
  * The details or options object of a declaration, the second argument in
476
575
  * `test('title', { tag: '@smoke' }, fn)` or Vitest's `test('title', { skip: true }, fn)`, also when it
@@ -479,7 +578,7 @@ function readTexts(sourceCode, node, out, via, path = []) {
479
578
  function detailsOf(sourceCode, call, optionsFirst) {
480
579
  const details = call.arguments.at(optionsFirst ? 0 : 1);
481
580
  if (details === undefined || isFunction(details))
482
- return { entries: [] };
581
+ return { entries: [], unread: [] };
483
582
  const target = unwrap(details);
484
583
  const value = target.type === 'ObjectExpression' ? target : constantValue(sourceCode, target);
485
584
  const object = value && unwrap(value);
@@ -487,16 +586,11 @@ function detailsOf(sourceCode, call, optionsFirst) {
487
586
  // A name that can't be read is the details object only when a body comes after it:
488
587
  // `test.skip('places an order', placeOrder)` has a body defined elsewhere and no details.
489
588
  return call.arguments.slice(optionsFirst ? 1 : 2).some(isFunction)
490
- ? { entries: [], unread: details }
491
- : { entries: [] };
589
+ ? { entries: [], unread: [details] }
590
+ : { entries: [], unread: [] };
492
591
  }
493
- const entries = object.properties.flatMap((prop) => {
494
- if (prop.type !== 'Property' || prop.computed)
495
- return [];
496
- const key = prop.key.type === 'Identifier' ? prop.key.name : String(prop.key.value);
497
- return [[key, prop.value]];
498
- });
499
- return { entries };
592
+ const unread = [];
593
+ return { entries: objectDetails(sourceCode, object, unread), unread };
500
594
  }
501
595
  const NO_TAGS = { tags: [], dynamic: false, unread: [], opaqueDetails: false };
502
596
  function collectTags(sourceCode, call, hasTitle, framework, details) {
@@ -521,9 +615,9 @@ function collectTags(sourceCode, call, hasTitle, framework, details) {
521
615
  scanTags(source, found, tags);
522
616
  }
523
617
  const unread = [...values.unread];
524
- if (details.unread && framework.tagKey !== undefined)
525
- unread.push(details.unread);
526
- return { tags, dynamic: title.unread.size > 0, unread, opaqueDetails: details.unread !== undefined };
618
+ if (details.unread.length > 0 && framework.tagKey !== undefined)
619
+ unread.push(...details.unread);
620
+ return { tags, dynamic: title.unread.size > 0, unread, opaqueDetails: details.unread.length > 0 };
527
621
  }
528
622
  /** Whether `node` is an object written out or held in a constant declared in this file. */
529
623
  function isObject(sourceCode, node) {
@@ -695,10 +789,20 @@ function runAnalysis(sourceCode, options) {
695
789
  if (chain === undefined)
696
790
  return undefined;
697
791
  const { root, path, args } = chain;
792
+ // `require('node:test').test.skip(...)`: the require call stands for the module, with its exports.
793
+ if (root.type !== 'Identifier') {
794
+ const source = requireSource(root);
795
+ if (source === undefined || !framework.ownsModule(source) || path.length === 0)
796
+ return undefined;
797
+ const exported = framework.exports.get(path[0]);
798
+ return exported === undefined ? undefined : { root: exported, path: path.slice(1), args };
799
+ }
698
800
  let base = testNames.roots.get(root.name);
699
801
  let rest = path;
700
- // `pw.test.skip`, or node:test's `test.describe`, whose module is also its `test`.
701
- const member = testNames.modules.has(root.name) ? framework.exports.get(path[0]) : undefined;
802
+ // `pw.test.skip`, or node:test's `test.describe`, whose module is also its `test` even when
803
+ // `test` was imported by name.
804
+ const moduleLike = testNames.modules.has(root.name) || (framework.defaultExport !== undefined && base === framework.defaultExport);
805
+ const member = moduleLike ? framework.exports.get(path[0]) : undefined;
702
806
  if (member !== undefined || base === undefined) {
703
807
  base = member;
704
808
  rest = path.slice(1);
@@ -989,8 +1093,60 @@ function runAnalysis(sourceCode, options) {
989
1093
  const { callee } = call;
990
1094
  // The innermost parameter with a name is the one the call uses.
991
1095
  const innermost = (name) => infos.findLast((info) => info.name === name);
1096
+ /**
1097
+ * What a name stands for: a context in scope, or a local constant that keeps one of its methods, as
1098
+ * `const skip = testInfo.skip`, `const { skip } = t` or `const p = pending`.
1099
+ */
1100
+ const resolveInfo = (id) => {
1101
+ const direct = innermost(id.name);
1102
+ if (direct !== undefined)
1103
+ return direct;
1104
+ const definition = declaredVariable(sourceCode, id)?.defs.find((def) => def.type === 'Variable');
1105
+ const declarator = definition?.node;
1106
+ if (declarator?.type !== 'VariableDeclarator' || declarator.init === null)
1107
+ return undefined;
1108
+ const init = unwrap(declarator.init);
1109
+ if (declarator.id.type === 'Identifier') {
1110
+ if (init.type === 'MemberExpression') {
1111
+ const name = memberName(init);
1112
+ if (name === undefined)
1113
+ return undefined;
1114
+ if (init.object.type === 'ThisExpression') {
1115
+ const owner = thisFunction(declarator);
1116
+ return owner !== undefined && thisBodies.has(owner) && framework.thisMethods.has(name)
1117
+ ? { name: id.name, method: name }
1118
+ : undefined;
1119
+ }
1120
+ const object = init.object.type === 'Identifier' ? innermost(init.object.name) : undefined;
1121
+ return object?.methods?.has(name) ? { name: id.name, method: name, step: object.step } : undefined;
1122
+ }
1123
+ if (init.type !== 'Identifier')
1124
+ return undefined;
1125
+ const source = innermost(init.name);
1126
+ if (source === undefined)
1127
+ return undefined;
1128
+ const fn = source.runtimeFunction;
1129
+ if (fn && declaredWhere(sourceCode, init) !== (fn.imported ? 'top' : 'global'))
1130
+ return undefined;
1131
+ if (source.method !== undefined)
1132
+ return { name: id.name, method: source.method, step: source.step };
1133
+ return { name: id.name, methods: source.methods, step: source.step };
1134
+ }
1135
+ if (declarator.id.type !== 'ObjectPattern' || init.type !== 'Identifier')
1136
+ return undefined;
1137
+ const source = innermost(init.name);
1138
+ if (source?.methods === undefined)
1139
+ return undefined;
1140
+ const property = declarator.id.properties.find((prop) => prop.type === 'Property' && prop.value.type === 'Identifier' && prop.value.name === id.name);
1141
+ if (property?.type !== 'Property')
1142
+ return undefined;
1143
+ const name = propertyName(property);
1144
+ return name !== undefined && source.methods.has(name)
1145
+ ? { name: id.name, method: name, step: source.step }
1146
+ : undefined;
1147
+ };
992
1148
  if (callee.type === 'Identifier') {
993
- const info = innermost(callee.name);
1149
+ const info = resolveInfo(callee);
994
1150
  if (info?.method === undefined)
995
1151
  return undefined;
996
1152
  // A global such as Jasmine's `pending` counts unless the file declares the name itself; an import of
@@ -1016,7 +1172,7 @@ function runAnalysis(sourceCode, options) {
1016
1172
  return owner !== undefined && thisBodies.has(owner) ? runtimeSubject(call, name) : undefined;
1017
1173
  }
1018
1174
  if (object.type === 'Identifier') {
1019
- const info = innermost(object.name);
1175
+ const info = resolveInfo(object);
1020
1176
  return info?.methods?.has(name) ? runtimeSubject(call, name, info.step) : undefined;
1021
1177
  }
1022
1178
  const isTestInfo = RUNTIME_MODIFIERS.has(name) &&
@@ -2,6 +2,8 @@
2
2
  export declare const MARKER_RE: RegExp;
3
3
  /** A tag as written in options: `@` followed by letters, digits, `-` and `_`. */
4
4
  export declare const TAG_RE: RegExp;
5
+ /** A host with no scheme, port or path: a host name, or `*.` and a domain of two or more labels for its subdomains. */
6
+ export declare const HOST_ENTRY_RE: RegExp;
5
7
  export declare const BUILTIN_STATE_NAMES: readonly ["skip", "fixme", "fail", "slow", "todo", "new", "quarantined"];
6
8
  export type BuiltinStateName = (typeof BUILTIN_STATE_NAMES)[number];
7
9
  /** The built-in states a tag puts a test in, so the only ones whose tag can be renamed. */
@@ -10,6 +12,6 @@ export type TagStateName = (typeof TAG_STATE_NAMES)[number];
10
12
  /** The test frameworks the rules understand. */
11
13
  export declare const FRAMEWORK_NAMES: readonly ["playwright", "jest", "vitest", "mocha", "cypress", "webdriverio", "node-test", "jasmine"];
12
14
  export type FrameworkName = (typeof FRAMEWORK_NAMES)[number];
13
- export declare const TICKET_PRESETS: readonly ["any", "jira", "github", "gitlab", "linear", "azure-devops", "numeric", "pattern"];
15
+ export declare const TICKET_PRESETS: readonly ["any", "jira", "github", "gitlab", "linear", "azure-devops", "trello", "notion", "asana", "clickup", "redmine", "monday", "youtrack", "basecamp", "shortcut", "url", "numeric", "pattern"];
14
16
  /** Comment keywords that must reference a ticket by default (`// TODO: TRADE-123`). */
15
17
  export declare const DEFAULT_COMMENT_KEYWORDS: string[];
@@ -2,6 +2,10 @@
2
2
  export const MARKER_RE = /^[A-Z][A-Z0-9_-]*$/;
3
3
  /** A tag as written in options: `@` followed by letters, digits, `-` and `_`. */
4
4
  export const TAG_RE = /^@[\w-]+$/;
5
+ /** One label of a host name: letters and digits, with hyphens only between them. */
6
+ const HOST_LABEL = '[A-Za-z0-9]+(?:-+[A-Za-z0-9]+)*';
7
+ /** A host with no scheme, port or path: a host name, or `*.` and a domain of two or more labels for its subdomains. */
8
+ export const HOST_ENTRY_RE = new RegExp(`^(?:${HOST_LABEL}(?:\\.${HOST_LABEL})*|\\*\\.${HOST_LABEL}(?:\\.${HOST_LABEL})+)\\.?$`);
5
9
  export const BUILTIN_STATE_NAMES = ['skip', 'fixme', 'fail', 'slow', 'todo', 'new', 'quarantined'];
6
10
  /** The built-in states a tag puts a test in, so the only ones whose tag can be renamed. */
7
11
  export const TAG_STATE_NAMES = ['new', 'quarantined'];
@@ -23,6 +27,16 @@ export const TICKET_PRESETS = [
23
27
  'gitlab',
24
28
  'linear',
25
29
  'azure-devops',
30
+ 'trello',
31
+ 'notion',
32
+ 'asana',
33
+ 'clickup',
34
+ 'redmine',
35
+ 'monday',
36
+ 'youtrack',
37
+ 'basecamp',
38
+ 'shortcut',
39
+ 'url',
26
40
  'numeric',
27
41
  'pattern',
28
42
  ];
@@ -43,8 +43,8 @@ export interface GovernanceOptions {
43
43
  * Turns built-in states on or off, renames their tag or marker, or gives them their own ticket format.
44
44
  * `skip`, `fixme` and `todo` are on by default, `fail` and `slow` off; a setting for `new` or `quarantined`
45
45
  * wins over `lifecycleTags`. Only `new` and `quarantined` have a tag, e.g.
46
- * `{ quarantined: { tag: '@flaky', marker: 'FLAKY' } }`. `fixme` and `slow` exist only in Playwright and
47
- * `todo` only in Jest and Vitest.
46
+ * `{ quarantined: { tag: '@flaky', marker: 'FLAKY' } }`. `fixme` and `slow` exist only in Playwright,
47
+ * `todo` in every framework but Playwright, and `fail` in Playwright, Jest and Vitest.
48
48
  */
49
49
  states?: Partial<Record<BuiltinStateName, boolean | StateOverride>>;
50
50
  /** Adds a state for a tag, e.g. `{ 'needs-data': { when: '@needs-data', marker: 'NEEDS-DATA' } }`. */