eslint-plugin-test-governance 0.10.1 → 0.11.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 +34 -1
- package/README.md +65 -25
- package/dist/rules/marker-matches-state.js +1 -1
- package/dist/rules/no-orphaned-marker.js +1 -1
- package/dist/rules/require-ticket.d.ts +1 -1
- package/dist/rules/require-ticket.js +24 -1
- package/dist/utils/analyze.d.ts +21 -2
- package/dist/utils/analyze.js +217 -8
- package/dist/utils/constants.d.ts +1 -1
- package/dist/utils/constants.js +6 -0
- package/dist/utils/frameworks.d.ts +9 -0
- package/dist/utils/frameworks.js +2 -0
- package/dist/utils/options.d.ts +17 -0
- package/dist/utils/options.js +25 -3
- package/dist/utils/schema.js +6 -0
- package/dist/utils/tickets.d.ts +18 -0
- package/dist/utils/tickets.js +111 -0
- package/docs/rules/require-ticket.md +1 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,37 @@ All notable changes to this project are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.11.1] - 2026-11-03
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- A skip declared through `Function.prototype.call`, `apply` or `bind`, such as
|
|
14
|
+
`test.skip.call(null, 'places an order', fn)` or `test.skip.bind(null)('places an order', fn)`, is
|
|
15
|
+
checked like the plain call, so it needs its ticket.
|
|
16
|
+
|
|
17
|
+
## [0.11.0] - 2026-11-01
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- Ticket presets for six more trackers: `openproject` (classic `#4821` work package IDs, per-project IDs
|
|
22
|
+
such as `AGILE-1`, and work package links once `host` is set), `bugzilla` (bug numbers, and
|
|
23
|
+
`show_bug.cgi?id=4821` links once `host` is set), `phabricator` (task monograms such as `T4821`, and
|
|
24
|
+
`/T4821` links once `host` is set; Phorge shares the format), `rally` (formatted IDs `US4821`, `DE4821`,
|
|
25
|
+
`TA4821`, `TC4821`), `servicenow` (record numbers such as `STRY0010001` for the `prefixes` you list) and
|
|
26
|
+
`plane` (work-item IDs such as `WEB-142`, and work-item links on `app.plane.so` or a `host`). The
|
|
27
|
+
`rally` preset rejects zero-padded IDs, which is how MITRE ATT&CK writes its technique IDs (`TA0006`).
|
|
28
|
+
- Tickets from test annotations: with `annotations` (default `{ type: 'issue' }`), a valid ticket in
|
|
29
|
+
Playwright's `annotation` of that type or Vitest's `meta` key of that name satisfies the ticket
|
|
30
|
+
requirement, on tests and describes; an invalid one is reported, and `annotations: false` turns it
|
|
31
|
+
off. Comments stay the form that works everywhere; Jest, Mocha, Jasmine, node:test and Cypress have
|
|
32
|
+
no annotations.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- A `host` value with a scheme, port or path, such as `https://jira.example.com` or `tracker.io:8080`, is
|
|
37
|
+
now a configuration error on every preset that takes `host`, not only on `notion`, `redmine` and
|
|
38
|
+
`youtrack`. Such a value never matched a URL hostname, so it already rejected every link silently.
|
|
39
|
+
|
|
9
40
|
## [0.10.1] - 2026-10-31
|
|
10
41
|
|
|
11
42
|
### Fixed
|
|
@@ -416,7 +447,9 @@ First release.
|
|
|
416
447
|
- Support for ESLint 9 and 10 flat config, JavaScript and TypeScript test files, `.extend()`
|
|
417
448
|
fixtures, describe blocks, runtime skips and tags in titles or the `tag` option.
|
|
418
449
|
|
|
419
|
-
[Unreleased]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.
|
|
450
|
+
[Unreleased]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.11.1...HEAD
|
|
451
|
+
[0.11.1]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.11.0...v0.11.1
|
|
452
|
+
[0.11.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.10.1...v0.11.0
|
|
420
453
|
[0.10.1]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.10.0...v0.10.1
|
|
421
454
|
[0.10.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.9.0...v0.10.0
|
|
422
455
|
[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
|
|
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`
|
|
281
|
-
| `github` | `#123`, `owner/repo#123`, `https://github.com/owner/repo/issues/123` (or `/pull/`)
|
|
282
|
-
| `gitlab` | `#123`, `group/project#123`, `https://gitlab.com/group/project/-/issues/123`
|
|
283
|
-
| `linear` | `RISK-123`, `https://linear.app/<workspace>/issue/RISK-123/...`
|
|
284
|
-
| `azure-devops` | `AB#123`, `#123`, `https://dev.azure.com/<org>/<project>/_workitems/edit/123`
|
|
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>`
|
|
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>`
|
|
289
|
-
| `redmine` | `#123`, `#123-4` and `#123#note-4` (a note), `##123`; with `host`, `https://<host>/issues/123` (also under a path)
|
|
290
|
-
| `monday` | item links, `https://<account>.monday.com/boards/<board>/pulses/<item>`, and custom keys such as `TIX-001`
|
|
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`)
|
|
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
|
-
| `
|
|
295
|
-
| `
|
|
296
|
-
| `
|
|
297
|
-
|
|
298
|
-
`
|
|
299
|
-
|
|
300
|
-
|
|
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}`)) {
|
package/dist/utils/analyze.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
116
|
-
export declare function
|
|
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
|
package/dist/utils/analyze.js
CHANGED
|
@@ -312,6 +312,46 @@ function isLocalVariable(sourceCode, id) {
|
|
|
312
312
|
}
|
|
313
313
|
const STATEMENT_CONTAINERS = new Set(['Program', 'BlockStatement', 'StaticBlock', 'SwitchCase', 'TSModuleBlock']);
|
|
314
314
|
/** The statement a call belongs to; its marker block sits above that statement. */
|
|
315
|
+
/**
|
|
316
|
+
* The declaration a call makes through `Function.prototype.call`, `apply` or `bind`, read as if it were
|
|
317
|
+
* written plainly: `it.skip.call(null, 'title', fn)`, `it.skip.apply(null, ['title', fn])` and
|
|
318
|
+
* `it.skip.bind(null)('title', fn)` all declare the test that `it.skip('title', fn)` declares. A call that
|
|
319
|
+
* isn't read this way, such as an `apply` with a list this file can't see, comes back unchanged.
|
|
320
|
+
*/
|
|
321
|
+
function throughFunctionMethods(original) {
|
|
322
|
+
let call = original;
|
|
323
|
+
// `it.skip.bind(null).call(null, 'title', fn)` unwraps twice; two more steps cost nothing and end the loop.
|
|
324
|
+
for (let step = 0; step < 4; step += 1) {
|
|
325
|
+
const callee = unwrap(call.callee);
|
|
326
|
+
// `it.skip.call(null, ...)`: the first argument is the receiver the method is called on.
|
|
327
|
+
if (callee.type === 'MemberExpression' && memberName(callee) === 'call') {
|
|
328
|
+
call = { ...call, callee: callee.object, arguments: call.arguments.slice(1) };
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
// `it.skip.apply(null, ['title', fn])`: the arguments are the elements of an array written here.
|
|
332
|
+
if (callee.type === 'MemberExpression' && memberName(callee) === 'apply') {
|
|
333
|
+
const list = call.arguments.at(1);
|
|
334
|
+
const elements = list === undefined ? undefined : unwrap(list);
|
|
335
|
+
if (elements?.type !== 'ArrayExpression' ||
|
|
336
|
+
elements.elements.some((e) => e === null || e.type === 'SpreadElement')) {
|
|
337
|
+
return call;
|
|
338
|
+
}
|
|
339
|
+
call = { ...call, callee: callee.object, arguments: elements.elements.filter((e) => e !== null) };
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
// `it.skip.bind(null, 'title', fn)()` and `it.skip.bind(null)('title', fn)`: `bind` returns the test
|
|
343
|
+
// function with the arguments it was given already in place.
|
|
344
|
+
if (callee.type === 'CallExpression') {
|
|
345
|
+
const bind = unwrap(callee.callee);
|
|
346
|
+
if (bind.type !== 'MemberExpression' || memberName(bind) !== 'bind')
|
|
347
|
+
return call;
|
|
348
|
+
call = { ...call, callee: bind.object, arguments: [...callee.arguments.slice(1), ...call.arguments] };
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
return call;
|
|
352
|
+
}
|
|
353
|
+
return call;
|
|
354
|
+
}
|
|
315
355
|
function anchorOf(call) {
|
|
316
356
|
let node = call;
|
|
317
357
|
// A call always sits inside a statement container, so the walk stops before it runs out of parents.
|
|
@@ -592,7 +632,128 @@ function detailsOf(sourceCode, call, optionsFirst) {
|
|
|
592
632
|
const unread = [];
|
|
593
633
|
return { entries: objectDetails(sourceCode, object, unread), unread };
|
|
594
634
|
}
|
|
595
|
-
|
|
635
|
+
/** 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. */
|
|
636
|
+
function staticText(sourceCode, node) {
|
|
637
|
+
const read = (value) => {
|
|
638
|
+
if (value.type === 'Literal')
|
|
639
|
+
return typeof value.value === 'string' ? value.value : undefined;
|
|
640
|
+
if (value.type !== 'TemplateLiteral' || value.expressions.length !== 0)
|
|
641
|
+
return undefined;
|
|
642
|
+
// `cooked` is only null in tagged templates, which no annotation value is.
|
|
643
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- see above
|
|
644
|
+
return value.quasis[0].value.cooked;
|
|
645
|
+
};
|
|
646
|
+
const target = unwrap(node);
|
|
647
|
+
const own = read(target);
|
|
648
|
+
if (own !== undefined)
|
|
649
|
+
return own;
|
|
650
|
+
const constant = constantValue(sourceCode, target);
|
|
651
|
+
return constant === undefined ? undefined : read(unwrap(constant));
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* The object or array a details value is, following a constant declared in this file: a carrier for
|
|
655
|
+
* annotations. Undefined for a value that plainly can't hold one, `'unread'` for one that can't be read.
|
|
656
|
+
*/
|
|
657
|
+
function annotationCarrier(sourceCode, node) {
|
|
658
|
+
const target = unwrap(node);
|
|
659
|
+
if (target.type === 'ObjectExpression' || target.type === 'ArrayExpression')
|
|
660
|
+
return target;
|
|
661
|
+
const constant = constantValue(sourceCode, target);
|
|
662
|
+
const resolved = constant === undefined ? undefined : unwrap(constant);
|
|
663
|
+
if (resolved?.type === 'ObjectExpression' || resolved?.type === 'ArrayExpression')
|
|
664
|
+
return resolved;
|
|
665
|
+
// A scalar that can be read holds no annotation; a value that can't be read might.
|
|
666
|
+
return target.type === 'Literal' || resolved !== undefined ? undefined : 'unread';
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Tickets from annotations of the configured type in a declaration's details: Playwright's `annotation`
|
|
670
|
+
* option, whose value is one `{ type, description }` or an array of them, and Vitest's `meta` object,
|
|
671
|
+
* with the ticket under the key named by the type. Only literal text is read; a value of the right
|
|
672
|
+
* type whose ticket can't be read is returned as unread, since it may carry the ticket.
|
|
673
|
+
*/
|
|
674
|
+
function collectAnnotations(sourceCode, details, source, annotations) {
|
|
675
|
+
const tickets = [];
|
|
676
|
+
const unread = [];
|
|
677
|
+
const readPlaywright = (object) => {
|
|
678
|
+
const unreadKeys = [];
|
|
679
|
+
const entries = objectDetails(sourceCode, object, unreadKeys);
|
|
680
|
+
const type = entries.find(([key]) => key === 'type');
|
|
681
|
+
if (type === undefined)
|
|
682
|
+
return;
|
|
683
|
+
const written = staticText(sourceCode, type[1]);
|
|
684
|
+
if (written === undefined) {
|
|
685
|
+
// The type might be the configured one, so the whole annotation may carry the ticket.
|
|
686
|
+
unread.push(type[1], ...unreadKeys);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
if (written !== annotations.type)
|
|
690
|
+
return;
|
|
691
|
+
const description = entries.find(([key]) => key === 'description');
|
|
692
|
+
if (description === undefined) {
|
|
693
|
+
// A key that can't be read may be the description.
|
|
694
|
+
unread.push(...unreadKeys);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
const text = staticText(sourceCode, description[1]);
|
|
698
|
+
if (text === undefined) {
|
|
699
|
+
unread.push(...unreadKeys, description[1]);
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
tickets.push({ text, node: description[1] });
|
|
703
|
+
};
|
|
704
|
+
const readValue = (value) => {
|
|
705
|
+
const carrier = annotationCarrier(sourceCode, value);
|
|
706
|
+
if (carrier === 'unread') {
|
|
707
|
+
unread.push(value);
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
if (carrier === undefined)
|
|
711
|
+
return;
|
|
712
|
+
if (source.shape === 'meta') {
|
|
713
|
+
if (carrier.type === 'ObjectExpression') {
|
|
714
|
+
const unreadKeys = [];
|
|
715
|
+
const entries = objectDetails(sourceCode, carrier, unreadKeys);
|
|
716
|
+
const entry = entries.find(([key]) => key === annotations.type);
|
|
717
|
+
if (entry === undefined)
|
|
718
|
+
unread.push(...unreadKeys);
|
|
719
|
+
else {
|
|
720
|
+
const text = staticText(sourceCode, entry[1]);
|
|
721
|
+
if (text !== undefined)
|
|
722
|
+
tickets.push({ text, node: entry[1] });
|
|
723
|
+
else
|
|
724
|
+
unread.push(...unreadKeys, entry[1]);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
if (carrier.type === 'ObjectExpression')
|
|
730
|
+
readPlaywright(carrier);
|
|
731
|
+
else {
|
|
732
|
+
for (const element of carrier.elements) {
|
|
733
|
+
if (element === null || element.type === 'SpreadElement')
|
|
734
|
+
continue;
|
|
735
|
+
const inner = annotationCarrier(sourceCode, element);
|
|
736
|
+
if (inner === 'unread')
|
|
737
|
+
unread.push(element);
|
|
738
|
+
else if (inner?.type === 'ObjectExpression')
|
|
739
|
+
readPlaywright(inner);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
};
|
|
743
|
+
for (const [key, value] of details.entries) {
|
|
744
|
+
if (key === source.key)
|
|
745
|
+
readValue(value);
|
|
746
|
+
}
|
|
747
|
+
return { annotations: tickets, unreadAnnotations: unread };
|
|
748
|
+
}
|
|
749
|
+
const NO_TAGS = {
|
|
750
|
+
tags: [],
|
|
751
|
+
dynamic: false,
|
|
752
|
+
unread: [],
|
|
753
|
+
opaqueDetails: false,
|
|
754
|
+
annotations: [],
|
|
755
|
+
unreadAnnotations: [],
|
|
756
|
+
};
|
|
596
757
|
function collectTags(sourceCode, call, hasTitle, framework, details) {
|
|
597
758
|
if (!hasTitle)
|
|
598
759
|
return NO_TAGS;
|
|
@@ -617,7 +778,15 @@ function collectTags(sourceCode, call, hasTitle, framework, details) {
|
|
|
617
778
|
const unread = [...values.unread];
|
|
618
779
|
if (details.unread.length > 0 && framework.tagKey !== undefined)
|
|
619
780
|
unread.push(...details.unread);
|
|
620
|
-
|
|
781
|
+
// headOf replaces these with the annotations it reads, since collectTags has no options.
|
|
782
|
+
return {
|
|
783
|
+
tags,
|
|
784
|
+
dynamic: title.unread.size > 0,
|
|
785
|
+
unread,
|
|
786
|
+
opaqueDetails: details.unread.length > 0,
|
|
787
|
+
annotations: [],
|
|
788
|
+
unreadAnnotations: [],
|
|
789
|
+
};
|
|
621
790
|
}
|
|
622
791
|
/** Whether `node` is an object written out or held in a constant declared in this file. */
|
|
623
792
|
function isObject(sourceCode, node) {
|
|
@@ -885,6 +1054,8 @@ function runAnalysis(sourceCode, options) {
|
|
|
885
1054
|
skipped: ownSkip || (kind !== 'runtime' && parent?.skipped === true),
|
|
886
1055
|
ownSkip,
|
|
887
1056
|
markers: block.flatMap((comment) => parseMarkers(comment, states)),
|
|
1057
|
+
annotationTickets: tagInfo.annotations,
|
|
1058
|
+
unreadTickets: tagInfo.unreadAnnotations,
|
|
888
1059
|
dynamicTitle: tagInfo.dynamic,
|
|
889
1060
|
};
|
|
890
1061
|
subjects.push(subject);
|
|
@@ -1019,12 +1190,18 @@ function runAnalysis(sourceCode, options) {
|
|
|
1019
1190
|
const infos = yes.kind === 'test' ? bodyInfos(args, yes.members) : [];
|
|
1020
1191
|
return { subject: makeSubject(yes.kind, call, modifiers, tagInfo), infos };
|
|
1021
1192
|
};
|
|
1022
|
-
/** The details and
|
|
1193
|
+
/** The details, tags and annotations of a declaration; node:test lets it leave out its title. */
|
|
1023
1194
|
const headOf = (call) => {
|
|
1024
1195
|
const args = call.arguments;
|
|
1025
1196
|
const untitled = leavesOutTitle(sourceCode, framework, args);
|
|
1026
1197
|
const details = detailsOf(sourceCode, call, untitled && !isFunction(args[0]));
|
|
1027
|
-
|
|
1198
|
+
const annotations = options.annotations === undefined || framework.annotations === undefined
|
|
1199
|
+
? { annotations: [], unreadAnnotations: [] }
|
|
1200
|
+
: collectAnnotations(sourceCode, details, framework.annotations, options.annotations);
|
|
1201
|
+
return {
|
|
1202
|
+
details,
|
|
1203
|
+
tagInfo: { ...collectTags(sourceCode, call, !untitled, framework, details), ...annotations },
|
|
1204
|
+
};
|
|
1028
1205
|
};
|
|
1029
1206
|
const declare = (call, { kind, members, args: conditions }, infos) => {
|
|
1030
1207
|
const args = call.arguments;
|
|
@@ -1047,7 +1224,8 @@ function runAnalysis(sourceCode, options) {
|
|
|
1047
1224
|
? declare(call, declaration, bodyInfos(call.arguments, []))
|
|
1048
1225
|
: undefined;
|
|
1049
1226
|
};
|
|
1050
|
-
const visitCall = (
|
|
1227
|
+
const visitCall = (original) => {
|
|
1228
|
+
const call = throughFunctionMethods(original);
|
|
1051
1229
|
const callee = target(chainOf(call.callee, framework.factories));
|
|
1052
1230
|
if (callee === undefined)
|
|
1053
1231
|
return subtestOf(call) ?? pickedDeclaration(call) ?? { subject: runtimeCall(call) };
|
|
@@ -1262,9 +1440,40 @@ export function evaluate(subject, state) {
|
|
|
1262
1440
|
const bad = marker.tickets.find((t) => t.result !== 'ok');
|
|
1263
1441
|
return bad ? { kind: 'bad-ticket', marker, ticket: bad } : { kind: 'no-ticket', marker };
|
|
1264
1442
|
}
|
|
1265
|
-
/**
|
|
1266
|
-
export function
|
|
1267
|
-
|
|
1443
|
+
/** Annotation tickets that can cover `subject`: its own and those of enclosing describes and tests. */
|
|
1444
|
+
export function annotationTicketsOf(subject) {
|
|
1445
|
+
const found = [...subject.annotationTickets];
|
|
1446
|
+
let current = subject.parent;
|
|
1447
|
+
while (current) {
|
|
1448
|
+
found.push(...current.annotationTickets);
|
|
1449
|
+
current = current.parent;
|
|
1450
|
+
}
|
|
1451
|
+
return found;
|
|
1452
|
+
}
|
|
1453
|
+
/** Annotation values whose ticket can't be read, `subject`'s own and those of enclosing describes and tests. */
|
|
1454
|
+
export function unreadAnnotationsOf(subject) {
|
|
1455
|
+
const found = [...subject.unreadTickets];
|
|
1456
|
+
let current = subject.parent;
|
|
1457
|
+
while (current) {
|
|
1458
|
+
found.push(...current.unreadTickets);
|
|
1459
|
+
current = current.parent;
|
|
1460
|
+
}
|
|
1461
|
+
return found;
|
|
1462
|
+
}
|
|
1463
|
+
/** Whether a valid annotation ticket covers `state` for `subject`. */
|
|
1464
|
+
export function hasAnnotationTicket(subject, state, options) {
|
|
1465
|
+
if (options.annotations === undefined)
|
|
1466
|
+
return false;
|
|
1467
|
+
return annotationTicketsOf(subject).some((ticket) => state.ticket.check(ticket.text) === 'ok');
|
|
1468
|
+
}
|
|
1469
|
+
/**
|
|
1470
|
+
* Required states on `subject` that have no ticket at all: no marker, not even a broken one, and no
|
|
1471
|
+
* valid annotation.
|
|
1472
|
+
*/
|
|
1473
|
+
export function missingStates(subject, options) {
|
|
1474
|
+
return subject.states
|
|
1475
|
+
.filter((s) => s.required && evaluate(subject, s.state).kind === 'missing' && !hasAnnotationTicket(subject, s.state, options))
|
|
1476
|
+
.map((s) => s.state);
|
|
1268
1477
|
}
|
|
1269
1478
|
/**
|
|
1270
1479
|
* 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[];
|
package/dist/utils/constants.js
CHANGED
|
@@ -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.
|
package/dist/utils/frameworks.js
CHANGED
|
@@ -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,
|
package/dist/utils/options.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/utils/options.js
CHANGED
|
@@ -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
|
-
*
|
|
64
|
-
*
|
|
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([
|
|
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,
|
package/dist/utils/schema.js
CHANGED
|
@@ -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' },
|
package/dist/utils/tickets.d.ts
CHANGED
|
@@ -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[];
|
package/dist/utils/tickets.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.11.1",
|
|
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",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json",
|
|
53
53
|
"typecheck": "tsc -p tsconfig.json",
|
|
54
54
|
"test": "node --test \"tests/**/*.test.ts\"",
|
|
55
|
-
"test:coverage": "node
|
|
55
|
+
"test:coverage": "node scripts/coverage.mjs",
|
|
56
56
|
"test:pack": "node scripts/pack-smoke.mjs",
|
|
57
57
|
"docs": "npm run build && eslint-doc-generator",
|
|
58
58
|
"docs:check": "npm run build && eslint-doc-generator --check",
|