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