eslint-plugin-test-governance 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,29 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.11.0] - 2026-11-01
10
+
11
+ ### Added
12
+
13
+ - Ticket presets for six more trackers: `openproject` (classic `#4821` work package IDs, per-project IDs
14
+ such as `AGILE-1`, and work package links once `host` is set), `bugzilla` (bug numbers, and
15
+ `show_bug.cgi?id=4821` links once `host` is set), `phabricator` (task monograms such as `T4821`, and
16
+ `/T4821` links once `host` is set; Phorge shares the format), `rally` (formatted IDs `US4821`, `DE4821`,
17
+ `TA4821`, `TC4821`), `servicenow` (record numbers such as `STRY0010001` for the `prefixes` you list) and
18
+ `plane` (work-item IDs such as `WEB-142`, and work-item links on `app.plane.so` or a `host`). The
19
+ `rally` preset rejects zero-padded IDs, which is how MITRE ATT&CK writes its technique IDs (`TA0006`).
20
+ - Tickets from test annotations: with `annotations` (default `{ type: 'issue' }`), a valid ticket in
21
+ Playwright's `annotation` of that type or Vitest's `meta` key of that name satisfies the ticket
22
+ requirement, on tests and describes; an invalid one is reported, and `annotations: false` turns it
23
+ off. Comments stay the form that works everywhere; Jest, Mocha, Jasmine, node:test and Cypress have
24
+ no annotations.
25
+
26
+ ### Changed
27
+
28
+ - A `host` value with a scheme, port or path, such as `https://jira.example.com` or `tracker.io:8080`, is
29
+ now a configuration error on every preset that takes `host`, not only on `notion`, `redmine` and
30
+ `youtrack`. Such a value never matched a URL hostname, so it already rejected every link silently.
31
+
9
32
  ## [0.10.1] - 2026-10-31
10
33
 
11
34
  ### Fixed
@@ -416,7 +439,8 @@ First release.
416
439
  - Support for ESLint 9 and 10 flat config, JavaScript and TypeScript test files, `.extend()`
417
440
  fixtures, describe blocks, runtime skips and tags in titles or the `tag` option.
418
441
 
