eslint-plugin-test-governance 0.13.2 → 0.13.3

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,52 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.13.3] - 2026-11-09
10
+
11
+ ### Fixed
12
+
13
+ - Vitest conditional factories can clear an earlier skip in a modifier chain. Runtime context methods
14
+ retain their state through TypeScript wrappers on the receiver or method.
15
+ - Mutation detection follows wrapped references and aliases, and recognizes writes through the global
16
+ `Object` and `Reflect` mutation methods without treating shadowed helpers as those built-ins.
17
+ - Retry counts are read through local constants and object members, with effective properties after
18
+ spreads and overrides. Unread spreads remain possible retries unless every relevant count is known.
19
+ - Commented-out tests no longer hide a broken marker or a disallowed note behind another valid marker.
20
+ - Negative numeric and bigint template interpolations, `void`, and unshadowed `undefined`, `NaN` and
21
+ `Infinity` retain their rendered text when checking tag boundaries.
22
+ - Mutable constant objects and arrays no longer supply stale tag or annotation values after detected
23
+ property writes, deletion, array mutation or writes through a local alias.
24
+ - Runtime context methods keep their original test owner inside nested subtests. Vitest skip notes
25
+ supplied through constants remain unconditional skips.
26
+ - Test callees retain their meaning through TypeScript assertions and type instantiations, sequence
27
+ expressions and defaulted local destructuring. Repeated conditional factories use the final condition.
28
+ - Nested conditional returns and throws, and conditional loop breaks and continues, guard later runtime
29
+ skips. Jest retry calls are recognized through CommonJS object bindings and method aliases, including
30
+ bound arguments.
31
+ - Primitive template interpolations retain their rendered text when checking tag boundaries. Object
32
+ prototype setters do not count as own tag or metadata properties, and suggestions use computed keys
33
+ for metadata named `__proto__`.
34
+ - Test functions are read through aliases of whole modules, local destructuring, bound functions and
35
+ nested conditional selections. Reassigning an alias to a helper no longer leaves it treated as a test.
36
+ - Runtime test contexts and their methods keep their meaning through chains of aliases, `bind()` and
37
+ default parameter values. Arguments supplied by `bind()` retain their condition and reason, and a
38
+ bound title no longer gives a diagnostic whose end is before its start.
39
+ - A runtime skip in a conditional loop, or after a conditional early exit inside a nested block, is
40
+ read as conditional. A TypeScript assertion around a literal boolean keeps that boolean's meaning.
41
+ - With the retry state on, Jest's retry calls are read through renamed and namespace imports, and a
42
+ Mocha describe chosen by a condition still supplies its suite context for `this.retries()`.
43
+ - Chains of constants retain their tags, details and annotation tickets. An unread computed property
44
+ no longer leaves an earlier member of a tag object treated as certain.
45
+ - Tags from literal and nested template interpolations are checked using the rendered text's boundaries,
46
+ so joining text around an `@` doesn't invent a state tag. Plain Vitest tags written as templates count
47
+ like quoted strings. Cyclic constant and template definitions remain unread instead of recursing.
48
+ - `annotations.only` checks every supplied issue ticket, including an invalid one next to a valid one.
49
+ - The commented-out-test rule recognizes renamed test imports.
50
+ - Removing an orphaned marker removes its complete line with CR or Unicode line endings too.
51
+ - A suggestion to add another state's marker no longer copies a ticket that state's format rejects.
52
+ - Suggestions that move a ticket into annotations escape line breaks in the annotation type or key,
53
+ keeping the suggested JavaScript valid.
54
+
9
55
  ## [0.13.2] - 2026-11-08
10
56
 
11
57
  ### Fixed
@@ -598,7 +644,8 @@ First release.
598
644
  - Support for ESLint 9 and 10 flat config, JavaScript and TypeScript test files, `.extend()`
599
645
  fixtures, describe blocks, runtime skips and tags in titles or the `tag` option.
600
646
 
601
- [Unreleased]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.13.2...HEAD
647
+ [Unreleased]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.13.3...HEAD
648
+ [0.13.3]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.13.2...v0.13.3
602
649
  [0.13.2]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.13.1...v0.13.2
603
650
  [0.13.1]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.13.0...v0.13.1
