eslint-plugin-test-governance 0.1.0 → 0.2.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.
@@ -1,4 +1,4 @@
1
- import { appliesTo, describeSubject, evaluate } from '../utils/analyze.js';
1
+ import { appliesTo, describeSubject, evaluate, headLoc } from '../utils/analyze.js';
2
2
  import { createRule } from '../utils/create-rule.js';
3
3
  export default createRule({
4
4
  name: 'require-ticket',
@@ -33,7 +33,9 @@ export default createRule({
33
33
  if (!required)
34
34
  continue;
35
35
  const result = evaluate(subject, state);
36
- if (result.kind !== 'ok' && result.kind !== 'missing' && !reportOnce(`${result.marker.comment.range[0]}:${state.name}`)) {
36
+ if (result.kind !== 'ok' &&
37
+ result.kind !== 'missing' &&
38
+ !reportOnce(`${result.marker.comment.range[0]}:${state.name}`)) {
37
39
  continue;
38
40
  }
39
41
  const data = { state: state.name, marker: state.marker, example: state.ticket.example };
@@ -42,7 +44,7 @@ export default createRule({
42
44
  break;
43
45
  case 'missing':
44
46
  context.report({
45
- node: subject.node,
47
+ loc: headLoc(subject),
46
48
  messageId: 'missingMarker',
47
49
  data: { ...data, subject: describeSubject(subject) },
48
50
  });
@@ -1,6 +1,6 @@
1
1
  import type { TSESLint, TSESTree } from '@typescript-eslint/utils';
2
2
  import type { ResolvedOptions, StateDef } from './options.js';
3
- import type { TicketCheck } from './tickets.js';
3
+ import type { TicketCheck, TicketMatcher } from './tickets.js';
4
4
  type Node = TSESTree.Node;
5
5
  type Comment = TSESTree.Comment;
6
6
  type SourceCode = Readonly<TSESLint.SourceCode>;
@@ -23,6 +23,11 @@ export interface Marker {
23
23
  range: TSESTree.Range;
24
24
  };
25
25
  }
26
+ /** A marker whose keyword is exactly a state's marker. */
27
+ export type StateMarker = Marker & {
28
+ state: StateDef;
29
+ };
30
+ export declare function isStateMarker(marker: Marker): marker is StateMarker;
26
31
  export interface TagOccurrence {
27
32
  tag: string;
28
33
  node: TSESTree.Literal | TSESTree.TemplateLiteral;
@@ -57,7 +62,9 @@ export interface Subject {
57
62
  export interface Analysis {
58
63
  subjects: Subject[];
59
64
  /** Markers that are not in the comment block of any test, describe or runtime call. */
60
- detachedMarkers: Marker[];
65
+ detachedMarkers: StateMarker[];
66
+ /** Whether the file uses Playwright at all: a Playwright import or a call to a test function. */
67
+ isTestFile: boolean;
61
68
  }
62
69
  export type Evaluation = {
63
70
  kind: 'ok';
@@ -74,14 +81,37 @@ export type Evaluation = {
74
81
  marker: Marker;
75
82
  ticket: MarkerTicket;
76
83
  };
84
+ /**
85
+ * Parses `TICKET[, TICKET...]` from the text after a marker's colon. `offset` is the source position
86
+ * of `text[0]`. Anything after the last ticket, including a trailing comma, is returned as `extra`.
87
+ */
88
+ export declare function parseTickets(text: string, offset: number, matcher: TicketMatcher): Pick<Marker, 'tickets' | 'extra'>;
77
89
  export declare function analyze(sourceCode: SourceCode, options: ResolvedOptions): Analysis;
78
90
  /** Whether `subject` is in `stateName` at all, required or not, including inherited and runtime states. */
79
91
  export declare function appliesTo(subject: Subject, stateName: string): boolean;
80
92
  export declare function evaluate(subject: Subject, state: StateDef): Evaluation;
81
93
  /** Required states on `subject` that have no marker at all (not even a broken one). */
82
94
  export declare function missingStates(subject: Subject): StateDef[];
83
- /** Markers in the subject's own block for states that don't apply to it. */
84
- export declare function strayMarkers(subject: Subject): Marker[];
95
+ /**
96
+ * Whether a marker reads as an ordinary comment rather than a ticket reference, such as
97
+ * `// FIXME: this breaks on slow machines`: it starts with something that is not a ticket and goes on
98
+ * after it.
99
+ */
100
+ export declare function isProse(marker: Marker): boolean;
101
+ /**
102
+ * Whether a marker is really an ordinary work comment such as `// FIXME: refactor this`: its keyword
103
+ * is also a work-comment keyword and it doesn't start with a valid ticket. `require-ticket-in-comments`
104
+ * reports those; the marker rules leave them alone.
105
+ */
106
+ export declare function isWorkComment(marker: Marker, options: ResolvedOptions): boolean;
107
+ /** Markers in the subject's own block for states that don't apply to it, leaving out work comments. */
108
+ export declare function strayMarkers(subject: Subject, options: ResolvedOptions): StateMarker[];
109
+ /**
110
+ * Where to report a problem with a test or describe: from the callee to the end of the title
111
+ * (`test.skip('pays with PayPal'`), so editors underline the declaration rather than the whole body.
112
+ * Runtime calls such as `test.skip()` are short, so they are reported whole.
113
+ */
114
+ export declare function headLoc(subject: Subject): TSESTree.SourceLocation;
85
115
  export declare function describeSubject(subject: Subject): string;
86
116
  export {};
87
117
  //# sourceMappingURL=analyze.d.ts.map
@@ -1,10 +1,19 @@
1
1
  const PLAYWRIGHT_MODULES = new Set(['@playwright/test', 'playwright/test']);
2
+ /** Other test runners. A name imported from one of these is never Playwright's `test`. */
3
+ const OTHER_FRAMEWORKS = new Set(['vitest', '@jest/globals', 'node:test', 'bun:test', 'mocha', 'ava', 'tap', 'uvu']);
4
+ /** `@playwright/test` and the component-testing packages such as `@playwright/experimental-ct-react`. */
5
+ function isPlaywrightModule(source) {
6
+ return PLAYWRIGHT_MODULES.has(source) || source.startsWith('@playwright/experimental-ct-');
7
+ }
2
8
  const TEST_MODIFIERS = new Set(['only', 'skip', 'fixme', 'fail', 'slow']);
3
9
  const DESCRIBE_MODIFIERS = new Set(['only', 'skip', 'fixme', 'serial', 'parallel']);
4
10
  const RUNTIME_MODIFIERS = new Set(['skip', 'fixme', 'fail', 'slow']);
5
11
  const SKIPPING_MODIFIERS = new Set(['skip', 'fixme']);
6
12
  const MARKER_LINE_RE = /^\s*\*?\s*([A-Za-z][\w-]*)\s*:(.*)$/;
7
13
  const TAG_RE = /(?<![\w@])@[\w-]+/g;
14
+ export function isStateMarker(marker) {
15
+ return marker.state !== undefined;
16
+ }
8
17
  function memberName(node) {
9
18
  if (!node.computed && node.property.type === 'Identifier')
10
19
  return node.property.name;
@@ -22,7 +31,7 @@ function chainOf(node) {
22
31
  path.unshift(name);
23
32
  current = current.object;
24
33
  }
25
- return current.type === 'Identifier' ? { root: current.name, path } : undefined;
34
+ return current.type === 'Identifier' ? { root: current, path } : undefined;
26
35
  }
27
36
  function unwrap(node) {
28
37
  let current = node;
@@ -36,26 +45,39 @@ function unwrap(node) {
36
45
  function isFunction(node) {
37
46
  return node?.type === 'ArrowFunctionExpression' || node?.type === 'FunctionExpression';
38
47
  }
39
- /** Collects the local names that refer to Playwright's `test`. */
48
+ /** Collects the top-level names that refer to Playwright's `test`. */
40
49
  function collectTestNames(program, configured) {
41
50
  const names = new Set(configured);
51
+ const mergeTests = new Set();
42
52
  const isTestExpression = (node) => {
43
53
  const target = unwrap(node);
44
54
  if (target.type === 'Identifier')
45
55
  return names.has(target.name);
46
- if (target.type !== 'CallExpression' || target.callee.type !== 'MemberExpression')
56
+ if (target.type !== 'CallExpression')
57
+ return false;
58
+ // mergeTests(dbTest, a11yTest)
59
+ if (target.callee.type === 'Identifier')
60
+ return mergeTests.has(target.callee.name);
61
+ if (target.callee.type !== 'MemberExpression')
47
62
  return false;
48
63
  return memberName(target.callee) === 'extend' && isTestExpression(target.callee.object);
49
64
  };
50
65
  for (const statement of program.body) {
51
66
  if (statement.type === 'ImportDeclaration') {
52
- if (!PLAYWRIGHT_MODULES.has(statement.source.value))
53
- continue;
54
- for (const spec of statement.specifiers) {
55
- if (spec.type === 'ImportDefaultSpecifier')
56
- names.add(spec.local.name);
57
- else if (spec.type === 'ImportSpecifier' && importedName(spec) === 'test')
58
- names.add(spec.local.name);
67
+ const source = statement.source.value;
68
+ if (OTHER_FRAMEWORKS.has(source)) {
69
+ for (const spec of statement.specifiers)
70
+ names.delete(spec.local.name);
71
+ }
72
+ else if (isPlaywrightModule(source)) {
73
+ for (const spec of statement.specifiers) {
74
+ if (spec.type === 'ImportDefaultSpecifier')
75
+ names.add(spec.local.name);
76
+ else if (spec.type === 'ImportSpecifier' && importedName(spec) === 'test')
77
+ names.add(spec.local.name);
78
+ else if (spec.type === 'ImportSpecifier' && importedName(spec) === 'mergeTests')
79
+ mergeTests.add(spec.local.name);
80
+ }
59
81
  }
60
82
  continue;
61
83
  }
@@ -69,11 +91,17 @@ function collectTestNames(program, configured) {
69
91
  if (declarator.id.type === 'Identifier' && init.type === 'CallExpression' && isTestExpression(init)) {
70
92
  names.add(declarator.id.name);
71
93
  }
72
- else if (declarator.id.type === 'ObjectPattern' && isPlaywrightRequire(init)) {
94
+ else if (declarator.id.type === 'ObjectPattern') {
95
+ const source = requireSource(init);
96
+ if (source === undefined)
97
+ continue;
73
98
  for (const prop of declarator.id.properties) {
74
- if (prop.type === 'Property' && prop.key.type === 'Identifier' && prop.key.name === 'test' && prop.value.type === 'Identifier') {
99
+ if (prop.type !== 'Property' || prop.key.type !== 'Identifier' || prop.value.type !== 'Identifier')
100
+ continue;
101
+ if (OTHER_FRAMEWORKS.has(source))
102
+ names.delete(prop.value.name);
103
+ else if (isPlaywrightModule(source) && prop.key.name === 'test')
75
104
  names.add(prop.value.name);
76
- }
77
105
  }
78
106
  }
79
107
  }
@@ -81,19 +109,36 @@ function collectTestNames(program, configured) {
81
109
  return names;
82
110
  }
83
111
  function importedName(spec) {
84
- return spec.imported.type === 'Identifier' ? spec.imported.name : String(spec.imported.value);
112
+ return spec.imported.type === 'Identifier' ? spec.imported.name : spec.imported.value;
113
+ }
114
+ /** The module name of `require('...')`, or undefined for anything else. */
115
+ function requireSource(node) {
116
+ if (node.type !== 'CallExpression' || node.callee.type !== 'Identifier' || node.callee.name !== 'require')
117
+ return undefined;
118
+ const first = node.arguments.at(0);
119
+ return first?.type === 'Literal' && typeof first.value === 'string' ? first.value : undefined;
85
120
  }
86
- function isPlaywrightRequire(node) {
87
- return (node.type === 'CallExpression' &&
88
- node.callee.type === 'Identifier' &&
89
- node.callee.name === 'require' &&
90
- node.arguments[0]?.type === 'Literal' &&
91
- PLAYWRIGHT_MODULES.has(String(node.arguments[0].value)));
121
+ /**
122
+ * Whether `id` refers to a variable declared inside a function or block, such as a local helper that
123
+ * happens to be called `test`. Top-level variables, imports, parameters and globals still count.
124
+ */
125
+ function isLocalVariable(sourceCode, id) {
126
+ for (let scope = sourceCode.getScope(id); scope; scope = scope.upper) {
127
+ const variable = scope.set.get(id.name);
128
+ if (!variable)
129
+ continue;
130
+ if (scope.type === 'module' || scope.type === 'global')
131
+ return false;
132
+ return variable.defs.some((def) => def.type === 'Variable');
133
+ }
134
+ return false;
92
135
  }
93
136
  const STATEMENT_CONTAINERS = new Set(['Program', 'BlockStatement', 'StaticBlock', 'SwitchCase', 'TSModuleBlock']);
94
137
  /** The statement a call belongs to; its marker block sits above that statement. */
95
138
  function anchorOf(call) {
96
139
  let node = call;
140
+ // A call always sits inside a statement container, so the walk stops before it runs out of parents.
141
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- see above
97
142
  while (!STATEMENT_CONTAINERS.has(node.parent.type))
98
143
  node = node.parent;
99
144
  return node;
@@ -108,7 +153,7 @@ function commentBlock(sourceCode, anchor, allowBlankLine) {
108
153
  if (!allowBlankLine && nextLine - comment.loc.end.line > 1)
109
154
  break;
110
155
  const before = sourceCode.getTokenBefore(comment, { includeComments: false });
111
- if (before && before.loc.end.line === comment.loc.start.line)
156
+ if (before?.loc.end.line === comment.loc.start.line)
112
157
  break;
113
158
  block.unshift(comment);
114
159
  nextLine = comment.loc.start.line;
@@ -119,7 +164,7 @@ function commentBlock(sourceCode, anchor, allowBlankLine) {
119
164
  * Parses `TICKET[, TICKET...]` from the text after a marker's colon. `offset` is the source position
120
165
  * of `text[0]`. Anything after the last ticket, including a trailing comma, is returned as `extra`.
121
166
  */
122
- function parseTickets(text, offset, state) {
167
+ export function parseTickets(text, offset, matcher) {
123
168
  const tickets = [];
124
169
  let index = text.length - text.trimStart().length;
125
170
  let lastEnd = 0;
@@ -129,7 +174,7 @@ function parseTickets(text, offset, state) {
129
174
  break;
130
175
  // Punctuation right after a ticket (`WEB-1:` or `WEB-1.`) is not part of it.
131
176
  const ticket = token.replace(/[.:;]+$/, '');
132
- tickets.push({ text: ticket, result: state.ticket.check(ticket) });
177
+ tickets.push({ text: ticket, result: matcher.check(ticket) });
133
178
  index += ticket.length;
134
179
  lastEnd = index;
135
180
  const separator = /^\s*,\s*/.exec(text.slice(index))?.[0];
@@ -156,7 +201,7 @@ function parseMarkers(comment, states) {
156
201
  const state = states.find((s) => s.marker === keyword);
157
202
  if (state) {
158
203
  const restOffset = start + match[0].length - rest.length;
159
- markers.push({ comment, keyword, state, ...parseTickets(rest, restOffset, state) });
204
+ markers.push({ comment, keyword, state, ...parseTickets(rest, restOffset, state.ticket) });
160
205
  continue;
161
206
  }
162
207
  const caseOf = states.find((s) => s.marker === keyword.toUpperCase());
@@ -166,8 +211,11 @@ function parseMarkers(comment, states) {
166
211
  return markers;
167
212
  }
168
213
  function scanTags(node, out) {
169
- // `cooked` is only null in tagged templates, which are never titles.
170
- const texts = node.type === 'Literal' ? [String(node.value)] : node.quasis.map((quasi) => quasi.value.cooked);
214
+ const texts = node.type === 'Literal'
215
+ ? [String(node.value)]
216
+ : // `cooked` is only null in tagged templates, which are never titles.
217
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- see above
218
+ node.quasis.map((quasi) => quasi.value.cooked);
171
219
  for (const text of texts) {
172
220
  for (const match of text.matchAll(TAG_RE))
173
221
  out.push({ tag: match[0], node });
@@ -201,8 +249,26 @@ function collectTags(call, hasTitle) {
201
249
  return { tags, dynamic };
202
250
  }
203
251
  function isUnconditional(call) {
204
- const [first] = call.arguments;
205
- return first === undefined || (first.type === 'Literal' && first.value === true);
252
+ const first = call.arguments.at(0);
253
+ return (first === undefined || (first.type === 'Literal' && first.value === true)) && !isGuarded(call);
254
+ }
255
+ const FUNCTIONS = new Set(['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression']);
256
+ /**
257
+ * Whether the call only runs on some paths of its function: under an `if`, a `switch` case, a ternary
258
+ * or the right side of `&&`, `||` or `??`. `if (!enabled) test.skip()` is the same as
259
+ * `test.skip(!enabled)`.
260
+ */
261
+ function isGuarded(call) {
262
+ let child = call;
263
+ for (let node = call.parent; node && !FUNCTIONS.has(node.type); child = node, node = node.parent) {
264
+ if ((node.type === 'IfStatement' || node.type === 'ConditionalExpression') && child !== node.test)
265
+ return true;
266
+ if (node.type === 'SwitchCase' && child !== node.test)
267
+ return true;
268
+ if (node.type === 'LogicalExpression' && child === node.right)
269
+ return true;
270
+ }
271
+ return false;
206
272
  }
207
273
  const cache = new WeakMap();
208
274
  export function analyze(sourceCode, options) {
@@ -221,6 +287,7 @@ export function analyze(sourceCode, options) {
221
287
  function runAnalysis(sourceCode, options) {
222
288
  const { states } = options;
223
289
  const testNames = collectTestNames(sourceCode.ast, options.testFunctions);
290
+ let usesTest = sourceCode.ast.body.some((statement) => statement.type === 'ImportDeclaration' && isPlaywrightModule(statement.source.value));
224
291
  const subjects = [];
225
292
  const claimed = new Set();
226
293
  const stack = [];
@@ -275,10 +342,11 @@ function runAnalysis(sourceCode, options) {
275
342
  const args = call.arguments;
276
343
  const last = args.at(-1);
277
344
  let testInfo;
278
- if (chain && testNames.has(chain.root)) {
345
+ if (chain && testNames.has(chain.root.name) && !isLocalVariable(sourceCode, chain.root)) {
346
+ usesTest = true;
279
347
  const { path } = chain;
280
348
  if (isFunction(last)) {
281
- const param = last.params[1];
349
+ const param = last.params.at(1);
282
350
  if (param?.type === 'Identifier')
283
351
  testInfo = param.name;
284
352
  }
@@ -304,7 +372,7 @@ function runAnalysis(sourceCode, options) {
304
372
  (object.type === 'CallExpression' &&
305
373
  (() => {
306
374
  const inner = chainOf(object.callee);
307
- return inner !== undefined && testNames.has(inner.root) && inner.path.join('.') === 'info';
375
+ return inner !== undefined && testNames.has(inner.root.name) && inner.path.join('.') === 'info';
308
376
  })());
309
377
  if (isTestInfo)
310
378
  return { subject: runtimeSubject(call, name) };
@@ -363,8 +431,8 @@ function runAnalysis(sourceCode, options) {
363
431
  .getAllComments()
364
432
  .filter((comment) => !claimed.has(comment))
365
433
  .flatMap((comment) => parseMarkers(comment, states))
366
- .filter((marker) => marker.state !== undefined);
367
- return { subjects, detachedMarkers };
434
+ .filter(isStateMarker);
435
+ return { subjects, detachedMarkers, isTestFile: usesTest };
368
436
  }
369
437
  /** Whether `subject` is in `stateName` at all, required or not, including inherited and runtime states. */
370
438
  export function appliesTo(subject, stateName) {
@@ -391,24 +459,50 @@ export function evaluate(subject, state) {
391
459
  const marker = subject.markers.find((m) => m.caseOf === state);
392
460
  return marker ? { kind: 'case', marker } : { kind: 'missing' };
393
461
  }
394
- let firstProblem;
395
- for (const marker of markers) {
396
- const bad = marker.tickets.find((t) => t.result !== 'ok');
397
- if (marker.tickets.length > 0 && !bad)
398
- return { kind: 'ok' };
399
- firstProblem ??= bad ? { kind: 'bad-ticket', marker, ticket: bad } : { kind: 'no-ticket', marker };
400
- }
401
- return firstProblem;
462
+ if (markers.some((m) => m.tickets.length > 0 && m.tickets.every((t) => t.result === 'ok')))
463
+ return { kind: 'ok' };
464
+ // Report the problem with the closest marker.
465
+ const [marker] = markers;
466
+ const bad = marker.tickets.find((t) => t.result !== 'ok');
467
+ return bad ? { kind: 'bad-ticket', marker, ticket: bad } : { kind: 'no-ticket', marker };
402
468
  }
403
469
  /** Required states on `subject` that have no marker at all (not even a broken one). */
404
470
  export function missingStates(subject) {
405
- return subject.states
406
- .filter((s) => s.required && evaluate(subject, s.state).kind === 'missing')
407
- .map((s) => s.state);
471
+ return subject.states.filter((s) => s.required && evaluate(subject, s.state).kind === 'missing').map((s) => s.state);
408
472
  }
409
- /** Markers in the subject's own block for states that don't apply to it. */
410
- export function strayMarkers(subject) {
411
- return subject.markers.filter((m) => m.state !== undefined && !appliesTo(subject, m.state.name));
473
+ /**
474
+ * Whether a marker reads as an ordinary comment rather than a ticket reference, such as
475
+ * `// FIXME: this breaks on slow machines`: it starts with something that is not a ticket and goes on
476
+ * after it.
477
+ */
478
+ export function isProse(marker) {
479
+ return marker.extra !== undefined && marker.tickets[0].result !== 'ok';
480
+ }
481
+ /**
482
+ * Whether a marker is really an ordinary work comment such as `// FIXME: refactor this`: its keyword
483
+ * is also a work-comment keyword and it doesn't start with a valid ticket. `require-ticket-in-comments`
484
+ * reports those; the marker rules leave them alone.
485
+ */
486
+ export function isWorkComment(marker, options) {
487
+ return options.workCommentKeywords.has(marker.keyword.toUpperCase()) && marker.tickets.at(0)?.result !== 'ok';
488
+ }
489
+ /** Markers in the subject's own block for states that don't apply to it, leaving out work comments. */
490
+ export function strayMarkers(subject, options) {
491
+ return subject.markers
492
+ .filter(isStateMarker)
493
+ .filter((m) => !appliesTo(subject, m.state.name) && !isWorkComment(m, options));
494
+ }
495
+ /**
496
+ * Where to report a problem with a test or describe: from the callee to the end of the title
497
+ * (`test.skip('pays with PayPal'`), so editors underline the declaration rather than the whole body.
498
+ * Runtime calls such as `test.skip()` are short, so they are reported whole.
499
+ */
500
+ export function headLoc(subject) {
501
+ const { node } = subject;
502
+ if (subject.kind === 'runtime')
503
+ return node.loc;
504
+ const title = node.arguments.length > 1 ? node.arguments[0] : undefined;
505
+ return { start: node.callee.loc.start, end: (title ?? node.callee).loc.end };
412
506
  }
413
507
  export function describeSubject(subject) {
414
508
  if (subject.kind === 'describe')
@@ -0,0 +1,10 @@
1
+ /** Marker keywords: uppercase letters, digits, `-` and `_`, starting with a letter. */
2
+ export declare const MARKER_RE: RegExp;
3
+ /** A tag as written in options: `@` followed by letters, digits, `-` and `_`. */
4
+ export declare const TAG_RE: RegExp;
5
+ export declare const BUILTIN_STATE_NAMES: readonly ["skip", "fixme", "fail", "slow", "new", "unstable"];
6
+ export type BuiltinStateName = (typeof BUILTIN_STATE_NAMES)[number];
7
+ export declare const TICKET_PRESETS: readonly ["any", "jira", "github", "gitlab", "linear", "azure-devops", "numeric", "pattern"];
8
+ /** Comment keywords that must reference a ticket by default (`// TODO: WEB-123`). */
9
+ export declare const DEFAULT_COMMENT_KEYWORDS: string[];
10
+ //# sourceMappingURL=constants.d.ts.map
@@ -0,0 +1,18 @@
1
+ /** Marker keywords: uppercase letters, digits, `-` and `_`, starting with a letter. */
2
+ export const MARKER_RE = /^[A-Z][A-Z0-9_-]*$/;
3
+ /** A tag as written in options: `@` followed by letters, digits, `-` and `_`. */
4
+ export const TAG_RE = /^@[\w-]+$/;
5
+ export const BUILTIN_STATE_NAMES = ['skip', 'fixme', 'fail', 'slow', 'new', 'unstable'];
6
+ export const TICKET_PRESETS = [
7
+ 'any',
8
+ 'jira',
9
+ 'github',
10
+ 'gitlab',
11
+ 'linear',
12
+ 'azure-devops',
13
+ 'numeric',
14
+ 'pattern',
15
+ ];
16
+ /** Comment keywords that must reference a ticket by default (`// TODO: WEB-123`). */
17
+ export const DEFAULT_COMMENT_KEYWORDS = ['FIXME', 'TODO'];
18
+ //# sourceMappingURL=constants.js.map
@@ -1,23 +1,28 @@
1
1
  import type { TSESLint } from '@typescript-eslint/utils';
2
2
  import { type Analysis } from './analyze.js';
3
- import { type GovernanceOptions, type ResolvedOptions } from './options.js';
3
+ import { type ResolvedOptions } from './options.js';
4
4
  export declare const REPO_URL = "https://github.com/Huldoser/eslint-plugin-test-governance";
5
- export type RuleOptions = [GovernanceOptions?];
5
+ /** Rules take no options of their own: everything is shared through `settings['test-governance']`. */
6
+ export type RuleOptions = [];
6
7
  export interface RuleDocs {
7
8
  description: string;
8
9
  recommended: 'error' | 'warn';
9
10
  url: string;
10
11
  }
11
- export type Rule<MessageIds extends string> = TSESLint.RuleModule<MessageIds, RuleOptions, RuleDocs>;
12
+ export type Rule<MessageIds extends string> = TSESLint.RuleModule<MessageIds, RuleOptions, RuleDocs> & {
13
+ meta: {
14
+ docs: RuleDocs;
15
+ };
16
+ };
12
17
  export interface RuleDefinition<MessageIds extends string> {
13
18
  name: string;
14
- meta: Omit<TSESLint.RuleMetaData<MessageIds, RuleDocs, RuleOptions>, 'docs' | 'schema' | 'defaultOptions'> & {
19
+ meta: Omit<TSESLint.RuleMetaData<MessageIds, RuleDocs>, 'docs' | 'schema' | 'defaultOptions'> & {
15
20
  docs: Omit<RuleDocs, 'url'>;
16
21
  };
17
22
  check(context: Readonly<TSESLint.RuleContext<MessageIds, RuleOptions>>, analysis: Analysis, options: ResolvedOptions): void;
18
23
  }
19
24
  /**
20
- * Builds a rule whose options are merged over `settings['test-governance']`.
25
+ * Builds a rule that reads its options from `settings['test-governance']`.
21
26
  * `check` runs once per file with the shared analysis.
22
27
  */
23
28
  export declare function createRule<MessageIds extends string>(definition: RuleDefinition<MessageIds>): Rule<MessageIds>;
@@ -1,9 +1,8 @@
1
1
  import { analyze } from './analyze.js';
2
2
  import { resolveOptions } from './options.js';
3
- import { optionsSchema } from './schema.js';
4
3
  export const REPO_URL = 'https://github.com/Huldoser/eslint-plugin-test-governance';
5
4
  /**
6
- * Builds a rule whose options are merged over `settings['test-governance']`.
5
+ * Builds a rule that reads its options from `settings['test-governance']`.
7
6
  * `check` runs once per file with the shared analysis.
8
7
  */
9
8
  export function createRule(definition) {
@@ -11,11 +10,11 @@ export function createRule(definition) {
11
10
  meta: {
12
11
  ...definition.meta,
13
12
  docs: { ...definition.meta.docs, url: `${REPO_URL}/blob/main/docs/rules/${definition.name}.md` },
14
- schema: optionsSchema,
13
+ schema: [],
15
14
  },
16
15
  defaultOptions: [],
17
16
  create(context) {
18
- const options = resolveOptions(context.settings, context.options[0]);
17
+ const options = resolveOptions(context.settings);
19
18
  return {
20
19
  'Program:exit'() {
21
20
  definition.check(context, analyze(context.sourceCode, options), options);
@@ -0,0 +1,5 @@
1
+ /** A problem with the plugin's options, thrown when the config loads or on the first lint. */
2
+ export declare class ConfigError extends Error {
3
+ constructor(message: string);
4
+ }
5
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1,8 @@
1
+ /** A problem with the plugin's options, thrown when the config loads or on the first lint. */
2
+ export class ConfigError extends Error {
3
+ constructor(message) {
4
+ super(`eslint-plugin-test-governance: ${message}`);
5
+ this.name = 'ConfigError';
6
+ }
7
+ }
8
+ //# sourceMappingURL=errors.js.map
package/dist/utils/fix.js CHANGED
@@ -4,6 +4,8 @@ function escapeRegExp(text) {
4
4
  /** Range of `keyword` where it is used as a marker (`KEYWORD:`) inside `comment`. */
5
5
  export function keywordRange(sourceCode, comment, keyword) {
6
6
  const text = sourceCode.getText(comment);
7
+ // Only called with a keyword that was parsed from this comment, so it always matches.
8
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- see above
7
9
  const match = new RegExp(`(^|[^\\w-])(${escapeRegExp(keyword)})\\s*:`).exec(text);
8
10
  const start = comment.range[0] + match.index + match[1].length;
9
11
  return [start, start + keyword.length];
@@ -20,13 +22,13 @@ export function removeComment(sourceCode, comment) {
20
22
  /** Indentation of the line `comment` starts on. */
21
23
  export function indentOf(sourceCode, comment) {
22
24
  const line = sourceCode.lines[comment.loc.start.line - 1];
23
- return /^\s*/.exec(line)[0];
25
+ return line.slice(0, line.length - line.trimStart().length);
24
26
  }
25
27
  /** Range of the `index`-th occurrence of `tag` in the source text of `node`. */
26
28
  export function tagRange(sourceCode, node, tag, index) {
27
29
  const text = sourceCode.getText(node);
28
30
  const re = new RegExp(`(?<![\\w@])${escapeRegExp(tag)}(?![\\w-])`, 'g');
29
- const match = [...text.matchAll(re)][index];
31
+ const match = [...text.matchAll(re)].at(index);
30
32
  return match === undefined ? undefined : [node.range[0] + match.index, node.range[0] + match.index + tag.length];
31
33
  }
32
34
  //# sourceMappingURL=fix.js.map
@@ -1,3 +1,4 @@
1
+ import { type BuiltinStateName } from './constants.js';
1
2
  import { type TicketMatcher, type TicketSpec } from './tickets.js';
2
3
  export declare const SETTINGS_KEY = "test-governance";
3
4
  /** A state override in `states`. `false` turns a state off, `true` turns it on. */
@@ -13,7 +14,7 @@ export interface CustomState {
13
14
  marker: string;
14
15
  ticket?: TicketSpec | TicketSpec[];
15
16
  }
16
- export type BuiltinStateName = 'skip' | 'fixme' | 'fail' | 'slow' | 'new' | 'unstable';
17
+ export type { BuiltinStateName } from './constants.js';
17
18
  /** Options shared by every rule. Set them once in `settings['test-governance']`. */
18
19
  export interface GovernanceOptions {
19
20
  /** Names that are treated as the Playwright `test` function. Defaults to `['test']`. */
@@ -30,20 +31,36 @@ export interface GovernanceOptions {
30
31
  requireTicketForConditional?: boolean;
31
32
  /** Let blank lines separate a marker from the test it belongs to. */
32
33
  allowBlankLine?: boolean;
34
+ /**
35
+ * Comment keywords that must start with a ticket anywhere in a test file, e.g. `// TODO: WEB-123`.
36
+ * Defaults to `{ keywords: ['FIXME', 'TODO'] }`; `false` allows free-form comments.
37
+ */
38
+ comments?: false | {
39
+ keywords?: string[];
40
+ };
33
41
  /** Allow free text after the tickets, e.g. `// SKIP: WEB-1 flaky on CI`. Off by default: details belong in the ticket. */
34
42
  allowNotes?: boolean;
35
43
  /** Report titles that aren't static text when a tag state is on, because their tags can't be read. */
36
44
  reportDynamicTitles?: boolean;
37
45
  }
38
- export interface StateDef {
46
+ export type Modifier = 'skip' | 'fixme' | 'fail' | 'slow';
47
+ interface StateBase {
39
48
  name: string;
40
49
  marker: string;
41
- /** Modifier (`test.skip`) for modifier states. */
42
- modifier?: 'skip' | 'fixme' | 'fail' | 'slow';
43
- /** Tag (`@new`) for tag states. */
44
- tag?: string;
45
50
  ticket: TicketMatcher;
46
51
  }
52
+ /** A state is entered either through a modifier (`test.skip`) or through a tag (`@new`), never both. */
53
+ export type StateDef = StateBase & ({
54
+ modifier: Modifier;
55
+ tag?: undefined;
56
+ } | {
57
+ tag: string;
58
+ modifier?: undefined;
59
+ });
60
+ export type TagStateDef = Extract<StateDef, {
61
+ tag: string;
62
+ }>;
63
+ export declare function isTagState(state: StateDef): state is TagStateDef;
47
64
  export interface ResolvedOptions {
48
65
  testFunctions: Set<string>;
49
66
  states: StateDef[];
@@ -51,25 +68,21 @@ export interface ResolvedOptions {
51
68
  allowBlankLine: boolean;
52
69
  allowNotes: boolean;
53
70
  reportDynamicTitles: boolean;
71
+ /** Keywords checked by `require-ticket-in-comments`; empty when `comments: false`. */
72
+ commentKeywords: string[];
73
+ /**
74
+ * Keywords that are ordinary work comments as well as markers (`FIXME`, `TODO` and any configured
75
+ * comment keywords). A `// FIXME: refactor` with no ticket is a work comment, never a stray marker.
76
+ */
77
+ workCommentKeywords: Set<string>;
78
+ /** Ticket format for comments: the shared default format. */
79
+ commentTicket: TicketMatcher;
54
80
  }
55
- interface BuiltinDef {
56
- marker: string;
57
- modifier?: StateDef['modifier'];
58
- tag?: string;
59
- lifecycle?: boolean;
60
- enabled: boolean;
61
- }
62
- export declare const BUILTIN_STATES: Record<BuiltinStateName, BuiltinDef>;
63
- export declare const MARKER_RE: RegExp;
64
- export declare const TAG_RE: RegExp;
65
- export declare class ConfigError extends Error {
66
- constructor(message: string);
67
- }
81
+ export { ConfigError } from './errors.js';
68
82
  /**
69
- * Merges rule options over `settings['test-governance']` and compiles the result.
70
- * The result is cached per settings object, so all four rules share one compile.
83
+ * Compiles `settings['test-governance']`. Every rule reads the same settings, so they always agree,
84
+ * and the result is cached per settings object, so the rules share one compile.
71
85
  */
72
- export declare function resolveOptions(settings: unknown, ruleOptions: GovernanceOptions | undefined): ResolvedOptions;
86
+ export declare function resolveOptions(settings: unknown): ResolvedOptions;
73
87
  export declare function compileOptions(options: GovernanceOptions): ResolvedOptions;
74
- export {};
75
88
  //# sourceMappingURL=options.d.ts.map