eslint-plugin-test-governance 0.13.1 → 0.13.2
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 +27 -1
- package/dist/rules/require-ticket-in-comments.js +3 -3
- package/dist/utils/analyze.js +75 -27
- package/dist/utils/fix.js +3 -1
- package/dist/utils/tickets.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,31 @@ All notable changes to this project are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.13.2] - 2026-11-08
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Local aliases such as `const check = it; check.skip(...)`, and aliases of conditional test functions,
|
|
14
|
+
are checked like the functions they keep.
|
|
15
|
+
- A helper's parameter or block variable named like a test context no longer makes its `skip()` or
|
|
16
|
+
`test()` calls count as runtime skips or subtests.
|
|
17
|
+
- Runtime calls with a literal `false` condition, such as Playwright's `test.skip(false)`, need neither
|
|
18
|
+
a ticket nor a reason.
|
|
19
|
+
- Details, options and annotations use the last value of a property, including values from object
|
|
20
|
+
spreads. An unread spread or computed key no longer leaves an earlier value treated as certain.
|
|
21
|
+
- A tag read beyond an array's bounds no longer crashes linting. An index after a spread, or a string
|
|
22
|
+
such as `'01'` that isn't an array index, is left unread instead of using the wrong tag.
|
|
23
|
+
- Circular object spreads no longer overflow the stack during analysis.
|
|
24
|
+
- Playwright annotation arrays read tickets from spreads of arrays declared in the same file, and
|
|
25
|
+
report unread spreads with `reportUnreadableTags`.
|
|
26
|
+
- A ticket marker in a block comment no longer hides unrelated `TODO` or `FIXME` work comments on
|
|
27
|
+
other lines of that block.
|
|
28
|
+
- The `pattern` preset matches the whole ticket even with the multiline flag, and no longer accepts
|
|
29
|
+
trailing line breaks outside the pattern.
|
|
30
|
+
- Moving a ticket into an annotation preserves comments inside an empty details object.
|
|
31
|
+
- node:test's `todo` state no longer makes `no-conflicting-states` call a new test skipped: Node still
|
|
32
|
+
runs its body.
|
|
33
|
+
|
|
9
34
|
## [0.13.1] - 2026-11-06
|
|
10
35
|
|
|
11
36
|
### Fixed
|
|
@@ -573,7 +598,8 @@ First release.
|
|
|
573
598
|
- Support for ESLint 9 and 10 flat config, JavaScript and TypeScript test files, `.extend()`
|
|
574
599
|
fixtures, describe blocks, runtime skips and tags in titles or the `tag` option.
|
|
575
600
|
|
|
576
|
-
[Unreleased]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.13.
|
|
601
|
+
[Unreleased]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.13.2...HEAD
|
|
602
|
+
[0.13.2]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.13.1...v0.13.2
|
|
577
603
|
[0.13.1]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.13.0...v0.13.1
|
|
578
604
|
[0.13.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.12.1...v0.13.0
|
|
579
605
|
[0.12.1]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.12.0...v0.12.1
|
|
@@ -37,7 +37,7 @@ export default createRule({
|
|
|
37
37
|
for (const marker of [...subject.markers, ...subject.regionMarkers]) {
|
|
38
38
|
const state = marker.state ?? marker.caseOf;
|
|
39
39
|
if (state && appliesTo(subject, state.name))
|
|
40
|
-
owned.add(marker.
|
|
40
|
+
owned.add(marker.keywordStart);
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
// `TODO`/`FIXME` in capitals counts with or without a colon. Other casings need the colon, so a
|
|
@@ -62,13 +62,13 @@ export default createRule({
|
|
|
62
62
|
return { ...parsed, extra: { text: note, range: [start + close, start + line.trimEnd().length] } };
|
|
63
63
|
}
|
|
64
64
|
for (const comment of sourceCode.getAllComments()) {
|
|
65
|
-
if (owned.has(comment))
|
|
66
|
-
continue;
|
|
67
65
|
for (const { text: line, start } of commentLines(comment)) {
|
|
68
66
|
const match = keywordRe.exec(line);
|
|
69
67
|
if (!match)
|
|
70
68
|
continue;
|
|
71
69
|
const [whole, indent, keyword] = match;
|
|
70
|
+
if (owned.has(start + indent.length))
|
|
71
|
+
continue;
|
|
72
72
|
// The parenthesised group is optional, so it can be undefined despite RegExpExecArray's typing.
|
|
73
73
|
const inParens = match[3];
|
|
74
74
|
const upper = keyword.toUpperCase();
|
package/dist/utils/analyze.js
CHANGED
|
@@ -100,14 +100,16 @@ function disableRegions(comments, states) {
|
|
|
100
100
|
/** The names a parameter gives to runtime info: `testInfo`, or `skip` in `({ skip }) => ...`. */
|
|
101
101
|
function infoNames(param, methods, step) {
|
|
102
102
|
if (param?.type === 'Identifier')
|
|
103
|
-
return [{ name: param.name, methods, step }];
|
|
103
|
+
return [{ name: param.name, binding: param, methods, step }];
|
|
104
104
|
if (param?.type !== 'ObjectPattern')
|
|
105
105
|
return [];
|
|
106
106
|
return param.properties.flatMap((prop) => {
|
|
107
107
|
if (prop.type !== 'Property' || prop.value.type !== 'Identifier')
|
|
108
108
|
return [];
|
|
109
109
|
const method = propertyName(prop);
|
|
110
|
-
return method !== undefined && methods.has(method)
|
|
110
|
+
return method !== undefined && methods.has(method)
|
|
111
|
+
? [{ name: prop.value.name, binding: prop.value, method, step }]
|
|
112
|
+
: [];
|
|
111
113
|
});
|
|
112
114
|
}
|
|
113
115
|
function memberName(node) {
|
|
@@ -630,10 +632,12 @@ function constantValue(sourceCode, node) {
|
|
|
630
632
|
return propertyValue(owner, key);
|
|
631
633
|
if (owner.type === 'ArrayExpression') {
|
|
632
634
|
const index = Number(key);
|
|
633
|
-
if (!Number.isInteger(index))
|
|
635
|
+
if (!Number.isInteger(index) || index < 0 || String(index) !== key)
|
|
634
636
|
return undefined;
|
|
635
|
-
|
|
636
|
-
|
|
637
|
+
if (owner.elements.slice(0, index + 1).some((element) => element?.type === 'SpreadElement'))
|
|
638
|
+
return undefined;
|
|
639
|
+
const element = owner.elements.at(index);
|
|
640
|
+
return element !== undefined && element !== null && element.type !== 'SpreadElement' ? element : undefined;
|
|
637
641
|
}
|
|
638
642
|
if (owner.type !== 'TSEnumDeclaration')
|
|
639
643
|
return undefined;
|
|
@@ -700,20 +704,35 @@ function detailKey(sourceCode, prop, unread) {
|
|
|
700
704
|
* The plain-keyed properties of a details object, following spread objects declared in this file and
|
|
701
705
|
* collecting the ones that can't be read.
|
|
702
706
|
*/
|
|
703
|
-
function objectDetails(sourceCode, object, unread) {
|
|
704
|
-
|
|
707
|
+
function objectDetails(sourceCode, object, unread, path = []) {
|
|
708
|
+
if (path.includes(object)) {
|
|
709
|
+
unread.push(object);
|
|
710
|
+
return [];
|
|
711
|
+
}
|
|
712
|
+
const entries = new Map();
|
|
713
|
+
for (const prop of object.properties) {
|
|
705
714
|
if (prop.type === 'SpreadElement') {
|
|
706
715
|
const argument = unwrap(prop.argument);
|
|
707
716
|
const value = argument.type === 'ObjectExpression' ? argument : constantValue(sourceCode, argument);
|
|
708
717
|
const spread = value && unwrap(value);
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
718
|
+
const before = unread.length;
|
|
719
|
+
const values = spread?.type === 'ObjectExpression' ? objectDetails(sourceCode, spread, unread, [...path, object]) : [];
|
|
720
|
+
if (spread?.type !== 'ObjectExpression')
|
|
721
|
+
unread.push(prop.argument);
|
|
722
|
+
// An unread spread may replace any earlier property; later known properties still win.
|
|
723
|
+
if (unread.length > before)
|
|
724
|
+
entries.clear();
|
|
725
|
+
for (const [key, value] of values)
|
|
726
|
+
entries.set(key, value);
|
|
727
|
+
continue;
|
|
713
728
|
}
|
|
714
729
|
const key = detailKey(sourceCode, prop, unread);
|
|
715
|
-
|
|
716
|
-
|
|
730
|
+
if (key === undefined)
|
|
731
|
+
entries.clear();
|
|
732
|
+
else
|
|
733
|
+
entries.set(key, prop.value);
|
|
734
|
+
}
|
|
735
|
+
return [...entries];
|
|
717
736
|
}
|
|
718
737
|
/**
|
|
719
738
|
* The details or options object of a declaration, the second argument in
|
|
@@ -805,7 +824,7 @@ function collectAnnotations(sourceCode, details, source, annotations) {
|
|
|
805
824
|
}
|
|
806
825
|
tickets.push({ text, node: description[1] });
|
|
807
826
|
};
|
|
808
|
-
const readValue = (value) => {
|
|
827
|
+
const readValue = (value, path = []) => {
|
|
809
828
|
const carrier = annotationCarrier(sourceCode, value);
|
|
810
829
|
if (carrier === 'unread') {
|
|
811
830
|
unread.push(value);
|
|
@@ -813,6 +832,10 @@ function collectAnnotations(sourceCode, details, source, annotations) {
|
|
|
813
832
|
}
|
|
814
833
|
if (carrier === undefined)
|
|
815
834
|
return;
|
|
835
|
+
if (path.includes(carrier)) {
|
|
836
|
+
unread.push(value);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
816
839
|
if (source.shape === 'meta') {
|
|
817
840
|
if (carrier.type === 'ObjectExpression') {
|
|
818
841
|
const unreadKeys = [];
|
|
@@ -834,8 +857,12 @@ function collectAnnotations(sourceCode, details, source, annotations) {
|
|
|
834
857
|
readPlaywright(carrier);
|
|
835
858
|
else {
|
|
836
859
|
for (const element of carrier.elements) {
|
|
837
|
-
if (element === null
|
|
860
|
+
if (element === null)
|
|
861
|
+
continue;
|
|
862
|
+
if (element.type === 'SpreadElement') {
|
|
863
|
+
readValue(element.argument, [...path, carrier]);
|
|
838
864
|
continue;
|
|
865
|
+
}
|
|
839
866
|
const inner = annotationCarrier(sourceCode, element);
|
|
840
867
|
if (inner === 'unread')
|
|
841
868
|
unread.push(element);
|
|
@@ -1134,7 +1161,7 @@ function runAnalysis(sourceCode, options) {
|
|
|
1134
1161
|
* `['describe', 'skip']` for `test.describe.skip` or `pw.test.describe.skip`. Undefined when the callee
|
|
1135
1162
|
* isn't one of the framework's test functions.
|
|
1136
1163
|
*/
|
|
1137
|
-
const target = (chain) => {
|
|
1164
|
+
const target = (chain, seen = []) => {
|
|
1138
1165
|
if (chain === undefined)
|
|
1139
1166
|
return undefined;
|
|
1140
1167
|
const { root, path, args } = chain;
|
|
@@ -1156,7 +1183,13 @@ function runAnalysis(sourceCode, options) {
|
|
|
1156
1183
|
base = member;
|
|
1157
1184
|
rest = path.slice(1);
|
|
1158
1185
|
}
|
|
1159
|
-
|
|
1186
|
+
if (base !== undefined && !isLocalVariable(sourceCode, root))
|
|
1187
|
+
return { root: base, path: rest, args };
|
|
1188
|
+
const value = declaredValue(sourceCode, root);
|
|
1189
|
+
if (value === undefined || seen.includes(value))
|
|
1190
|
+
return undefined;
|
|
1191
|
+
const alias = target(chainOf(unwrap(value), framework.factories), [...seen, value]);
|
|
1192
|
+
return alias && { root: alias.root, path: [...alias.path, ...path], args: new Map([...alias.args, ...args]) };
|
|
1160
1193
|
};
|
|
1161
1194
|
let usesTest = sourceCode.ast.body.some((statement) => statement.type === 'ImportDeclaration' && framework.ownsModule(statement.source.value));
|
|
1162
1195
|
const subjects = [];
|
|
@@ -1179,6 +1212,13 @@ function runAnalysis(sourceCode, options) {
|
|
|
1179
1212
|
}
|
|
1180
1213
|
const stack = [];
|
|
1181
1214
|
const infos = [];
|
|
1215
|
+
const infoOf = (id) => {
|
|
1216
|
+
const info = infos.findLast((entry) => entry.name === id.name);
|
|
1217
|
+
return info?.binding !== undefined &&
|
|
1218
|
+
declaredVariable(sourceCode, id) !== declaredVariable(sourceCode, info.binding)
|
|
1219
|
+
? undefined
|
|
1220
|
+
: info;
|
|
1221
|
+
};
|
|
1182
1222
|
const keys = sourceCode.visitorKeys;
|
|
1183
1223
|
/** States an options object can put a test in, such as `skip` for Vitest's `{ skip: true }`. */
|
|
1184
1224
|
const optionModifiers = new Set(framework.optionEffects.values());
|
|
@@ -1284,7 +1324,7 @@ function runAnalysis(sourceCode, options) {
|
|
|
1284
1324
|
for (const tag of parent.tags)
|
|
1285
1325
|
inheritedTags.add(tag.tag);
|
|
1286
1326
|
}
|
|
1287
|
-
const ownSkip = [...modifiers].some(([modifier, always]) => always && SKIPPING_MODIFIERS.has(modifier));
|
|
1327
|
+
const ownSkip = [...modifiers].some(([modifier, always]) => always && SKIPPING_MODIFIERS.has(modifier) && !(modifier === 'todo' && framework.name === 'node-test'));
|
|
1288
1328
|
const subject = {
|
|
1289
1329
|
kind,
|
|
1290
1330
|
node: call,
|
|
@@ -1482,7 +1522,7 @@ function runAnalysis(sourceCode, options) {
|
|
|
1482
1522
|
if (!framework.subtests || callee.type !== 'MemberExpression' || memberName(callee) !== 'test')
|
|
1483
1523
|
return undefined;
|
|
1484
1524
|
const { object } = callee;
|
|
1485
|
-
if (object.type !== 'Identifier' || !
|
|
1525
|
+
if (object.type !== 'Identifier' || !infoOf(object)?.methods)
|
|
1486
1526
|
return undefined;
|
|
1487
1527
|
const declaration = { kind: 'test', members: [], written: [], args: new Map() };
|
|
1488
1528
|
return declares(declaration, call.arguments)
|
|
@@ -1497,8 +1537,16 @@ function runAnalysis(sourceCode, options) {
|
|
|
1497
1537
|
const aliasValue = (callee) => {
|
|
1498
1538
|
if (callee.type !== 'Identifier' || testNames.roots.has(callee.name))
|
|
1499
1539
|
return undefined;
|
|
1500
|
-
const
|
|
1501
|
-
|
|
1540
|
+
const seen = new Set();
|
|
1541
|
+
let value = declaredValue(sourceCode, callee);
|
|
1542
|
+
while (value !== undefined && !seen.has(value)) {
|
|
1543
|
+
seen.add(value);
|
|
1544
|
+
const expression = unwrap(value);
|
|
1545
|
+
if (expression.type !== 'Identifier')
|
|
1546
|
+
return ALIAS_VALUES.has(expression.type) ? value : undefined;
|
|
1547
|
+
value = declaredValue(sourceCode, expression);
|
|
1548
|
+
}
|
|
1549
|
+
return undefined;
|
|
1502
1550
|
};
|
|
1503
1551
|
/**
|
|
1504
1552
|
* Whether the declaration of a constant that holds a test function says why above it or at the end of its
|
|
@@ -1573,14 +1621,12 @@ function runAnalysis(sourceCode, options) {
|
|
|
1573
1621
|
*/
|
|
1574
1622
|
const runtimeCall = (call) => {
|
|
1575
1623
|
const { callee } = call;
|
|
1576
|
-
// The innermost parameter with a name is the one the call uses.
|
|
1577
|
-
const innermost = (name) => infos.findLast((info) => info.name === name);
|
|
1578
1624
|
/**
|
|
1579
1625
|
* What a name stands for: a context in scope, or a local constant that keeps one of its methods, as
|
|
1580
1626
|
* `const skip = testInfo.skip`, `const { skip } = t` or `const p = pending`.
|
|
1581
1627
|
*/
|
|
1582
1628
|
const resolveInfo = (id) => {
|
|
1583
|
-
const direct =
|
|
1629
|
+
const direct = infoOf(id);
|
|
1584
1630
|
if (direct !== undefined)
|
|
1585
1631
|
return direct;
|
|
1586
1632
|
const definition = declaredVariable(sourceCode, id)?.defs.find((def) => def.type === 'Variable');
|
|
@@ -1599,12 +1645,12 @@ function runAnalysis(sourceCode, options) {
|
|
|
1599
1645
|
? { name: id.name, method: name }
|
|
1600
1646
|
: undefined;
|
|
1601
1647
|
}
|
|
1602
|
-
const object = init.object.type === 'Identifier' ?
|
|
1648
|
+
const object = init.object.type === 'Identifier' ? infoOf(init.object) : undefined;
|
|
1603
1649
|
return object?.methods?.has(name) ? { name: id.name, method: name, step: object.step } : undefined;
|
|
1604
1650
|
}
|
|
1605
1651
|
if (init.type !== 'Identifier')
|
|
1606
1652
|
return undefined;
|
|
1607
|
-
const source =
|
|
1653
|
+
const source = infoOf(init);
|
|
1608
1654
|
if (source === undefined)
|
|
1609
1655
|
return undefined;
|
|
1610
1656
|
const fn = source.runtimeFunction;
|
|
@@ -1616,7 +1662,7 @@ function runAnalysis(sourceCode, options) {
|
|
|
1616
1662
|
}
|
|
1617
1663
|
if (declarator.id.type !== 'ObjectPattern' || init.type !== 'Identifier')
|
|
1618
1664
|
return undefined;
|
|
1619
|
-
const source =
|
|
1665
|
+
const source = infoOf(init);
|
|
1620
1666
|
if (source?.methods === undefined)
|
|
1621
1667
|
return undefined;
|
|
1622
1668
|
const property = declarator.id.properties.find((prop) => prop.type === 'Property' && prop.value.type === 'Identifier' && prop.value.name === id.name);
|
|
@@ -1701,6 +1747,8 @@ function runAnalysis(sourceCode, options) {
|
|
|
1701
1747
|
const runtimeSubject = (call, modifier, step, always = isUnconditional(call, framework)) => {
|
|
1702
1748
|
if (!states.some((s) => s.modifier === modifier))
|
|
1703
1749
|
return undefined;
|
|
1750
|
+
if (framework.reasonArgument !== 'only' && booleanOf(call.arguments.at(0)) === false)
|
|
1751
|
+
return undefined;
|
|
1704
1752
|
const subject = makeSubject('runtime', call, new Map([[modifier, always]]), NO_TAGS, step ?? stack.findLast((s) => s.kind !== 'step'));
|
|
1705
1753
|
const parent = subject.parent;
|
|
1706
1754
|
if (parent) {
|
package/dist/utils/fix.js
CHANGED
|
@@ -91,6 +91,8 @@ export function moveToAnnotation(sourceCode, call, marker, source, type) {
|
|
|
91
91
|
const last = details.properties.at(-1);
|
|
92
92
|
return (fixer) => [
|
|
93
93
|
remove,
|
|
94
|
-
last === undefined
|
|
94
|
+
last === undefined
|
|
95
|
+
? fixer.insertTextBeforeRange([details.range[1] - 1, details.range[1]], ` ${property} `)
|
|
96
|
+
: fixer.insertTextAfter(last, `, ${property}`),
|
|
95
97
|
];
|
|
96
98
|
}
|
package/dist/utils/tickets.js
CHANGED
|
@@ -139,7 +139,7 @@ export function ticketPattern(pattern, flags) {
|
|
|
139
139
|
const kept = flags?.replace(/[gy]/g, '');
|
|
140
140
|
// Compiled alone first: a stray `)` in it would otherwise close the anchoring group and match anything.
|
|
141
141
|
new RegExp(pattern, kept);
|
|
142
|
-
return new RegExp(
|
|
142
|
+
return new RegExp(`(?<![\\s\\S])(?:${pattern})(?![\\s\\S])`, kept);
|
|
143
143
|
}
|
|
144
144
|
function compilePreset(spec) {
|
|
145
145
|
switch (spec.preset) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eslint-plugin-test-governance",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.2",
|
|
4
4
|
"description": "ESLint rules that require a ticket for skipped, fixme, todo, retried, tagged and commented-out Playwright, Jest, Vitest, Mocha, Cypress, WebdriverIO, node:test and Jasmine tests.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"eslint",
|