eslint-plugin-test-governance 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -1
- package/README.md +15 -15
- package/dist/rules/marker-matches-state.js +14 -5
- package/dist/rules/no-conflicting-states.js +4 -1
- package/dist/rules/require-ticket-in-comments.js +24 -3
- package/dist/utils/analyze.js +36 -7
- package/dist/utils/options.js +45 -2
- package/dist/utils/schema.js +5 -2
- package/dist/utils/tickets.js +3 -1
- package/docs/rules/no-orphaned-marker.md +9 -2
- package/docs/rules/require-ticket-in-comments.md +4 -0
- package/docs/rules/require-ticket.md +8 -2
- package/package.json +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,49 @@ All notable changes to this project are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.3.0] - 2026-10-06
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `require-ticket-in-comments` accepts the ticket in parentheses: `// TODO(WEB-123)`. A name in
|
|
14
|
+
parentheses, as in `// TODO(alice): refactor`, is not a ticket and is reported. Before, comments
|
|
15
|
+
written this way were never checked.
|
|
16
|
+
- Configuration mistakes that used to be accepted silently now fail with a clear message: lowercase
|
|
17
|
+
Jira `projects` or Linear `teams` (`'web'` never matches a ticket like `WEB-1`), options that belong
|
|
18
|
+
to another ticket preset (such as `host` on the `any` preset), and a custom state that reuses a
|
|
19
|
+
built-in state's name (`customStates: { fixme: ... }`).
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- A skip in a `catch` block (`catch { test.skip(true, 'Mail server is down') }`) or after an early
|
|
24
|
+
exit such as `if (ready) return;` counts as conditional, like one under an `if`, so it no longer
|
|
25
|
+
needs a ticket by default.
|
|
26
|
+
- A plain `// FIXME: some text` above `test.skip(...)` no longer gets a confusing `marker-matches-state`
|
|
27
|
+
error claiming the test is also in the `fixme` state. `require-ticket-in-comments` and
|
|
28
|
+
`require-ticket` report what is actually wrong. The `marker-matches-state` message for a test in
|
|
29
|
+
two states now starts its second sentence with a capital letter.
|
|
30
|
+
- `@stable`, `@untestable` and other tags two or more letters longer or shorter than `@unstable`
|
|
31
|
+
(or any other state tag) are no longer reported as typos of it.
|
|
32
|
+
- A broken marker next to a valid one for the same state, as in `// SKIP: WEB-1` followed by
|
|
33
|
+
`// SKIP: nope`, is reported. Before, the valid marker hid it.
|
|
34
|
+
- A full stop or colon after the last ticket, as in `// SKIP: WEB-1.`, is no longer reported as
|
|
35
|
+
extra text.
|
|
36
|
+
|
|
37
|
+
## [0.2.1] - 2026-10-03
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- A `funding` link in `package.json`, so `npm fund` points to
|
|
42
|
+
[GitHub Sponsors](https://github.com/sponsors/Huldoser).
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
|
|
46
|
+
- A `pattern` ticket preset with the `g` or `y` flag no longer rejects every other valid ticket. Those
|
|
47
|
+
flags are now ignored, since a ticket is always matched whole.
|
|
48
|
+
- A marker directly above a test that is the body of an arrow function, such as
|
|
49
|
+
`rows.forEach((row) =>` followed by `// SKIP: WEB-1` and `test.skip(...)`, is found. It was
|
|
50
|
+
reported both as missing and as a stray marker.
|
|
51
|
+
|
|
9
52
|
## [0.2.0] - 2026-10-01
|
|
10
53
|
|
|
11
54
|
0.1.1 was published with these changes by mistake and is deprecated. Use 0.2.0, or stay on 0.1.0
|
|
@@ -78,7 +121,9 @@ First release.
|
|
|
78
121
|
- Support for ESLint 9 and 10 flat config, JavaScript and TypeScript test files, `.extend()`
|
|
79
122
|
fixtures, describe blocks, runtime skips and tags in titles or the `tag` option.
|
|
80
123
|
|
|
81
|
-
[Unreleased]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.
|
|
124
|
+
[Unreleased]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.3.0...HEAD
|
|
125
|
+
[0.3.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.2.1...v0.3.0
|
|
126
|
+
[0.2.1]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.2.0...v0.2.1
|
|
82
127
|
[0.2.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.1.1...v0.2.0
|
|
83
128
|
[0.1.1]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.1.0...v0.1.1
|
|
84
129
|
[0.1.0]: https://github.com/Huldoser/eslint-plugin-test-governance/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -47,7 +47,7 @@ Any flat config file works. The package is ESM, and `require('eslint-plugin-test
|
|
|
47
47
|
[`jiti`](https://github.com/unjs/jiti), so install it as a dev dependency too.
|
|
48
48
|
|
|
49
49
|
To change the defaults, use `configure()`. It returns the same recommended config with your options
|
|
50
|
-
stored in `settings['test-governance']`, which all
|
|
50
|
+
stored in `settings['test-governance']`, which all the rules read:
|
|
51
51
|
|
|
52
52
|
```js
|
|
53
53
|
export default [
|
|
@@ -100,20 +100,20 @@ eslint-plugin-test-governance: invalid options:
|
|
|
100
100
|
|
|
101
101
|
The defaults follow how most teams already work, so `recommended` is useful with no options.
|
|
102
102
|
|
|
103
|
-
| Setting
|
|
104
|
-
|
|
|
105
|
-
| States that need a ticket
|
|
106
|
-
| `@new`, `@unstable`
|
|
107
|
-
| Conditional skips (`test.skip(browserName === 'webkit', ...)`, `if (...) test.skip()`) | no ticket needed | These are usually permanent platform limits, not bugs. `requireTicketForConditional: true` changes this. |
|
|
108
|
-
| `test.fail`, `test.slow`
|
|
109
|
-
| Marker format
|
|
110
|
-
| Notes after the ticket
|
|
111
|
-
| `FIXME` / `TODO` comments
|
|
112
|
-
| Placement
|
|
113
|
-
| Ticket format
|
|
114
|
-
| Placeholder tickets
|
|
115
|
-
| Describe blocks
|
|
116
|
-
| Severity
|
|
103
|
+
| Setting | Default | Why |
|
|
104
|
+
| --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
|
105
|
+
| States that need a ticket | `skip`, `fixme` | Every team skips tests. Chromium, GitLab and Slack all require a bug link on disabled tests. |
|
|
106
|
+
| `@new`, `@unstable` | off; `lifecycleTags: true` turns both on | A promotion workflow is not universal. Teams that call it `@flaky` or `@quarantine` use `customStates`. |
|
|
107
|
+
| Conditional skips (`test.skip(browserName === 'webkit', ...)`, `if (...) test.skip()`, a skip in `catch`) | no ticket needed | These are usually permanent platform limits, not bugs. `requireTicketForConditional: true` changes this. |
|
|
108
|
+
| `test.fail`, `test.slow` | off | They don't remove coverage. Turn them on with `states: { fail: true, slow: true }`. |
|
|
109
|
+
| Marker format | `// SKIP: WEB-123` or `// SKIP: WEB-123, WEB-124` | Uppercase keyword and colon, like `TODO:`. Easy to grep. |
|
|
110
|
+
| Notes after the ticket | not allowed; `allowNotes: true` allows them | The ticket is the source of truth. A note in a comment goes stale while the ticket stays current. |
|
|
111
|
+
| `FIXME` / `TODO` comments | must start with a ticket, e.g. `// TODO: WEB-123` or `// TODO(WEB-123)` | The ticket is the source of truth; an untracked `TODO` is never done. `comments: false` turns this off. |
|
|
112
|
+
| Placement | comment block directly above; other comments allowed; a blank line breaks it | Survives `eslint-disable-next-line` and Prettier. |
|
|
113
|
+
| Ticket format | `any`: `PROJ-123`, `#123`, `owner/repo#123`, or an http(s) URL | Works for Jira, Linear, GitHub and GitLab out of the box. Narrow it with a preset. |
|
|
114
|
+
| Placeholder tickets | rejected: `TODO`, `TBD`, `XXX-*`, `0`, `123`, `1234`, `12345`, and any ticket numbered 0 (`PROJ-0`, `#0`) | A fake ticket is worse than none, and no tracker issues number 0. |
|
|
115
|
+
| Describe blocks | a marker on a skipped or tagged describe covers its tests | One ticket per skipped suite. |
|
|
116
|
+
| Severity | `require-ticket`, `marker-matches-state`, `no-orphaned-marker`: error; `no-conflicting-states`: warn | A marker must always match a real state, so a missing, wrong or leftover marker fails the lint. Tag spelling is cleanup. |
|
|
117
117
|
|
|
118
118
|
## Options
|
|
119
119
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describeSubject, isStateMarker, missingStates, strayMarkers } from '../utils/analyze.js';
|
|
1
|
+
import { appliesTo, describeSubject, isStateMarker, missingStates, strayMarkers } from '../utils/analyze.js';
|
|
2
2
|
import { createRule } from '../utils/create-rule.js';
|
|
3
3
|
import { indentOf, keywordRange } from '../utils/fix.js';
|
|
4
4
|
export default createRule({
|
|
@@ -24,12 +24,17 @@ export default createRule({
|
|
|
24
24
|
if (missing.length === 0)
|
|
25
25
|
continue;
|
|
26
26
|
const stray = strayMarkers(subject, options);
|
|
27
|
-
const subjectName = describeSubject(subject)
|
|
27
|
+
const subjectName = describeSubject(subject);
|
|
28
28
|
for (const marker of stray) {
|
|
29
29
|
context.report({
|
|
30
30
|
loc: marker.comment.loc,
|
|
31
31
|
messageId: 'wrongMarker',
|
|
32
|
-
data: {
|
|
32
|
+
data: {
|
|
33
|
+
found: marker.keyword,
|
|
34
|
+
expected: missing[0].marker,
|
|
35
|
+
state: missing[0].name,
|
|
36
|
+
subject: subjectName.replace(/^This/, 'this'),
|
|
37
|
+
},
|
|
33
38
|
suggest: missing.map((state) => ({
|
|
34
39
|
messageId: 'renameMarker',
|
|
35
40
|
data: { expected: state.marker },
|
|
@@ -39,8 +44,12 @@ export default createRule({
|
|
|
39
44
|
}
|
|
40
45
|
if (stray.length > 0)
|
|
41
46
|
continue;
|
|
42
|
-
// One marker written for a test that is in two states.
|
|
43
|
-
|
|
47
|
+
// One marker written for a test that is in two states. A marker for a state the test isn't in
|
|
48
|
+
// is not "shared": with no stray markers left, it can only be a work comment such as
|
|
49
|
+
// `// FIXME: flaky` above `test.skip(...)`, which require-ticket-in-comments reports.
|
|
50
|
+
const present = subject.markers
|
|
51
|
+
.filter(isStateMarker)
|
|
52
|
+
.find((m) => m.state !== missing[0] && appliesTo(subject, m.state.name));
|
|
44
53
|
if (!present)
|
|
45
54
|
continue;
|
|
46
55
|
for (const state of missing) {
|
|
@@ -71,9 +71,12 @@ export default createRule({
|
|
|
71
71
|
});
|
|
72
72
|
return;
|
|
73
73
|
}
|
|
74
|
+
// A typo changes, adds, drops or swaps a letter or two. A tag that is two or more letters
|
|
75
|
+
// shorter or longer, like `@stable` or `@untestable` next to `@unstable`, is a different word.
|
|
74
76
|
const near = tagStates.find((s) => {
|
|
75
77
|
const expected = s.tag.toLowerCase();
|
|
76
|
-
return
|
|
78
|
+
return (Math.abs(lower.length - expected.length) < 2 &&
|
|
79
|
+
editDistance(lower, expected) <= typoThreshold(expected.length - 1));
|
|
77
80
|
});
|
|
78
81
|
if (near) {
|
|
79
82
|
context.report({ node: occurrence.node, messageId: 'tagTypo', data: { found: tag, expected: near.tag } });
|
|
@@ -38,9 +38,26 @@ export default createRule({
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
// `TODO`/`FIXME` in capitals counts with or without a colon. Other casings need the colon, so a
|
|
41
|
-
// sentence that happens to start with "Todo" is not mistaken for a work comment.
|
|
41
|
+
// sentence that happens to start with "Todo" is not mistaken for a work comment. The keyword can
|
|
42
|
+
// carry its ticket in parentheses, `TODO(WEB-123): ...`, a common style that tools also use for
|
|
43
|
+
// a name, `TODO(alice)`, which is not a ticket.
|
|
42
44
|
const alternatives = keywords.map(escapeRegExp).join('|');
|
|
43
|
-
const keywordRe = new RegExp(`^(\\s*\\*?\\s*)(${alternatives})(?:\\s*:|(?=\\s|$))`, 'i');
|
|
45
|
+
const keywordRe = new RegExp(`^(\\s*\\*?\\s*)(${alternatives})(?:\\(([^)]*)\\))?(?:\\s*:|(?=\\s|$))`, 'i');
|
|
46
|
+
/**
|
|
47
|
+
* `TODO(WEB-1, WEB-2): text`: the tickets are in the parentheses, and anything after them, from the
|
|
48
|
+
* closing parenthesis on, is a note.
|
|
49
|
+
*/
|
|
50
|
+
function parseParenthesized(line, start, open, inParens) {
|
|
51
|
+
const parsed = parseTickets(inParens, start + open + 1, matcher);
|
|
52
|
+
const close = open + inParens.length + 2;
|
|
53
|
+
const note = line
|
|
54
|
+
.slice(close)
|
|
55
|
+
.replace(/^\s*:?/, '')
|
|
56
|
+
.trim();
|
|
57
|
+
if (note === '' || parsed.extra)
|
|
58
|
+
return parsed;
|
|
59
|
+
return { ...parsed, extra: { text: note, range: [start + close, start + line.trimEnd().length] } };
|
|
60
|
+
}
|
|
44
61
|
for (const comment of sourceCode.getAllComments()) {
|
|
45
62
|
if (owned.has(comment))
|
|
46
63
|
continue;
|
|
@@ -54,10 +71,14 @@ export default createRule({
|
|
|
54
71
|
if (!match)
|
|
55
72
|
continue;
|
|
56
73
|
const [whole, indent, keyword] = match;
|
|
74
|
+
// The parenthesised group is optional, so it can be undefined despite RegExpExecArray's typing.
|
|
75
|
+
const inParens = match[3];
|
|
57
76
|
const upper = keyword.toUpperCase();
|
|
58
77
|
if (keyword !== upper && !whole.endsWith(':'))
|
|
59
78
|
continue;
|
|
60
|
-
const { tickets, extra } =
|
|
79
|
+
const { tickets, extra } = inParens === undefined
|
|
80
|
+
? parseTickets(line.slice(whole.length), start + whole.length, matcher)
|
|
81
|
+
: parseParenthesized(line, start, indent.length + keyword.length, inParens);
|
|
61
82
|
const loc = {
|
|
62
83
|
start: sourceCode.getLocFromIndex(start + indent.length),
|
|
63
84
|
end: sourceCode.getLocFromIndex(start + line.trimEnd().length),
|
package/dist/utils/analyze.js
CHANGED
|
@@ -183,7 +183,8 @@ export function parseTickets(text, offset, matcher) {
|
|
|
183
183
|
index += separator.length;
|
|
184
184
|
}
|
|
185
185
|
const rest = text.slice(lastEnd).trimEnd();
|
|
186
|
-
|
|
186
|
+
// A sentence-ending `WEB-1.` has nothing after the ticket; `WEB-1: flaky` has a note.
|
|
187
|
+
if (tickets.length === 0 || /^[.:;]*$/.test(rest.trim()))
|
|
187
188
|
return { tickets };
|
|
188
189
|
return { tickets, extra: { text: rest.trim(), range: [offset + lastEnd, offset + lastEnd + rest.length] } };
|
|
189
190
|
}
|
|
@@ -253,10 +254,22 @@ function isUnconditional(call) {
|
|
|
253
254
|
return (first === undefined || (first.type === 'Literal' && first.value === true)) && !isGuarded(call);
|
|
254
255
|
}
|
|
255
256
|
const FUNCTIONS = new Set(['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression']);
|
|
257
|
+
/** Whether a statement always leaves the function or block: `return`, `throw`, or a block containing one. */
|
|
258
|
+
function exits(statement) {
|
|
259
|
+
if (statement.type === 'ReturnStatement' || statement.type === 'ThrowStatement')
|
|
260
|
+
return true;
|
|
261
|
+
return statement.type === 'BlockStatement' && statement.body.some(exits);
|
|
262
|
+
}
|
|
263
|
+
/** `if (ready) return;` or `if (!ok) { throw error; }`: code after it only runs on some paths. */
|
|
264
|
+
function isEarlyExit(statement) {
|
|
265
|
+
return (statement.type === 'IfStatement' &&
|
|
266
|
+
(exits(statement.consequent) || (statement.alternate !== null && exits(statement.alternate))));
|
|
267
|
+
}
|
|
256
268
|
/**
|
|
257
|
-
* Whether the call only runs on some paths of its function: under an `if`, a `switch` case, a ternary
|
|
258
|
-
*
|
|
259
|
-
* `test.skip(!enabled)
|
|
269
|
+
* Whether the call only runs on some paths of its function: under an `if`, a `switch` case, a ternary,
|
|
270
|
+
* the right side of `&&`, `||` or `??`, in a `catch` block, or after an early exit such as
|
|
271
|
+
* `if (ready) return;`. `if (!enabled) test.skip()` is the same as `test.skip(!enabled)`, and
|
|
272
|
+
* `catch { test.skip(true, 'service is down') }` only skips when the service is down.
|
|
260
273
|
*/
|
|
261
274
|
function isGuarded(call) {
|
|
262
275
|
let child = call;
|
|
@@ -267,6 +280,13 @@ function isGuarded(call) {
|
|
|
267
280
|
return true;
|
|
268
281
|
if (node.type === 'LogicalExpression' && child === node.right)
|
|
269
282
|
return true;
|
|
283
|
+
if (node.type === 'CatchClause')
|
|
284
|
+
return true;
|
|
285
|
+
if (node.type === 'BlockStatement') {
|
|
286
|
+
const index = node.body.indexOf(child);
|
|
287
|
+
if (node.body.slice(0, index).some(isEarlyExit))
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
270
290
|
}
|
|
271
291
|
return false;
|
|
272
292
|
}
|
|
@@ -297,6 +317,10 @@ function runAnalysis(sourceCode, options) {
|
|
|
297
317
|
const parent = stack.at(-1);
|
|
298
318
|
const anchor = anchorOf(call);
|
|
299
319
|
const block = commentBlock(sourceCode, anchor, options.allowBlankLine);
|
|
320
|
+
// `rows.forEach((row) =>\n // SKIP: WEB-1\n test.skip(...))`: the marker sits above the call itself.
|
|
321
|
+
if (call.parent.type === 'ArrowFunctionExpression' && call.parent.body === call) {
|
|
322
|
+
block.push(...commentBlock(sourceCode, call, options.allowBlankLine));
|
|
323
|
+
}
|
|
300
324
|
for (const comment of block)
|
|
301
325
|
claimed.add(comment);
|
|
302
326
|
const ownTags = new Set(tagInfo.tags.map((t) => t.tag));
|
|
@@ -459,10 +483,15 @@ export function evaluate(subject, state) {
|
|
|
459
483
|
const marker = subject.markers.find((m) => m.caseOf === state);
|
|
460
484
|
return marker ? { kind: 'case', marker } : { kind: 'missing' };
|
|
461
485
|
}
|
|
462
|
-
|
|
486
|
+
const isValid = (m) => m.tickets.length > 0 && m.tickets.every((t) => t.result === 'ok');
|
|
487
|
+
// A broken marker in the subject's own block is reported even when another marker is valid: in
|
|
488
|
+
// `// SKIP: WEB-1` + `// SKIP: nope`, the second line is still wrong. Broken markers further up
|
|
489
|
+
// are reported for the test or describe they sit above.
|
|
490
|
+
const brokenOwn = markers.find((m) => subject.markers.includes(m) && !isValid(m));
|
|
491
|
+
if (!brokenOwn && markers.some(isValid))
|
|
463
492
|
return { kind: 'ok' };
|
|
464
|
-
// Report the problem with the closest marker.
|
|
465
|
-
const
|
|
493
|
+
// Report the problem with the closest broken marker.
|
|
494
|
+
const marker = brokenOwn ?? markers[0];
|
|
466
495
|
const bad = marker.tickets.find((t) => t.result !== 'ok');
|
|
467
496
|
return bad ? { kind: 'bad-ticket', marker, ticket: bad } : { kind: 'no-ticket', marker };
|
|
468
497
|
}
|
package/dist/utils/options.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DEFAULT_COMMENT_KEYWORDS } from './constants.js';
|
|
1
|
+
import { BUILTIN_STATE_NAMES, DEFAULT_COMMENT_KEYWORDS } from './constants.js';
|
|
2
2
|
import { ConfigError } from './errors.js';
|
|
3
3
|
import { optionsSchema } from './schema.js';
|
|
4
4
|
import { compileTicketSpec, DEFAULT_PLACEHOLDERS } from './tickets.js';
|
|
@@ -34,8 +34,51 @@ export function resolveOptions(settings) {
|
|
|
34
34
|
}
|
|
35
35
|
return resolved;
|
|
36
36
|
}
|
|
37
|
+
/** The options each ticket preset takes besides `preset`. */
|
|
38
|
+
const PRESET_OPTIONS = {
|
|
39
|
+
any: [],
|
|
40
|
+
jira: ['projects', 'host'],
|
|
41
|
+
github: ['host'],
|
|
42
|
+
gitlab: ['host'],
|
|
43
|
+
linear: ['teams'],
|
|
44
|
+
'azure-devops': ['host'],
|
|
45
|
+
numeric: ['minLength', 'maxLength'],
|
|
46
|
+
pattern: ['pattern', 'flags'],
|
|
47
|
+
};
|
|
48
|
+
/** Options set on a ticket spec that its preset ignores, such as `host` on the `any` preset. */
|
|
49
|
+
function presetProblems(ticket, path) {
|
|
50
|
+
if (ticket === undefined)
|
|
51
|
+
return [];
|
|
52
|
+
const specs = Array.isArray(ticket) ? ticket : [ticket];
|
|
53
|
+
return specs.flatMap((spec, index) => {
|
|
54
|
+
const at = Array.isArray(ticket) ? `${path}[${index}]` : path;
|
|
55
|
+
const allowed = PRESET_OPTIONS[spec.preset];
|
|
56
|
+
return Object.keys(spec)
|
|
57
|
+
.filter((key) => key !== 'preset' && spec[key] !== undefined && !allowed.includes(key))
|
|
58
|
+
.map((key) => {
|
|
59
|
+
const presets = Object.entries(PRESET_OPTIONS).filter(([, keys]) => keys.includes(key));
|
|
60
|
+
return `${at}.${key} is not an option of the "${spec.preset}" preset (it applies to ${presets.map(([name]) => `"${name}"`).join(', ')})`;
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/** Problems the schema can't express: options that belong to another preset, and reused state names. */
|
|
65
|
+
function crossFieldProblems(options) {
|
|
66
|
+
const problems = presetProblems(options.ticket, 'ticket');
|
|
67
|
+
for (const [name, override] of Object.entries(options.states ?? {})) {
|
|
68
|
+
if (typeof override === 'object')
|
|
69
|
+
problems.push(...presetProblems(override.ticket, `states.${name}.ticket`));
|
|
70
|
+
}
|
|
71
|
+
for (const [name, custom] of Object.entries(options.customStates ?? {})) {
|
|
72
|
+
if (BUILTIN_STATE_NAMES.includes(name)) {
|
|
73
|
+
problems.push(`customStates.${name} reuses the name of the built-in "${name}" state; give it another name, or use states.${name} to change the built-in`);
|
|
74
|
+
}
|
|
75
|
+
problems.push(...presetProblems(custom.ticket, `customStates.${name}.ticket`));
|
|
76
|
+
}
|
|
77
|
+
return problems;
|
|
78
|
+
}
|
|
37
79
|
export function compileOptions(options) {
|
|
38
|
-
const
|
|
80
|
+
const schemaProblems = validate(options, optionsSchema);
|
|
81
|
+
const problems = schemaProblems.length > 0 ? schemaProblems : crossFieldProblems(options);
|
|
39
82
|
if (problems.length > 0) {
|
|
40
83
|
throw new ConfigError(`invalid options:\n${problems.map((problem) => ` - ${problem}`).join('\n')}`);
|
|
41
84
|
}
|
package/dist/utils/schema.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { BUILTIN_STATE_NAMES, MARKER_RE, TAG_RE, TICKET_PRESETS } from './constants.js';
|
|
2
2
|
const MARKER_HINT = 'an uppercase keyword such as NEEDS-DATA (letters, digits, "-" and "_")';
|
|
3
3
|
const TAG_HINT = 'a tag such as @needs-data';
|
|
4
|
+
/** Jira project and Linear team keys, as they appear in tickets: `WEB` in `WEB-123`. */
|
|
5
|
+
const KEY_PATTERN = '^[A-Z][A-Z0-9_]+$';
|
|
6
|
+
const KEY_HINT = 'an uppercase key as it appears in tickets, such as WEB for WEB-123';
|
|
4
7
|
const ticketSpecSchema = {
|
|
5
8
|
type: 'object',
|
|
6
9
|
properties: {
|
|
7
10
|
preset: { type: 'string', enum: [...TICKET_PRESETS] },
|
|
8
|
-
projects: { type: 'array', items: { type: 'string' } },
|
|
9
|
-
teams: { type: 'array', items: { type: 'string' } },
|
|
11
|
+
projects: { type: 'array', items: { type: 'string', pattern: KEY_PATTERN, patternHint: KEY_HINT } },
|
|
12
|
+
teams: { type: 'array', items: { type: 'string', pattern: KEY_PATTERN, patternHint: KEY_HINT } },
|
|
10
13
|
host: { type: 'string' },
|
|
11
14
|
minLength: { type: 'integer', minimum: 1 },
|
|
12
15
|
maxLength: { type: 'integer', minimum: 1 },
|
package/dist/utils/tickets.js
CHANGED
|
@@ -139,7 +139,9 @@ function compilePreset(spec) {
|
|
|
139
139
|
}
|
|
140
140
|
let re;
|
|
141
141
|
try {
|
|
142
|
-
|
|
142
|
+
// `g` and `y` make `test()` resume from the last match, so the same ticket would pass and fail
|
|
143
|
+
// in turn. A ticket is matched whole, so they never change what matches; drop them.
|
|
144
|
+
re = new RegExp(`^(?:${spec.pattern})$`, spec.flags?.replace(/[gy]/g, ''));
|
|
143
145
|
}
|
|
144
146
|
catch (error) {
|
|
145
147
|
throw new ConfigError(`invalid ticket pattern "${spec.pattern}": ${error.message}`);
|
|
@@ -16,8 +16,15 @@ A leftover marker makes searches for open tickets lie. This rule reports:
|
|
|
16
16
|
|
|
17
17
|
`FIXME:` and `TODO:` are also everyday work comments, so this rule leaves them to
|
|
18
18
|
[`require-ticket-in-comments`](require-ticket-in-comments.md), which requires a ticket in them anywhere
|
|
19
|
-
in a test file. A `// FIXME:
|
|
20
|
-
|
|
19
|
+
in a test file. A `// FIXME: refactor this` gets one clear error from that rule rather than a confusing
|
|
20
|
+
one from this.
|
|
21
|
+
|
|
22
|
+
A `FIXME:` or `TODO:` comment that starts with a valid ticket is treated by where it sits:
|
|
23
|
+
|
|
24
|
+
- Above a helper or any other code that isn't a test, it is a tracked work comment and is allowed.
|
|
25
|
+
- Above a test, describe or skip call that is not in the `fixme` state, it reads as a leftover marker
|
|
26
|
+
and is reported, since `// FIXME: WEB-12` above a test usually means the test was fixed and the
|
|
27
|
+
marker forgotten. Move a work comment into the test body, or onto the line of code it is about.
|
|
21
28
|
|
|
22
29
|
Other markers that read as a sentence, such as `// SKIP: this one is flaky`, are left alone too: the rule
|
|
23
30
|
counts a comment as prose when its first word is not a valid ticket and more text follows.
|
|
@@ -15,6 +15,8 @@ source of truth and nothing is forgotten in a comment.
|
|
|
15
15
|
```ts
|
|
16
16
|
// TODO: WEB-512 ✓
|
|
17
17
|
// FIXME: WEB-77, WEB-78 ✓ several tickets, separated by commas
|
|
18
|
+
// TODO(WEB-512) ✓ the ticket can go in parentheses
|
|
19
|
+
// TODO(alice): refactor this ✖ a name is not a ticket
|
|
18
20
|
// TODO: refactor this ✖ no ticket
|
|
19
21
|
// FIXME: WEB-77 flaky locator ✖ only tickets are allowed (allowNotes: true allows the note)
|
|
20
22
|
```
|
|
@@ -24,6 +26,8 @@ How comments are read:
|
|
|
24
26
|
- The keyword starts the comment (or a line of a block comment). `TODO` and `FIXME` in capitals
|
|
25
27
|
count with or without a colon. Other casings, such as `todo:`, need the colon, so a sentence that
|
|
26
28
|
happens to start with "Todo" is left alone.
|
|
29
|
+
- The tickets can also go in parentheses straight after the keyword, `TODO(WEB-512)`, a style many
|
|
30
|
+
teams already use. Anything after the parentheses is a note, as it would be after `TODO: WEB-512`.
|
|
27
31
|
- The tickets follow the same rules as markers: the shared ticket format, placeholders such as `TBD`
|
|
28
32
|
rejected, and no text after the tickets unless `allowNotes` is set.
|
|
29
33
|
- A `// FIXME:` marker directly above a `test.fixme(...)` is checked by
|
|
@@ -53,7 +53,8 @@ How ticket lists are read:
|
|
|
53
53
|
| `// SKIP: WEB-1 , WEB-2 ` | two tickets; extra spaces are fine |
|
|
54
54
|
| `// SKIP: https://acme.atlassian.net/browse/WEB-1?focusedCommentId=5#comment-5` | one ticket; query strings and fragments are part of a URL |
|
|
55
55
|
| `// SKIP: WEB-1 flaky on CI`, `// SKIP: WEB-1: flaky` | extra text `flaky on CI` / `: flaky` |
|
|
56
|
-
| `// SKIP: WEB-1
|
|
56
|
+
| `// SKIP: WEB-1.` or `// SKIP: WEB-1:` | one ticket; a full stop or colon at the end is fine |
|
|
57
|
+
| `// SKIP: WEB-1,` | extra text `,` |
|
|
57
58
|
| `// SKIP: WEB-1 and WEB-2` | extra text `and WEB-2`; separate tickets with commas |
|
|
58
59
|
| `// SKIP: WEB-1;WEB-2`, `// SKIP: WEB-1/WEB-2` | invalid ticket, since only commas separate tickets |
|
|
59
60
|
| `// SKIP: flaky on CI` | invalid ticket `flaky`: the first word must be a ticket |
|
|
@@ -64,7 +65,12 @@ lines. Extra text is reported only once the tickets on that line are valid.
|
|
|
64
65
|
Conditional skips such as `test.skip(browserName === 'webkit', 'Not supported')` usually describe a
|
|
65
66
|
permanent platform limit, so they need no ticket unless you set `requireTicketForConditional: true`.
|
|
66
67
|
A runtime skip under an `if`, a `switch` case, a ternary or `&&` counts as conditional too, since
|
|
67
|
-
`if (!enabled) test.skip()` does the same as `test.skip(!enabled)`.
|
|
68
|
+
`if (!enabled) test.skip()` does the same as `test.skip(!enabled)`. So does a skip in a `catch` block,
|
|
69
|
+
such as `catch { test.skip(true, 'Mail server is down') }`, and a skip after an early exit such as
|
|
70
|
+
`if (ready) return;` or `if (!ok) throw error;` earlier in the same function.
|
|
71
|
+
|
|
72
|
+
If one test or describe has several markers for the same state, each must be valid: in
|
|
73
|
+
`// SKIP: WEB-1` followed by `// SKIP: nope`, the second line is reported.
|
|
68
74
|
|
|
69
75
|
## Examples
|
|
70
76
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eslint-plugin-test-governance",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "ESLint rules that require a ticket comment above skipped, fixme and tagged Playwright tests.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"eslint",
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
"bugs": {
|
|
14
14
|
"url": "https://github.com/Huldoser/eslint-plugin-test-governance/issues"
|
|
15
15
|
},
|
|
16
|
+
"funding": {
|
|
17
|
+
"type": "github",
|
|
18
|
+
"url": "https://github.com/sponsors/Huldoser"
|
|
19
|
+
},
|
|
16
20
|
"repository": {
|
|
17
21
|
"type": "git",
|
|
18
22
|
"url": "git+https://github.com/Huldoser/eslint-plugin-test-governance.git"
|