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.
- package/CHANGELOG.md +84 -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
|
@@ -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' &&
|
|
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
|
-
|
|
47
|
+
loc: headLoc(subject),
|
|
46
48
|
messageId: 'missingMarker',
|
|
47
49
|
data: { ...data, subject: describeSubject(subject) },
|
|
48
50
|
});
|
package/dist/utils/analyze.d.ts
CHANGED
|
@@ -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:
|
|
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
|
-
/**
|
|
84
|
-
|
|
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
|
package/dist/utils/analyze.js
CHANGED
|
@@ -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
|
|
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
|
|
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'
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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'
|
|
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
|
|
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 :
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
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,
|
|
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:
|
|
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
|
-
|
|
170
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
/**
|
|
410
|
-
|
|
411
|
-
|
|
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
|
|
3
|
+
import { type ResolvedOptions } from './options.js';
|
|
4
4
|
export declare const REPO_URL = "https://github.com/Huldoser/eslint-plugin-test-governance";
|
|
5
|
-
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
13
|
+
schema: [],
|
|
15
14
|
},
|
|
16
15
|
defaultOptions: [],
|
|
17
16
|
create(context) {
|
|
18
|
-
const options = resolveOptions(context.settings
|
|
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,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
|
|
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)]
|
|
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
|
package/dist/utils/options.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
70
|
-
*
|
|
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
|
|
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
|