eslint-plugin-test-governance 0.10.0 → 0.10.1

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,26 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.10.1] - 2026-10-31
10
+
11
+ ### Fixed
12
+
13
+ - Options that belong to another preset, such as `prefixes` on `trello`, are reported even when a value next
14
+ to them is malformed, so one run shows both the misplaced option and the bad value.
15
+ - A placeholder with several `*` characters, such as `*a*a*a*a*b`, matches in one pass instead of
16
+ backtracking on a long ticket.
17
+ - A details object written as a literal with a spread or a computed key, `{ ...base, tag: '@new' }` or
18
+ `{ ['tag']: '@new' }`, keeps its tags and options; a key or spread that can't be read, such as an object
19
+ imported from another file, is reported with `reportUnreadableTags` instead of being ignored.
20
+ - A tag read from an array element, `TAGS[1]`, or from a constant inside a template, `` `${TAG}` ``, is
21
+ checked like the constant itself.
22
+ - A runtime skip method kept in a local variable, `const { skip } = t`, `const s = testInfo.skip` or
23
+ `const p = pending`, is checked like the method called on the context, in every framework.
24
+ - `node:test`'s `test.describe` and `test.it` are read after `import { test } from 'node:test'`, as they were
25
+ after `require()` or a default import. A state member kept in a variable, `const s = it.skip` or
26
+ `const { skip } = it`, is checked like `it.skip(...)` in every framework. The same holds for a skip called
27
+ on a required module used inline, `require('node:test').test.skip(...)`.
28
+
9
29
  ## [0.10.0] - 2026-10-30
10
30
 
11
31
  ### Added
@@ -396,7 +416,8 @@ First release.
396
416
  - Support for ESLint 9 and 10 flat config, JavaScript and TypeScript test files, `.extend()`
397
417
  fixtures, describe blocks, runtime skips and tags in titles or the `tag` option.
398
418
 
