eslint-plugin-test-governance 0.13.0 → 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.
@@ -32,14 +32,16 @@ export function directiveOf(comment) {
32
32
  return { kind, rules, description: { separator: separator.index, start: separator.index + separator[0].length } };
33
33
  }
34
34
  // A call at the start of a comment line, as `test('...'`, `test.describe.skip(` or `await test.step(`. Code has
35
- // no space before a call's `(`, and a sentence such as `// it (see above) ...` has one.
36
- const COMMENTED_CALL_RE = /^(\s*\*?\s*)(?:await\s+)?([A-Za-z_$][\w$]*)(?:\.[A-Za-z_$][\w$]*)*\(/;
35
+ // no space before a call's `(`, and a sentence such as `// it (see above) ...` has one. An empty call, as
36
+ // `test.skip()`, changes a test at runtime and declares none.
37
+ const COMMENTED_CALL_RE = /^(\s*\*?\s*)(?:await\s+)?([A-Za-z_$][\w$]*)((?:\.[A-Za-z_$][\w$]*)*)\((?!\))/;
37
38
  /**
38
39
  * Blocks of comments that hold a test or describe written out as code: comments on consecutive lines with
39
- * no code before them, one of whose lines starts with a call of `names`. A JSDoc comment is documentation,
40
- * so a test in its example doesn't count.
40
+ * no code before them, one of whose lines starts with a call of `names` whose members all declare tests,
41
+ * as `test.describe.skip(`, unlike `test.use(`. A JSDoc comment is documentation, so a test in its example
42
+ * doesn't count.
41
43
  */
42
- function findCommentedOutTests(sourceCode, comments, names, states) {
44
+ function findCommentedOutTests(sourceCode, comments, names, members, states) {
43
45
  const blocks = [];
44
46
  for (const comment of comments) {
45
47
  if (sourceCode.getTokenBefore(comment)?.loc.end.line === comment.loc.start.line)
@@ -59,7 +61,13 @@ function findCommentedOutTests(sourceCode, comments, names, states) {
59
61
  .flatMap(commentLines)
60
62
  .flatMap(({ text, start }) => {
61
63
  const match = COMMENTED_CALL_RE.exec(text);
62
- return match && names.has(match[2]) ? [[start + match[1].length, start + text.trimEnd().length]] : [];
64
+ const declares = match !== null &&
65
+ names.has(match[2]) &&
66
+ match[3]
67
+ .split('.')
68
+ .slice(1)
69
+ .every((member) => members.has(member));
70
+ return declares ? [[start + match[1].length, start + text.trimEnd().length]] : [];
63
71
  })
64
72
  .at(0);
65
73
  if (range === undefined)
@@ -92,14 +100,16 @@ function disableRegions(comments, states) {
92
100
  /** The names a parameter gives to runtime info: `testInfo`, or `skip` in `({ skip }) => ...`. */
93
101
  function infoNames(param, methods, step) {
94
102
  if (param?.type === 'Identifier')
95
- return [{ name: param.name, methods, step }];
103
+ return [{ name: param.name, binding: param, methods, step }];
96
104
  if (param?.type !== 'ObjectPattern')
97
105
  return [];
98
106
  return param.properties.flatMap((prop) => {
99
107
  if (prop.type !== 'Property' || prop.value.type !== 'Identifier')
100
108
  return [];
101
109
  const method = propertyName(prop);
102
- 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
+ : [];
103
113
  });
104
114
  }
105
115
  function memberName(node) {
@@ -389,8 +399,15 @@ function isLocalVariable(sourceCode, id) {
389
399
  }
390
400
  return false;
391
401
  }
402
+ /** Values a constant can hold a test function in: `it.skip`, `it.skipIf(...)`, `` it.each`...` `` or `a ? b : c`. */
403
+ const ALIAS_VALUES = new Set([
404
+ 'MemberExpression',
405
+ 'CallExpression',
406
+ 'TaggedTemplateExpression',
407
+ 'ConditionalExpression',
408
+ ]);
392
409
  const STATEMENT_CONTAINERS = new Set(['Program', 'BlockStatement', 'StaticBlock', 'SwitchCase', 'TSModuleBlock']);
393
- /** The statement a call belongs to; its marker block sits above that statement. */
410
+ /** The statement a call, or a value, belongs to; its marker block sits above that statement. */
394
411
  /**
395
412
  * The declaration a call makes through `Function.prototype.call`, `apply` or `bind`, read as if it were
396
413
  * written plainly: `it.skip.call(null, 'title', fn)`, `it.skip.apply(null, ['title', fn])` and
@@ -516,20 +533,21 @@ export function commentLines(comment) {
516
533
  }
517
534
  function parseMarkers(comment, states) {
518
535
  const markers = [];
519
- for (const { text, start } of commentLines(comment)) {
536
+ for (const [line, { text, start }] of commentLines(comment).entries()) {
520
537
  const match = MARKER_LINE_RE.exec(text);
521
538
  if (!match)
522
539
  continue;
523
540
  const [, keyword, rest] = match;
524
541
  const restOffset = start + match[0].length - rest.length;
542
+ const at = { comment, keyword, line, keywordStart: start + text.indexOf(keyword) };
525
543
  const state = states.find((s) => s.marker === keyword);
526
544
  if (state) {
527
- markers.push({ comment, keyword, state, ...parseTickets(rest, restOffset, state.ticket) });
545
+ markers.push({ ...at, state, ...parseTickets(rest, restOffset, state.ticket) });
528
546
  continue;
529
547
  }
530
548
  const caseOf = states.find((s) => s.marker === keyword.toUpperCase());
531
549
  if (caseOf)
532
- markers.push({ comment, keyword, caseOf, ...parseTickets(rest, restOffset, caseOf.ticket) });
550
+ markers.push({ ...at, caseOf, ...parseTickets(rest, restOffset, caseOf.ticket) });
533
551
  }
534
552
  return markers;
535
553
  }
@@ -614,10 +632,12 @@ function constantValue(sourceCode, node) {
614
632
  return propertyValue(owner, key);
615
633
  if (owner.type === 'ArrayExpression') {
616
634
  const index = Number(key);
617
- if (!Number.isInteger(index))
635
+ if (!Number.isInteger(index) || index < 0 || String(index) !== key)
636
+ return undefined;
637
+ if (owner.elements.slice(0, index + 1).some((element) => element?.type === 'SpreadElement'))
618
638
  return undefined;
619
- const element = owner.elements[index];
620
- return element !== null && element.type !== 'SpreadElement' ? element : undefined;
639
+ const element = owner.elements.at(index);
640
+ return element !== undefined && element !== null && element.type !== 'SpreadElement' ? element : undefined;
621
641
  }
622
642
  if (owner.type !== 'TSEnumDeclaration')
623
643
  return undefined;
@@ -684,20 +704,35 @@ function detailKey(sourceCode, prop, unread) {
684
704
  * The plain-keyed properties of a details object, following spread objects declared in this file and
685
705
  * collecting the ones that can't be read.
686
706
  */
687
- function objectDetails(sourceCode, object, unread) {
688
- 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) {
689
714
  if (prop.type === 'SpreadElement') {
690
715
  const argument = unwrap(prop.argument);
691
716
  const value = argument.type === 'ObjectExpression' ? argument : constantValue(sourceCode, argument);
692
717
  const spread = value && unwrap(value);
693
- if (spread?.type === 'ObjectExpression')
694
- return objectDetails(sourceCode, spread, unread);
695
- unread.push(prop.argument);
696
- 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;
697
728
  }
698
729
  const key = detailKey(sourceCode, prop, unread);
699
- return key === undefined ? [] : [[key, prop.value]];
700
- });
730
+ if (key === undefined)
731
+ entries.clear();
732
+ else
733
+ entries.set(key, prop.value);
734
+ }
735
+ return [...entries];
701
736
  }
702
737
  /**
703
738
  * The details or options object of a declaration, the second argument in
@@ -789,7 +824,7 @@ function collectAnnotations(sourceCode, details, source, annotations) {
789
824
  }
790
825
  tickets.push({ text, node: description[1] });
791
826
  };
792
- const readValue = (value) => {
827
+ const readValue = (value, path = []) => {
793
828
  const carrier = annotationCarrier(sourceCode, value);
794
829
  if (carrier === 'unread') {
795
830
  unread.push(value);
@@ -797,6 +832,10 @@ function collectAnnotations(sourceCode, details, source, annotations) {
797
832
  }
798
833
  if (carrier === undefined)
799
834
  return;
835
+ if (path.includes(carrier)) {
836
+ unread.push(value);
837
+ return;
838
+ }
800
839
  if (source.shape === 'meta') {
801
840
  if (carrier.type === 'ObjectExpression') {
802
841
  const unreadKeys = [];
@@ -818,8 +857,12 @@ function collectAnnotations(sourceCode, details, source, annotations) {
818
857
  readPlaywright(carrier);
819
858
  else {
820
859
  for (const element of carrier.elements) {
821
- if (element === null || element.type === 'SpreadElement')
860
+ if (element === null)
861
+ continue;
862
+ if (element.type === 'SpreadElement') {
863
+ readValue(element.argument, [...path, carrier]);
822
864
  continue;
865
+ }
823
866
  const inner = annotationCarrier(sourceCode, element);
824
867
  if (inner === 'unread')
825
868
  unread.push(element);
@@ -983,9 +1026,13 @@ const OPTION_VALUES = new Set([
983
1026
  * Whether a runtime call always applies: it has no condition, or `true`, or with `notes` only a note, as
984
1027
  * Vitest's `context.skip('Broker sandbox is down')`, and nothing around it makes it conditional.
985
1028
  */
986
- function isUnconditional(call, notes) {
1029
+ function isUnconditional(call, framework) {
987
1030
  const first = call.arguments.at(0);
988
- const always = first === undefined || booleanOf(first) === true || (notes && isStaticText(first));
1031
+ const always = first === undefined ||
1032
+ booleanOf(first) === true ||
1033
+ // node:test's `t.skip(message)` and Jasmine's `pending(message)` take no condition, so any argument is a note.
1034
+ framework.reasonArgument === 'only' ||
1035
+ (framework.skipNotes && isStaticText(first));
989
1036
  return always && !isGuarded(call);
990
1037
  }
991
1038
  /**
@@ -1006,9 +1053,13 @@ function saysWhy(sourceCode, node) {
1006
1053
  const value = unwrap(node);
1007
1054
  return value.type !== 'Literal' && !(value.type === 'Identifier' && value.name === 'undefined');
1008
1055
  }
1009
- /** Whether `node` is text that isn't blank. */
1056
+ /**
1057
+ * Whether `node` is text that isn't blank, or a template with values in it, as `` `${browserName} has no
1058
+ * clipboard` ``, which is taken on trust.
1059
+ */
1010
1060
  function isReasonText(sourceCode, node) {
1011
- return Boolean(staticText(sourceCode, node)?.trim());
1061
+ const text = staticText(sourceCode, node);
1062
+ return text === undefined ? unwrap(node).type === 'TemplateLiteral' : text.trim() !== '';
1012
1063
  }
1013
1064
  /** Whether a runtime call gives the reason for its state where its framework takes one (see `reasonArgument`). */
1014
1065
  function callReason(sourceCode, call, where) {
@@ -1017,7 +1068,8 @@ function callReason(sourceCode, call, where) {
1017
1068
  if (where === 'only')
1018
1069
  return first !== undefined && saysWhy(sourceCode, first);
1019
1070
  // Vitest's `context.skip(isMobile)` takes a condition, so only text first is a reason.
1020
- if (where === 'either' && first !== undefined && staticText(sourceCode, first) !== undefined) {
1071
+ const text = first !== undefined && (staticText(sourceCode, first) !== undefined || unwrap(first).type === 'TemplateLiteral');
1072
+ if (where === 'either' && text) {
1021
1073
  return isReasonText(sourceCode, first);
1022
1074
  }
1023
1075
  return second !== undefined && saysWhy(sourceCode, second);
@@ -1109,7 +1161,7 @@ function runAnalysis(sourceCode, options) {
1109
1161
  * `['describe', 'skip']` for `test.describe.skip` or `pw.test.describe.skip`. Undefined when the callee
1110
1162
  * isn't one of the framework's test functions.
1111
1163
  */
1112
- const target = (chain) => {
1164
+ const target = (chain, seen = []) => {
1113
1165
  if (chain === undefined)
1114
1166
  return undefined;
1115
1167
  const { root, path, args } = chain;
@@ -1131,7 +1183,13 @@ function runAnalysis(sourceCode, options) {
1131
1183
  base = member;
1132
1184
  rest = path.slice(1);
1133
1185
  }
1134
- 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]) };
1135
1193
  };
1136
1194
  let usesTest = sourceCode.ast.body.some((statement) => statement.type === 'ImportDeclaration' && framework.ownsModule(statement.source.value));
1137
1195
  const subjects = [];
@@ -1140,9 +1198,11 @@ function runAnalysis(sourceCode, options) {
1140
1198
  const commentedOut = findCommentedOutTests(sourceCode, allComments, new Set([
1141
1199
  ...options.testFunctions,
1142
1200
  ...[...framework.exports, ...framework.globals].flatMap(([name, root]) => (root.kind === 'hook' ? [] : [name])),
1143
- ]), states);
1201
+ ]), new Set([...framework.testMembers, ...framework.describeMembers, 'describe', 'step'].map((m) => m.replace('()', ''))), states);
1144
1202
  const comments = allComments.filter((comment) => !commentedOut.comments.has(comment));
1145
1203
  const regions = disableRegions(comments, states);
1204
+ // A region's marker covers every test up to its `eslint-enable`, so it is never the own marker of the first.
1205
+ const regionComments = new Set(regions.map((r) => r.comment));
1146
1206
  // `test.skip(...); // eslint-disable-line rule -- SKIP: TRADE-1` holds its marker on the test's own line.
1147
1207
  const lineDirectives = new Map();
1148
1208
  for (const comment of comments) {
@@ -1152,6 +1212,13 @@ function runAnalysis(sourceCode, options) {
1152
1212
  }
1153
1213
  const stack = [];
1154
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
+ };
1155
1222
  const keys = sourceCode.visitorKeys;
1156
1223
  /** States an options object can put a test in, such as `skip` for Vitest's `{ skip: true }`. */
1157
1224
  const optionModifiers = new Set(framework.optionEffects.values());
@@ -1162,12 +1229,12 @@ function runAnalysis(sourceCode, options) {
1162
1229
  /** Describe bodies written as a `function`, whose `this` is Mocha's suite, which `this.retries()` acts on. */
1163
1230
  const suiteBodies = new Set();
1164
1231
  /**
1165
- * Whether a statement around `anchor` has a comment that says something above it, as `// why` above an `if`,
1166
- * up to `owner`, the test or describe the call acts on. The walk goes on through a callback passed to another
1232
+ * Whether `anchor` or a statement around it has a comment that says something above it or at the end of its
1233
+ * first line, as `// why` above an `if` or after its `{`, up to `owner`, the test or describe the call acts on. The walk goes on through a callback passed to another
1167
1234
  * call, such as a hook's or a step's, but stops at a function declared some other way.
1168
1235
  */
1169
1236
  const commentedAround = (anchor, owner) => {
1170
- for (let node = anchor.parent; node; node = node.parent) {
1237
+ for (let node = anchor; node; node = node.parent) {
1171
1238
  if (FUNCTIONS.has(node.type)) {
1172
1239
  const call = node.parent;
1173
1240
  if (call?.type !== 'CallExpression' || call === owner || !call.arguments.includes(node)) {
@@ -1179,7 +1246,11 @@ function runAnalysis(sourceCode, options) {
1179
1246
  if (!container || !STATEMENT_CONTAINERS.has(container.type))
1180
1247
  continue;
1181
1248
  const block = commentBlock(sourceCode, node, options.allowBlankLine);
1182
- if (block.some((comment) => !commentedOut.comments.has(comment) && hasText(comment)))
1249
+ if (block.some((c) => !commentedOut.comments.has(c) && !regionComments.has(c) && hasText(c)))
1250
+ return true;
1251
+ // `if (isMobile) { // why`: a comment at the end of the line the statement starts on.
1252
+ const first = sourceCode.getCommentsInside(node).at(0);
1253
+ if (first?.loc.start.line === node.loc.start.line && hasText(first))
1183
1254
  return true;
1184
1255
  }
1185
1256
  return false;
@@ -1200,17 +1271,18 @@ function runAnalysis(sourceCode, options) {
1200
1271
  block.push(...commentBlock(sourceCode, call, options.allowBlankLine).filter((c) => !commentedOut.comments.has(c)));
1201
1272
  }
1202
1273
  block.push(...(lineDirectives.get(call.loc.start.line) ?? []));
1203
- const inRegions = regions.filter((r) => r.start <= call.range[0] && call.range[0] < r.end && !block.includes(r.comment));
1274
+ const inRegions = regions.filter((r) => r.start <= call.range[0] && call.range[0] < r.end);
1204
1275
  for (const comment of [...block, ...inRegions.map((r) => r.comment)])
1205
1276
  claimed.add(comment);
1206
- const commented = [...block, ...inRegions.map((r) => r.comment)].some(hasText);
1207
- // A runtime call can also say why above the statements around it in its function, above its test, or in
1208
- // its arguments.
1209
- const reasonedCall = () => kind === 'runtime' &&
1210
- ((framework.reasonArgument !== undefined && callReason(sourceCode, call, framework.reasonArgument)) ||
1211
- parent?.commented === true ||
1212
- trailingText(anchor) ||
1213
- commentedAround(anchor, parent?.node));
1277
+ // A region's description says why a rule is off, not why a test is skipped; its marker's ticket counts.
1278
+ const commented = block.some((comment) => !regionComments.has(comment) && hasText(comment));
1279
+ // A comment at the end of the line also says why. A runtime call can also say why above the statements
1280
+ // around it in its function, above its test, or in its arguments.
1281
+ const reasonedCall = () => trailingText(anchor) ||
1282
+ (kind === 'runtime' &&
1283
+ ((framework.reasonArgument !== undefined && callReason(sourceCode, call, framework.reasonArgument)) ||
1284
+ parent?.commented === true ||
1285
+ commentedAround(anchor, parent?.node)));
1214
1286
  const ownTags = new Set(tagInfo.tags.map((t) => t.tag));
1215
1287
  const sources = [];
1216
1288
  for (const state of states) {
@@ -1252,7 +1324,7 @@ function runAnalysis(sourceCode, options) {
1252
1324
  for (const tag of parent.tags)
1253
1325
  inheritedTags.add(tag.tag);
1254
1326
  }
1255
- 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'));
1256
1328
  const subject = {
1257
1329
  kind,
1258
1330
  node: call,
@@ -1266,9 +1338,10 @@ function runAnalysis(sourceCode, options) {
1266
1338
  inheritedTags,
1267
1339
  unreadTags: tagInfo.unread,
1268
1340
  unknownStates,
1341
+ sharedStates: new Set(),
1269
1342
  skipped: ownSkip || (kind !== 'runtime' && parent?.skipped === true),
1270
1343
  ownSkip,
1271
- markers: block.flatMap((comment) => parseMarkers(comment, states)),
1344
+ markers: block.flatMap((comment) => (regionComments.has(comment) ? [] : parseMarkers(comment, states))),
1272
1345
  regionMarkers: inRegions.flatMap((r) => r.markers),
1273
1346
  annotationTickets: tagInfo.annotations,
1274
1347
  unreadTickets: tagInfo.unreadAnnotations,
@@ -1386,8 +1459,8 @@ function runAnalysis(sourceCode, options) {
1386
1459
  * `(isCI ? describe.skip : describe)('order entry', ...)`: a test or describe whose function is picked by
1387
1460
  * a condition. A state that only one side has depends on the condition, like `test.skip(isCI)`.
1388
1461
  */
1389
- const pickedDeclaration = (call) => {
1390
- const callee = unwrap(call.callee);
1462
+ const pickedDeclaration = (call, picker) => {
1463
+ const callee = unwrap(picker);
1391
1464
  if (callee.type !== 'ConditionalExpression')
1392
1465
  return undefined;
1393
1466
  const [yes, no] = [callee.consequent, callee.alternate].map((side) => {
@@ -1449,29 +1522,67 @@ function runAnalysis(sourceCode, options) {
1449
1522
  if (!framework.subtests || callee.type !== 'MemberExpression' || memberName(callee) !== 'test')
1450
1523
  return undefined;
1451
1524
  const { object } = callee;
1452
- if (object.type !== 'Identifier' || !infos.findLast((info) => info.name === object.name)?.methods)
1525
+ if (object.type !== 'Identifier' || !infoOf(object)?.methods)
1453
1526
  return undefined;
1454
1527
  const declaration = { kind: 'test', members: [], written: [], args: new Map() };
1455
1528
  return declares(declaration, call.arguments)
1456
1529
  ? declare(call, declaration, bodyInfos(call.arguments, []))
1457
1530
  : undefined;
1458
1531
  };
1532
+ /**
1533
+ * The value of a constant that holds a test function the collected names can't: one a condition picks, as
1534
+ * `const itIf = isCI ? it.skip : it`, one a factory builds, as `const d = describe.runIf(hasDocker)`, or any
1535
+ * declared inside a function.
1536
+ */
1537
+ const aliasValue = (callee) => {
1538
+ if (callee.type !== 'Identifier' || testNames.roots.has(callee.name))
1539
+ return 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;
1550
+ };
1551
+ /**
1552
+ * Whether the declaration of a constant that holds a test function says why above it or at the end of its
1553
+ * line, as `// no symlinks on Windows` above `const posixTest = isWindows ? test.skip : test`.
1554
+ */
1555
+ const aliasCommented = (value) => {
1556
+ const statement = anchorOf(value);
1557
+ const block = commentBlock(sourceCode, statement, options.allowBlankLine);
1558
+ return (block.some((c) => !commentedOut.comments.has(c) && !regionComments.has(c) && hasText(c)) ||
1559
+ trailingText(statement));
1560
+ };
1459
1561
  const visitCall = (original) => {
1562
+ const alias = aliasValue(throughFunctionMethods(original).callee);
1563
+ const visited = visitDeclaredCall(original, alias);
1564
+ if (alias && visited.subject && aliasCommented(alias)) {
1565
+ for (const source of visited.subject.states)
1566
+ if (source.reasoned === false)
1567
+ source.reasoned = true;
1568
+ }
1569
+ return visited;
1570
+ };
1571
+ const visitDeclaredCall = (original, alias) => {
1460
1572
  const call = throughFunctionMethods(original);
1461
- const callee = target(chainOf(call.callee, framework.factories));
1573
+ const written = alias ?? call.callee;
1574
+ const callee = target(chainOf(written, framework.factories));
1462
1575
  if (callee === undefined) {
1463
- return subtestOf(call) ?? pickedDeclaration(call) ?? { subject: runtimeCall(call) ?? retryCall(call) };
1576
+ return subtestOf(call) ?? pickedDeclaration(call, written) ?? { subject: runtimeCall(call) ?? retryCall(call) };
1464
1577
  }
1465
1578
  usesTest = true;
1466
1579
  const args = call.arguments;
1467
1580
  const { path } = callee;
1468
- if (framework.retryCall === 'describe.configure' && path.join('.') === 'describe.configure') {
1469
- const options = args.at(0);
1470
- const object = options && unwrap(options);
1471
- const count = object?.type === 'ObjectExpression'
1472
- ? object.properties.find((p) => p.type === 'Property' && propertyName(p) === 'retries')
1473
- : undefined;
1474
- return { subject: retrySubject(call, count?.type === 'Property' ? count.value : undefined, 'configure') };
1581
+ // `test.describe.configure(...)`, or `describe.configure(...)` with `const { describe } = test`.
1582
+ const configure = path.join('.') === 'describe.configure' || (callee.root === DESCRIBE && path.join('.') === 'configure');
1583
+ if (framework.retryCall === 'describe.configure' && configure) {
1584
+ const count = detailsOf(sourceCode, call, true).entries.findLast(([key]) => key === 'retries');
1585
+ return { subject: retrySubject(call, count?.[1], 'configure') };
1475
1586
  }
1476
1587
  const declaration = declarationOf(callee);
1477
1588
  // Vitest's `beforeEach((context) => context.skip())` and Mocha's `before(function () { this.skip(); })`
@@ -1510,14 +1621,12 @@ function runAnalysis(sourceCode, options) {
1510
1621
  */
1511
1622
  const runtimeCall = (call) => {
1512
1623
  const { callee } = call;
1513
- // The innermost parameter with a name is the one the call uses.
1514
- const innermost = (name) => infos.findLast((info) => info.name === name);
1515
1624
  /**
1516
1625
  * What a name stands for: a context in scope, or a local constant that keeps one of its methods, as
1517
1626
  * `const skip = testInfo.skip`, `const { skip } = t` or `const p = pending`.
1518
1627
  */
1519
1628
  const resolveInfo = (id) => {
1520
- const direct = innermost(id.name);
1629
+ const direct = infoOf(id);
1521
1630
  if (direct !== undefined)
1522
1631
  return direct;
1523
1632
  const definition = declaredVariable(sourceCode, id)?.defs.find((def) => def.type === 'Variable');
@@ -1536,12 +1645,12 @@ function runAnalysis(sourceCode, options) {
1536
1645
  ? { name: id.name, method: name }
1537
1646
  : undefined;
1538
1647
  }
1539
- const object = init.object.type === 'Identifier' ? innermost(init.object.name) : undefined;
1648
+ const object = init.object.type === 'Identifier' ? infoOf(init.object) : undefined;
1540
1649
  return object?.methods?.has(name) ? { name: id.name, method: name, step: object.step } : undefined;
1541
1650
  }
1542
1651
  if (init.type !== 'Identifier')
1543
1652
  return undefined;
1544
- const source = innermost(init.name);
1653
+ const source = infoOf(init);
1545
1654
  if (source === undefined)
1546
1655
  return undefined;
1547
1656
  const fn = source.runtimeFunction;
@@ -1553,7 +1662,7 @@ function runAnalysis(sourceCode, options) {
1553
1662
  }
1554
1663
  if (declarator.id.type !== 'ObjectPattern' || init.type !== 'Identifier')
1555
1664
  return undefined;
1556
- const source = innermost(init.name);
1665
+ const source = infoOf(init);
1557
1666
  if (source?.methods === undefined)
1558
1667
  return undefined;
1559
1668
  const property = declarator.id.properties.find((prop) => prop.type === 'Property' && prop.value.type === 'Identifier' && prop.value.name === id.name);
@@ -1635,9 +1744,11 @@ function runAnalysis(sourceCode, options) {
1635
1744
  return subject;
1636
1745
  };
1637
1746
  /** `step` is set for `step.skip()`, which skips only its step; the other calls act on the whole test. */
1638
- const runtimeSubject = (call, modifier, step, always = isUnconditional(call, framework.skipNotes)) => {
1747
+ const runtimeSubject = (call, modifier, step, always = isUnconditional(call, framework)) => {
1639
1748
  if (!states.some((s) => s.modifier === modifier))
1640
1749
  return undefined;
1750
+ if (framework.reasonArgument !== 'only' && booleanOf(call.arguments.at(0)) === false)
1751
+ return undefined;
1641
1752
  const subject = makeSubject('runtime', call, new Map([[modifier, always]]), NO_TAGS, step ?? stack.findLast((s) => s.kind !== 'step'));
1642
1753
  const parent = subject.parent;
1643
1754
  if (parent) {
@@ -1676,7 +1787,36 @@ function runAnalysis(sourceCode, options) {
1676
1787
  .filter((comment) => !claimed.has(comment))
1677
1788
  .flatMap((comment) => parseMarkers(comment, states))
1678
1789
  .filter(isStateMarker);
1679
- return { subjects, detachedMarkers, commentedOutTests: commentedOut.found, isTestFile: usesTest };
1790
+ const byAnchor = new Map();
1791
+ for (const subject of subjects)
1792
+ byAnchor.set(subject.anchor, [...(byAnchor.get(subject.anchor) ?? []), subject]);
1793
+ for (const subject of subjects) {
1794
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- every subject's anchor is a key
1795
+ for (const other of byAnchor.get(subject.anchor)) {
1796
+ if (other === subject)
1797
+ continue;
1798
+ for (const marker of subject.markers) {
1799
+ if (marker.state && appliesTo(other, marker.state.name))
1800
+ subject.sharedStates.add(marker.state.name);
1801
+ }
1802
+ }
1803
+ }
1804
+ const idleRegionMarkers = regions
1805
+ .flatMap((region) => region.markers)
1806
+ .filter(isStateMarker)
1807
+ .filter((marker) => {
1808
+ const covered = subjects.filter((subject) => subject.regionMarkers.includes(marker));
1809
+ return (covered.length > 0 &&
1810
+ !isWorkComment(marker, options) &&
1811
+ !covered.some((s) => appliesTo(s, marker.state.name) || s.unknownStates.has(marker.state.name)));
1812
+ });
1813
+ return {
1814
+ subjects,
1815
+ detachedMarkers,
1816
+ idleRegionMarkers,
1817
+ commentedOutTests: commentedOut.found,
1818
+ isTestFile: usesTest,
1819
+ };
1680
1820
  }
1681
1821
  /** Whether `subject` is in `stateName` at all, required or not, including inherited and runtime states. */
1682
1822
  export function appliesTo(subject, stateName) {
@@ -1701,7 +1841,7 @@ function candidateMarkers(subject, state) {
1701
1841
  export function evaluate(subject, state) {
1702
1842
  const markers = candidateMarkers(subject, state);
1703
1843
  if (markers.length === 0) {
1704
- const marker = subject.markers.find((m) => m.caseOf === state);
1844
+ const marker = [...subject.markers, ...subject.regionMarkers].find((m) => m.caseOf === state);
1705
1845
  return marker ? { kind: 'case', marker } : { kind: 'missing' };
1706
1846
  }
1707
1847
  const isValid = (m) => m.tickets.length > 0 && m.tickets.every((t) => t.result === 'ok');
@@ -1775,8 +1915,12 @@ function isWorkComment(marker, options) {
1775
1915
  export function strayMarkers(subject, options) {
1776
1916
  return subject.markers.flatMap((marker) => {
1777
1917
  const state = marker.state ?? (marker.tickets.at(0)?.result === 'ok' ? marker.caseOf : undefined);
1778
- if (state === undefined || appliesTo(subject, state.name) || subject.unknownStates.has(state.name))
1918
+ if (state === undefined ||
1919
+ appliesTo(subject, state.name) ||
1920
+ subject.unknownStates.has(state.name) ||
1921
+ subject.sharedStates.has(state.name)) {
1779
1922
  return [];
1923
+ }
1780
1924
  return isWorkComment(marker, options) ? [] : [{ ...marker, state }];
1781
1925
  });
1782
1926
  }
@@ -7,7 +7,6 @@ import { resolveOptions } from "./options.js";
7
7
  export function createRule(definition) {
8
8
  return {
9
9
  meta: definition.meta,
10
- defaultOptions: [],
11
10
  create(context) {
12
11
  const options = resolveOptions(context.settings);
13
12
  return {
@@ -1,13 +1,16 @@
1
1
  import type { TSESLint, TSESTree } from '@typescript-eslint/utils';
2
2
  import { type Marker } from './analyze.ts';
3
3
  type SourceCode = Readonly<TSESLint.SourceCode>;
4
- /** Range of `keyword` where it is used as a marker (`KEYWORD:`) inside `comment`. */
5
- export declare function keywordRange(sourceCode: SourceCode, comment: TSESTree.Comment, keyword: string): TSESTree.Range;
4
+ /** Range of a marker's keyword on the marker's own line. */
5
+ export declare function keywordRange(marker: Marker): TSESTree.Range;
6
6
  /**
7
- * Removes a marker comment, and its whole line when nothing else is on it. From an ESLint directive it
8
- * removes only the description, so the directive keeps working.
7
+ * Removes a marker. From a comment that says more, it removes only the marker's line. Otherwise it removes
8
+ * the comment, and its whole line when nothing else is on it, or from an ESLint directive only the
9
+ * description, so the directive keeps working.
9
10
  */
10
- export declare function removeMarkerComment(sourceCode: SourceCode, comment: TSESTree.Comment): TSESLint.RuleFix;
11
+ export declare function removeMarkerComment(sourceCode: SourceCode, marker: Marker): TSESLint.RuleFix;
12
+ /** The line break that ends the line `comment` ends on. */
13
+ export declare function lineBreakAfter(sourceCode: SourceCode, comment: TSESTree.Comment): string;
11
14
  /** Indentation of the line `comment` starts on. */
12
15
  export declare function indentOf(sourceCode: SourceCode, comment: TSESTree.Comment): string;
13
16
  /**