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 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
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Andrey
3
+ Copyright (c) 2026 Andrey Rychkov
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
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 typo fails immediately rather
66
- than on the first lint.
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                  | Description | 💼 | ⚠️ | 🔧 | 💡 |
79
- | :----------------------------------------------------------- | :-------------------------------------------------------------------------------- | :- | :- | :- | :- |
80
- | [marker-matches-state](docs/rules/marker-matches-state.md) | Require the marker keyword to match the test's state | ✅ | | | 💡 |
81
- | [no-conflicting-states](docs/rules/no-conflicting-states.md) | Disallow state tags that contradict each other, differ in case or look like typos | | ✅ | 🔧 | |
82
- | [no-orphaned-marker](docs/rules/no-orphaned-marker.md) | Disallow marker comments that no longer match a test state | ✅ | | | 💡 |
83
- | [require-ticket](docs/rules/require-ticket.md) | Require a ticket marker comment above skipped, fixme and tagged tests | ✅ | | | 💡 |
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 | Default | Why |
92
- | --- | --- | --- |
93
- | States that need a ticket | `skip`, `fixme` | Every team skips tests. Chromium, GitLab and Slack all require a bug link on disabled tests. |
94
- | `@new`, `@unstable` | off; `lifecycleTags: true` turns both on | A promotion workflow is not universal. Teams that call it `@flaky` or `@quarantine` use `customStates`. |
95
- | Conditional skips (`test.skip(browserName === 'webkit', ...)`) | no ticket needed | These are usually permanent platform limits, not bugs. `requireTicketForConditional: true` changes this. |
96
- | `test.fail`, `test.slow` | off | They don't remove coverage. Turn them on with `states: { fail: true, slow: true }`. |
97
- | Marker format | `// SKIP: WEB-123` or `// SKIP: WEB-123, WEB-124` | Uppercase keyword and colon, like `TODO:`. Easy to grep. |
98
- | 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. |
99
- | Placement | comment block directly above; other comments allowed; a blank line breaks it | Survives `eslint-disable-next-line` and Prettier. |
100
- | 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. |
101
- | 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. |
102
- | Describe blocks | a marker on a skipped or tagged describe covers its tests | One ticket per skipped suite. |
103
- | 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. |
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. Each rule also accepts the same object as a rule option, which overrides the shared
109
- settings for that rule.
110
-
111
- | Option | Type | Default | Description |
112
- | --- | --- | --- | --- |
113
- | `ticket` | `TicketSpec \| TicketSpec[]` | `{ preset: 'any' }` | Accepted ticket formats. An array accepts a ticket that matches any entry. |
114
- | `placeholders` | `string[]` | see above | Tickets rejected as placeholders. `*` matches any characters. Case-insensitive; a leading `#` is ignored. Tickets numbered 0 are always rejected. |
115
- | `lifecycleTags` | `boolean` | `false` | Turns on the `new` (`@new`) and `unstable` (`@unstable`) states. |
116
- | `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. |
117
- | `customStates` | `{ [name]: { when, marker, ticket? } }` | `{}` | Adds a state for a tag, e.g. `{ quarantine: { when: '@quarantine', marker: 'QUARANTINE' } }`. |
118
- | `requireTicketForConditional` | `boolean` | `false` | Require a ticket for `test.skip(condition, ...)` too. |
119
- | `allowBlankLine` | `boolean` | `false` | Let blank lines separate a marker from its test. |
120
- | `allowNotes` | `boolean` | `false` | Allow free text after the tickets, e.g. `// SKIP: WEB-123 flaky on CI`. |
121
- | `testFunctions` | `string[]` | `['test']` | Names treated as Playwright's `test`. See below. |
122
- | `reportDynamicTitles` | `boolean` | `false` | When a tag state is on, report titles that aren't static text, since their tags can't be read. |
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 | Accepts | Options |
127
- | --- | --- | --- |
128
- | `any` | `PROJ-123`, `#123`, `owner/repo#123`, any http(s) URL | |
129
- | `jira` | `WEB-123`, `https://<host>/browse/WEB-123`, board URLs with `selectedIssue=WEB-123` | `projects`, `host` |
130
- | `github` | `#123`, `owner/repo#123`, `https://github.com/owner/repo/issues/123` (or `/pull/`) | `host` for GitHub Enterprise |
131
- | `gitlab` | `#123`, `group/project#123`, `https://gitlab.com/group/project/-/issues/123` | `host` for self-managed |
132
- | `linear` | `ENG-123`, `https://linear.app/<workspace>/issue/ENG-123/...` | `teams` |
133
- | `azure-devops` | `AB#123`, `https://dev.azure.com/<org>/<project>/_workitems/edit/123` | `host` for Azure DevOps Server |
134
- | `numeric` | `4821` | `minLength` (1), `maxLength` (20) |
135
- | `pattern` | anything matching your regex, anchored at both ends | `pattern`, `flags` |
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`, including aliases such as `import { test as it }` and
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 `const test = base.extend({...})`.
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
- const VERSION = '0.1.0';
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) => [`${PLUGIN_NAME}/${name}`, ruleModules[name].meta.docs.recommended]));
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 !== undefined && m.state !== missing[0]);
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((s) => s.tag !== undefined);
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 index = ownIndex.get(occurrence.tag + '\0' + occurrence.node.range[0]) ?? 0;
29
- ownIndex.set(occurrence.tag + '\0' + occurrence.node.range[0], index + 1);
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 ownUnstable = own(unstableTag);
39
- if (ownNew || ownUnstable) {
41
+ const occurrence = ownNew ?? own(unstableTag);
42
+ if (occurrence) {
40
43
  context.report({
41
- node: (ownNew ?? ownUnstable).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({ node: ownNew?.node ?? subject.node, messageId: 'newSkipped', data: { new: newTag } });
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 = analysis.detachedMarkers.map((marker) => ({ marker, messageId: 'detached', subject: '' }));
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,3 @@
1
+ declare const _default: import("../utils/create-rule.js").Rule<"missingTicket" | "invalidTicket" | "placeholderTicket" | "extraText" | "removeExtraText">;
2
+ export default _default;
3
+ //# sourceMappingURL=require-ticket-in-comments.d.ts.map
@@ -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<"missingMarker" | "missingTicket" | "invalidTicket" | "placeholderTicket" | "markerCase" | "extraText" | "removeExtraText" | "dynamicTitle">;
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