399
- [Unreleased]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.10.0...HEAD
419
+ [Unreleased]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.10.1...HEAD
420
+ [0.10.1]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.10.0...v0.10.1
400
421
  [0.10.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.9.0...v0.10.0
401
422
  [0.9.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.8.1...v0.9.0
402
423
  [0.8.1]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.8.0...v0.8.1
@@ -30,6 +30,12 @@ function memberName(node) {
30
30
  return node.property.value;
31
31
  return undefined;
32
32
  }
33
+ /** The key a member expression reads, including an array index as in `TAGS[1]`. */
34
+ function memberKey(node) {
35
+ if (node.property.type === 'Literal' && typeof node.property.value === 'number')
36
+ return String(node.property.value);
37
+ return memberName(node);
38
+ }
33
39
  /**
34
40
  * The name a callee starts from and the members after it: `['describe', 'skip']` for
35
41
  * `test.describe.skip`. A member called to build the function, as `each` in `test.each(table)` or
@@ -63,7 +69,7 @@ function chainOf(node, factories) {
63
69
  args.set(`${name}()`, current.arguments.at(0));
64
70
  current = factory.object;
65
71
  }
66
- return current.type === 'Identifier' ? { root: current, path, args } : undefined;
72
+ return current.type === 'Identifier' || current.type === 'CallExpression' ? { root: current, path, args } : undefined;
67
73
  }
68
74
  function unwrap(node) {
69
75
  let current = node;
@@ -79,9 +85,11 @@ function isFunction(node) {
79
85
  }
80
86
  /** Collects the top-level names that refer to the framework's test functions or to its module. */
81
87
  function collectTestNames(program, framework, configured) {
88
+ /** The framework's `test` function, which is also its module when `moduleIsDefault`. */
89
+ const testRoot = framework.defaultExport ?? TEST;
82
90
  const roots = new Map(framework.globals);
83
91
  for (const name of configured)
84
- roots.set(name, TEST);
92
+ roots.set(name, testRoot);
85
93
  const runtime = new Map();
86
94
  const modules = new Set();
87
95
  const mergeTests = new Set();
@@ -91,7 +99,23 @@ function collectTestNames(program, framework, configured) {
91
99
  const moduleMember = (node) => node.type === 'MemberExpression' && node.object.type === 'Identifier' && modules.has(node.object.name)
92
100
  ? memberName(node)
93
101
  : undefined;
94
- /** The test function an expression is, such as `base.extend({...})` or `mergeTests(a, b)`. */
102
+ /**
103
+ * What a member of a test function is: `it.skip` is `it` in the skip state, and node:test's
104
+ * `test.describe` is the `describe` export, since its module is also its `test`.
105
+ */
106
+ const memberRoot = (object, name) => {
107
+ if (framework.defaultExport !== undefined && object === framework.defaultExport) {
108
+ const exported = framework.exports.get(name);
109
+ if (exported !== undefined)
110
+ return exported;
111
+ }
112
+ const effect = framework.effects.get(name);
113
+ if (effect !== undefined && effect.when === undefined) {
114
+ return { kind: object.kind, modifiers: [...object.modifiers, effect.modifier] };
115
+ }
116
+ return undefined;
117
+ };
118
+ /** The test function an expression is, such as `base.extend({...})`, `mergeTests(a, b)` or `it.skip`. */
95
119
  const resolve = (node) => {
96
120
  const target = unwrap(node);
97
121
  if (target.type === 'Identifier')
@@ -99,15 +123,20 @@ function collectTestNames(program, framework, configured) {
99
123
  const member = moduleMember(target);
100
124
  if (member !== undefined)
101
125
  return framework.exports.get(member);
126
+ if (target.type === 'MemberExpression') {
127
+ const object = resolve(target.object);
128
+ const name = memberName(target);
129
+ return object === undefined || name === undefined ? undefined : memberRoot(object, name);
130
+ }
102
131
  if (target.type !== 'CallExpression')
103
132
  return undefined;
104
133
  // mergeTests(dbTest, a11yTest) or pw.mergeTests(dbTest, a11yTest)
105
134
  if (target.callee.type === 'Identifier')
106
- return mergeTests.has(target.callee.name) ? TEST : undefined;
135
+ return mergeTests.has(target.callee.name) ? testRoot : undefined;
107
136
  if (target.callee.type !== 'MemberExpression')
108
137
  return undefined;
109
138
  if (moduleMember(target.callee) === 'mergeTests')
110
- return TEST;
139
+ return testRoot;
111
140
  return memberName(target.callee) === 'extend' ? resolve(target.callee.object) : undefined;
112
141
  };
113
142
  // require('node:test'), ava, ava.serial or `ava as TestFn<Context>`
@@ -189,12 +218,18 @@ function collectTestNames(program, framework, configured) {
189
218
  }
190
219
  }
191
220
  else if (declarator.id.type === 'ObjectPattern') {
192
- // Playwright's `const { describe } = test`, used as `describe.skip(...)`.
193
- if (framework.playwright && resolve(init)) {
221
+ // `const { describe } = test` and `const { skip } = it`, used as `describe.skip(...)`.
222
+ const base = resolve(init);
223
+ if (base !== undefined) {
194
224
  for (const prop of declarator.id.properties) {
195
- if (prop.type === 'Property' && prop.value.type === 'Identifier' && propertyName(prop) === 'describe') {
196
- roots.set(prop.value.name, DESCRIBE);
197
- }
225
+ if (prop.type !== 'Property' || prop.value.type !== 'Identifier')
226
+ continue;
227
+ const name = propertyName(prop);
228
+ if (name === undefined)
229
+ continue;
230
+ const root = name === 'describe' && framework.playwright ? DESCRIBE : memberRoot(base, name);
231
+ if (root)
232
+ roots.set(prop.value.name, root);
198
233
  }
199
234
  continue;
200
235
  }
@@ -249,6 +284,15 @@ function declaredWhere(sourceCode, id) {
249
284
  }
250
285
  return 'global';
251
286
  }
287
+ /** The variable a name resolves to, walking out through the enclosing scopes. */
288
+ function declaredVariable(sourceCode, id) {
289
+ for (let scope = sourceCode.getScope(id); scope; scope = scope.upper) {
290
+ const variable = scope.set.get(id.name);
291
+ if (variable)
292
+ return variable;
293
+ }
294
+ return undefined;
295
+ }
252
296
  /**
253
297
  * Whether `id` refers to a variable or function declared inside a function or block, such as a local
254
298
  * helper that happens to be called `test`. Top-level variables, imports, parameters and globals still count.
@@ -434,13 +478,20 @@ function constantValue(sourceCode, node) {
434
478
  return declaredValue(sourceCode, node);
435
479
  if (node.type !== 'MemberExpression')
436
480
  return undefined;
437
- const key = memberName(node);
481
+ const key = memberKey(node);
438
482
  const value = constantValue(sourceCode, unwrap(node.object));
439
483
  const owner = value && unwrap(value);
440
484
  if (key === undefined || owner === undefined)
441
485
  return undefined;
442
486
  if (owner.type === 'ObjectExpression')
443
487
  return propertyValue(owner, key);
488
+ if (owner.type === 'ArrayExpression') {
489
+ const index = Number(key);
490
+ if (!Number.isInteger(index))
491
+ return undefined;
492
+ const element = owner.elements[index];
493
+ return element !== null && element.type !== 'SpreadElement' ? element : undefined;
494
+ }
444
495
  if (owner.type !== 'TSEnumDeclaration')
445
496
  return undefined;
446
497
  const member = owner.body.members.find((m) => (m.id.type === 'Identifier' ? m.id.name : m.id.value) === key);
@@ -455,6 +506,15 @@ function readTexts(sourceCode, node, out, via, path = []) {
455
506
  const target = unwrap(node);
456
507
  if (isStaticText(target)) {
457
508
  out.texts.push({ text: target, via });
509
+ // A template that holds a constant, as in `` `${TAG}` ``, reads the constant's text too.
510
+ if (target.type === 'TemplateLiteral') {
511
+ for (const expression of target.expressions) {
512
+ const value = constantValue(sourceCode, unwrap(expression));
513
+ const resolved = value && unwrap(value);
514
+ if (resolved !== undefined && isStaticText(resolved))
515
+ out.texts.push({ text: resolved, via });
516
+ }
517
+ }
458
518
  return;
459
519
  }
460
520
  if (target.type === 'ArrayExpression') {
@@ -471,6 +531,45 @@ function readTexts(sourceCode, node, out, via, path = []) {
471
531
  else
472
532
  readTexts(sourceCode, value, out, via ?? target, [...path, value]);
473
533
  }
534
+ /**
535
+ * The key of a details property, following a computed string key declared in this file. A computed key that
536
+ * can't be read, as in `{ [key]: '@new' }`, goes in `unread`, since it may be the tag or an option.
537
+ */
538
+ function detailKey(sourceCode, prop, unread) {
539
+ const key = unwrap(prop.key);
540
+ if (!prop.computed) {
541
+ return key.type === 'Identifier' ? key.name : String(key.value);
542
+ }
543
+ if (key.type === 'Literal' && typeof key.value === 'string')
544
+ return key.value;
545
+ if (key.type === 'TemplateLiteral' && key.expressions.length === 0)
546
+ return key.quasis[0]?.value.cooked ?? undefined;
547
+ const value = constantValue(sourceCode, key);
548
+ const resolved = value && unwrap(value);
549
+ if (resolved?.type === 'Literal' && typeof resolved.value === 'string')
550
+ return resolved.value;
551
+ unread.push(prop.key);
552
+ return undefined;
553
+ }
554
+ /**
555
+ * The plain-keyed properties of a details object, following spread objects declared in this file and
556
+ * collecting the ones that can't be read.
557
+ */
558
+ function objectDetails(sourceCode, object, unread) {
559
+ return object.properties.flatMap((prop) => {
560
+ if (prop.type === 'SpreadElement') {
561
+ const argument = unwrap(prop.argument);
562
+ const value = argument.type === 'ObjectExpression' ? argument : constantValue(sourceCode, argument);
563
+ const spread = value && unwrap(value);
564
+ if (spread?.type === 'ObjectExpression')
565
+ return objectDetails(sourceCode, spread, unread);
566
+ unread.push(prop.argument);
567
+ return [];
568
+ }
569
+ const key = detailKey(sourceCode, prop, unread);
570
+ return key === undefined ? [] : [[key, prop.value]];
571
+ });
572
+ }
474
573
  /**
475
574
  * The details or options object of a declaration, the second argument in
476
575
  * `test('title', { tag: '@smoke' }, fn)` or Vitest's `test('title', { skip: true }, fn)`, also when it
@@ -479,7 +578,7 @@ function readTexts(sourceCode, node, out, via, path = []) {
479
578
  function detailsOf(sourceCode, call, optionsFirst) {
480
579
  const details = call.arguments.at(optionsFirst ? 0 : 1);
481
580
  if (details === undefined || isFunction(details))
482
- return { entries: [] };
581
+ return { entries: [], unread: [] };
483
582
  const target = unwrap(details);
484
583
  const value = target.type === 'ObjectExpression' ? target : constantValue(sourceCode, target);
485
584
  const object = value && unwrap(value);
@@ -487,16 +586,11 @@ function detailsOf(sourceCode, call, optionsFirst) {
487
586
  // A name that can't be read is the details object only when a body comes after it:
488
587
  // `test.skip('places an order', placeOrder)` has a body defined elsewhere and no details.
489
588
  return call.arguments.slice(optionsFirst ? 1 : 2).some(isFunction)
490
- ? { entries: [], unread: details }
491
- : { entries: [] };
589
+ ? { entries: [], unread: [details] }
590
+ : { entries: [], unread: [] };
492
591
  }
493
- const entries = object.properties.flatMap((prop) => {
494
- if (prop.type !== 'Property' || prop.computed)
495
- return [];
496
- const key = prop.key.type === 'Identifier' ? prop.key.name : String(prop.key.value);
497
- return [[key, prop.value]];
498
- });
499
- return { entries };
592
+ const unread = [];
593
+ return { entries: objectDetails(sourceCode, object, unread), unread };
500
594
  }
501
595
  const NO_TAGS = { tags: [], dynamic: false, unread: [], opaqueDetails: false };
502
596
  function collectTags(sourceCode, call, hasTitle, framework, details) {
@@ -521,9 +615,9 @@ function collectTags(sourceCode, call, hasTitle, framework, details) {
521
615
  scanTags(source, found, tags);
522
616
  }
523
617
  const unread = [...values.unread];
524
- if (details.unread && framework.tagKey !== undefined)
525
- unread.push(details.unread);
526
- return { tags, dynamic: title.unread.size > 0, unread, opaqueDetails: details.unread !== undefined };
618
+ if (details.unread.length > 0 && framework.tagKey !== undefined)
619
+ unread.push(...details.unread);
620
+ return { tags, dynamic: title.unread.size > 0, unread, opaqueDetails: details.unread.length > 0 };
527
621
  }
528
622
  /** Whether `node` is an object written out or held in a constant declared in this file. */
529
623
  function isObject(sourceCode, node) {
@@ -695,10 +789,20 @@ function runAnalysis(sourceCode, options) {
695
789
  if (chain === undefined)
696
790
  return undefined;
697
791
  const { root, path, args } = chain;
792
+ // `require('node:test').test.skip(...)`: the require call stands for the module, with its exports.
793
+ if (root.type !== 'Identifier') {
794
+ const source = requireSource(root);
795
+ if (source === undefined || !framework.ownsModule(source) || path.length === 0)
796
+ return undefined;
797
+ const exported = framework.exports.get(path[0]);
798
+ return exported === undefined ? undefined : { root: exported, path: path.slice(1), args };
799
+ }
698
800
  let base = testNames.roots.get(root.name);
699
801
  let rest = path;
700
- // `pw.test.skip`, or node:test's `test.describe`, whose module is also its `test`.
701
- const member = testNames.modules.has(root.name) ? framework.exports.get(path[0]) : undefined;
802
+ // `pw.test.skip`, or node:test's `test.describe`, whose module is also its `test` even when
803
+ // `test` was imported by name.
804
+ const moduleLike = testNames.modules.has(root.name) || (framework.defaultExport !== undefined && base === framework.defaultExport);
805
+ const member = moduleLike ? framework.exports.get(path[0]) : undefined;
702
806
  if (member !== undefined || base === undefined) {
703
807
  base = member;
704
808
  rest = path.slice(1);
@@ -989,8 +1093,60 @@ function runAnalysis(sourceCode, options) {
989
1093
  const { callee } = call;
990
1094
  // The innermost parameter with a name is the one the call uses.
991
1095
  const innermost = (name) => infos.findLast((info) => info.name === name);
1096
+ /**
1097
+ * What a name stands for: a context in scope, or a local constant that keeps one of its methods, as
1098
+ * `const skip = testInfo.skip`, `const { skip } = t` or `const p = pending`.
1099
+ */
1100
+ const resolveInfo = (id) => {
1101
+ const direct = innermost(id.name);
1102
+ if (direct !== undefined)
1103
+ return direct;
1104
+ const definition = declaredVariable(sourceCode, id)?.defs.find((def) => def.type === 'Variable');
1105
+ const declarator = definition?.node;
1106
+ if (declarator?.type !== 'VariableDeclarator' || declarator.init === null)
1107
+ return undefined;
1108
+ const init = unwrap(declarator.init);
1109
+ if (declarator.id.type === 'Identifier') {
1110
+ if (init.type === 'MemberExpression') {
1111
+ const name = memberName(init);
1112
+ if (name === undefined)
1113
+ return undefined;
1114
+ if (init.object.type === 'ThisExpression') {
1115
+ const owner = thisFunction(declarator);
1116
+ return owner !== undefined && thisBodies.has(owner) && framework.thisMethods.has(name)
1117
+ ? { name: id.name, method: name }
1118
+ : undefined;
1119
+ }
1120
+ const object = init.object.type === 'Identifier' ? innermost(init.object.name) : undefined;
1121
+ return object?.methods?.has(name) ? { name: id.name, method: name, step: object.step } : undefined;
1122
+ }
1123
+ if (init.type !== 'Identifier')
1124
+ return undefined;
1125
+ const source = innermost(init.name);
1126
+ if (source === undefined)
1127
+ return undefined;
1128
+ const fn = source.runtimeFunction;
1129
+ if (fn && declaredWhere(sourceCode, init) !== (fn.imported ? 'top' : 'global'))
1130
+ return undefined;
1131
+ if (source.method !== undefined)
1132
+ return { name: id.name, method: source.method, step: source.step };
1133
+ return { name: id.name, methods: source.methods, step: source.step };
1134
+ }
1135
+ if (declarator.id.type !== 'ObjectPattern' || init.type !== 'Identifier')
1136
+ return undefined;
1137
+ const source = innermost(init.name);
1138
+ if (source?.methods === undefined)
1139
+ return undefined;
1140
+ const property = declarator.id.properties.find((prop) => prop.type === 'Property' && prop.value.type === 'Identifier' && prop.value.name === id.name);
1141
+ if (property?.type !== 'Property')
1142
+ return undefined;
1143
+ const name = propertyName(property);
1144
+ return name !== undefined && source.methods.has(name)
1145
+ ? { name: id.name, method: name, step: source.step }
1146
+ : undefined;
1147
+ };
992
1148
  if (callee.type === 'Identifier') {
993
- const info = innermost(callee.name);
1149
+ const info = resolveInfo(callee);
994
1150
  if (info?.method === undefined)
995
1151
  return undefined;
996
1152
  // A global such as Jasmine's `pending` counts unless the file declares the name itself; an import of
@@ -1016,7 +1172,7 @@ function runAnalysis(sourceCode, options) {
1016
1172
  return owner !== undefined && thisBodies.has(owner) ? runtimeSubject(call, name) : undefined;
1017
1173
  }
1018
1174
  if (object.type === 'Identifier') {
1019
- const info = innermost(object.name);
1175
+ const info = resolveInfo(object);
1020
1176
  return info?.methods?.has(name) ? runtimeSubject(call, name, info.step) : undefined;
1021
1177
  }
1022
1178
  const isTestInfo = RUNTIME_MODIFIERS.has(name) &&
@@ -67,15 +67,15 @@ const STRICT_HOST_PRESETS = new Set(['notion', 'redmine', 'youtrack']);
67
67
  /** Option values the schema allows for every preset but this one can't use. */
68
68
  function ownOptionProblems(spec, at) {
69
69
  const problems = [];
70
- if (spec.preset === 'jira') {
71
- for (const [i, key] of (spec.projects ?? []).entries()) {
72
- if (!/^[A-Z][A-Z0-9_]+$/.test(key)) {
70
+ if (spec.preset === 'jira' && Array.isArray(spec.projects)) {
71
+ for (const [i, key] of spec.projects.entries()) {
72
+ if (typeof key !== 'string' || !/^[A-Z][A-Z0-9_]+$/.test(key)) {
73
73
  problems.push(`${at}.projects[${i}] must start with a letter and have at least two characters, as Jira keys do (got "${key}")`);
74
74
  }
75
75
  }
76
76
  }
77
77
  const { host } = spec;
78
- if (STRICT_HOST_PRESETS.has(spec.preset) && host !== undefined && !HOST_ENTRY_RE.test(host)) {
78
+ if (STRICT_HOST_PRESETS.has(spec.preset) && typeof host === 'string' && !HOST_ENTRY_RE.test(host)) {
79
79
  problems.push(`${at}.host must be a host name such as tracker.example.com or *.example.com, with no scheme, port or path (got "${host}")`);
80
80
  }
81
81
  if (spec.preset === 'url' && spec.hosts === undefined) {
@@ -83,41 +83,63 @@ function ownOptionProblems(spec, at) {
83
83
  }
84
84
  return problems;
85
85
  }
86
+ /** A spec the cross-field checks can read: an object whose `preset` names one of the presets. */
87
+ function isTicketSpec(spec) {
88
+ if (typeof spec !== 'object' || spec === null || Array.isArray(spec))
89
+ return false;
90
+ const { preset } = spec;
91
+ return typeof preset === 'string' && Object.hasOwn(PRESET_OPTIONS, preset);
92
+ }
86
93
  /** Options set on a ticket spec that its preset ignores, such as `host` on the `any` preset. */
87
94
  function presetProblems(ticket, path) {
88
- if (ticket === undefined)
95
+ if (!ticket)
89
96
  return [];
90
97
  const specs = Array.isArray(ticket) ? ticket : [ticket];
91
98
  return specs.flatMap((spec, index) => {
99
+ if (!isTicketSpec(spec))
100
+ return [];
92
101
  const at = Array.isArray(ticket) ? `${path}[${index}]` : path;
93
102
  const allowed = PRESET_OPTIONS[spec.preset];
94
103
  const misplaced = Object.keys(spec)
95
104
  .filter((key) => key !== 'preset' && spec[key] !== undefined && !allowed.includes(key))
96
- .map((key) => {
105
+ .flatMap((key) => {
97
106
  const presets = Object.entries(PRESET_OPTIONS).filter(([, keys]) => keys.includes(key));
98
- return `${at}.${key} is not an option of the "${spec.preset}" preset (it applies to ${presets.map(([name]) => `"${name}"`).join(', ')})`;
107
+ // An option no preset defines is a typo, which the schema already reports with a suggestion.
108
+ if (presets.length === 0)
109
+ return [];
110
+ return [
111
+ `${at}.${key} is not an option of the "${spec.preset}" preset (it applies to ${presets.map(([name]) => `"${name}"`).join(', ')})`,
112
+ ];
99
113
  });
100
114
  return [...misplaced, ...ownOptionProblems(spec, at)];
101
115
  });
102
116
  }
103
117
  /** Problems the schema can't express: options that belong to another preset, and reused state names. */
104
118
  function crossFieldProblems(options) {
119
+ // `options` comes from user settings, so at runtime it can be anything; the schema reports a non-object.
120
+ const raw = options;
121
+ if (typeof raw !== 'object' || raw === null)
122
+ return [];
105
123
  const problems = presetProblems(options.ticket, 'ticket');
106
124
  for (const [name, override] of Object.entries(options.states ?? {})) {
107
- if (typeof override === 'object')
108
- problems.push(...presetProblems(override.ticket, `states.${name}.ticket`));
125
+ const state = override;
126
+ if (typeof state === 'object' && state !== null) {
127
+ problems.push(...presetProblems(state.ticket, `states.${name}.ticket`));
128
+ }
109
129
  }
110
130
  for (const [name, custom] of Object.entries(options.customStates ?? {})) {
111
131
  if (BUILTIN_STATE_NAMES.includes(name)) {
112
132
  problems.push(`customStates.${name} reuses the name of the built-in "${name}" state; give it another name, or use states.${name} to change the built-in`);
113
133
  }
114
- problems.push(...presetProblems(custom.ticket, `customStates.${name}.ticket`));
134
+ problems.push(...presetProblems(custom?.ticket, `customStates.${name}.ticket`));
115
135
  }
116
136
  return problems;
117
137
  }
118
138
  export function compileOptions(options) {
119
139
  const schemaProblems = validate(options, optionsSchema);
120
- const problems = schemaProblems.length > 0 ? schemaProblems : crossFieldProblems(options);
140
+ // The cross-field problems come first: a misplaced option is worth knowing even when a value next to it is
141
+ // malformed, and both are about the same setting.
142
+ const problems = [...crossFieldProblems(options), ...schemaProblems];
121
143
  if (problems.length > 0) {
122
144
  throw new ConfigError(`invalid options:\n${problems.map((problem) => ` - ${problem}`).join('\n')}`);
123
145
  }
@@ -391,20 +391,46 @@ function compilePreset(spec) {
391
391
  }
392
392
  }
393
393
  }
394
- function globToRegExp(glob) {
395
- const body = glob
396
- .split('*')
397
- .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
398
- .join('.*');
399
- return new RegExp(`^${body}$`, 'i');
394
+ /**
395
+ * Matches a `*` wildcard placeholder against a ticket, case-insensitively, in one pass. A regex with `.*` per
396
+ * star backtracks on a placeholder like `*a*a*a*a*b`, which a user can write by hand.
397
+ */
398
+ function matchesGlob(glob, value) {
399
+ const pattern = glob.toLowerCase();
400
+ const text = value.toLowerCase();
401
+ let p = 0;
402
+ let t = 0;
403
+ let star = -1;
404
+ let mark = 0;
405
+ while (t < text.length) {
406
+ if (p < pattern.length && pattern[p] === text[t]) {
407
+ p += 1;
408
+ t += 1;
409
+ }
410
+ else if (p < pattern.length && pattern[p] === '*') {
411
+ star = p;
412
+ p += 1;
413
+ mark = t;
414
+ }
415
+ else if (star !== -1) {
416
+ mark += 1;
417
+ t = mark;
418
+ p = star + 1;
419
+ }
420
+ else {
421
+ return false;
422
+ }
423
+ }
424
+ while (p < pattern.length && pattern[p] === '*')
425
+ p += 1;
426
+ return p === pattern.length;
400
427
  }
401
428
  export function compileTicketSpec(specs, placeholders) {
402
429
  const presets = specs.map(compilePreset);
403
- const placeholderRes = placeholders.map(globToRegExp);
404
430
  return {
405
431
  check(ticket) {
406
432
  const bare = ticket.replace(/^#/, '');
407
- if (placeholderRes.some((re) => re.test(ticket) || re.test(bare)))
433
+ if (placeholders.some((glob) => matchesGlob(glob, ticket) || matchesGlob(glob, bare)))
408
434
  return 'placeholder';
409
435
  for (const generic of [false, true]) {
410
436
  const ids = presets.flatMap((preset) => Boolean(preset.generic) === generic ? (preset.find(ticket) ?? []) : []);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-test-governance",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "description": "ESLint rules that require a ticket comment above skipped, fixme, todo and tagged Playwright, Jest, Vitest, Mocha, Cypress, WebdriverIO, node:test and Jasmine tests.",
5
5
  "keywords": [
6
6
  "eslint",