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 +22 -1
- package/dist/utils/analyze.js +184 -28
- package/dist/utils/options.js +33 -11
- package/dist/utils/tickets.js +34 -8
- package/package.json +1 -1
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.
|
|
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
|
package/dist/utils/analyze.js
CHANGED
|
@@ -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,
|
|
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
|
-
/**
|
|
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) ?
|
|
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
|
|
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
|
-
//
|
|
193
|
-
|
|
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
|
|
196
|
-
|
|
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 =
|
|
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
|
|
494
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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) &&
|
package/dist/utils/options.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
.
|
|
105
|
+
.flatMap((key) => {
|
|
97
106
|
const presets = Object.entries(PRESET_OPTIONS).filter(([, keys]) => keys.includes(key));
|
|
98
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/dist/utils/tickets.js
CHANGED
|
@@ -391,20 +391,46 @@ function compilePreset(spec) {
|
|
|
391
391
|
}
|
|
392
392
|
}
|
|
393
393
|
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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 (
|
|
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.
|
|
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",
|