eslint-plugin-test-governance 0.1.0 → 0.1.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 +76 -0
- package/LICENSE +1 -1
- package/README.md +69 -50
- package/dist/index.d.ts +7 -2
- package/dist/index.js +27 -16
- package/dist/rules/marker-matches-state.js +4 -4
- package/dist/rules/no-conflicting-states.js +14 -7
- package/dist/rules/no-orphaned-marker.js +16 -4
- package/dist/rules/require-ticket-in-comments.d.ts +3 -0
- package/dist/rules/require-ticket-in-comments.js +96 -0
- package/dist/rules/require-ticket.d.ts +1 -1
- package/dist/rules/require-ticket.js +5 -3
- package/dist/utils/analyze.d.ts +34 -4
- package/dist/utils/analyze.js +141 -47
- package/dist/utils/constants.d.ts +10 -0
- package/dist/utils/constants.js +18 -0
- package/dist/utils/create-rule.d.ts +10 -5
- package/dist/utils/create-rule.js +3 -4
- package/dist/utils/errors.d.ts +5 -0
- package/dist/utils/errors.js +8 -0
- package/dist/utils/fix.js +4 -2
- package/dist/utils/options.d.ts +36 -23
- package/dist/utils/options.js +28 -36
- package/dist/utils/schema.d.ts +3 -3
- package/dist/utils/schema.js +40 -31
- package/dist/utils/tickets.js +9 -1
- package/dist/utils/validate.d.ts +21 -0
- package/dist/utils/validate.js +122 -0
- package/docs/rules/marker-matches-state.md +4 -1
- package/docs/rules/no-conflicting-states.md +5 -1
- package/docs/rules/no-orphaned-marker.md +24 -2
- package/docs/rules/require-ticket-in-comments.md +80 -0
- package/docs/rules/require-ticket.md +31 -21
- package/package.json +17 -7
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
|
|
5
|
+
[Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- New rule `require-ticket-in-comments`, on as an error in `recommended`: `FIXME` and `TODO` comments
|
|
12
|
+
in test files must start with a ticket, such as `// TODO: WEB-123`. The `comments` option sets
|
|
13
|
+
the keywords (`{ keywords: ['FIXME'] }` allows free-form `TODO`s) and `comments: false` turns the
|
|
14
|
+
check off.
|
|
15
|
+
- `configure()` and `settings['test-governance']` are validated. A typo such as `lifecycleTag` or an
|
|
16
|
+
unknown ticket preset fails with a clear message and a "did you mean" hint instead of being
|
|
17
|
+
ignored or crashing with a `TypeError`.
|
|
18
|
+
- Tests from Playwright's component-testing packages (`@playwright/experimental-ct-*`) and fixtures
|
|
19
|
+
combined with `mergeTests()` are recognised.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- **Breaking:** rules no longer take options of their own. All options live in
|
|
24
|
+
`settings['test-governance']` (set by `configure()`), so every rule reads the same settings and
|
|
25
|
+
they can't disagree. Move any rule options into `configure({...})`.
|
|
26
|
+
- A missing marker is reported on the test's head, `test.skip('title'`, instead of underlining the
|
|
27
|
+
whole test body in editors.
|
|
28
|
+
- `test` imported from another test runner (`vitest`, `@jest/globals`, `node:test`, `bun:test`,
|
|
29
|
+
`mocha`, `ava`, `tap`, `uvu`), or a local variable called `test` inside a function, is no longer
|
|
30
|
+
treated as Playwright's `test`.
|
|
31
|
+
- `no-orphaned-marker` leaves `FIXME` and `TODO` comments to `require-ticket-in-comments`, and none
|
|
32
|
+
of the comment checks run in files that don't use Playwright, such as application code.
|
|
33
|
+
|
|
34
|
+
## [0.1.1] - 2026-10-01
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- Releases are published from a version tag by GitHub Actions, with npm provenance and a GitHub
|
|
39
|
+
release whose notes come from this changelog.
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- Node.js 22.12 or later is required, the first 22.x release that can `require()` an ES module
|
|
44
|
+
([#6](https://github.com/Huldoser/eslint-plugin-test-governance/pull/6)).
|
|
45
|
+
- A runtime skip under an `if`, a `switch` case, a ternary or `&&`/`||`/`??`, such as
|
|
46
|
+
`if (!enabled) test.skip()`, counts as a conditional skip and needs no ticket by default
|
|
47
|
+
([#7](https://github.com/Huldoser/eslint-plugin-test-governance/pull/7)).
|
|
48
|
+
- The plugin reads `meta.version` from `package.json`.
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
|
|
52
|
+
- `require('eslint-plugin-test-governance')` returns the plugin itself, so a CommonJS config that
|
|
53
|
+
registers the plugin and extends its config no longer fails with "Cannot redefine plugin"
|
|
54
|
+
([#6](https://github.com/Huldoser/eslint-plugin-test-governance/pull/6)).
|
|
55
|
+
- `no-orphaned-marker` no longer reports ordinary comments such as
|
|
56
|
+
`// FIXME: this check is flaky on slow machines`
|
|
57
|
+
([#7](https://github.com/Huldoser/eslint-plugin-test-governance/pull/7)).
|
|
58
|
+
|
|
59
|
+
## [0.1.0] - 2026-09-30
|
|
60
|
+
|
|
61
|
+
First release.
|
|
62
|
+
|
|
63
|
+
### Added
|
|
64
|
+
|
|
65
|
+
- Rules `require-ticket`, `marker-matches-state`, `no-orphaned-marker` and `no-conflicting-states`.
|
|
66
|
+
- `configs.recommended`, and `configure()` for shared options that all four rules read.
|
|
67
|
+
- States `skip` and `fixme` on by default; `fail` and `slow` on request; `@new` and `@unstable`
|
|
68
|
+
with `lifecycleTags: true`; any other tag with `customStates`.
|
|
69
|
+
- Ticket presets `any`, `jira`, `github`, `gitlab`, `linear`, `azure-devops`, `numeric` and
|
|
70
|
+
`pattern`, which can be combined and set per state. Placeholder tickets are rejected.
|
|
71
|
+
- Support for ESLint 9 and 10 flat config, JavaScript and TypeScript test files, `.extend()`
|
|
72
|
+
fixtures, describe blocks, runtime skips and tags in titles or the `tag` option.
|
|
73
|
+
|
|
74
|
+
[Unreleased]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.1.1...HEAD
|
|
75
|
+
[0.1.1]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.1.0...v0.1.1
|
|
76
|
+
[0.1.0]: https://github.com/Huldoser/eslint-plugin-test-governance/releases/tag/v0.1.0
|
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ comments, so `grep -rn "SKIP:" tests/` lists every disabled test and its ticket.
|
|
|
22
22
|
npm install --save-dev eslint-plugin-test-governance
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
Requires ESLint 9 or 10 with flat config, and Node.js 22 or later. For TypeScript specs, also install
|
|
25
|
+
Requires ESLint 9 or 10 with flat config, and Node.js 22.12 or later. For TypeScript specs, also install
|
|
26
26
|
`@typescript-eslint/parser` (or `typescript-eslint`).
|
|
27
27
|
|
|
28
28
|
## Usage
|
|
@@ -41,6 +41,11 @@ export default [
|
|
|
41
41
|
];
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
+
Any flat config file works. The package is ESM, and `require('eslint-plugin-test-governance')` in an
|
|
45
|
+
`eslint.config.cjs` returns the plugin itself. With `defineConfig`, register the plugin and use
|
|
46
|
+
`extends: ['test-governance/recommended']`. ESLint loads an `eslint.config.ts` through
|
|
47
|
+
[`jiti`](https://github.com/unjs/jiti), so install it as a dev dependency too.
|
|
48
|
+
|
|
44
49
|
To change the defaults, use `configure()`. It returns the same recommended config with your options
|
|
45
50
|
stored in `settings['test-governance']`, which all four rules read:
|
|
46
51
|
|
|
@@ -62,8 +67,14 @@ export default [
|
|
|
62
67
|
];
|
|
63
68
|
```
|
|
64
69
|
|
|
65
|
-
`configure()` validates the options when the config file loads, so a
|
|
66
|
-
|
|
70
|
+
`configure()` validates the options when the config file loads, so a mistake fails immediately
|
|
71
|
+
with a clear message rather than being ignored:
|
|
72
|
+
|
|
73
|
+
```text
|
|
74
|
+
eslint-plugin-test-governance: invalid options:
|
|
75
|
+
- lifecycleTag is not a known option; did you mean "lifecycleTags"?
|
|
76
|
+
- ticket.preset must be one of "any", "jira", "github", … (got "jria")
|
|
77
|
+
```
|
|
67
78
|
|
|
68
79
|
## Rules
|
|
69
80
|
|
|
@@ -75,12 +86,13 @@ than on the first lint.
|
|
|
75
86
|
🔧 Automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix).\
|
|
76
87
|
💡 Manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).
|
|
77
88
|
|
|
78
|
-
| Name
|
|
79
|
-
|
|
|
80
|
-
| [marker-matches-state](docs/rules/marker-matches-state.md)
|
|
81
|
-
| [no-conflicting-states](docs/rules/no-conflicting-states.md)
|
|
82
|
-
| [no-orphaned-marker](docs/rules/no-orphaned-marker.md)
|
|
83
|
-
| [require-ticket](docs/rules/require-ticket.md)
|
|
89
|
+
| Name | Description | 💼 | ⚠️ | 🔧 | 💡 |
|
|
90
|
+
| :--------------------------------------------------------------------- | :-------------------------------------------------------------------------------- | :-- | :-- | :-- | :-- |
|
|
91
|
+
| [marker-matches-state](docs/rules/marker-matches-state.md) | Require the marker keyword to match the test's state | ✅ | | | 💡 |
|
|
92
|
+
| [no-conflicting-states](docs/rules/no-conflicting-states.md) | Disallow state tags that contradict each other, differ in case or look like typos | | ✅ | 🔧 | |
|
|
93
|
+
| [no-orphaned-marker](docs/rules/no-orphaned-marker.md) | Disallow marker comments that no longer match a test state | ✅ | | | 💡 |
|
|
94
|
+
| [require-ticket](docs/rules/require-ticket.md) | Require a ticket marker comment above skipped, fixme and tagged tests | ✅ | | | 💡 |
|
|
95
|
+
| [require-ticket-in-comments](docs/rules/require-ticket-in-comments.md) | Require FIXME and TODO comments in test files to start with a ticket | ✅ | | | 💡 |
|
|
84
96
|
|
|
85
97
|
<!-- end auto-generated rules list -->
|
|
86
98
|
|
|
@@ -88,51 +100,53 @@ than on the first lint.
|
|
|
88
100
|
|
|
89
101
|
The defaults follow how most teams already work, so `recommended` is useful with no options.
|
|
90
102
|
|
|
91
|
-
| Setting
|
|
92
|
-
|
|
|
93
|
-
| States that need a ticket
|
|
94
|
-
| `@new`, `@unstable`
|
|
95
|
-
| Conditional skips (`test.skip(browserName === 'webkit', ...)`) | no ticket needed
|
|
96
|
-
| `test.fail`, `test.slow`
|
|
97
|
-
| Marker format
|
|
98
|
-
| Notes after the ticket
|
|
99
|
-
|
|
|
100
|
-
|
|
|
101
|
-
|
|
|
102
|
-
|
|
|
103
|
-
|
|
|
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()`) | 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` | 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. |
|
|
104
117
|
|
|
105
118
|
## Options
|
|
106
119
|
|
|
107
120
|
All options go in `configure({...})`, or in `settings['test-governance']` if you build the config
|
|
108
|
-
yourself.
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
| Option
|
|
112
|
-
|
|
|
113
|
-
| `ticket`
|
|
114
|
-
| `placeholders`
|
|
115
|
-
| `lifecycleTags`
|
|
116
|
-
| `states`
|
|
117
|
-
| `customStates`
|
|
118
|
-
| `requireTicketForConditional` | `boolean`
|
|
119
|
-
| `allowBlankLine`
|
|
120
|
-
| `allowNotes`
|
|
121
|
-
| `
|
|
122
|
-
| `
|
|
121
|
+
yourself. The rules have no options of their own: they all read these shared settings, so they
|
|
122
|
+
always agree on ticket formats, states and markers. To turn a rule off, set it to `'off'` as usual.
|
|
123
|
+
|
|
124
|
+
| Option | Type | Default | Description |
|
|
125
|
+
| ----------------------------- | -------------------------------------------------------- | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
126
|
+
| `ticket` | `TicketSpec \| TicketSpec[]` | `{ preset: 'any' }` | Accepted ticket formats. An array accepts a ticket that matches any entry. |
|
|
127
|
+
| `placeholders` | `string[]` | see above | Tickets rejected as placeholders. `*` matches any characters. Case-insensitive; a leading `#` is ignored. Tickets numbered 0 are always rejected. |
|
|
128
|
+
| `lifecycleTags` | `boolean` | `false` | Turns on the `new` (`@new`) and `unstable` (`@unstable`) states. |
|
|
129
|
+
| `states` | `{ [state]: boolean \| { enabled?, marker?, ticket? } }` | | Turns built-in states (`skip`, `fixme`, `fail`, `slow`, `new`, `unstable`) on or off, renames their marker, or gives them their own ticket format. |
|
|
130
|
+
| `customStates` | `{ [name]: { when, marker, ticket? } }` | `{}` | Adds a state for a tag, e.g. `{ quarantine: { when: '@quarantine', marker: 'QUARANTINE' } }`. |
|
|
131
|
+
| `requireTicketForConditional` | `boolean` | `false` | Require a ticket for `test.skip(condition, ...)` and `if (...) test.skip()` too. |
|
|
132
|
+
| `allowBlankLine` | `boolean` | `false` | Let blank lines separate a marker from its test. |
|
|
133
|
+
| `allowNotes` | `boolean` | `false` | Allow free text after the tickets, e.g. `// SKIP: WEB-123 flaky on CI`. |
|
|
134
|
+
| `comments` | `false \| { keywords?: string[] }` | `{ keywords: ['FIXME', 'TODO'] }` | Comment keywords that must start with a ticket anywhere in a test file, e.g. `// TODO: WEB-123`. `false` allows free-form comments. |
|
|
135
|
+
| `testFunctions` | `string[]` | `['test']` | Names treated as Playwright's `test`. See below. |
|
|
136
|
+
| `reportDynamicTitles` | `boolean` | `false` | When a tag state is on, report titles that aren't static text, since their tags can't be read. |
|
|
123
137
|
|
|
124
138
|
### Ticket presets
|
|
125
139
|
|
|
126
|
-
| Preset
|
|
127
|
-
|
|
|
128
|
-
| `any`
|
|
129
|
-
| `jira`
|
|
130
|
-
| `github`
|
|
131
|
-
| `gitlab`
|
|
132
|
-
| `linear`
|
|
133
|
-
| `azure-devops` | `AB#123`, `https://dev.azure.com/<org>/<project>/_workitems/edit/123`
|
|
134
|
-
| `numeric`
|
|
135
|
-
| `pattern`
|
|
140
|
+
| Preset | Accepts | Options |
|
|
141
|
+
| -------------- | ----------------------------------------------------------------------------------- | --------------------------------- |
|
|
142
|
+
| `any` | `PROJ-123`, `#123`, `owner/repo#123`, any http(s) URL | |
|
|
143
|
+
| `jira` | `WEB-123`, `https://<host>/browse/WEB-123`, board URLs with `selectedIssue=WEB-123` | `projects`, `host` |
|
|
144
|
+
| `github` | `#123`, `owner/repo#123`, `https://github.com/owner/repo/issues/123` (or `/pull/`) | `host` for GitHub Enterprise |
|
|
145
|
+
| `gitlab` | `#123`, `group/project#123`, `https://gitlab.com/group/project/-/issues/123` | `host` for self-managed |
|
|
146
|
+
| `linear` | `ENG-123`, `https://linear.app/<workspace>/issue/ENG-123/...` | `teams` |
|
|
147
|
+
| `azure-devops` | `AB#123`, `https://dev.azure.com/<org>/<project>/_workitems/edit/123` | `host` for Azure DevOps Server |
|
|
148
|
+
| `numeric` | `4821` | `minLength` (1), `maxLength` (20) |
|
|
149
|
+
| `pattern` | anything matching your regex, anchored at both ends | `pattern`, `flags` |
|
|
136
150
|
|
|
137
151
|
Presets combine, and each state can have its own format. For example, bugs in Jira and flaky-test
|
|
138
152
|
reports in GitHub:
|
|
@@ -150,12 +164,18 @@ so keep it free of nested quantifiers.
|
|
|
150
164
|
|
|
151
165
|
### Which functions count as `test`
|
|
152
166
|
|
|
153
|
-
- `test` imported from `@playwright/test
|
|
167
|
+
- `test` imported from `@playwright/test` or a component-testing package such as
|
|
168
|
+
`@playwright/experimental-ct-react`, including aliases such as `import { test as it }` and
|
|
154
169
|
`const { test } = require('@playwright/test')`.
|
|
155
|
-
- Fixtures made with `.extend()` in the same file, such as
|
|
170
|
+
- Fixtures made with `.extend()` or `mergeTests()` in the same file, such as
|
|
171
|
+
`const test = base.extend({...})`.
|
|
156
172
|
- Any name in `testFunctions`, for fixtures imported from your own modules. The default `['test']`
|
|
157
173
|
covers the common `import { test } from './fixtures'`.
|
|
158
174
|
|
|
175
|
+
A name imported from another test runner (`vitest`, `@jest/globals`, `node:test`, `bun:test`,
|
|
176
|
+
`mocha`, `ava`, `tap`, `uvu`) is never treated as Playwright's `test`, and neither is a local
|
|
177
|
+
variable declared inside a function that happens to be called `test`.
|
|
178
|
+
|
|
159
179
|
### Syntax the rules understand
|
|
160
180
|
|
|
161
181
|
- `test.skip('title', fn)`, `test.fixme(...)`, `test.fail(...)`, and the same with a details object.
|
|
@@ -169,7 +189,6 @@ so keep it free of nested quantifiers.
|
|
|
169
189
|
Not covered in this version: tests generated in loops with dynamic titles, fixtures imported from
|
|
170
190
|
other modules under a name not in `testFunctions`, and Playwright annotations as markers.
|
|
171
191
|
|
|
172
|
-
|
|
173
192
|
## Development
|
|
174
193
|
|
|
175
194
|
```sh
|
package/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { ESLint, Rule } from 'eslint';
|
|
|
2
2
|
import { type GovernanceOptions } from './utils/options.js';
|
|
3
3
|
export type { CustomState, GovernanceOptions, StateOverride } from './utils/options.js';
|
|
4
4
|
export type { TicketSpec } from './utils/tickets.js';
|
|
5
|
-
export type RuleName = 'require-ticket' | 'marker-matches-state' | 'no-orphaned-marker' | 'no-conflicting-states';
|
|
5
|
+
export type RuleName = 'require-ticket' | 'marker-matches-state' | 'no-orphaned-marker' | 'no-conflicting-states' | 'require-ticket-in-comments';
|
|
6
6
|
export declare const rules: Record<RuleName, Rule.RuleModule>;
|
|
7
7
|
export interface FlatConfig {
|
|
8
8
|
name: string;
|
|
@@ -23,9 +23,14 @@ export interface TestGovernancePlugin extends ESLint.Plugin {
|
|
|
23
23
|
configure(options?: GovernanceOptions): FlatConfig;
|
|
24
24
|
}
|
|
25
25
|
declare const plugin: TestGovernancePlugin;
|
|
26
|
+
/**
|
|
27
|
+
* Returns the recommended config with shared options in `settings['test-governance']`,
|
|
28
|
+
* so all rules read the same ticket formats and states.
|
|
29
|
+
*/
|
|
30
|
+
export declare function configure(options?: GovernanceOptions): FlatConfig;
|
|
26
31
|
export declare const configs: {
|
|
27
32
|
recommended: FlatConfig;
|
|
28
33
|
};
|
|
29
|
-
export declare const configure: (options?: GovernanceOptions) => FlatConfig;
|
|
30
34
|
export default plugin;
|
|
35
|
+
export { plugin as 'module.exports' };
|
|
31
36
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -1,43 +1,54 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
1
2
|
import markerMatchesState from './rules/marker-matches-state.js';
|
|
2
3
|
import noConflictingStates from './rules/no-conflicting-states.js';
|
|
3
4
|
import noOrphanedMarker from './rules/no-orphaned-marker.js';
|
|
5
|
+
import requireTicketInComments from './rules/require-ticket-in-comments.js';
|
|
4
6
|
import requireTicket from './rules/require-ticket.js';
|
|
5
7
|
import { compileOptions, SETTINGS_KEY } from './utils/options.js';
|
|
6
8
|
const PLUGIN_NAME = 'test-governance';
|
|
7
|
-
|
|
9
|
+
// Read at load time so the reported version always matches the published package.
|
|
10
|
+
const { version: VERSION } = createRequire(import.meta.url)('../package.json');
|
|
8
11
|
const ruleModules = {
|
|
9
12
|
'require-ticket': requireTicket,
|
|
10
13
|
'marker-matches-state': markerMatchesState,
|
|
11
14
|
'no-orphaned-marker': noOrphanedMarker,
|
|
12
15
|
'no-conflicting-states': noConflictingStates,
|
|
16
|
+
'require-ticket-in-comments': requireTicketInComments,
|
|
13
17
|
};
|
|
14
18
|
// Public types come from `eslint` itself, so users don't need typescript-eslint installed.
|
|
15
19
|
export const rules = ruleModules;
|
|
16
|
-
const recommendedRules = Object.fromEntries(Object.keys(ruleModules).map((name) => [
|
|
20
|
+
const recommendedRules = Object.fromEntries(Object.keys(ruleModules).map((name) => [
|
|
21
|
+
`${PLUGIN_NAME}/${name}`,
|
|
22
|
+
ruleModules[name].meta.docs.recommended,
|
|
23
|
+
]));
|
|
17
24
|
const plugin = {
|
|
18
25
|
meta: { name: 'eslint-plugin-test-governance', version: VERSION, namespace: PLUGIN_NAME },
|
|
19
26
|
rules,
|
|
20
27
|
configs: {},
|
|
21
|
-
|
|
22
|
-
* Returns the recommended config with shared options in `settings['test-governance']`,
|
|
23
|
-
* so all rules read the same ticket formats and states.
|
|
24
|
-
*/
|
|
25
|
-
configure(options = {}) {
|
|
26
|
-
compileOptions(options); // fail on a bad config at load time, not on the first lint
|
|
27
|
-
return {
|
|
28
|
-
name: `${PLUGIN_NAME}/recommended`,
|
|
29
|
-
plugins: { [PLUGIN_NAME]: plugin },
|
|
30
|
-
rules: { ...recommendedRules },
|
|
31
|
-
settings: { [SETTINGS_KEY]: options },
|
|
32
|
-
};
|
|
33
|
-
},
|
|
28
|
+
configure,
|
|
34
29
|
};
|
|
30
|
+
/**
|
|
31
|
+
* Returns the recommended config with shared options in `settings['test-governance']`,
|
|
32
|
+
* so all rules read the same ticket formats and states.
|
|
33
|
+
*/
|
|
34
|
+
export function configure(options = {}) {
|
|
35
|
+
compileOptions(options); // fail on a bad config at load time, not on the first lint
|
|
36
|
+
return {
|
|
37
|
+
name: `${PLUGIN_NAME}/recommended`,
|
|
38
|
+
plugins: { [PLUGIN_NAME]: plugin },
|
|
39
|
+
rules: { ...recommendedRules },
|
|
40
|
+
settings: { [SETTINGS_KEY]: options },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
35
43
|
plugin.configs.recommended = {
|
|
36
44
|
name: `${PLUGIN_NAME}/recommended`,
|
|
37
45
|
plugins: { [PLUGIN_NAME]: plugin },
|
|
38
46
|
rules: { ...recommendedRules },
|
|
39
47
|
};
|
|
40
48
|
export const configs = plugin.configs;
|
|
41
|
-
export const configure = plugin.configure;
|
|
42
49
|
export default plugin;
|
|
50
|
+
// Makes `require('eslint-plugin-test-governance')` return the plugin itself rather than the module
|
|
51
|
+
// namespace. With the namespace, a CommonJS config that registers the plugin next to
|
|
52
|
+
// `configs.recommended` fails with "Cannot redefine plugin".
|
|
53
|
+
export { plugin as 'module.exports' };
|
|
43
54
|
//# sourceMappingURL=index.js.map
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describeSubject, missingStates, strayMarkers } from '../utils/analyze.js';
|
|
1
|
+
import { 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({
|
|
@@ -17,13 +17,13 @@ export default createRule({
|
|
|
17
17
|
addMarker: 'Add `// {{expected}}: {{tickets}}` with the same ticket.',
|
|
18
18
|
},
|
|
19
19
|
},
|
|
20
|
-
check(context, analysis) {
|
|
20
|
+
check(context, analysis, options) {
|
|
21
21
|
const { sourceCode } = context;
|
|
22
22
|
for (const subject of analysis.subjects) {
|
|
23
23
|
const missing = missingStates(subject);
|
|
24
24
|
if (missing.length === 0)
|
|
25
25
|
continue;
|
|
26
|
-
const stray = strayMarkers(subject);
|
|
26
|
+
const stray = strayMarkers(subject, options);
|
|
27
27
|
const subjectName = describeSubject(subject).replace(/^This/, 'this');
|
|
28
28
|
for (const marker of stray) {
|
|
29
29
|
context.report({
|
|
@@ -40,7 +40,7 @@ export default createRule({
|
|
|
40
40
|
if (stray.length > 0)
|
|
41
41
|
continue;
|
|
42
42
|
// One marker written for a test that is in two states.
|
|
43
|
-
const present = subject.markers.find((m) => m.state !==
|
|
43
|
+
const present = subject.markers.filter(isStateMarker).find((m) => m.state !== missing[0]);
|
|
44
44
|
if (!present)
|
|
45
45
|
continue;
|
|
46
46
|
for (const state of missing) {
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { headLoc } from '../utils/analyze.js';
|
|
1
2
|
import { createRule } from '../utils/create-rule.js';
|
|
3
|
+
import { isTagState } from '../utils/options.js';
|
|
2
4
|
import { editDistance, typoThreshold } from '../utils/distance.js';
|
|
3
5
|
import { tagRange } from '../utils/fix.js';
|
|
4
6
|
export default createRule({
|
|
@@ -19,14 +21,15 @@ export default createRule({
|
|
|
19
21
|
},
|
|
20
22
|
check(context, analysis, options) {
|
|
21
23
|
const { sourceCode } = context;
|
|
22
|
-
const tagStates = options.states.filter(
|
|
24
|
+
const tagStates = options.states.filter(isTagState);
|
|
23
25
|
const newTag = options.states.find((s) => s.name === 'new')?.tag;
|
|
24
26
|
const unstableTag = options.states.find((s) => s.name === 'unstable')?.tag;
|
|
25
27
|
for (const subject of analysis.subjects) {
|
|
26
28
|
const ownIndex = new Map();
|
|
27
29
|
for (const occurrence of subject.tags) {
|
|
28
|
-
const
|
|
29
|
-
ownIndex.
|
|
30
|
+
const key = `${occurrence.tag}\0${occurrence.node.range[0]}`;
|
|
31
|
+
const index = ownIndex.get(key) ?? 0;
|
|
32
|
+
ownIndex.set(key, index + 1);
|
|
30
33
|
checkSpelling(occurrence, index);
|
|
31
34
|
}
|
|
32
35
|
if (newTag === undefined)
|
|
@@ -35,17 +38,21 @@ export default createRule({
|
|
|
35
38
|
const has = (tag) => own(tag) !== undefined || subject.inheritedTags.has(tag);
|
|
36
39
|
const ownNew = own(newTag);
|
|
37
40
|
if (unstableTag !== undefined && has(newTag) && has(unstableTag)) {
|
|
38
|
-
const
|
|
39
|
-
if (
|
|
41
|
+
const occurrence = ownNew ?? own(unstableTag);
|
|
42
|
+
if (occurrence) {
|
|
40
43
|
context.report({
|
|
41
|
-
node:
|
|
44
|
+
node: occurrence.node,
|
|
42
45
|
messageId: 'newAndUnstable',
|
|
43
46
|
data: { new: newTag, unstable: unstableTag },
|
|
44
47
|
});
|
|
45
48
|
}
|
|
46
49
|
}
|
|
47
50
|
if (has(newTag) && subject.skipped && (ownNew || subject.parent?.skipped !== true)) {
|
|
48
|
-
context.report({
|
|
51
|
+
context.report({
|
|
52
|
+
...(ownNew ? { node: ownNew.node } : { loc: headLoc(subject) }),
|
|
53
|
+
messageId: 'newSkipped',
|
|
54
|
+
data: { new: newTag },
|
|
55
|
+
});
|
|
49
56
|
}
|
|
50
57
|
}
|
|
51
58
|
function checkSpelling(occurrence, index) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describeSubject, missingStates, strayMarkers } from '../utils/analyze.js';
|
|
1
|
+
import { describeSubject, isProse, missingStates, strayMarkers } from '../utils/analyze.js';
|
|
2
2
|
import { createRule } from '../utils/create-rule.js';
|
|
3
3
|
import { removeComment } from '../utils/fix.js';
|
|
4
4
|
export default createRule({
|
|
@@ -16,18 +16,30 @@ export default createRule({
|
|
|
16
16
|
removeMarker: 'Remove this comment.',
|
|
17
17
|
},
|
|
18
18
|
},
|
|
19
|
-
check(context, analysis) {
|
|
19
|
+
check(context, analysis, options) {
|
|
20
|
+
// Files without Playwright tests, such as application code, have no markers to check.
|
|
21
|
+
if (!analysis.isTestFile)
|
|
22
|
+
return;
|
|
20
23
|
const { sourceCode } = context;
|
|
21
|
-
const markers =
|
|
24
|
+
const markers = [];
|
|
25
|
+
for (const marker of analysis.detachedMarkers) {
|
|
26
|
+
// `// FIXME: WEB-12` above a helper is a tracked work comment, checked by require-ticket-in-comments.
|
|
27
|
+
if (options.workCommentKeywords.has(marker.keyword))
|
|
28
|
+
continue;
|
|
29
|
+
markers.push({ marker, messageId: 'detached', subject: '' });
|
|
30
|
+
}
|
|
22
31
|
for (const subject of analysis.subjects) {
|
|
23
32
|
// A stray marker next to a missing one is a mismatch, reported by marker-matches-state.
|
|
24
33
|
if (missingStates(subject).length > 0)
|
|
25
34
|
continue;
|
|
26
35
|
const name = describeSubject(subject).replace(/^This/, 'this');
|
|
27
|
-
for (const marker of strayMarkers(subject))
|
|
36
|
+
for (const marker of strayMarkers(subject, options))
|
|
28
37
|
markers.push({ marker, messageId: 'orphaned', subject: name });
|
|
29
38
|
}
|
|
30
39
|
for (const { marker, messageId, subject } of markers) {
|
|
40
|
+
// `// SKIP: this is flaky` is prose, not a leftover ticket.
|
|
41
|
+
if (isProse(marker))
|
|
42
|
+
continue;
|
|
31
43
|
context.report({
|
|
32
44
|
loc: marker.comment.loc,
|
|
33
45
|
messageId,
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { appliesTo, isStateMarker, parseTickets } from '../utils/analyze.js';
|
|
2
|
+
import { createRule } from '../utils/create-rule.js';
|
|
3
|
+
import { compileTicketSpec } from '../utils/tickets.js';
|
|
4
|
+
/** Recognises anything shaped like a ticket, to tell a wrong-format ticket from plain prose. */
|
|
5
|
+
const ticketShape = compileTicketSpec([{ preset: 'any' }], []);
|
|
6
|
+
function escapeRegExp(text) {
|
|
7
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
8
|
+
}
|
|
9
|
+
export default createRule({
|
|
10
|
+
name: 'require-ticket-in-comments',
|
|
11
|
+
meta: {
|
|
12
|
+
type: 'suggestion',
|
|
13
|
+
hasSuggestions: true,
|
|
14
|
+
docs: {
|
|
15
|
+
description: 'Require FIXME and TODO comments in test files to start with a ticket',
|
|
16
|
+
recommended: 'error',
|
|
17
|
+
},
|
|
18
|
+
messages: {
|
|
19
|
+
missingTicket: '`{{keyword}}` comments must start with a ticket, e.g. `// {{upper}}: {{example}}`. Put the details in the ticket.',
|
|
20
|
+
invalidTicket: "'{{ticket}}' is not a valid ticket. Expected {{expected}}.",
|
|
21
|
+
placeholderTicket: "'{{ticket}}' is a placeholder, not a real ticket. Link the ticket that tracks this work.",
|
|
22
|
+
extraText: 'Only ticket IDs are allowed after `{{keyword}}:`, separated by commas. Put details in the ticket instead of `{{text}}`.',
|
|
23
|
+
removeExtraText: 'Remove `{{text}}`.',
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
check(context, analysis, options) {
|
|
27
|
+
const keywords = options.commentKeywords;
|
|
28
|
+
if (keywords.length === 0 || !analysis.isTestFile)
|
|
29
|
+
return;
|
|
30
|
+
const { sourceCode } = context;
|
|
31
|
+
const matcher = options.commentTicket;
|
|
32
|
+
// A marker directly above a test in that state belongs to require-ticket.
|
|
33
|
+
const owned = new Set();
|
|
34
|
+
for (const subject of analysis.subjects) {
|
|
35
|
+
for (const marker of subject.markers) {
|
|
36
|
+
if (isStateMarker(marker) && appliesTo(subject, marker.state.name))
|
|
37
|
+
owned.add(marker.comment);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
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.
|
|
42
|
+
const alternatives = keywords.map(escapeRegExp).join('|');
|
|
43
|
+
const keywordRe = new RegExp(`^(\\s*\\*?\\s*)(${alternatives})(?:\\s*:|(?=\\s|$))`, 'i');
|
|
44
|
+
for (const comment of sourceCode.getAllComments()) {
|
|
45
|
+
if (owned.has(comment))
|
|
46
|
+
continue;
|
|
47
|
+
// `//` and `/*` are both two characters, so the comment's value starts two characters in.
|
|
48
|
+
let lineStart = comment.range[0] + 2;
|
|
49
|
+
for (const rawLine of comment.value.split('\n')) {
|
|
50
|
+
const line = rawLine.replace(/\r$/, '');
|
|
51
|
+
const start = lineStart;
|
|
52
|
+
lineStart += rawLine.length + 1;
|
|
53
|
+
const match = keywordRe.exec(line);
|
|
54
|
+
if (!match)
|
|
55
|
+
continue;
|
|
56
|
+
const [whole, indent, keyword] = match;
|
|
57
|
+
const upper = keyword.toUpperCase();
|
|
58
|
+
if (keyword !== upper && !whole.endsWith(':'))
|
|
59
|
+
continue;
|
|
60
|
+
const { tickets, extra } = parseTickets(line.slice(whole.length), start + whole.length, matcher);
|
|
61
|
+
const loc = {
|
|
62
|
+
start: sourceCode.getLocFromIndex(start + indent.length),
|
|
63
|
+
end: sourceCode.getLocFromIndex(start + line.trimEnd().length),
|
|
64
|
+
};
|
|
65
|
+
const data = { keyword, upper, example: matcher.example };
|
|
66
|
+
const first = tickets.at(0);
|
|
67
|
+
const bad = tickets.find((t) => t.result !== 'ok');
|
|
68
|
+
if (first === undefined || (first.result === 'format' && ticketShape.check(first.text) !== 'ok')) {
|
|
69
|
+
context.report({ loc, messageId: 'missingTicket', data });
|
|
70
|
+
}
|
|
71
|
+
else if (bad) {
|
|
72
|
+
context.report({
|
|
73
|
+
loc,
|
|
74
|
+
messageId: bad.result === 'placeholder' ? 'placeholderTicket' : 'invalidTicket',
|
|
75
|
+
data: { ...data, ticket: bad.text, expected: matcher.expected },
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
else if (extra && !options.allowNotes) {
|
|
79
|
+
context.report({
|
|
80
|
+
loc: { start: sourceCode.getLocFromIndex(extra.range[0]), end: sourceCode.getLocFromIndex(extra.range[1]) },
|
|
81
|
+
messageId: 'extraText',
|
|
82
|
+
data: { keyword, text: extra.text },
|
|
83
|
+
suggest: [
|
|
84
|
+
{
|
|
85
|
+
messageId: 'removeExtraText',
|
|
86
|
+
data: { text: extra.text },
|
|
87
|
+
fix: (fixer) => fixer.removeRange(extra.range),
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
//# sourceMappingURL=require-ticket-in-comments.js.map
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
declare const _default: import("../utils/create-rule.js").Rule<"
|
|
1
|
+
declare const _default: import("../utils/create-rule.js").Rule<"missingTicket" | "invalidTicket" | "placeholderTicket" | "extraText" | "removeExtraText" | "missingMarker" | "markerCase" | "dynamicTitle">;
|
|
2
2
|
export default _default;
|
|
3
3
|
//# sourceMappingURL=require-ticket.d.ts.map
|