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 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.1...HEAD
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.comment);
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();
@@ -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) ? [{ name: prop.value.name, method, step }] : [];
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
- const element = owner.elements[index];
636
- return element !== null && element.type !== 'SpreadElement' ? element : undefined;
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
- return object.properties.flatMap((prop) => {
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
- if (spread?.type === 'ObjectExpression')
710
- return objectDetails(sourceCode, spread, unread);
711
- unread.push(prop.argument);
712
- return [];
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
- return key === undefined ? [] : [[key, prop.value]];
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 || element.type === 'SpreadElement')
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
- return base !== undefined && !isLocalVariable(sourceCode, root) ? { root: base, path: rest, args } : undefined;
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' || !infos.findLast((info) => info.name === object.name)?.methods)
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 value = declaredValue(sourceCode, callee);
1501
- return value && ALIAS_VALUES.has(unwrap(value).type) ? value : undefined;
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 = innermost(id.name);
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' ? innermost(init.object.name) : undefined;
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 = innermost(init.name);
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 = innermost(init.name);
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 ? fixer.replaceText(details, `{ ${property} }`) : fixer.insertTextAfter(last, `, ${property}`),
94
+ last === undefined
95
+ ? fixer.insertTextBeforeRange([details.range[1] - 1, details.range[1]], ` ${property} `)
96
+ : fixer.insertTextAfter(last, `, ${property}`),
95
97
  ];
96
98
  }
@@ -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(`^(?:${pattern})$`, kept);
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.1",
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",