604
651
  [0.13.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.12.1...v0.13.0
@@ -70,7 +70,10 @@ export default createRule({
70
70
  state: state.name,
71
71
  subject: subjectName,
72
72
  },
73
- suggest: present.comment.type === 'Line' && tickets !== '' && directive?.kind !== 'eslint-disable-line'
73
+ suggest: present.comment.type === 'Line' &&
74
+ tickets !== '' &&
75
+ present.tickets.every((ticket) => state.ticket.check(ticket.text) === 'ok') &&
76
+ directive?.kind !== 'eslint-disable-line'
74
77
  ? [
75
78
  {
76
79
  messageId: 'addMarker',
@@ -32,9 +32,9 @@ export default createRule({
32
32
  context.report({ loc, messageId: 'missingMarker', data });
33
33
  continue;
34
34
  }
35
- const valid = own.find((m) => m.tickets.length > 0 && m.tickets.every((t) => t.result === 'ok'));
36
- if (valid) {
37
- const { extra } = valid;
35
+ const broken = own.find((m) => m.tickets.length === 0 || m.tickets.some((t) => t.result !== 'ok'));
36
+ if (broken === undefined) {
37
+ const extra = own.find((marker) => marker.extra !== undefined)?.extra;
38
38
  if (extra === undefined || options.allowNotes)
39
39
  continue;
40
40
  context.report({
@@ -51,7 +51,7 @@ export default createRule({
51
51
  });
52
52
  continue;
53
53
  }
54
- const [marker] = own;
54
+ const marker = broken;
55
55
  const bad = marker.tickets.find((t) => t.result !== 'ok');
56
56
  if (bad === undefined) {
57
57
  context.report({ loc: marker.comment.loc, messageId: 'missingTicket', data });
@@ -107,11 +107,6 @@ export default createRule({
107
107
  reportReason(subject, state);
108
108
  continue;
109
109
  }
110
- if (annotationOnly) {
111
- reportAnnotationOnly(subject, state, annotationOnly);
112
- continue;
113
- }
114
- const result = evaluate(subject, state);
115
110
  // Annotations are checked whenever a ticket is needed, whatever the markers say.
116
111
  for (const ticket of annotationTicketsOf(subject)) {
117
112
  if (state.ticket.check(ticket.text) !== 'ok' && reportOnce(`annotation:${ticket.node.range[0]}`)) {
@@ -122,6 +117,11 @@ export default createRule({
122
117
  });
123
118
  }
124
119
  }
120
+ if (annotationOnly) {
121
+ reportAnnotationOnly(subject, state, annotationOnly);
122
+ continue;
123
+ }
124
+ const result = evaluate(subject, state);
125
125
  if (result.kind === 'missing' && hasAnnotationTicket(subject, state, options))
126
126
  continue;
127
127
  if (result.kind !== 'ok' &&
@@ -99,17 +99,19 @@ function disableRegions(comments, states) {
99
99
  }
100
100
  /** The names a parameter gives to runtime info: `testInfo`, or `skip` in `({ skip }) => ...`. */
101
101
  function infoNames(param, methods, step) {
102
- if (param?.type === 'Identifier')
103
- return [{ name: param.name, binding: param, methods, step }];
104
- if (param?.type !== 'ObjectPattern')
102
+ const target = param?.type === 'AssignmentPattern' ? param.left : param;
103
+ if (target?.type === 'Identifier')
104
+ return [{ name: target.name, binding: target, methods, step }];
105
+ if (target?.type !== 'ObjectPattern')
105
106
  return [];
106
- return param.properties.flatMap((prop) => {
107
- if (prop.type !== 'Property' || prop.value.type !== 'Identifier')
107
+ return target.properties.flatMap((prop) => {
108
+ if (prop.type !== 'Property')
109
+ return [];
110
+ const value = prop.value.type === 'AssignmentPattern' ? prop.value.left : prop.value;
111
+ if (value.type !== 'Identifier')
108
112
  return [];
109
113
  const method = propertyName(prop);
110
- return method !== undefined && methods.has(method)
111
- ? [{ name: prop.value.name, binding: prop.value, method, step }]
112
- : [];
114
+ return method !== undefined && methods.has(method) ? [{ name: value.name, binding: value, method, step }] : [];
113
115
  });
114
116
  }
115
117
  function memberName(node) {
@@ -133,14 +135,14 @@ function memberKey(node) {
133
135
  function chainOf(node, factories) {
134
136
  const path = [];
135
137
  const args = new Map();
136
- let current = node;
138
+ let current = unwrap(node);
137
139
  for (;;) {
138
140
  if (current.type === 'MemberExpression') {
139
141
  const name = memberName(current);
140
142
  if (name === undefined)
141
143
  return undefined;
142
144
  path.unshift(name);
143
- current = current.object;
145
+ current = unwrap(current.object);
144
146
  continue;
145
147
  }
146
148
  const factory = current.type === 'CallExpression'
@@ -154,9 +156,9 @@ function chainOf(node, factories) {
154
156
  if (name === undefined || !factories.has(name))
155
157
  return undefined;
156
158
  path.unshift(`${name}()`);
157
- if (current.type === 'CallExpression')
159
+ if (current.type === 'CallExpression' && !args.has(`${name}()`))
158
160
  args.set(`${name}()`, current.arguments.at(0));
159
- current = factory.object;
161
+ current = unwrap(factory.object);
160
162
  }
161
163
  return current.type === 'Identifier' || current.type === 'CallExpression' ? { root: current, path, args } : undefined;
162
164
  }
@@ -164,8 +166,12 @@ function unwrap(node) {
164
166
  let current = node;
165
167
  while (current.type === 'TSAsExpression' ||
166
168
  current.type === 'TSNonNullExpression' ||
167
- current.type === 'TSSatisfiesExpression') {
168
- current = current.expression;
169
+ current.type === 'TSSatisfiesExpression' ||
170
+ current.type === 'TSTypeAssertion' ||
171
+ current.type === 'TSInstantiationExpression' ||
172
+ current.type === 'SequenceExpression') {
173
+ current =
174
+ current.type === 'SequenceExpression' ? current.expressions[current.expressions.length - 1] : current.expression;
169
175
  }
170
176
  return current;
171
177
  }
@@ -382,6 +388,67 @@ function declaredVariable(sourceCode, id) {
382
388
  }
383
389
  return undefined;
384
390
  }
391
+ const reassignedBindings = new WeakMap();
392
+ /** Whether the binding has writes beyond its initial declaration. */
393
+ function isReassigned(sourceCode, id) {
394
+ const variable = declaredVariable(sourceCode, id);
395
+ if (variable === undefined)
396
+ return false;
397
+ const cached = reassignedBindings.get(variable);
398
+ if (cached !== undefined)
399
+ return cached;
400
+ const written = variable.references.some((reference) => reference.isWrite() && !reference.init);
401
+ reassignedBindings.set(variable, written);
402
+ return written;
403
+ }
404
+ const mutatedBindings = new WeakMap();
405
+ const ARRAY_MUTATORS = new Set(['copyWithin', 'fill', 'pop', 'push', 'reverse', 'shift', 'sort', 'splice', 'unshift']);
406
+ const OBJECT_MUTATORS = new Set(['assign', 'defineProperty', 'defineProperties']);
407
+ const REFLECT_MUTATORS = new Set(['set', 'deleteProperty', 'defineProperty']);
408
+ /** Whether a binding's object is written through a property, an array mutator or a local alias. */
409
+ function isMutated(sourceCode, id, seen = []) {
410
+ const variable = declaredVariable(sourceCode, id);
411
+ if (variable === undefined || seen.includes(variable))
412
+ return false;
413
+ const cached = mutatedBindings.get(variable);
414
+ if (cached !== undefined)
415
+ return cached;
416
+ const written = variable.references.some((reference) => {
417
+ let value = reference.identifier;
418
+ let parent = value.parent;
419
+ while (parent.type !== 'Program' &&
420
+ ((parent.type === 'MemberExpression' && parent.object === value) || unwrap(parent) === value)) {
421
+ value = parent;
422
+ parent = value.parent;
423
+ }
424
+ if (parent.type === 'VariableDeclarator' && parent.init === value && parent.id.type === 'Identifier') {
425
+ return isMutated(sourceCode, parent.id, [...seen, variable]);
426
+ }
427
+ if (parent.type === 'CallExpression' && parent.arguments[0] === value) {
428
+ const callee = unwrap(parent.callee);
429
+ if (callee.type === 'MemberExpression' &&
430
+ callee.object.type === 'Identifier' &&
431
+ declaredWhere(sourceCode, callee.object) === 'global') {
432
+ const methods = callee.object.name === 'Object'
433
+ ? OBJECT_MUTATORS
434
+ : callee.object.name === 'Reflect'
435
+ ? REFLECT_MUTATORS
436
+ : undefined;
437
+ if (methods?.has(memberName(callee) ?? ''))
438
+ return true;
439
+ }
440
+ }
441
+ const member = unwrap(value);
442
+ if (member.type !== 'MemberExpression')
443
+ return false;
444
+ return ((parent.type === 'AssignmentExpression' && parent.left === value) ||
445
+ parent.type === 'UpdateExpression' ||
446
+ (parent.type === 'UnaryExpression' && parent.operator === 'delete') ||
447
+ (parent.type === 'CallExpression' && parent.callee === value && ARRAY_MUTATORS.has(memberName(member) ?? '')));
448
+ });
449
+ mutatedBindings.set(variable, written);
450
+ return written;
451
+ }
385
452
  /**
386
453
  * Whether `id` refers to a variable or function declared inside a function or block, such as a local
387
454
  * helper that happens to be called `test`. Top-level variables, imports, parameters and globals still count.
@@ -551,33 +618,66 @@ function parseMarkers(comment, states) {
551
618
  }
552
619
  return markers;
553
620
  }
554
- function scanTags(source, { text: node, via }, out) {
621
+ /** Rendered template text and the offsets of its alternating literal and expression parts. */
622
+ function templateText(sourceCode, node) {
623
+ const parts = node.quasis.flatMap((quasi, i) => [
624
+ // `cooked` is null only in tagged templates, which are not read as title or tag text.
625
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- see above
626
+ quasi.value.cooked,
627
+ ...(i < node.expressions.length ? [interpolationText(sourceCode, node.expressions[i]) ?? ' '] : []),
628
+ ]);
629
+ let offset = 0;
630
+ const offsets = parts.map((part) => {
631
+ const start = offset;
632
+ offset += part.length;
633
+ return start;
634
+ });
635
+ return { parts, text: parts.join(''), offsets };
636
+ }
637
+ function scanTags(sourceCode, found, out) {
638
+ const { text: node, via, before = '', after = '' } = found;
639
+ const source = sourceCode.text;
555
640
  // Each piece of text with where it starts in the source, after its opening quote, backtick or `}`.
556
- const parts = node.type === 'Literal'
557
- ? [{ text: node.value, start: node.range[0] + 1 }]
558
- : node.quasis.map((quasi) => ({
641
+ let parts;
642
+ if (node.type === 'Literal')
643
+ parts = [{ text: node.value, start: node.range[0] + 1, before, after }];
644
+ else {
645
+ const rendered = templateText(sourceCode, node);
646
+ parts = node.quasis.map((quasi, i) => ({
559
647
  // `cooked` is only null in tagged templates, which are never titles.
560
648
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- see above
561
649
  text: quasi.value.cooked,
562
650
  start: quasi.range[0] + 1,
651
+ before: rendered.offsets[i * 2] === 0 ? before : rendered.text.charAt(rendered.offsets[i * 2] - 1),
652
+ after: rendered.text.slice(rendered.offsets[i * 2] + rendered.parts[i * 2].length) + after,
563
653
  }));
564
- for (const { text, start } of parts) {
565
- for (const match of text.matchAll(TAG_RE)) {
566
- const end = match.index + match[0].length;
567
- const written = via === undefined && source.slice(start, start + end) === text.slice(0, end);
568
- out.push({ tag: match[0], node: via ?? node, ...(written && { range: [start + match.index, start + end] }) });
654
+ }
655
+ for (const { text, start, before, after } of parts) {
656
+ const prefix = before.slice(-1);
657
+ const tagStart = text.lastIndexOf('@');
658
+ const continuation = tagStart >= 0 && /^[\w-]*$/.test(text.slice(tagStart + 1)) ? (/^[\w-]+/.exec(after)?.[0] ?? '') : '';
659
+ for (const match of (prefix + text + continuation).matchAll(TAG_RE)) {
660
+ const index = match.index - prefix.length;
661
+ if (index < 0 || index >= text.length)
662
+ continue;
663
+ const end = index + match[0].length;
664
+ const written = via === undefined && end <= text.length && source.slice(start, start + end) === text.slice(0, end);
665
+ out.push({ tag: match[0], node: via ?? node, ...(written && { range: [start + index, start + end] }) });
569
666
  }
570
667
  }
571
668
  }
572
669
  const PLAIN_TAG_RE = /^[\w-]+$/;
573
670
  /** A Vitest tag name such as `'flaky'` in `tags`, which stands for the `@flaky` tag. */
574
- function plainTag(source, { text, via }) {
575
- if (text.type !== 'Literal' || !PLAIN_TAG_RE.test(text.value))
671
+ function plainTag(sourceCode, { text, via, embedded }) {
672
+ if (embedded)
673
+ return undefined;
674
+ const value = staticText(sourceCode, text);
675
+ if (value === undefined || !PLAIN_TAG_RE.test(value))
576
676
  return undefined;
577
677
  const start = text.range[0] + 1;
578
- const end = start + text.value.length;
579
- const written = via === undefined && source.slice(start, end) === text.value;
580
- return { tag: `@${text.value}`, node: via ?? text, plain: true, ...(written && { range: [start, end] }) };
678
+ const end = start + value.length;
679
+ const written = via === undefined && sourceCode.text.slice(start, end) === value;
680
+ return { tag: `@${value}`, node: via ?? text, plain: true, ...(written && { range: [start, end] }) };
581
681
  }
582
682
  function isStaticText(node) {
583
683
  return (node.type === 'Literal' && typeof node.value === 'string') || node.type === 'TemplateLiteral';
@@ -587,6 +687,8 @@ function isStaticText(node) {
587
687
  * or the enum itself. Undefined for any other name, such as an import, a parameter or a `let`.
588
688
  */
589
689
  function declaredValue(sourceCode, id) {
690
+ if (isMutated(sourceCode, id))
691
+ return undefined;
590
692
  for (let scope = sourceCode.getScope(id); scope; scope = scope.upper) {
591
693
  const variable = scope.set.get(id.name);
592
694
  if (!variable)
@@ -602,13 +704,16 @@ function declaredValue(sourceCode, id) {
602
704
  }
603
705
  return undefined;
604
706
  }
707
+ function isPrototypeSetter(prop) {
708
+ return (!prop.computed && !prop.shorthand && !prop.method && prop.kind === 'init' && propertyName(prop) === '__proto__');
709
+ }
605
710
  /** The value of the last property called `key`, or undefined when a spread written after it may replace it. */
606
711
  function propertyValue(object, key) {
607
712
  for (let i = object.properties.length - 1; i >= 0; i--) {
608
713
  const prop = object.properties[i];
609
- if (prop.type === 'SpreadElement')
714
+ if (prop.type === 'SpreadElement' || (prop.computed && propertyName(prop) === undefined))
610
715
  return undefined;
611
- if (propertyName(prop) === key)
716
+ if (!isPrototypeSetter(prop) && propertyName(prop) === key)
612
717
  return prop.value;
613
718
  }
614
719
  return undefined;
@@ -618,18 +723,32 @@ function propertyValue(object, key) {
618
723
  * after `const NEW = '@new'`, or of `Tags.NEW` after `const Tags = { NEW: '@new' }` or
619
724
  * `enum Tags { NEW = '@new' }`.
620
725
  */
621
- function constantValue(sourceCode, node) {
726
+ function constantValue(sourceCode, node, seen = []) {
727
+ if (seen.includes(node))
728
+ return undefined;
729
+ const path = [...seen, node];
730
+ const follow = (value) => {
731
+ if (value === undefined)
732
+ return undefined;
733
+ const target = unwrap(value);
734
+ return target.type === 'Identifier' || target.type === 'MemberExpression'
735
+ ? constantValue(sourceCode, target, path)
736
+ : target;
737
+ };
622
738
  if (node.type === 'Identifier')
623
- return declaredValue(sourceCode, node);
739
+ return follow(declaredValue(sourceCode, node));
624
740
  if (node.type !== 'MemberExpression')
625
741
  return undefined;
626
742
  const key = memberKey(node);
627
- const value = constantValue(sourceCode, unwrap(node.object));
743
+ const object = unwrap(node.object);
744
+ const value = object.type === 'ObjectExpression' || object.type === 'ArrayExpression'
745
+ ? object
746
+ : constantValue(sourceCode, object, path);
628
747
  const owner = value && unwrap(value);
629
748
  if (key === undefined || owner === undefined)
630
749
  return undefined;
631
750
  if (owner.type === 'ObjectExpression')
632
- return propertyValue(owner, key);
751
+ return follow(propertyValue(owner, key));
633
752
  if (owner.type === 'ArrayExpression') {
634
753
  const index = Number(key);
635
754
  if (!Number.isInteger(index) || index < 0 || String(index) !== key)
@@ -637,29 +756,43 @@ function constantValue(sourceCode, node) {
637
756
  if (owner.elements.slice(0, index + 1).some((element) => element?.type === 'SpreadElement'))
638
757
  return undefined;
639
758
  const element = owner.elements.at(index);
640
- return element !== undefined && element !== null && element.type !== 'SpreadElement' ? element : undefined;
759
+ return element !== undefined && element !== null && element.type !== 'SpreadElement' ? follow(element) : undefined;
641
760
  }
642
761
  if (owner.type !== 'TSEnumDeclaration')
643
762
  return undefined;
644
763
  const member = owner.body.members.find((m) => (m.id.type === 'Identifier' ? m.id.name : m.id.value) === key);
645
- return member?.initializer;
764
+ return follow(member?.initializer);
646
765
  }
647
766
  /**
648
767
  * Reads the text a title or tag value is made of: strings, template literals and arrays of them,
649
768
  * following constants declared in this file. What can't be read, such as a name imported from another
650
769
  * file, goes in `unread`: the name the call uses, or the value itself.
651
770
  */
652
- function readTexts(sourceCode, node, out, via, path = []) {
771
+ function readTexts(sourceCode, node, out, via, path = [], context = {}) {
653
772
  const target = unwrap(node);
773
+ if (path.includes(target)) {
774
+ out.unread.add(via ?? target);
775
+ return;
776
+ }
654
777
  if (isStaticText(target)) {
655
- out.texts.push({ text: target, via });
778
+ out.texts.push({ text: target, via, ...context });
656
779
  // A template that holds a constant, as in `` `${TAG}` ``, reads the constant's text too.
657
780
  if (target.type === 'TemplateLiteral') {
658
- for (const expression of target.expressions) {
659
- const value = constantValue(sourceCode, unwrap(expression));
781
+ const rendered = templateText(sourceCode, target);
782
+ for (const [i, expression] of target.expressions.entries()) {
783
+ const own = unwrap(expression);
784
+ const value = isStaticText(own) ? own : constantValue(sourceCode, own);
660
785
  const resolved = value && unwrap(value);
661
- if (resolved !== undefined && isStaticText(resolved))
662
- out.texts.push({ text: resolved, via });
786
+ if (resolved !== undefined && isStaticText(resolved)) {
787
+ readTexts(sourceCode, resolved, out, via, [...path, target], {
788
+ before: rendered.offsets[i * 2 + 1] === 0
789
+ ? (context.before ?? '')
790
+ : rendered.text.charAt(rendered.offsets[i * 2 + 1] - 1),
791
+ after: rendered.text.slice(rendered.offsets[i * 2 + 1] + rendered.parts[i * 2 + 1].length) +
792
+ (context.after ?? ''),
793
+ embedded: true,
794
+ });
795
+ }
663
796
  }
664
797
  }
665
798
  return;
@@ -667,7 +800,7 @@ function readTexts(sourceCode, node, out, via, path = []) {
667
800
  if (target.type === 'ArrayExpression') {
668
801
  for (const item of target.elements) {
669
802
  if (item)
670
- readTexts(sourceCode, item.type === 'SpreadElement' ? item.argument : item, out, via, path);
803
+ readTexts(sourceCode, item.type === 'SpreadElement' ? item.argument : item, out, via, [...path, target], context);
671
804
  }
672
805
  return;
673
806
  }
@@ -676,7 +809,7 @@ function readTexts(sourceCode, node, out, via, path = []) {
676
809
  if (value === undefined || path.includes(value))
677
810
  out.unread.add(via ?? target);
678
811
  else
679
- readTexts(sourceCode, value, out, via ?? target, [...path, value]);
812
+ readTexts(sourceCode, value, out, via ?? target, [...path, target], context);
680
813
  }
681
814
  /**
682
815
  * The key of a details property, following a computed string key declared in this file. A computed key that
@@ -726,6 +859,8 @@ function objectDetails(sourceCode, object, unread, path = []) {
726
859
  entries.set(key, value);
727
860
  continue;
728
861
  }
862
+ if (isPrototypeSetter(prop))
863
+ continue;
729
864
  const key = detailKey(sourceCode, prop, unread);
730
865
  if (key === undefined)
731
866
  entries.clear();
@@ -755,16 +890,22 @@ function detailsOf(sourceCode, call, optionsFirst) {
755
890
  const unread = [];
756
891
  return { entries: objectDetails(sourceCode, object, unread), unread };
757
892
  }
758
- /** The static text a value gives: a string literal, a template with no expressions, or one of those held in a constant declared in this file. */
759
- function staticText(sourceCode, node) {
893
+ /** Static string text, including templates whose expressions resolve to strings in this file. */
894
+ function staticText(sourceCode, node, seen = []) {
895
+ if (seen.includes(node))
896
+ return undefined;
897
+ const path = [...seen, node];
760
898
  const read = (value) => {
761
899
  if (value.type === 'Literal')
762
900
  return typeof value.value === 'string' ? value.value : undefined;
763
- if (value.type !== 'TemplateLiteral' || value.expressions.length !== 0)
901
+ if (value.type !== 'TemplateLiteral')
902
+ return undefined;
903
+ const expressions = value.expressions.map((expression) => interpolationText(sourceCode, expression, path));
904
+ if (expressions.some((text) => text === undefined))
764
905
  return undefined;
765
906
  // `cooked` is only null in tagged templates, which no annotation value is.
766
907
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- see above
767
- return value.quasis[0].value.cooked;
908
+ return value.quasis.map((quasi, i) => quasi.value.cooked + (expressions[i] ?? '')).join('');
768
909
  };
769
910
  const target = unwrap(node);
770
911
  const own = read(target);
@@ -773,6 +914,31 @@ function staticText(sourceCode, node) {
773
914
  const constant = constantValue(sourceCode, target);
774
915
  return constant === undefined ? undefined : read(unwrap(constant));
775
916
  }
917
+ /** Static primitive interpolation text, without evaluating object coercion or user code. */
918
+ function interpolationText(sourceCode, node, seen = []) {
919
+ const text = staticText(sourceCode, node, seen);
920
+ if (text !== undefined)
921
+ return text;
922
+ const target = unwrap(node);
923
+ const value = constantValue(sourceCode, target) ?? target;
924
+ if (value.type === 'Identifier' &&
925
+ declaredWhere(sourceCode, value) === 'global' &&
926
+ ['undefined', 'NaN', 'Infinity'].includes(value.name))
927
+ return value.name;
928
+ if (value.type === 'UnaryExpression') {
929
+ if (value.operator === 'void')
930
+ return 'undefined';
931
+ const argument = unwrap(value.argument);
932
+ if (value.operator === '-' &&
933
+ argument.type === 'Literal' &&
934
+ (typeof argument.value === 'number' || typeof argument.value === 'bigint'))
935
+ return String(-argument.value);
936
+ }
937
+ if (value.type !== 'Literal' ||
938
+ (value.value !== null && !['number', 'boolean', 'bigint'].includes(typeof value.value)))
939
+ return undefined;
940
+ return String(value.value);
941
+ }
776
942
  /**
777
943
  * The object or array a details value is, following a constant declared in this file: a carrier for
778
944
  * annotations. Undefined for a value that plainly can't hold one, `'unread'` for one that can't be read.
@@ -887,25 +1053,24 @@ const NO_TAGS = {
887
1053
  reasonedOptions: new Set(),
888
1054
  };
889
1055
  function collectTags(sourceCode, call, hasTitle, framework, details) {
890
- const source = sourceCode.text;
891
1056
  const tags = [];
892
1057
  const title = { texts: [], unread: new Set() };
893
1058
  // node:test's `test({ tags: ['db'] }, fn)` has no title, only options.
894
1059
  if (hasTitle)
895
1060
  readTexts(sourceCode, call.arguments[0], title);
896
1061
  for (const found of title.texts)
897
- scanTags(source, found, tags);
1062
+ scanTags(sourceCode, found, tags);
898
1063
  const values = { texts: [], unread: new Set() };
899
1064
  for (const [key, value] of details.entries) {
900
1065
  if (key === framework.tagKey)
901
1066
  readTexts(sourceCode, value, values);
902
1067
  }
903
1068
  for (const found of values.texts) {
904
- const plain = framework.plainTags ? plainTag(source, found) : undefined;
1069
+ const plain = framework.plainTags ? plainTag(sourceCode, found) : undefined;
905
1070
  if (plain)
906
1071
  tags.push(plain);
907
1072
  else
908
- scanTags(source, found, tags);
1073
+ scanTags(sourceCode, found, tags);
909
1074
  }
910
1075
  const unread = [...values.unread];
911
1076
  if (details.unread.length > 0 && framework.tagKey !== undefined)
@@ -977,7 +1142,8 @@ function thisFunction(node) {
977
1142
  }
978
1143
  /** The value of a `true` or `false` literal; undefined for anything else. */
979
1144
  function booleanOf(node) {
980
- return node?.type === 'Literal' && typeof node.value === 'boolean' ? node.value : undefined;
1145
+ const value = node === undefined ? undefined : unwrap(node);
1146
+ return value?.type === 'Literal' && typeof value.value === 'boolean' ? value.value : undefined;
981
1147
  }
982
1148
  /**
983
1149
  * Whether a node:test option applies: Node counts any value but `false` and `undefined`, so a reason such
@@ -994,23 +1160,31 @@ function optionApplies(node) {
994
1160
  }
995
1161
  /** Keys of a retry options object that hold a count: Vitest's `count`, Cypress's `runMode` and `openMode`. */
996
1162
  const RETRY_COUNT_KEYS = new Set(['count', 'runMode', 'openMode']);
1163
+ const VITEST_RETRY_COUNT_KEYS = new Set(['count']);
1164
+ const CYPRESS_RETRY_COUNT_KEYS = new Set(['runMode', 'openMode']);
997
1165
  /**
998
1166
  * Whether a retry count gives retries: anything but `0`, `false`, `null` or `undefined` written out, since a
999
1167
  * count known only when the tests run, as `process.env.CI ? 2 : 0`, retries where flaky tests hide. An
1000
1168
  * object retries unless every count it holds is one of those.
1001
1169
  */
1002
- function retries(node) {
1170
+ function retries(sourceCode, node, keys = RETRY_COUNT_KEYS, seen = []) {
1003
1171
  if (node === undefined)
1004
1172
  return false;
1005
- const value = unwrap(node);
1173
+ if (seen.includes(node))
1174
+ return true;
1175
+ const own = unwrap(node);
1176
+ const value = constantValue(sourceCode, own) ?? own;
1006
1177
  if (value.type === 'Literal')
1007
1178
  return value.value !== 0 && value.value !== false && value.value !== null;
1008
1179
  if (value.type === 'Identifier')
1009
1180
  return value.name !== 'undefined';
1010
1181
  if (value.type !== 'ObjectExpression')
1011
1182
  return true;
1012
- const counts = value.properties.flatMap((prop) => prop.type === 'Property' && RETRY_COUNT_KEYS.has(propertyName(prop) ?? '') ? [prop.value] : []);
1013
- return counts.length === 0 || counts.some(retries);
1183
+ const unread = [];
1184
+ const counts = objectDetails(sourceCode, value, unread).filter(([key]) => keys.has(key));
1185
+ if (unread.length > 0 && ![...keys].every((key) => counts.some(([name]) => name === key)))
1186
+ return true;
1187
+ return counts.length === 0 || counts.some(([, count]) => retries(sourceCode, count, keys, [...seen, node]));
1014
1188
  }
1015
1189
  /** Values that are never `false` or `undefined`. */
1016
1190
  const OPTION_VALUES = new Set([
@@ -1026,13 +1200,13 @@ const OPTION_VALUES = new Set([
1026
1200
  * Whether a runtime call always applies: it has no condition, or `true`, or with `notes` only a note, as
1027
1201
  * Vitest's `context.skip('Broker sandbox is down')`, and nothing around it makes it conditional.
1028
1202
  */
1029
- function isUnconditional(call, framework) {
1203
+ function isUnconditional(sourceCode, call, framework) {
1030
1204
  const first = call.arguments.at(0);
1031
1205
  const always = first === undefined ||
1032
1206
  booleanOf(first) === true ||
1033
1207
  // node:test's `t.skip(message)` and Jasmine's `pending(message)` take no condition, so any argument is a note.
1034
1208
  framework.reasonArgument === 'only' ||
1035
- (framework.skipNotes && isStaticText(first));
1209
+ (framework.skipNotes && (isStaticText(unwrap(first)) || staticText(sourceCode, first) !== undefined));
1036
1210
  return always && !isGuarded(call);
1037
1211
  }
1038
1212
  /**
@@ -1087,19 +1261,26 @@ function optionReason(sourceCode, node) {
1087
1261
  return isReasonText(sourceCode, value.consequent) || isReasonText(sourceCode, value.alternate);
1088
1262
  }
1089
1263
  const FUNCTIONS = new Set(['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression']);
1090
- /** Whether a statement always leaves the function or block: `return`, `throw`, or a block containing one. */
1264
+ /** Whether a statement can leave the enclosing body, without entering nested functions or loops. */
1091
1265
  function exits(statement) {
1092
- if (statement.type === 'ReturnStatement' || statement.type === 'ThrowStatement')
1266
+ if (statement.type === 'ReturnStatement' ||
1267
+ statement.type === 'ThrowStatement' ||
1268
+ statement.type === 'BreakStatement' ||
1269
+ statement.type === 'ContinueStatement')
1093
1270
  return true;
1271
+ if (statement.type === 'IfStatement')
1272
+ return exits(statement.consequent) || (statement.alternate !== null && exits(statement.alternate));
1094
1273
  return statement.type === 'BlockStatement' && statement.body.some(exits);
1095
1274
  }
1096
1275
  /** `if (ready) return;` or `if (!ok) { throw error; }`: code after it only runs on some paths. */
1097
1276
  function isEarlyExit(statement) {
1277
+ if (statement.type === 'BlockStatement')
1278
+ return statement.body.some(isEarlyExit);
1098
1279
  return (statement.type === 'IfStatement' &&
1099
1280
  (exits(statement.consequent) || (statement.alternate !== null && exits(statement.alternate))));
1100
1281
  }
1101
1282
  /**
1102
- * Whether the call only runs on some paths of its function: under an `if`, a `switch` case, a ternary,
1283
+ * Whether the call only runs on some paths of its function: under an `if`, a `switch` case, a ternary or a loop,
1103
1284
  * the right side of `&&`, `||` or `??`, in a `catch` block, after other code in a `try` block that has a
1104
1285
  * `catch`, or after an early exit such as `if (ready) return;`. `if (!enabled) test.skip()` is the same as `test.skip(!enabled)`, and
1105
1286
  * `catch { test.skip(true, 'service is down') }` only skips when the service is down.
@@ -1107,11 +1288,18 @@ function isEarlyExit(statement) {
1107
1288
  function isGuarded(call) {
1108
1289
  let child = call;
1109
1290
  for (let node = call.parent; node && !FUNCTIONS.has(node.type); child = node, node = node.parent) {
1110
- if ((node.type === 'IfStatement' || node.type === 'ConditionalExpression') && child !== node.test)
1291
+ const same = (other) => child.range[0] === other.range[0] && child.range[1] === other.range[1];
1292
+ if ((node.type === 'IfStatement' || node.type === 'ConditionalExpression') && !same(node.test))
1293
+ return true;
1294
+ if (node.type === 'SwitchCase' && (node.test === null || !same(node.test)))
1111
1295
  return true;
1112
- if (node.type === 'SwitchCase' && child !== node.test)
1296
+ if (node.type === 'LogicalExpression' && same(node.right))
1113
1297
  return true;
1114
- if (node.type === 'LogicalExpression' && child === node.right)
1298
+ if (((node.type === 'WhileStatement' || node.type === 'ForStatement') &&
1299
+ node.test !== null &&
1300
+ booleanOf(node.test) !== true &&
1301
+ child === node.body) ||
1302
+ ((node.type === 'ForInStatement' || node.type === 'ForOfStatement') && child === node.body))
1115
1303
  return true;
1116
1304
  if (node.type === 'CatchClause')
1117
1305
  return true;
@@ -1156,6 +1344,22 @@ export function analyze(sourceCode, options) {
1156
1344
  function runAnalysis(sourceCode, options) {
1157
1345
  const { states, framework } = options;
1158
1346
  const testNames = collectTestNames(sourceCode.ast, framework, options.testFunctions);
1347
+ /** Whole-module imports or requires, including constant aliases of them. */
1348
+ const moduleOf = (node, seen = []) => {
1349
+ const value = unwrap(node);
1350
+ if (seen.includes(value))
1351
+ return false;
1352
+ if (value.type === 'Identifier') {
1353
+ if (isReassigned(sourceCode, value))
1354
+ return false;
1355
+ if (testNames.modules.has(value.name) && declaredWhere(sourceCode, value) === 'top')
1356
+ return true;
1357
+ const alias = declaredValue(sourceCode, value);
1358
+ return alias !== undefined && moduleOf(alias, [...seen, value]);
1359
+ }
1360
+ const source = requireSource(value);
1361
+ return source !== undefined && framework.ownsModule(source);
1362
+ };
1159
1363
  /**
1160
1364
  * The test function a callee starts from and the members after it, such as `test` and
1161
1365
  * `['describe', 'skip']` for `test.describe.skip` or `pw.test.describe.skip`. Undefined when the callee
@@ -1173,11 +1377,13 @@ function runAnalysis(sourceCode, options) {
1173
1377
  const exported = framework.exports.get(path[0]);
1174
1378
  return exported === undefined ? undefined : { root: exported, path: path.slice(1), args };
1175
1379
  }
1380
+ if (isReassigned(sourceCode, root))
1381
+ return undefined;
1176
1382
  let base = testNames.roots.get(root.name);
1177
1383
  let rest = path;
1178
1384
  // `pw.test.skip`, or node:test's `test.describe`, whose module is also its `test` even when
1179
1385
  // `test` was imported by name.
1180
- const moduleLike = testNames.modules.has(root.name) || (framework.defaultExport !== undefined && base === framework.defaultExport);
1386
+ const moduleLike = moduleOf(root) || (framework.defaultExport !== undefined && base === framework.defaultExport);
1181
1387
  const member = moduleLike ? framework.exports.get(path[0]) : undefined;
1182
1388
  if (member !== undefined || base === undefined) {
1183
1389
  base = member;
@@ -1185,6 +1391,28 @@ function runAnalysis(sourceCode, options) {
1185
1391
  }
1186
1392
  if (base !== undefined && !isLocalVariable(sourceCode, root))
1187
1393
  return { root: base, path: rest, args };
1394
+ const definition = declaredVariable(sourceCode, root)?.defs.find((def) => def.type === 'Variable');
1395
+ const declarator = definition?.node;
1396
+ if (declarator?.type === 'VariableDeclarator' &&
1397
+ declarator.id.type === 'ObjectPattern' &&
1398
+ declarator.init !== null) {
1399
+ if (seen.includes(declarator))
1400
+ return undefined;
1401
+ const property = declarator.id.properties.find((prop) => {
1402
+ if (prop.type !== 'Property')
1403
+ return false;
1404
+ const binding = prop.value.type === 'AssignmentPattern' ? prop.value.left : prop.value;
1405
+ return binding.type === 'Identifier' && binding.name === root.name;
1406
+ });
1407
+ const key = property?.type === 'Property' ? propertyName(property) : undefined;
1408
+ const init = chainOf(unwrap(declarator.init), framework.factories);
1409
+ return key === undefined || init === undefined
1410
+ ? undefined
1411
+ : target({ ...init, path: [...init.path, key, ...path], args: new Map([...init.args, ...args]) }, [
1412
+ ...seen,
1413
+ declarator,
1414
+ ]);
1415
+ }
1188
1416
  const value = declaredValue(sourceCode, root);
1189
1417
  if (value === undefined || seen.includes(value))
1190
1418
  return undefined;
@@ -1197,6 +1425,7 @@ function runAnalysis(sourceCode, options) {
1197
1425
  const allComments = sourceCode.getAllComments();
1198
1426
  const commentedOut = findCommentedOutTests(sourceCode, allComments, new Set([
1199
1427
  ...options.testFunctions,
1428
+ ...[...testNames.roots].flatMap(([name, root]) => (root.kind === 'hook' ? [] : [name])),
1200
1429
  ...[...framework.exports, ...framework.globals].flatMap(([name, root]) => (root.kind === 'hook' ? [] : [name])),
1201
1430
  ]), new Set([...framework.testMembers, ...framework.describeMembers, 'describe', 'step'].map((m) => m.replace('()', ''))), states);
1202
1431
  const comments = allComments.filter((comment) => !commentedOut.comments.has(comment));
@@ -1371,14 +1600,16 @@ function runAnalysis(sourceCode, options) {
1371
1600
  // `skipIf(true)` always skips and `skipIf(false)` never does, like `{ skip: true }` and `{ skip: false }`.
1372
1601
  const condition = booleanOf(args.get(member));
1373
1602
  if (condition === undefined)
1374
- add(effect.modifier, false);
1603
+ modifiers.set(effect.modifier, false);
1375
1604
  else if (condition === effect.when)
1376
- add(effect.modifier, true);
1605
+ modifiers.set(effect.modifier, true);
1606
+ else
1607
+ modifiers.delete(effect.modifier);
1377
1608
  }
1378
1609
  // `{ skip: true }` always skips, `{ skip: isMobile }` only sometimes, `{ skip: false }` never.
1379
1610
  for (const [key, value] of details) {
1380
1611
  if (key === framework.retryOption) {
1381
- if (retries(value))
1612
+ if (retries(sourceCode, value, framework.name === 'vitest' ? VITEST_RETRY_COUNT_KEYS : CYPRESS_RETRY_COUNT_KEYS))
1382
1613
  add('retry', true);
1383
1614
  continue;
1384
1615
  }
@@ -1463,26 +1694,38 @@ function runAnalysis(sourceCode, options) {
1463
1694
  const callee = unwrap(picker);
1464
1695
  if (callee.type !== 'ConditionalExpression')
1465
1696
  return undefined;
1466
- const [yes, no] = [callee.consequent, callee.alternate].map((side) => {
1467
- const picked = target(chainOf(unwrap(side), framework.factories));
1468
- return picked && declarationOf(picked);
1469
- });
1697
+ const choices = (node) => {
1698
+ const value = unwrap(node);
1699
+ if (value.type === 'ConditionalExpression') {
1700
+ const yes = choices(value.consequent);
1701
+ const no = choices(value.alternate);
1702
+ return yes === undefined || no === undefined ? undefined : [...yes, ...no];
1703
+ }
1704
+ const picked = target(chainOf(value, framework.factories));
1705
+ const declaration = picked && declarationOf(picked);
1706
+ return declaration && [declaration];
1707
+ };
1708
+ const alternatives = choices(callee);
1470
1709
  const args = call.arguments;
1471
- if (yes === undefined || no === undefined)
1710
+ if (alternatives === undefined)
1472
1711
  return undefined;
1473
- if (yes.kind !== no.kind || !declares(yes, args) || !declares(no, args))
1712
+ const [first] = alternatives;
1713
+ if (!alternatives.every((choice) => choice.kind === first.kind && declares(choice, args)))
1474
1714
  return undefined;
1475
1715
  usesTest = true;
1476
1716
  const { details, tagInfo } = headOf(call);
1477
- const [ifYes, ifNo] = [yes, no].map(({ members, args: conditions }) => declarationModifiers(yes.kind, args, modifiersOf(members, details.entries, conditions)));
1478
- if (yes.kind === 'test')
1717
+ const branches = alternatives.map(({ members, args: conditions }) => declarationModifiers(first.kind, args, modifiersOf(members, details.entries, conditions)));
1718
+ if (first.kind === 'test')
1479
1719
  addThisBody(args);
1720
+ const body = args.findLast(isFunction);
1721
+ if (first.kind === 'describe' && body?.type === 'FunctionExpression')
1722
+ suiteBodies.add(body);
1480
1723
  const modifiers = new Map();
1481
- for (const modifier of new Set([...ifYes.keys(), ...ifNo.keys()])) {
1482
- modifiers.set(modifier, ifYes.get(modifier) === true && ifNo.get(modifier) === true);
1724
+ for (const modifier of new Set(branches.flatMap((branch) => [...branch.keys()]))) {
1725
+ modifiers.set(modifier, branches.every((branch) => branch.get(modifier) === true));
1483
1726
  }
1484
- const infos = yes.kind === 'test' ? bodyInfos(args, yes.members) : [];
1485
- return { subject: makeSubject(yes.kind, call, modifiers, tagInfo), infos };
1727
+ const infos = first.kind === 'test' ? bodyInfos(args, first.members) : [];
1728
+ return { subject: makeSubject(first.kind, call, modifiers, tagInfo), infos };
1486
1729
  };
1487
1730
  /** The details, tags and annotations of a declaration; node:test lets it leave out its title. */
1488
1731
  const headOf = (call) => {
@@ -1516,13 +1759,65 @@ function runAnalysis(sourceCode, options) {
1516
1759
  suiteBodies.add(body);
1517
1760
  return { subject: makeSubject(kind, call, modifiers, tagInfo), infos };
1518
1761
  };
1762
+ /** A context or runtime method, following stable local aliases and bound methods. */
1763
+ const resolveInfo = (node, seen = []) => {
1764
+ const value = unwrap(node);
1765
+ if (seen.includes(value))
1766
+ return undefined;
1767
+ const path = [...seen, value];
1768
+ if (value.type === 'CallExpression') {
1769
+ const bind = unwrap(value.callee);
1770
+ if (bind.type !== 'MemberExpression' || memberName(bind) !== 'bind')
1771
+ return undefined;
1772
+ const source = resolveInfo(bind.object, path);
1773
+ return source?.method === undefined
1774
+ ? undefined
1775
+ : { ...source, boundArgs: [...(source.boundArgs ?? []), ...value.arguments.slice(1)] };
1776
+ }
1777
+ if (value.type === 'MemberExpression') {
1778
+ const name = memberName(value);
1779
+ if (name === undefined)
1780
+ return undefined;
1781
+ if (value.object.type === 'ThisExpression') {
1782
+ const owner = thisFunction(value);
1783
+ return owner !== undefined && thisBodies.has(owner) && framework.thisMethods.has(name)
1784
+ ? { name, method: name }
1785
+ : undefined;
1786
+ }
1787
+ const object = resolveInfo(value.object, path);
1788
+ return object?.methods?.has(name) ? { name, method: name, step: object.step, owner: object.owner } : undefined;
1789
+ }
1790
+ if (value.type !== 'Identifier' || isReassigned(sourceCode, value))
1791
+ return undefined;
1792
+ const direct = infoOf(value);
1793
+ if (direct !== undefined) {
1794
+ const fn = direct.runtimeFunction;
1795
+ return fn && declaredWhere(sourceCode, value) !== (fn.imported ? 'top' : 'global') ? undefined : direct;
1796
+ }
1797
+ const definition = declaredVariable(sourceCode, value)?.defs.find((def) => def.type === 'Variable');
1798
+ const declarator = definition?.node;
1799
+ if (declarator?.type !== 'VariableDeclarator' || declarator.init === null)
1800
+ return undefined;
1801
+ const source = resolveInfo(declarator.init, path);
1802
+ if (source === undefined)
1803
+ return undefined;
1804
+ if (declarator.id.type === 'Identifier')
1805
+ return { ...source, name: value.name, runtimeFunction: undefined };
1806
+ if (declarator.id.type !== 'ObjectPattern' || source.methods === undefined)
1807
+ return undefined;
1808
+ const property = declarator.id.properties.find((prop) => prop.type === 'Property' && prop.value.type === 'Identifier' && prop.value.name === value.name);
1809
+ const name = property?.type === 'Property' ? propertyName(property) : undefined;
1810
+ return name !== undefined && source.methods.has(name)
1811
+ ? { name: value.name, method: name, step: source.step, owner: source.owner }
1812
+ : undefined;
1813
+ };
1519
1814
  /** node:test's `t.test('title', fn)`: a subtest declared through the context of the test around it. */
1520
1815
  const subtestOf = (call) => {
1521
1816
  const { callee } = call;
1522
1817
  if (!framework.subtests || callee.type !== 'MemberExpression' || memberName(callee) !== 'test')
1523
1818
  return undefined;
1524
1819
  const { object } = callee;
1525
- if (object.type !== 'Identifier' || !infoOf(object)?.methods)
1820
+ if (object.type !== 'Identifier' || !resolveInfo(object)?.methods)
1526
1821
  return undefined;
1527
1822
  const declaration = { kind: 'test', members: [], written: [], args: new Map() };
1528
1823
  return declares(declaration, call.arguments)
@@ -1569,11 +1864,16 @@ function runAnalysis(sourceCode, options) {
1569
1864
  return visited;
1570
1865
  };
1571
1866
  const visitDeclaredCall = (original, alias) => {
1572
- const call = throughFunctionMethods(original);
1573
- const written = alias ?? call.callee;
1867
+ const normalized = throughFunctionMethods(original);
1868
+ const expanded = alias === undefined
1869
+ ? normalized
1870
+ : throughFunctionMethods({ ...normalized, callee: alias });
1871
+ const call = expanded.arguments === normalized.arguments ? normalized : { ...normalized, arguments: expanded.arguments };
1872
+ const written = expanded.callee;
1574
1873
  const callee = target(chainOf(written, framework.factories));
1575
1874
  if (callee === undefined) {
1576
- return subtestOf(call) ?? pickedDeclaration(call, written) ?? { subject: runtimeCall(call) ?? retryCall(call) };
1875
+ return (subtestOf(call) ??
1876
+ pickedDeclaration(call, written) ?? { subject: runtimeCall(call) ?? retryCall({ ...call, callee: written }) });
1577
1877
  }
1578
1878
  usesTest = true;
1579
1879
  const args = call.arguments;
@@ -1621,88 +1921,21 @@ function runAnalysis(sourceCode, options) {
1621
1921
  */
1622
1922
  const runtimeCall = (call) => {
1623
1923
  const { callee } = call;
1624
- /**
1625
- * What a name stands for: a context in scope, or a local constant that keeps one of its methods, as
1626
- * `const skip = testInfo.skip`, `const { skip } = t` or `const p = pending`.
1627
- */
1628
- const resolveInfo = (id) => {
1629
- const direct = infoOf(id);
1630
- if (direct !== undefined)
1631
- return direct;
1632
- const definition = declaredVariable(sourceCode, id)?.defs.find((def) => def.type === 'Variable');
1633
- const declarator = definition?.node;
1634
- if (declarator?.type !== 'VariableDeclarator' || declarator.init === null)
1635
- return undefined;
1636
- const init = unwrap(declarator.init);
1637
- if (declarator.id.type === 'Identifier') {
1638
- if (init.type === 'MemberExpression') {
1639
- const name = memberName(init);
1640
- if (name === undefined)
1641
- return undefined;
1642
- if (init.object.type === 'ThisExpression') {
1643
- const owner = thisFunction(declarator);
1644
- return owner !== undefined && thisBodies.has(owner) && framework.thisMethods.has(name)
1645
- ? { name: id.name, method: name }
1646
- : undefined;
1647
- }
1648
- const object = init.object.type === 'Identifier' ? infoOf(init.object) : undefined;
1649
- return object?.methods?.has(name) ? { name: id.name, method: name, step: object.step } : undefined;
1650
- }
1651
- if (init.type !== 'Identifier')
1652
- return undefined;
1653
- const source = infoOf(init);
1654
- if (source === undefined)
1655
- return undefined;
1656
- const fn = source.runtimeFunction;
1657
- if (fn && declaredWhere(sourceCode, init) !== (fn.imported ? 'top' : 'global'))
1658
- return undefined;
1659
- if (source.method !== undefined)
1660
- return { name: id.name, method: source.method, step: source.step };
1661
- return { name: id.name, methods: source.methods, step: source.step };
1662
- }
1663
- if (declarator.id.type !== 'ObjectPattern' || init.type !== 'Identifier')
1664
- return undefined;
1665
- const source = infoOf(init);
1666
- if (source?.methods === undefined)
1667
- return undefined;
1668
- const property = declarator.id.properties.find((prop) => prop.type === 'Property' && prop.value.type === 'Identifier' && prop.value.name === id.name);
1669
- if (property?.type !== 'Property')
1670
- return undefined;
1671
- const name = propertyName(property);
1672
- return name !== undefined && source.methods.has(name)
1673
- ? { name: id.name, method: name, step: source.step }
1674
- : undefined;
1675
- };
1676
- if (callee.type === 'Identifier') {
1677
- const info = resolveInfo(callee);
1678
- if (info?.method === undefined)
1679
- return undefined;
1680
- // A global such as Jasmine's `pending` counts unless the file declares the name itself; an import of
1681
- // one counts unless a function or block declares the name again.
1682
- const fn = info.runtimeFunction;
1683
- if (fn && declaredWhere(sourceCode, callee) !== (fn.imported ? 'top' : 'global'))
1684
- return undefined;
1685
- const subject = runtimeSubject(call, info.method, info.step);
1924
+ const info = resolveInfo(callee);
1925
+ if (info?.method !== undefined) {
1926
+ const applied = info.boundArgs === undefined ? call : { ...call, arguments: [...info.boundArgs, ...call.arguments] };
1927
+ const subject = runtimeSubject(applied, info.method, info.step ?? info.owner);
1686
1928
  if (subject)
1687
- subject.functionName = fn?.name;
1929
+ subject.functionName = info.runtimeFunction?.name;
1688
1930
  return subject;
1689
1931
  }
1690
- if (callee.type !== 'MemberExpression')
1932
+ const member = unwrap(callee);
1933
+ if (member.type !== 'MemberExpression')
1691
1934
  return undefined;
1692
- const name = memberName(callee);
1935
+ const name = memberName(member);
1693
1936
  if (name === undefined)
1694
1937
  return undefined;
1695
- const { object } = callee;
1696
- // Mocha's `this.skip()` or node:test's `this.todo()`, in a test or hook whose `function` gives it the
1697
- // test's `this`.
1698
- if (object.type === 'ThisExpression') {
1699
- const owner = framework.thisMethods.has(name) ? thisFunction(call) : undefined;
1700
- return owner !== undefined && thisBodies.has(owner) ? runtimeSubject(call, name) : undefined;
1701
- }
1702
- if (object.type === 'Identifier') {
1703
- const info = resolveInfo(object);
1704
- return info?.methods?.has(name) ? runtimeSubject(call, name, info.step) : undefined;
1705
- }
1938
+ const object = unwrap(member.object);
1706
1939
  const isTestInfo = RUNTIME_MODIFIERS.has(name) &&
1707
1940
  object.type === 'CallExpression' &&
1708
1941
  target(chainOf(object.callee, framework.factories))?.path.join('.') === 'info';
@@ -1723,20 +1956,37 @@ function runAnalysis(sourceCode, options) {
1723
1956
  const ownsThis = owner !== undefined && (thisBodies.has(owner) || suiteBodies.has(owner));
1724
1957
  return ownsThis ? retrySubject(call, call.arguments.at(0), name) : undefined;
1725
1958
  }
1726
- if (framework.retryCall !== 'jest.retryTimes' || name !== 'retryTimes' || object.type !== 'Identifier') {
1959
+ if (framework.retryCall !== 'jest.retryTimes' || name !== 'retryTimes') {
1727
1960
  return undefined;
1728
1961
  }
1729
- const def = declaredVariable(sourceCode, object)?.defs.at(0);
1730
- const isJest = object.name === 'jest' &&
1731
- (def === undefined ||
1732
- (def.type === 'ImportBinding' &&
1962
+ const isJestObject = (value, seen = []) => {
1963
+ if (seen.includes(value))
1964
+ return false;
1965
+ if (value.type === 'MemberExpression')
1966
+ return memberName(value) === 'jest' && moduleOf(value.object);
1967
+ if (value.type !== 'Identifier' || isReassigned(sourceCode, value))
1968
+ return false;
1969
+ const def = declaredVariable(sourceCode, value)?.defs.at(0);
1970
+ if (def === undefined)
1971
+ return value.name === 'jest';
1972
+ if (def.type === 'ImportBinding')
1973
+ return (def.node.type === 'ImportSpecifier' &&
1974
+ importedName(def.node) === 'jest' &&
1733
1975
  def.parent.type === 'ImportDeclaration' &&
1734
- framework.ownsModule(def.parent.source.value)));
1976
+ framework.ownsModule(def.parent.source.value));
1977
+ if (def.type === 'Variable' && def.node.id.type === 'ObjectPattern' && def.node.init !== null) {
1978
+ const property = def.node.id.properties.find((prop) => prop.type === 'Property' && prop.value.type === 'Identifier' && prop.value.name === value.name);
1979
+ return property?.type === 'Property' && propertyName(property) === 'jest' && moduleOf(def.node.init);
1980
+ }
1981
+ const alias = declaredValue(sourceCode, value);
1982
+ return alias !== undefined && isJestObject(unwrap(alias), [...seen, value]);
1983
+ };
1984
+ const isJest = isJestObject(object);
1735
1985
  return isJest ? retrySubject(call, call.arguments.at(0), name) : undefined;
1736
1986
  };
1737
1987
  /** A call that gives retries when `count` does; retries need a ticket even when only some runs get them. */
1738
1988
  const retrySubject = (call, count, name) => {
1739
- if (!retries(count))
1989
+ if (!retries(sourceCode, count))
1740
1990
  return undefined;
1741
1991
  const subject = runtimeSubject(call, 'retry', undefined, true);
1742
1992
  if (subject)
@@ -1744,7 +1994,7 @@ function runAnalysis(sourceCode, options) {
1744
1994
  return subject;
1745
1995
  };
1746
1996
  /** `step` is set for `step.skip()`, which skips only its step; the other calls act on the whole test. */
1747
- const runtimeSubject = (call, modifier, step, always = isUnconditional(call, framework)) => {
1997
+ const runtimeSubject = (call, modifier, step, always = isUnconditional(sourceCode, call, framework)) => {
1748
1998
  if (!states.some((s) => s.modifier === modifier))
1749
1999
  return undefined;
1750
2000
  if (framework.reasonArgument !== 'only' && booleanOf(call.arguments.at(0)) === false)
@@ -1768,6 +2018,8 @@ function runAnalysis(sourceCode, options) {
1768
2018
  pushedSubject = true;
1769
2019
  }
1770
2020
  for (const info of visit.infos ?? []) {
2021
+ if (!info.runtimeFunction && info.step === undefined && visit.subject?.kind === 'test')
2022
+ info.owner = visit.subject;
1771
2023
  infos.push(info);
1772
2024
  pushedInfos++;
1773
2025
  }
@@ -1937,7 +2189,10 @@ export function headLoc(subject) {
1937
2189
  // todo test has only a title.
1938
2190
  const [first] = node.arguments;
1939
2191
  const title = isFunction(first) || first.type === 'ObjectExpression' ? undefined : first;
1940
- return { start: node.callee.loc.start, end: (title ?? node.callee).loc.end };
2192
+ return {
2193
+ start: node.callee.loc.start,
2194
+ end: (title && title.range[1] > node.callee.range[1] ? title : node.callee).loc.end,
2195
+ };
1941
2196
  }
1942
2197
  export function describeSubject(subject) {
1943
2198
  if (subject.kind === 'describe')
package/dist/utils/fix.js CHANGED
@@ -28,9 +28,10 @@ export function removeMarkerComment(sourceCode, marker) {
28
28
  return { range: [valueStart + description.separator, valueStart + comment.value.trimEnd().length], text: '' };
29
29
  }
30
30
  const { text } = sourceCode;
31
- const lineStart = text.lastIndexOf('\n', comment.range[0] - 1) + 1;
32
- const newline = text.indexOf('\n', comment.range[1]);
33
- const lineEnd = newline === -1 ? text.length : newline + 1;
31
+ const lineStart = sourceCode.getIndexFromLoc({ line: comment.loc.start.line, column: 0 });
32
+ const lineEnd = comment.loc.end.line < sourceCode.lines.length
33
+ ? sourceCode.getIndexFromLoc({ line: comment.loc.end.line + 1, column: 0 })
34
+ : text.length;
34
35
  const aloneOnLine = text.slice(lineStart, comment.range[0]).trim() === '' && text.slice(comment.range[1], lineEnd).trim() === '';
35
36
  return { range: aloneOnLine ? [lineStart, lineEnd] : comment.range, text: '' };
36
37
  }
@@ -47,7 +48,7 @@ export function indentOf(sourceCode, comment) {
47
48
  }
48
49
  const IDENTIFIER_RE = /^[A-Za-z_$][\w$]*$/;
49
50
  function quote(text) {
50
- return `'${text.replace(/[\\']/g, '\\$&')}'`;
51
+ return `'${text.replace(/[\\']/g, '\\$&').replace(/\r/g, '\\r').replace(/\n/g, '\\n')}'`;
51
52
  }
52
53
  /**
53
54
  * Moves a marker's tickets into an annotation on `call`: Playwright's `annotation` or Vitest's `meta`,
@@ -64,7 +65,7 @@ export function moveToAnnotation(sourceCode, call, marker, source, type) {
64
65
  return undefined;
65
66
  const one = (ticket) => `{ type: ${quote(type)}, description: ${quote(ticket)} }`;
66
67
  const value = source.shape === 'meta'
67
- ? `{ ${IDENTIFIER_RE.test(type) ? type : quote(type)}: ${quote(tickets[0])} }`
68
+ ? `{ ${type === '__proto__' ? `[${quote(type)}]` : IDENTIFIER_RE.test(type) ? type : quote(type)}: ${quote(tickets[0])} }`
68
69
  : tickets.length === 1
69
70
  ? one(tickets[0])
70
71
  : `[${tickets.map(one).join(', ')}]`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-test-governance",
3
- "version": "0.13.2",
3
+ "version": "0.13.3",
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",