419
- [Unreleased]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.10.1...HEAD
442
+ [Unreleased]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.11.0...HEAD
443
+ [0.11.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.10.1...v0.11.0
420
444
  [0.10.1]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.10.0...v0.10.1
421
445
  [0.10.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.9.0...v0.10.0
422
446
  [0.9.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.8.1...v0.9.0
package/README.md CHANGED
@@ -242,6 +242,7 @@ and markers. To turn a rule off, set it to `'off'` as usual.
242
242
  | `allowBlankLine` | `boolean` | `false` | Let blank lines separate a marker from its test. |
243
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
244
  | `comments` | `false \| { keywords?: string[] }` | `{ keywords: ['FIXME', 'TODO'] }` | Comment keywords that must start with a ticket anywhere in a test file, e.g. `// TODO: TRADE-123`. Your keywords replace the default ones. `false` allows free-form comments. |
245
+ | `annotations` | `false \| { type?: string }` | `{ type: 'issue' }` | Accept the ticket from a test annotation as well: Playwright's `annotation` of the given type or Vitest's `meta` key of that name. One valid annotation covers every state the test is in, and a describe's covers its tests. See [Tickets from annotations](#tickets-from-annotations). |
245
246
  | `testFunctions` | `string[]` | `['test']` | Names treated as the framework's `test`. Jest and Vitest default to `['test', 'it']`, Mocha, Cypress and WebdriverIO to `['it', 'specify', 'test']`, and Jasmine to `['it']`. Your list replaces the default one, so include those names if you still need them. See below. |
246
247
  | `reportUnreadableTags` | `boolean` | `false` | When a tag state is on, report titles that aren't static text, and tags or details that can't be read in the file, such as ones imported from another module, since their tags can't be checked. |
247
248
 
@@ -274,37 +275,48 @@ a `customStates` entry.
274
275
 
275
276
  ### Ticket presets
276
277
 
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
278
+ | Preset | Accepts | Options |
279
+ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
280
+ | `any` | `PROJ-123`, `#123`, `owner/repo#123`, any http(s) URL | |
281
+ | `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) |
282
+ | `github` | `#123`, `owner/repo#123`, `https://github.com/owner/repo/issues/123` (or `/pull/`) | `host` for GitHub Enterprise (`github.com` by default) |
283
+ | `gitlab` | `#123`, `group/project#123`, `https://gitlab.com/group/project/-/issues/123` | `host` for self-managed (`gitlab.com` by default) |
284
+ | `linear` | `RISK-123`, `https://linear.app/<workspace>/issue/RISK-123/...` | `teams` |
285
+ | `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) |
286
+ | `trello` | card links, `https://trello.com/c/nqPiDKmw` with or without the `/9-title` after it | |
287
+ | `notion` | Unique IDs such as `TASK-123`, `https://www.notion.so/TASK-123`, page links that end in the page ID (also `https://app.notion.com/p/...`), database links with `?p=<page ID>` | `prefixes`; `host` for a custom domain (`notion.so`, `www.notion.so`, `app.notion.com` and `*.notion.site` by default) |
288
+ | `asana` | task links, `https://app.asana.com/0/<project>/<task>` and the newer `https://app.asana.com/1/<workspace>/.../task/<task>` | |
289
+ | `clickup` | `CU-86abc123`, `#86abc123`, custom IDs such as `DEV-123`, `https://app.clickup.com/t/86abc123` or `/t/<workspace>/DEV-123`, view links with `?p=<task>` | `prefixes` for custom IDs |
290
+ | `redmine` | `#123`, `#123-4` and `#123#note-4` (a note), `##123`; with `host`, `https://<host>/issues/123` (also under a path) | `host`, needed for links |
291
+ | `monday` | item links, `https://<account>.monday.com/boards/<board>/pulses/<item>`, and custom keys such as `TIX-001` | `prefixes` for custom keys |
292
+ | `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) |
293
+ | `basecamp` | to-do and card links, `https://3.basecamp.com/<account>/buckets/<project>/todos/<id>` or `/card_tables/cards/<id>` | |
294
+ | `shortcut` | `sc-123`, `[sc-123]`, `https://app.shortcut.com/<workspace>/story/123/...` | |
295
+ | `openproject` | `#4821` and per-project IDs such as `AGILE-1`; with `host`, `https://<host>/projects/<identifier>/work_packages/AGILE-1` (also `/work_packages/4821` and the short `/wp/4821`) | `host`, needed for links |
296
+ | `bugzilla` | bug numbers `4821`; with `host`, `https://<host>/show_bug.cgi?id=4821`, also under a path as in `/bugzilla/show_bug.cgi` | `host`, needed for links |
297
+ | `phabricator` | task monograms `T4821`; with `host`, `https://<host>/T4821`. Phorge, Phabricator's maintained fork, shares the format | `host`, needed for links |
298
+ | `rally` | formatted IDs `US4821`, `DE4821`, `TA4821`, `TC4821`, never zero-padded (so MITRE ATT&CK's `TA0006` isn't one) | |
299
+ | `servicenow` | record numbers `STRY0010001` for the prefixes you list, any digit count | `prefixes`, required: record-number prefixes such as `STRY` |
300
+ | `plane` | `WEB-142`; `https://app.plane.so/<workspace>/<project>/issues/WEB-142` (or a UUID), and the same on a `host` for self-hosted | `projects`; `host` for self-hosted (`app.plane.so` by default) |
301
+ | `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 |
302
+ | `numeric` | `4821` | `minLength` (1), `maxLength` (20) |
303
+ | `pattern` | anything matching your regex, anchored at both ends | `pattern`, `flags` |
304
+
305
+ `projects` (Jira, YouTrack, Plane), `teams` (Linear) and `prefixes` (Notion, ClickUp, monday.com,
306
+ ServiceNow) are keys as they appear in tickets, in uppercase: `TRADE` for `TRADE-123`, `STRY` for
307
+ `STRY0010001`. YouTrack project IDs may also start with a digit, as `4DM`. Without them, any key passes;
308
+ ServiceNow's list is required. `host` is a host name such as `jira.example.com`, or `*.example.com` for
309
+ every subdomain, as in `{ preset: 'jira', host: '*.atlassian.net' }`. Every preset that takes `host`
310
+ requires a plain host name: a value with a scheme, port or path never matched a link, so it is an error.
311
+ Each preset
301
312
  takes only its own options, so `host` on the `any` preset fails with a clear message instead of being
302
313
  ignored. A `pattern` always matches the whole ticket, so the `g` and `y` flags make no difference and are
303
314
  ignored.
304
315
 
305
316
  A preset rejects links that aren't one ticket in its tracker, such as a Trello board, an Asana project or
306
317
  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.
318
+ links pass only once `host` is set. The same holds for OpenProject, Bugzilla and Phabricator, which also
319
+ run on their own servers.
308
320
 
309
321
  For a tracker without a preset, use `url` with its host, for example
310
322
  `{ preset: 'url', hosts: ['www.wrike.com'] }` for Wrike links such as
@@ -329,6 +341,34 @@ testGovernance.configure({
329
341
  The built-in presets are written to run in linear time. A `pattern` preset runs your regex as given,
330
342
  so keep it free of nested quantifiers.
331
343
 
344
+ ### Tickets from annotations
345
+
346
+ A comment never reaches a test report. Playwright and Vitest can carry the ticket in data that does, so
347
+ `annotations` accepts it from there as well:
348
+
349
+ ```js
350
+ test.skip(
351
+ 'rejects a margin order above the buying power',
352
+ {
353
+ annotation: { type: 'issue', description: 'TRADE-123' },
354
+ },
355
+ async () => {},
356
+ );
357
+ ```
358
+
359
+ ```js
360
+ // Vitest: the ticket is the `issue` key of `meta`.
361
+ test.skip('rejects a margin order above the buying power', { meta: { issue: 'TRADE-123' } }, () => {});
362
+ ```
363
+
364
+ The `type` names Playwright's annotation type and Vitest's `meta` key; `'issue'` by default, matched
365
+ exactly. The value must be the ticket and nothing else, as in a marker. One valid annotation covers every
366
+ state the test is in, and a describe's annotation covers its tests. An annotation of another type is
367
+ left alone, and an annotation whose ticket isn't static text is reported with `reportUnreadableTags`.
368
+ `annotations: false` turns it off. A marker comment still works everywhere, and when both are written,
369
+ both must be valid. Jest, Mocha, Jasmine, node:test and Cypress have no annotations, so for them it is
370
+ comments only.
371
+
332
372
  ### Which functions count as `test`
333
373
 
334
374
  In Playwright files:
@@ -21,7 +21,7 @@ export default createRule({
21
21
  create(context, analysis, options) {
22
22
  const { sourceCode } = context;
23
23
  for (const subject of analysis.subjects) {
24
- const missing = missingStates(subject);
24
+ const missing = missingStates(subject, options);
25
25
  if (missing.length === 0)
26
26
  continue;
27
27
  const stray = strayMarkers(subject, options);
@@ -31,7 +31,7 @@ export default createRule({
31
31
  }
32
32
  for (const subject of analysis.subjects) {
33
33
  // A stray marker next to a missing one is a mismatch, reported by marker-matches-state.
34
- if (missingStates(subject).length > 0)
34
+ if (missingStates(subject, options).length > 0)
35
35
  continue;
36
36
  const name = describeSubject(subject).replace(/^This/, 'this');
37
37
  for (const marker of strayMarkers(subject, options)) {
@@ -1,2 +1,2 @@
1
- declare const _default: import("../utils/create-rule.ts").Rule<"missingTicket" | "invalidTicket" | "placeholderTicket" | "extraText" | "removeExtraText" | "missingMarker" | "markerCase" | "dynamicTitle" | "dynamicTags">;
1
+ declare const _default: import("../utils/create-rule.ts").Rule<"missingTicket" | "invalidTicket" | "placeholderTicket" | "extraText" | "removeExtraText" | "missingMarker" | "markerCase" | "dynamicTitle" | "dynamicTags" | "invalidAnnotation" | "unreadAnnotation">;
2
2
  export default _default;
@@ -1,4 +1,4 @@
1
- import { appliesTo, describeSubject, evaluate, headLoc } from "../utils/analyze.js";
1
+ import { annotationTicketsOf, appliesTo, describeSubject, evaluate, hasAnnotationTicket, headLoc, unreadAnnotationsOf, } from "../utils/analyze.js";
2
2
  import { createRule } from "../utils/create-rule.js";
3
3
  export default createRule({
4
4
  meta: {
@@ -20,6 +20,8 @@ export default createRule({
20
20
  removeExtraText: 'Remove `{{text}}`.',
21
21
  dynamicTitle: "This title isn't static text, so its tags can't be checked. Use a string or template literal.",
22
22
  dynamicTags: "These tags can't be read in this file, so they can't be checked. Use strings, or constants declared in this file.",
23
+ invalidAnnotation: "'{{ticket}}' is not a valid ticket for the '{{state}}' state. Expected {{expected}}.",
24
+ unreadAnnotation: "This annotation's ticket isn't static text, so it can't be checked. Use a string, or a constant declared in this file.",
23
25
  },
24
26
  },
25
27
  create(context, analysis, options) {
@@ -28,16 +30,37 @@ export default createRule({
28
30
  const reported = new Set();
29
31
  const reportOnce = (key) => !reported.has(key) && Boolean(reported.add(key));
30
32
  for (const subject of analysis.subjects) {
33
+ const needsTicket = subject.states.some((s) => s.required);
31
34
  if (options.reportUnreadableTags && hasTagStates) {
32
35
  if (subject.dynamicTitle)
33
36
  context.report({ node: subject.node.arguments[0], messageId: 'dynamicTitle' });
34
37
  for (const node of subject.unreadTags)
35
38
  context.report({ node, messageId: 'dynamicTags' });
36
39
  }
40
+ // An annotation whose ticket can't be read may be the one that was supposed to cover the test.
41
+ if (options.reportUnreadableTags && needsTicket) {
42
+ for (const node of unreadAnnotationsOf(subject)) {
43
+ if (reportOnce(`annotation-unread:${node.range[0]}`)) {
44
+ context.report({ node, messageId: 'unreadAnnotation' });
45
+ }
46
+ }
47
+ }
37
48
  for (const { state, required } of subject.states) {
38
49
  if (!required)
39
50
  continue;
40
51
  const result = evaluate(subject, state);
52
+ // Annotations are checked whenever a ticket is needed, whatever the markers say.
53
+ for (const ticket of annotationTicketsOf(subject)) {
54
+ if (state.ticket.check(ticket.text) !== 'ok' && reportOnce(`annotation:${ticket.node.range[0]}`)) {
55
+ context.report({
56
+ node: ticket.node,
57
+ messageId: 'invalidAnnotation',
58
+ data: { ticket: ticket.text, state: state.name, expected: state.ticket.expected },
59
+ });
60
+ }
61
+ }
62
+ if (result.kind === 'missing' && hasAnnotationTicket(subject, state, options))
63
+ continue;
41
64
  if (result.kind !== 'ok' &&
42
65
  result.kind !== 'missing' &&
43
66
  !reportOnce(`${result.marker.comment.range[0]}:${state.name}`)) {
@@ -72,6 +72,13 @@ export interface Subject {
72
72
  skipped: boolean;
73
73
  ownSkip: boolean;
74
74
  markers: Marker[];
75
+ /** Tickets read from annotations of the configured type on this call, with the node to report at. */
76
+ annotationTickets: {
77
+ text: string;
78
+ node: Node;
79
+ }[];
80
+ /** Annotation values of the configured type on this call whose ticket can't be read. */
81
+ unreadTickets: Node[];
75
82
  dynamicTitle: boolean;
76
83
  /** For a runtime function such as Jasmine's `pending()`, its name; messages call the others by their state. */
77
84
  functionName?: string;
@@ -112,8 +119,20 @@ export declare function analyze(sourceCode: SourceCode, options: ResolvedOptions
112
119
  /** Whether `subject` is in `stateName` at all, required or not, including inherited and runtime states. */
113
120
  export declare function appliesTo(subject: Subject, stateName: string): boolean;
114
121
  export declare function evaluate(subject: Subject, state: StateDef): Evaluation;
115
- /** Required states on `subject` that have no marker at all (not even a broken one). */
116
- export declare function missingStates(subject: Subject): StateDef[];
122
+ /** Annotation tickets that can cover `subject`: its own and those of enclosing describes and tests. */
123
+ export declare function annotationTicketsOf(subject: Subject): {
124
+ text: string;
125
+ node: Node;
126
+ }[];
127
+ /** Annotation values whose ticket can't be read, `subject`'s own and those of enclosing describes and tests. */
128
+ export declare function unreadAnnotationsOf(subject: Subject): Node[];
129
+ /** Whether a valid annotation ticket covers `state` for `subject`. */
130
+ export declare function hasAnnotationTicket(subject: Subject, state: StateDef, options: ResolvedOptions): boolean;
131
+ /**
132
+ * Required states on `subject` that have no ticket at all: no marker, not even a broken one, and no
133
+ * valid annotation.
134
+ */
135
+ export declare function missingStates(subject: Subject, options: ResolvedOptions): StateDef[];
117
136
  /**
118
137
  * Whether a marker reads as an ordinary comment rather than a ticket reference, such as
119
138
  * `// FIXME: this breaks on slow machines`: it starts with something that is not a ticket and goes on
@@ -592,7 +592,128 @@ function detailsOf(sourceCode, call, optionsFirst) {
592
592
  const unread = [];
593
593
  return { entries: objectDetails(sourceCode, object, unread), unread };
594
594
  }
595
- const NO_TAGS = { tags: [], dynamic: false, unread: [], opaqueDetails: false };
595
+ /** The static text a value gives: a string literal, a template with no expressions, or one of those held in a constant declared in this file. */
596
+ function staticText(sourceCode, node) {
597
+ const read = (value) => {
598
+ if (value.type === 'Literal')
599
+ return typeof value.value === 'string' ? value.value : undefined;
600
+ if (value.type !== 'TemplateLiteral' || value.expressions.length !== 0)
601
+ return undefined;
602
+ // `cooked` is only null in tagged templates, which no annotation value is.
603
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- see above
604
+ return value.quasis[0].value.cooked;
605
+ };
606
+ const target = unwrap(node);
607
+ const own = read(target);
608
+ if (own !== undefined)
609
+ return own;
610
+ const constant = constantValue(sourceCode, target);
611
+ return constant === undefined ? undefined : read(unwrap(constant));
612
+ }
613
+ /**
614
+ * The object or array a details value is, following a constant declared in this file: a carrier for
615
+ * annotations. Undefined for a value that plainly can't hold one, `'unread'` for one that can't be read.
616
+ */
617
+ function annotationCarrier(sourceCode, node) {
618
+ const target = unwrap(node);
619
+ if (target.type === 'ObjectExpression' || target.type === 'ArrayExpression')
620
+ return target;
621
+ const constant = constantValue(sourceCode, target);
622
+ const resolved = constant === undefined ? undefined : unwrap(constant);
623
+ if (resolved?.type === 'ObjectExpression' || resolved?.type === 'ArrayExpression')
624
+ return resolved;
625
+ // A scalar that can be read holds no annotation; a value that can't be read might.
626
+ return target.type === 'Literal' || resolved !== undefined ? undefined : 'unread';
627
+ }
628
+ /**
629
+ * Tickets from annotations of the configured type in a declaration's details: Playwright's `annotation`
630
+ * option, whose value is one `{ type, description }` or an array of them, and Vitest's `meta` object,
631
+ * with the ticket under the key named by the type. Only literal text is read; a value of the right
632
+ * type whose ticket can't be read is returned as unread, since it may carry the ticket.
633
+ */
634
+ function collectAnnotations(sourceCode, details, source, annotations) {
635
+ const tickets = [];
636
+ const unread = [];
637
+ const readPlaywright = (object) => {
638
+ const unreadKeys = [];
639
+ const entries = objectDetails(sourceCode, object, unreadKeys);
640
+ const type = entries.find(([key]) => key === 'type');
641
+ if (type === undefined)
642
+ return;
643
+ const written = staticText(sourceCode, type[1]);
644
+ if (written === undefined) {
645
+ // The type might be the configured one, so the whole annotation may carry the ticket.
646
+ unread.push(type[1], ...unreadKeys);
647
+ return;
648
+ }
649
+ if (written !== annotations.type)
650
+ return;
651
+ const description = entries.find(([key]) => key === 'description');
652
+ if (description === undefined) {
653
+ // A key that can't be read may be the description.
654
+ unread.push(...unreadKeys);
655
+ return;
656
+ }
657
+ const text = staticText(sourceCode, description[1]);
658
+ if (text === undefined) {
659
+ unread.push(...unreadKeys, description[1]);
660
+ return;
661
+ }
662
+ tickets.push({ text, node: description[1] });
663
+ };
664
+ const readValue = (value) => {
665
+ const carrier = annotationCarrier(sourceCode, value);
666
+ if (carrier === 'unread') {
667
+ unread.push(value);
668
+ return;
669
+ }
670
+ if (carrier === undefined)
671
+ return;
672
+ if (source.shape === 'meta') {
673
+ if (carrier.type === 'ObjectExpression') {
674
+ const unreadKeys = [];
675
+ const entries = objectDetails(sourceCode, carrier, unreadKeys);
676
+ const entry = entries.find(([key]) => key === annotations.type);
677
+ if (entry === undefined)
678
+ unread.push(...unreadKeys);
679
+ else {
680
+ const text = staticText(sourceCode, entry[1]);
681
+ if (text !== undefined)
682
+ tickets.push({ text, node: entry[1] });
683
+ else
684
+ unread.push(...unreadKeys, entry[1]);
685
+ }
686
+ }
687
+ return;
688
+ }
689
+ if (carrier.type === 'ObjectExpression')
690
+ readPlaywright(carrier);
691
+ else {
692
+ for (const element of carrier.elements) {
693
+ if (element === null || element.type === 'SpreadElement')
694
+ continue;
695
+ const inner = annotationCarrier(sourceCode, element);
696
+ if (inner === 'unread')
697
+ unread.push(element);
698
+ else if (inner?.type === 'ObjectExpression')
699
+ readPlaywright(inner);
700
+ }
701
+ }
702
+ };
703
+ for (const [key, value] of details.entries) {
704
+ if (key === source.key)
705
+ readValue(value);
706
+ }
707
+ return { annotations: tickets, unreadAnnotations: unread };
708
+ }
709
+ const NO_TAGS = {
710
+ tags: [],
711
+ dynamic: false,
712
+ unread: [],
713
+ opaqueDetails: false,
714
+ annotations: [],
715
+ unreadAnnotations: [],
716
+ };
596
717
  function collectTags(sourceCode, call, hasTitle, framework, details) {
597
718
  if (!hasTitle)
598
719
  return NO_TAGS;
@@ -617,7 +738,15 @@ function collectTags(sourceCode, call, hasTitle, framework, details) {
617
738
  const unread = [...values.unread];
618
739
  if (details.unread.length > 0 && framework.tagKey !== undefined)
619
740
  unread.push(...details.unread);
620
- return { tags, dynamic: title.unread.size > 0, unread, opaqueDetails: details.unread.length > 0 };
741
+ // headOf replaces these with the annotations it reads, since collectTags has no options.
742
+ return {
743
+ tags,
744
+ dynamic: title.unread.size > 0,
745
+ unread,
746
+ opaqueDetails: details.unread.length > 0,
747
+ annotations: [],
748
+ unreadAnnotations: [],
749
+ };
621
750
  }
622
751
  /** Whether `node` is an object written out or held in a constant declared in this file. */
623
752
  function isObject(sourceCode, node) {
@@ -885,6 +1014,8 @@ function runAnalysis(sourceCode, options) {
885
1014
  skipped: ownSkip || (kind !== 'runtime' && parent?.skipped === true),
886
1015
  ownSkip,
887
1016
  markers: block.flatMap((comment) => parseMarkers(comment, states)),
1017
+ annotationTickets: tagInfo.annotations,
1018
+ unreadTickets: tagInfo.unreadAnnotations,
888
1019
  dynamicTitle: tagInfo.dynamic,
889
1020
  };
890
1021
  subjects.push(subject);
@@ -1019,12 +1150,18 @@ function runAnalysis(sourceCode, options) {
1019
1150
  const infos = yes.kind === 'test' ? bodyInfos(args, yes.members) : [];
1020
1151
  return { subject: makeSubject(yes.kind, call, modifiers, tagInfo), infos };
1021
1152
  };
1022
- /** The details and tags of a declaration; node:test lets it leave out its title. */
1153
+ /** The details, tags and annotations of a declaration; node:test lets it leave out its title. */
1023
1154
  const headOf = (call) => {
1024
1155
  const args = call.arguments;
1025
1156
  const untitled = leavesOutTitle(sourceCode, framework, args);
1026
1157
  const details = detailsOf(sourceCode, call, untitled && !isFunction(args[0]));
1027
- return { details, tagInfo: collectTags(sourceCode, call, !untitled, framework, details) };
1158
+ const annotations = options.annotations === undefined || framework.annotations === undefined
1159
+ ? { annotations: [], unreadAnnotations: [] }
1160
+ : collectAnnotations(sourceCode, details, framework.annotations, options.annotations);
1161
+ return {
1162
+ details,
1163
+ tagInfo: { ...collectTags(sourceCode, call, !untitled, framework, details), ...annotations },
1164
+ };
1028
1165
  };
1029
1166
  const declare = (call, { kind, members, args: conditions }, infos) => {
1030
1167
  const args = call.arguments;
@@ -1262,9 +1399,40 @@ export function evaluate(subject, state) {
1262
1399
  const bad = marker.tickets.find((t) => t.result !== 'ok');
1263
1400
  return bad ? { kind: 'bad-ticket', marker, ticket: bad } : { kind: 'no-ticket', marker };
1264
1401
  }
1265
- /** Required states on `subject` that have no marker at all (not even a broken one). */
1266
- export function missingStates(subject) {
1267
- return subject.states.filter((s) => s.required && evaluate(subject, s.state).kind === 'missing').map((s) => s.state);
1402
+ /** Annotation tickets that can cover `subject`: its own and those of enclosing describes and tests. */
1403
+ export function annotationTicketsOf(subject) {
1404
+ const found = [...subject.annotationTickets];
1405
+ let current = subject.parent;
1406
+ while (current) {
1407
+ found.push(...current.annotationTickets);
1408
+ current = current.parent;
1409
+ }
1410
+ return found;
1411
+ }
1412
+ /** Annotation values whose ticket can't be read, `subject`'s own and those of enclosing describes and tests. */
1413
+ export function unreadAnnotationsOf(subject) {
1414
+ const found = [...subject.unreadTickets];
1415
+ let current = subject.parent;
1416
+ while (current) {
1417
+ found.push(...current.unreadTickets);
1418
+ current = current.parent;
1419
+ }
1420
+ return found;
1421
+ }
1422
+ /** Whether a valid annotation ticket covers `state` for `subject`. */
1423
+ export function hasAnnotationTicket(subject, state, options) {
1424
+ if (options.annotations === undefined)
1425
+ return false;
1426
+ return annotationTicketsOf(subject).some((ticket) => state.ticket.check(ticket.text) === 'ok');
1427
+ }
1428
+ /**
1429
+ * Required states on `subject` that have no ticket at all: no marker, not even a broken one, and no
1430
+ * valid annotation.
1431
+ */
1432
+ export function missingStates(subject, options) {
1433
+ return subject.states
1434
+ .filter((s) => s.required && evaluate(subject, s.state).kind === 'missing' && !hasAnnotationTicket(subject, s.state, options))
1435
+ .map((s) => s.state);
1268
1436
  }
1269
1437
  /**
1270
1438
  * Whether a marker reads as an ordinary comment rather than a ticket reference, such as
@@ -12,6 +12,6 @@ export type TagStateName = (typeof TAG_STATE_NAMES)[number];
12
12
  /** The test frameworks the rules understand. */
13
13
  export declare const FRAMEWORK_NAMES: readonly ["playwright", "jest", "vitest", "mocha", "cypress", "webdriverio", "node-test", "jasmine"];
14
14
  export type FrameworkName = (typeof FRAMEWORK_NAMES)[number];
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"];
15
+ export declare const TICKET_PRESETS: readonly ["any", "jira", "github", "gitlab", "linear", "azure-devops", "trello", "notion", "asana", "clickup", "redmine", "monday", "youtrack", "basecamp", "shortcut", "openproject", "bugzilla", "phabricator", "rally", "servicenow", "plane", "url", "numeric", "pattern"];
16
16
  /** Comment keywords that must reference a ticket by default (`// TODO: TRADE-123`). */
17
17
  export declare const DEFAULT_COMMENT_KEYWORDS: string[];
@@ -36,6 +36,12 @@ export const TICKET_PRESETS = [
36
36
  'youtrack',
37
37
  'basecamp',
38
38
  'shortcut',
39
+ 'openproject',
40
+ 'bugzilla',
41
+ 'phabricator',
42
+ 'rally',
43
+ 'servicenow',
44
+ 'plane',
39
45
  'url',
40
46
  'numeric',
41
47
  'pattern',
@@ -69,6 +69,15 @@ export interface Framework {
69
69
  tagKey?: string;
70
70
  /** Whether that key holds names without their `@`, as Vitest's `tags: ['flaky']`, which stand for `@flaky`. */
71
71
  plainTags: boolean;
72
+ /**
73
+ * How a ticket is read from an annotation: the details key it sits under and how the ticket is reached
74
+ * in its value, as Playwright's `annotation` or Vitest's `meta`. Unset for frameworks without
75
+ * annotations.
76
+ */
77
+ annotations?: {
78
+ key: string;
79
+ shape: 'annotation' | 'meta';
80
+ };
72
81
  /**
73
82
  * The parameter of a test's body that can change its state at runtime, such as Playwright's
74
83
  * `testInfo` or Vitest's test context, and the methods it has for that.
@@ -43,6 +43,7 @@ const playwright = {
43
43
  optionalTitle: false,
44
44
  tagKey: 'tag',
45
45
  plainTags: false,
46
+ annotations: { key: 'annotation', shape: 'annotation' },
46
47
  info: { param: 1, methods: RUNTIME_MODIFIERS },
47
48
  subtests: false,
48
49
  skipWithoutBody: false,
@@ -130,6 +131,7 @@ const vitest = {
130
131
  optionalTitle: false,
131
132
  tagKey: 'tags',
132
133
  plainTags: true,
134
+ annotations: { key: 'meta', shape: 'meta' },
133
135
  info: { param: 0, methods: new Set(['skip']) },
134
136
  subtests: false,
135
137
  skipWithoutBody: true,
@@ -61,6 +61,16 @@ export interface GovernanceOptions {
61
61
  comments?: false | {
62
62
  keywords?: string[];
63
63
  };
64
+ /**
65
+ * Accept the ticket from a test annotation as well: Playwright's `annotation` of the given type or
66
+ * Vitest's `meta` key of that name, as in
67
+ * `test.skip('title', { annotation: { type: 'issue', description: 'TRADE-123' } }, fn)`. One valid
68
+ * annotation covers every state the test is in, and a describe's covers its tests. Defaults to
69
+ * `{ type: 'issue' }`; `false` turns it off. Comments stay the form that works everywhere.
70
+ */
71
+ annotations?: false | {
72
+ type?: string;
73
+ };
64
74
  /** Allow free text after the tickets, e.g. `// SKIP: TRADE-1 flaky on CI`. Off by default: details belong in the ticket. */
65
75
  allowNotes?: boolean;
66
76
  /**
@@ -94,6 +104,13 @@ export interface ResolvedOptions {
94
104
  allowBlankLine: boolean;
95
105
  allowNotes: boolean;
96
106
  reportUnreadableTags: boolean;
107
+ /**
108
+ * How annotation tickets are accepted, for frameworks that have annotations: the type Playwright's
109
+ * `annotation` must have and the Vitest `meta` key that holds the ticket.
110
+ */
111
+ annotations?: {
112
+ type: string;
113
+ };
97
114
  /** Keywords checked by `require-ticket-in-comments`; empty when `comments: false`. */
98
115
  commentKeywords: string[];
99
116
  /**
@@ -55,15 +55,33 @@ const PRESET_OPTIONS = {
55
55
  youtrack: ['projects', 'host'],
56
56
  basecamp: [],
57
57
  shortcut: [],
58
+ openproject: ['host'],
59
+ bugzilla: ['host'],
60
+ phabricator: ['host'],
61
+ rally: [],
62
+ servicenow: ['prefixes'],
63
+ plane: ['projects', 'host'],
58
64
  url: ['hosts'],
59
65
  numeric: ['minLength', 'maxLength'],
60
66
  pattern: ['pattern', 'flags'],
61
67
  };
62
68
  /**
63
- * Presets whose `host` must be a plain host name. The older presets still take any string, since
64
- * rejecting one now would break configs whose short forms, such as `#123`, already work.
69
+ * Every preset that takes `host` requires a plain host name: a value with a scheme, port or path can never
70
+ * match a URL hostname, so before 0.11.0 it silently rejected every link.
65
71
  */
66
- const STRICT_HOST_PRESETS = new Set(['notion', 'redmine', 'youtrack']);
72
+ const STRICT_HOST_PRESETS = new Set([
73
+ 'jira',
74
+ 'github',
75
+ 'gitlab',
76
+ 'azure-devops',
77
+ 'notion',
78
+ 'redmine',
79
+ 'youtrack',
80
+ 'openproject',
81
+ 'bugzilla',
82
+ 'phabricator',
83
+ 'plane',
84
+ ]);
67
85
  /** Option values the schema allows for every preset but this one can't use. */
68
86
  function ownOptionProblems(spec, at) {
69
87
  const problems = [];
@@ -81,6 +99,9 @@ function ownOptionProblems(spec, at) {
81
99
  if (spec.preset === 'url' && spec.hosts === undefined) {
82
100
  problems.push(`${at}.hosts is required by the "url" preset: the hosts its links may point to`);
83
101
  }
102
+ if (spec.preset === 'servicenow' && spec.prefixes === undefined) {
103
+ problems.push(`${at}.prefixes is required by the "servicenow" preset: the record-number prefixes it accepts, such as STRY`);
104
+ }
84
105
  return problems;
85
106
  }
86
107
  /** A spec the cross-field checks can read: an object whose `preset` names one of the presets. */
@@ -193,6 +214,7 @@ export function compileOptions(options) {
193
214
  allowBlankLine: options.allowBlankLine ?? false,
194
215
  allowNotes: options.allowNotes ?? false,
195
216
  reportUnreadableTags: options.reportUnreadableTags ?? false,
217
+ annotations: options.annotations === false ? undefined : { type: options.annotations?.type ?? 'issue' },
196
218
  commentKeywords,
197
219
  workCommentKeywords: new Set([...DEFAULT_COMMENT_KEYWORDS, ...commentKeywords]),
198
220
  commentTicket: defaultMatcher,
@@ -108,6 +108,12 @@ export const optionsSchema = {
108
108
  },
109
109
  ],
110
110
  },
111
+ annotations: {
112
+ anyOf: [
113
+ { type: 'boolean', enum: [false] },
114
+ { type: 'object', properties: { type: { type: 'string' } }, additionalProperties: false },
115
+ ],
116
+ },
111
117
  requireTicketForConditional: { type: 'boolean' },
112
118
  allowBlankLine: { type: 'boolean' },
113
119
  allowNotes: { type: 'boolean' },
@@ -41,6 +41,24 @@ export type TicketSpec = {
41
41
  preset: 'basecamp';
42
42
  } | {
43
43
  preset: 'shortcut';
44
+ } | {
45
+ preset: 'openproject';
46
+ host?: string;
47
+ } | {
48
+ preset: 'bugzilla';
49
+ host?: string;
50
+ } | {
51
+ preset: 'phabricator';
52
+ host?: string;
53
+ } | {
54
+ preset: 'rally';
55
+ } | {
56
+ preset: 'servicenow';
57
+ prefixes: string[];
58
+ } | {
59
+ preset: 'plane';
60
+ projects?: string[];
61
+ host?: string;
44
62
  } | {
45
63
  preset: 'url';
46
64
  hosts: string[];
@@ -36,6 +36,22 @@ const YOUTRACK_PATH_RE = /\/(?:issue|projects\/[^/]+\/issues)\/([A-Z0-9_]+-\d+)(
36
36
  const BASECAMP_PATH_RE = /^\/\d+\/buckets\/\d+\/(?:todos|card_tables\/cards)\/(\d+)\/?$/;
37
37
  const SHORTCUT_REF_RE = /^(?:sc-(\d+)|\[sc-(\d+)\])$/i;
38
38
  const SHORTCUT_PATH_RE = /^\/[^/]+\/story\/(\d+)(?:\/[^/]*)?$/;
39
+ // OpenProject work packages: the canonical `/projects/<identifier>/work_packages/<id or AGILE-1>` with any
40
+ // tab path after it, and the `/wp/<id>` short form its changelog links use.
41
+ const OPENPROJECT_PATH_RE = /\/projects\/[^/]+\/work_packages\/([A-Z][A-Z0-9_]+-\d+|\d+)(?:\/.*)?\/?$/;
42
+ const OPENPROJECT_SHORT_RE = /^\/(?:work_packages|wp)\/(\d+)(?:\/.*)?\/?$/;
43
+ // Bugzilla runs under any path, as `/bugzilla/show_bug.cgi`; the bug is in the `id` query value.
44
+ const BUGZILLA_CGI_RE = /\/show_bug\.cgi\/?$/;
45
+ // Phabricator tasks: the `T4821` monogram, and the `/T4821` path its instances use.
46
+ const PHABRICATOR_REF_RE = /^T(\d+)$/;
47
+ const PHABRICATOR_PATH_RE = /^\/T(\d+)\/?$/;
48
+ // Rally's Formatted IDs: a user story, defect, task or test case.
49
+ // Rally numbers its work items per workspace from 1 without padding (US42, US41048), so a zero-padded ID is
50
+ // no Rally ticket: it is how MITRE ATT&CK writes its technique IDs (TA0001 to TA0043), which security suites
51
+ // put above tests. An all-zero ID such as TA0000 is a placeholder, as in every preset.
52
+ const RALLY_REF_RE = /^(?:US|DE|TA|TC)([1-9]\d*|0*)$/;
53
+ // Plane work items: `/<workspace>/<project>/issues/<WEB-142 or uuid>`, unanchored for a self-hosted path.
54
+ 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})\/?$/;
39
55
  // No tracker issues number 0. A preset hands back the ID it finds, and an ID of zeros (0, 000, or a
40
56
  // Notion page ID of zeros) makes the ticket a placeholder. A ticket no preset reads is a placeholder
41
57
  // when it ends in a zero ID, as PROJ-0, #0, AB#0 or .../issues/0 do.
@@ -340,6 +356,101 @@ function compilePreset(spec) {
340
356
  expected: 'a Shortcut story like sc-123, or a story link',
341
357
  example: 'sc-123',
342
358
  };
359
+ case 'openproject': {
360
+ const { host } = spec;
361
+ return {
362
+ find: (t) => {
363
+ if (ISSUE_NUMBER_RE.test(t) || KEY_RE.test(t))
364
+ return numberOf(t);
365
+ const url = parseUrl(t);
366
+ if (!url || host === undefined || !hostMatches(url, host))
367
+ return undefined;
368
+ return OPENPROJECT_PATH_RE.exec(url.pathname)?.[1] ?? OPENPROJECT_SHORT_RE.exec(url.pathname)?.[1];
369
+ },
370
+ expected: host === undefined
371
+ ? 'an OpenProject work package like #4821 or AGILE-1 (set host to accept work package links)'
372
+ : `an OpenProject work package like #4821 or AGILE-1, or a work package URL on ${host}`,
373
+ example: '#4821',
374
+ };
375
+ }
376
+ case 'bugzilla': {
377
+ const { host } = spec;
378
+ return {
379
+ find: (t) => {
380
+ if (DIGITS_RE.test(t))
381
+ return t;
382
+ const url = parseUrl(t);
383
+ if (!url || host === undefined || !hostMatches(url, host))
384
+ return undefined;
385
+ if (!BUGZILLA_CGI_RE.test(url.pathname))
386
+ return undefined;
387
+ const id = url.searchParams.get('id');
388
+ return id !== null && DIGITS_RE.test(id) ? id : undefined;
389
+ },
390
+ expected: host === undefined
391
+ ? 'a Bugzilla bug number like 4821 (set host to accept bug links)'
392
+ : `a Bugzilla bug number like 4821, or a bug URL on ${host}`,
393
+ example: '4821',
394
+ };
395
+ }
396
+ case 'phabricator': {
397
+ const { host } = spec;
398
+ return {
399
+ find: (t) => {
400
+ const ref = PHABRICATOR_REF_RE.exec(t)?.[1];
401
+ if (ref !== undefined)
402
+ return ref;
403
+ const url = parseUrl(t);
404
+ return url && host !== undefined && hostMatches(url, host)
405
+ ? PHABRICATOR_PATH_RE.exec(url.pathname)?.[1]
406
+ : undefined;
407
+ },
408
+ expected: host === undefined
409
+ ? 'a Phabricator task like T4821 (set host to accept task links)'
410
+ : `a Phabricator task like T4821, or a task URL on ${host}`,
411
+ example: 'T4821',
412
+ };
413
+ }
414
+ case 'rally':
415
+ return {
416
+ find: (t) => RALLY_REF_RE.exec(t)?.[1],
417
+ expected: 'a Rally formatted ID like US4821, DE4821, TA4821 or TC4821, without a leading zero',
418
+ example: 'US4821',
419
+ };
420
+ case 'servicenow': {
421
+ // The schema checks `prefixes` when options come from settings; direct use of compileTicketSpec checks it here.
422
+ const prefixes = spec.prefixes;
423
+ if (prefixes === undefined ||
424
+ prefixes.length === 0 ||
425
+ !prefixes.every((prefix) => /^[A-Z][A-Z0-9_]+$/.test(prefix))) {
426
+ throw new ConfigError('the "servicenow" ticket preset needs a `prefixes` array of uppercase record-number prefixes, such as ["STRY"].');
427
+ }
428
+ const re = new RegExp(`^(?:${prefixes.join('|')})(\\d+)$`);
429
+ return {
430
+ find: (t) => re.exec(t)?.[1],
431
+ expected: `a ServiceNow record number like ${prefixes[0]}0010001${describeList('with prefix', prefixes)}`,
432
+ example: `${prefixes[0]}0010001`,
433
+ };
434
+ }
435
+ case 'plane': {
436
+ const { projects, host } = spec;
437
+ const hosts = host === undefined ? ['app.plane.so'] : [host];
438
+ const keyOk = (key) => KEY_RE.test(key) && keyInList(key, projects);
439
+ return {
440
+ find: (t) => {
441
+ if (KEY_RE.test(t))
442
+ return keyId(t, keyOk);
443
+ const url = parseUrl(t);
444
+ if (!url || !hostInList(url, hosts))
445
+ return undefined;
446
+ const found = PLANE_PATH_RE.exec(url.pathname)?.[1];
447
+ // A key link honors `projects` too; a UUID link stands on its own.
448
+ return found === undefined ? undefined : KEY_RE.test(found) ? keyId(found, keyOk) : found;
449
+ },
450
+ expected: `a Plane work item like ${projects?.[0] ?? 'WEB'}-142${describeList('in project', projects)}, or a Plane work item link${host === undefined ? '' : ` on ${host}`}`,
451
+ example: `${projects?.[0] ?? 'WEB'}-142`,
452
+ };
453
+ }
343
454
  case 'url': {
344
455
  const { hosts } = spec;
345
456
  return {
@@ -333,5 +333,6 @@ change what it reports:
333
333
  - `requireTicketForConditional`: conditional skips need a ticket too.
334
334
  - `allowBlankLine`: blank lines may separate a marker from its test.
335
335
  - `allowNotes`: text may follow the tickets.
336
+ - `annotations`: the ticket may also come from a Playwright annotation of the configured type or Vitest's `meta` key of that name.
336
337
  - `reportUnreadableTags`: report titles and tags that can't be read (see [Titles and tags that can't be read](#titles-and-tags-that-cant-be-read)).
337
338
  - `framework` and `testFunctions`: which functions count as the framework's `test`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-test-governance",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "ESLint rules that require a ticket comment above skipped, fixme, todo and tagged Playwright, Jest, Vitest, Mocha, Cypress, WebdriverIO, node:test and Jasmine tests.",
5
5
  "keywords": [
6
6
  "eslint",