@cjser/eslint-node-test 0.2.0-cjser.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.
Files changed (128) hide show
  1. package/dist-cjser/index.cjs +8561 -0
  2. package/index.d.ts +11 -0
  3. package/index.js +49 -0
  4. package/license +9 -0
  5. package/package.json +141 -0
  6. package/readme.md +172 -0
  7. package/rules/assertion-arguments.js +147 -0
  8. package/rules/ast/call-or-new-expression.js +94 -0
  9. package/rules/ast/function-types.js +7 -0
  10. package/rules/ast/index.js +17 -0
  11. package/rules/ast/is-expression-statement.js +7 -0
  12. package/rules/ast/is-function.js +5 -0
  13. package/rules/ast/is-loop.js +5 -0
  14. package/rules/ast/is-member-expression.js +92 -0
  15. package/rules/ast/is-method-call.js +62 -0
  16. package/rules/ast/literal.js +32 -0
  17. package/rules/ast/loop-types.js +9 -0
  18. package/rules/consistent-assert-style.js +169 -0
  19. package/rules/consistent-assert-throws-callback-style.js +241 -0
  20. package/rules/consistent-modifier-style.js +95 -0
  21. package/rules/consistent-test-context-name.js +75 -0
  22. package/rules/consistent-test-filename.js +70 -0
  23. package/rules/consistent-test-it.js +84 -0
  24. package/rules/fix/index.js +5 -0
  25. package/rules/fix/remove-argument.js +58 -0
  26. package/rules/fix/replace-member-expression-property.js +25 -0
  27. package/rules/hooks-order.js +147 -0
  28. package/rules/index.js +84 -0
  29. package/rules/max-assertions.js +88 -0
  30. package/rules/max-nested-describe.js +70 -0
  31. package/rules/no-assert-in-describe.js +61 -0
  32. package/rules/no-assert-in-hook.js +61 -0
  33. package/rules/no-assert-match-string.js +166 -0
  34. package/rules/no-assert-throws-async.js +113 -0
  35. package/rules/no-assert-throws-call.js +106 -0
  36. package/rules/no-assert-throws-multiple-statements.js +194 -0
  37. package/rules/no-assert-throws-string.js +78 -0
  38. package/rules/no-async-describe.js +50 -0
  39. package/rules/no-async-fn-without-await.js +74 -0
  40. package/rules/no-callback-and-promise.js +56 -0
  41. package/rules/no-commented-tests.js +64 -0
  42. package/rules/no-compound-assertion.js +131 -0
  43. package/rules/no-conditional-assertion.js +108 -0
  44. package/rules/no-conditional-in-test.js +66 -0
  45. package/rules/no-conditional-tests.js +75 -0
  46. package/rules/no-conflicting-modifiers.js +73 -0
  47. package/rules/no-constant-assertion.js +218 -0
  48. package/rules/no-done-callback.js +58 -0
  49. package/rules/no-duplicate-assertions.js +172 -0
  50. package/rules/no-duplicate-hooks.js +75 -0
  51. package/rules/no-duplicate-plan.js +209 -0
  52. package/rules/no-export.js +45 -0
  53. package/rules/no-identical-assertion-arguments.js +84 -0
  54. package/rules/no-identical-title.js +101 -0
  55. package/rules/no-incorrect-deep-equal.js +113 -0
  56. package/rules/no-incorrect-strict-equal.js +99 -0
  57. package/rules/no-loop-static-title.js +93 -0
  58. package/rules/no-misused-concurrency.js +85 -0
  59. package/rules/no-mock-timers-destructured-import.js +150 -0
  60. package/rules/no-nested-tests.js +71 -0
  61. package/rules/no-only-test.js +11 -0
  62. package/rules/no-parent-test-context.js +244 -0
  63. package/rules/no-process-env-mutation.js +495 -0
  64. package/rules/no-process-exit-in-test.js +96 -0
  65. package/rules/no-skip-test.js +11 -0
  66. package/rules/no-skip-without-reason.js +90 -0
  67. package/rules/no-skip-without-return.js +123 -0
  68. package/rules/no-sleep-in-test.js +482 -0
  69. package/rules/no-snapshot-in-loop.js +132 -0
  70. package/rules/no-standalone-assert.js +51 -0
  71. package/rules/no-test-inside-hook.js +68 -0
  72. package/rules/no-test-return-statement.js +217 -0
  73. package/rules/no-todo-test.js +11 -0
  74. package/rules/no-unawaited-promise-assertion.js +366 -0
  75. package/rules/no-unawaited-rejects.js +87 -0
  76. package/rules/no-unawaited-subtest.js +66 -0
  77. package/rules/no-unknown-test-options.js +77 -0
  78. package/rules/no-useless-assertion.js +60 -0
  79. package/rules/prefer-assert-match.js +253 -0
  80. package/rules/prefer-assert-throws.js +97 -0
  81. package/rules/prefer-async-await.js +203 -0
  82. package/rules/prefer-context-mock.js +55 -0
  83. package/rules/prefer-diagnostic.js +94 -0
  84. package/rules/prefer-equality-assertion.js +124 -0
  85. package/rules/prefer-hooks-on-top.js +75 -0
  86. package/rules/prefer-lowercase-title.js +119 -0
  87. package/rules/prefer-mock-method.js +115 -0
  88. package/rules/prefer-strict-assert.js +82 -0
  89. package/rules/prefer-test-context-assert.js +162 -0
  90. package/rules/prefer-todo.js +98 -0
  91. package/rules/require-assertion.js +99 -0
  92. package/rules/require-await-concurrent-subtests.js +119 -0
  93. package/rules/require-context-assert-with-plan.js +127 -0
  94. package/rules/require-hook.js +108 -0
  95. package/rules/require-mock-timers-advance.js +313 -0
  96. package/rules/require-mock-timers-apis.js +383 -0
  97. package/rules/require-throws-expectation.js +65 -0
  98. package/rules/require-throws-validator-return-true.js +156 -0
  99. package/rules/require-top-level-describe.js +89 -0
  100. package/rules/rule/index.js +9 -0
  101. package/rules/rule/to-eslint-create.js +35 -0
  102. package/rules/rule/to-eslint-listener.js +52 -0
  103. package/rules/rule/to-eslint-problem.js +36 -0
  104. package/rules/rule/to-eslint-rule-fixer.js +47 -0
  105. package/rules/rule/to-eslint-rule.js +36 -0
  106. package/rules/rule/to-eslint-rules.js +8 -0
  107. package/rules/rule/unicorn-context.js +34 -0
  108. package/rules/rule/unicorn-listeners.js +56 -0
  109. package/rules/rule/utilities.js +26 -0
  110. package/rules/shared/test-modifier-rule.js +92 -0
  111. package/rules/test-title-format.js +86 -0
  112. package/rules/test-title.js +139 -0
  113. package/rules/utils/contains-suspension-point.js +35 -0
  114. package/rules/utils/get-comments.js +15 -0
  115. package/rules/utils/get-documentation-url.js +9 -0
  116. package/rules/utils/get-enclosing-function.js +18 -0
  117. package/rules/utils/index.js +15 -0
  118. package/rules/utils/is-conditional-branch.js +37 -0
  119. package/rules/utils/is-promise-type.js +26 -0
  120. package/rules/utils/is-same-reference.js +179 -0
  121. package/rules/utils/is-value-not-usable.js +5 -0
  122. package/rules/utils/node-test.js +883 -0
  123. package/rules/utils/parentheses/get-parent-syntax-opening-parenthesis.js +78 -0
  124. package/rules/utils/parentheses/iterate-surrounding-parentheses.js +80 -0
  125. package/rules/utils/parentheses/parentheses.js +69 -0
  126. package/rules/utils/types.js +5 -0
  127. package/rules/utils/unwrap-typescript-expression.js +16 -0
  128. package/rules/valid-describe-callback.js +63 -0
@@ -0,0 +1,124 @@
1
+ import {
2
+ resolveImports,
3
+ parseAssertionCall,
4
+ createContextTracker,
5
+ isAssertionCallWithSupportedContext,
6
+ } from './utils/node-test.js';
7
+ import {isParenthesized, getParenthesizedRange} from './utils/index.js';
8
+ import unwrapTypeScriptExpression from './utils/unwrap-typescript-expression.js';
9
+
10
+ const MESSAGE_ID = 'prefer-equality-assertion';
11
+
12
+ const messages = {
13
+ [MESSAGE_ID]: 'Prefer `{{replacement}}` over `{{method}}` with a `{{operator}}` comparison for a clearer failure message.',
14
+ };
15
+
16
+ // Comparison operators and the assertion that preserves their semantics.
17
+ const OPERATOR_TO_METHOD = new Map([
18
+ ['===', 'strictEqual'],
19
+ ['!==', 'notStrictEqual'],
20
+ ['==', 'equal'],
21
+ ['!=', 'notEqual'],
22
+ ]);
23
+
24
+ /** @param {import('eslint').Rule.RuleContext} context */
25
+ const create = context => {
26
+ const {sourceCode} = context;
27
+ const imports = resolveImports(context);
28
+ if (!imports.isAssertOrTestFile) {
29
+ return;
30
+ }
31
+
32
+ const tracker = createContextTracker(imports, {trackHooks: true});
33
+
34
+ context.on('CallExpression', node => {
35
+ tracker.update(node);
36
+
37
+ const parsed = parseAssertionCall(node, imports);
38
+ // Only the truthiness assertions (`assert(…)` / `assert.ok(…)`) benefit.
39
+ if (parsed?.method !== 'ok' || !isAssertionCallWithSupportedContext(node, tracker)) {
40
+ return;
41
+ }
42
+
43
+ const [rawArgument] = node.arguments;
44
+ // Unwrap TypeScript casts (`(a === b) as boolean`) so the comparison is still recognized.
45
+ const argument = rawArgument && unwrapTypeScriptExpression(rawArgument);
46
+ if (argument?.type !== 'BinaryExpression') {
47
+ return;
48
+ }
49
+
50
+ if (
51
+ parsed.isStrict
52
+ && (
53
+ argument.operator === '=='
54
+ || argument.operator === '!='
55
+ )
56
+ ) {
57
+ return;
58
+ }
59
+
60
+ const replacement = OPERATOR_TO_METHOD.get(argument.operator);
61
+ if (!replacement) {
62
+ return;
63
+ }
64
+
65
+ const {callee} = node;
66
+ const method = callee.type === 'MemberExpression' ? callee.property.name : 'ok';
67
+
68
+ const problem = {
69
+ node,
70
+ messageId: MESSAGE_ID,
71
+ data: {method, replacement, operator: argument.operator},
72
+ };
73
+
74
+ // Skip the autofix when it would be unsafe: extra parentheses or a comment inside
75
+ // the comparison would be mangled by the rewrite, and a bare named import (`ok`)
76
+ // cannot be rewritten to an unimported `strictEqual`.
77
+ const isBareNamedImport = callee.type === 'Identifier' && !imports.assertNamespace.has(callee.name);
78
+ if (
79
+ isBareNamedImport
80
+ || isParenthesized(argument, context)
81
+ || sourceCode.getCommentsInside(argument).length > 0
82
+ ) {
83
+ return problem;
84
+ }
85
+
86
+ problem.fix = function * (fixer) {
87
+ // `assert.ok(…)` / `t.assert.ok(…)` rewrite just the method, while the bare
88
+ // `assert(…)` namespace function becomes `assert.strictEqual(…)`.
89
+ yield callee.type === 'MemberExpression'
90
+ ? fixer.replaceText(callee.property, replacement)
91
+ : fixer.replaceText(callee, `${callee.name}.${replacement}`);
92
+
93
+ // Split the comparison into two arguments: `left === right` -> `left, right`.
94
+ // Use the parenthesized ranges so parenthesized operands stay intact.
95
+ const leftEnd = getParenthesizedRange(argument.left, context)[1];
96
+ const rightStart = getParenthesizedRange(argument.right, context)[0];
97
+ yield fixer.replaceTextRange([leftEnd, rightStart], ', ');
98
+ };
99
+
100
+ return problem;
101
+ });
102
+
103
+ context.onExit('CallExpression', node => {
104
+ tracker.leave(node);
105
+ });
106
+ };
107
+
108
+ /** @type {import('eslint').Rule.RuleModule} */
109
+ const config = {
110
+ create,
111
+ meta: {
112
+ type: 'suggestion',
113
+ docs: {
114
+ description: 'Prefer an equality assertion over a truthiness assertion on a comparison.',
115
+ recommended: 'unopinionated',
116
+ },
117
+ fixable: 'code',
118
+ schema: [],
119
+ messages,
120
+ languages: ['js/js'],
121
+ },
122
+ };
123
+
124
+ export default config;
@@ -0,0 +1,75 @@
1
+ import {resolveImports, parseTestCall, getTestCallback} from './utils/node-test.js';
2
+
3
+ const MESSAGE_ID = 'prefer-hooks-on-top';
4
+
5
+ const messages = {
6
+ [MESSAGE_ID]: 'Hook `{{name}}` should come before any test or `describe` in its scope.',
7
+ };
8
+
9
+ /** @param {import('eslint').Rule.RuleContext} context */
10
+ const create = context => {
11
+ const imports = resolveImports(context);
12
+ if (!imports.isTestFile) {
13
+ return;
14
+ }
15
+
16
+ // Stack of scopes; each tracks whether a test/suite has appeared in it yet.
17
+ const scopeStack = [{seenTest: false}];
18
+ const pushedCalls = new Set();
19
+
20
+ context.on('CallExpression', node => {
21
+ const parsed = parseTestCall(node, imports);
22
+ if (!parsed) {
23
+ return;
24
+ }
25
+
26
+ const scope = scopeStack.at(-1);
27
+
28
+ let problem;
29
+ if (parsed.kind === 'hook' && scope.seenTest) {
30
+ problem = {
31
+ node,
32
+ messageId: MESSAGE_ID,
33
+ data: {name: parsed.name},
34
+ };
35
+ }
36
+
37
+ if (parsed.kind === 'test' || parsed.kind === 'suite') {
38
+ scope.seenTest = true;
39
+
40
+ const callback = getTestCallback(node);
41
+ if (callback) {
42
+ scopeStack.push({seenTest: false});
43
+ pushedCalls.add(node);
44
+ }
45
+ }
46
+
47
+ return problem;
48
+ });
49
+
50
+ context.onExit('CallExpression', node => {
51
+ if (!pushedCalls.has(node)) {
52
+ return;
53
+ }
54
+
55
+ pushedCalls.delete(node);
56
+ scopeStack.pop();
57
+ });
58
+ };
59
+
60
+ /** @type {import('eslint').Rule.RuleModule} */
61
+ const config = {
62
+ create,
63
+ meta: {
64
+ type: 'suggestion',
65
+ docs: {
66
+ description: 'Require hooks to be declared before the tests in their scope.',
67
+ recommended: true,
68
+ },
69
+ schema: [],
70
+ messages,
71
+ languages: ['js/js'],
72
+ },
73
+ };
74
+
75
+ export default config;
@@ -0,0 +1,119 @@
1
+ import {resolveImports, parseTestCall, getTestTitle} from './utils/node-test.js';
2
+
3
+ const MESSAGE_ID = 'prefer-lowercase-title';
4
+
5
+ const messages = {
6
+ [MESSAGE_ID]: 'Start the title with a lowercase letter.',
7
+ };
8
+
9
+ /** Get the static text at the start of a title node, or `undefined` if there is none. */
10
+ const getLeadingText = node => {
11
+ if (node.type === 'Literal' && typeof node.value === 'string') {
12
+ return node.value;
13
+ }
14
+
15
+ // `` `text ${x}` `` — the text before the first expression. Empty if it starts with `${…}`.
16
+ if (node.type === 'TemplateLiteral') {
17
+ return node.quasis[0].value.cooked || undefined;
18
+ }
19
+
20
+ return undefined;
21
+ };
22
+
23
+ /** @param {import('eslint').Rule.RuleContext} context */
24
+ const create = context => {
25
+ const {sourceCode} = context;
26
+ const imports = resolveImports(context);
27
+ if (!imports.isTestFile) {
28
+ return;
29
+ }
30
+
31
+ const {ignore, allowedPrefixes} = context.options[0];
32
+
33
+ context.on('CallExpression', node => {
34
+ const parsed = parseTestCall(node, imports);
35
+ if (parsed?.kind !== 'test' && parsed?.kind !== 'suite') {
36
+ return;
37
+ }
38
+
39
+ if (ignore.includes(parsed.name)) {
40
+ return;
41
+ }
42
+
43
+ const titleNode = getTestTitle(node, context);
44
+ if (!titleNode) {
45
+ return;
46
+ }
47
+
48
+ const leadingText = getLeadingText(titleNode);
49
+ if (!leadingText) {
50
+ return;
51
+ }
52
+
53
+ if (allowedPrefixes.some(prefix => leadingText.startsWith(prefix))) {
54
+ return;
55
+ }
56
+
57
+ const firstCharacter = leadingText[0];
58
+ if (!/\p{Uppercase_Letter}/u.test(firstCharacter)) {
59
+ return;
60
+ }
61
+
62
+ const problem = {
63
+ node: titleNode,
64
+ messageId: MESSAGE_ID,
65
+ };
66
+
67
+ // The first content character sits right after the opening quote/backtick.
68
+ const start = sourceCode.getRange(titleNode)[0] + 1;
69
+ // Skip the fix when the first character is written as a Unicode/hex escape, so the
70
+ // raw source does not start with the letter itself and replacing it would corrupt the escape.
71
+ if (sourceCode.getText(titleNode)[1] === firstCharacter) {
72
+ problem.fix = fixer => fixer.replaceTextRange([start, start + 1], firstCharacter.toLowerCase());
73
+ }
74
+
75
+ return problem;
76
+ });
77
+ };
78
+
79
+ /** @type {import('eslint').Rule.RuleModule} */
80
+ const config = {
81
+ create,
82
+ meta: {
83
+ type: 'suggestion',
84
+ docs: {
85
+ description: 'Enforce lowercase test titles.',
86
+ recommended: false,
87
+ },
88
+ fixable: 'code',
89
+ schema: [
90
+ {
91
+ type: 'object',
92
+ properties: {
93
+ ignore: {
94
+ type: 'array',
95
+ items: {
96
+ enum: ['test', 'it', 'describe', 'suite'],
97
+ },
98
+ uniqueItems: true,
99
+ description: 'Test functions whose titles are not checked.',
100
+ },
101
+ allowedPrefixes: {
102
+ type: 'array',
103
+ items: {
104
+ type: 'string',
105
+ },
106
+ uniqueItems: true,
107
+ description: 'Title prefixes that are allowed to start with an uppercase letter.',
108
+ },
109
+ },
110
+ additionalProperties: false,
111
+ },
112
+ ],
113
+ defaultOptions: [{ignore: [], allowedPrefixes: []}],
114
+ messages,
115
+ languages: ['js/js'],
116
+ },
117
+ };
118
+
119
+ export default config;
@@ -0,0 +1,115 @@
1
+ import {resolveImports, createContextTracker, isGlobalMock} from './utils/node-test.js';
2
+ import {isValueNotUsable} from './utils/index.js';
3
+
4
+ const MESSAGE_ID_ERROR = 'prefer-mock-method/error';
5
+ const MESSAGE_ID_SUGGESTION = 'prefer-mock-method/suggestion';
6
+
7
+ const messages = {
8
+ [MESSAGE_ID_ERROR]: 'Prefer `{{base}}.method()` over assigning `{{base}}.fn()` to a property, so the original method is tracked and can be restored.',
9
+ [MESSAGE_ID_SUGGESTION]: 'Replace with `{{base}}.method()`.',
10
+ };
11
+
12
+ /** @param {import('eslint').Rule.RuleContext} context */
13
+ const create = context => {
14
+ const {sourceCode} = context;
15
+ const imports = resolveImports(context);
16
+ if (!imports.isTestFile) {
17
+ return;
18
+ }
19
+
20
+ const tracker = createContextTracker(imports);
21
+
22
+ // The context `<ctx>.mock`.
23
+ const isContextMock = node =>
24
+ node.type === 'MemberExpression'
25
+ && !node.computed
26
+ && node.property.type === 'Identifier'
27
+ && node.property.name === 'mock'
28
+ && node.object.type === 'Identifier'
29
+ && tracker.isContextName(node.object.name);
30
+
31
+ // Keep the context-name stack in sync as we enter and leave test callbacks.
32
+ context.on('CallExpression', node => {
33
+ tracker.update(node);
34
+ });
35
+ context.onExit('CallExpression', node => {
36
+ tracker.leave(node);
37
+ });
38
+
39
+ context.on('AssignmentExpression', node => {
40
+ if (node.operator !== '=' || node.left.type !== 'MemberExpression' || node.right.type !== 'CallExpression') {
41
+ return;
42
+ }
43
+
44
+ const {callee} = node.right;
45
+ if (
46
+ callee.type !== 'MemberExpression'
47
+ || callee.computed
48
+ || callee.property.type !== 'Identifier'
49
+ || callee.property.name !== 'fn'
50
+ || (!isGlobalMock(callee.object, imports) && !isContextMock(callee.object))
51
+ ) {
52
+ return;
53
+ }
54
+
55
+ const base = sourceCode.getText(callee.object);
56
+ const problem = {
57
+ node,
58
+ messageId: MESSAGE_ID_ERROR,
59
+ data: {base},
60
+ };
61
+
62
+ const {left} = node;
63
+ const mockArguments = node.right.arguments;
64
+
65
+ // Resolve the property name to a `mock.method` second argument.
66
+ let key;
67
+ if (!left.computed && left.property.type === 'Identifier') {
68
+ key = `'${left.property.name}'`;
69
+ } else if (left.computed) {
70
+ key = sourceCode.getText(left.property);
71
+ }
72
+
73
+ // Only suggest a rewrite for the simple cases: a resolvable key, at most one argument (the
74
+ // implementation, which becomes `mock.method`'s third argument), and no inner comments to drop.
75
+ // `<obj>.method = mock.fn()` evaluates to the mock function, but `mock.method(…)` returns the
76
+ // original method, so skip the suggestion when the assignment's value is used.
77
+ const canRewrite = key !== undefined
78
+ && mockArguments.length <= 1
79
+ && isValueNotUsable(node)
80
+ && sourceCode.getCommentsInside(node).length === 0;
81
+
82
+ if (canRewrite) {
83
+ const objectText = sourceCode.getText(left.object);
84
+ const implementation = mockArguments.length === 1 ? `, ${sourceCode.getText(mockArguments[0])}` : '';
85
+ const replacement = `${base}.method(${objectText}, ${key}${implementation})`;
86
+ problem.suggest = [
87
+ {
88
+ messageId: MESSAGE_ID_SUGGESTION,
89
+ data: {base},
90
+ fix: fixer => fixer.replaceText(node, replacement),
91
+ },
92
+ ];
93
+ }
94
+
95
+ return problem;
96
+ });
97
+ };
98
+
99
+ /** @type {import('eslint').Rule.RuleModule} */
100
+ const config = {
101
+ create,
102
+ meta: {
103
+ type: 'suggestion',
104
+ docs: {
105
+ description: 'Prefer `mock.method()` over assigning `mock.fn()` to an object property.',
106
+ recommended: true,
107
+ },
108
+ hasSuggestions: true,
109
+ schema: [],
110
+ messages,
111
+ languages: ['js/js'],
112
+ },
113
+ };
114
+
115
+ export default config;
@@ -0,0 +1,82 @@
1
+ import {
2
+ resolveImports,
3
+ parseAssertionCall,
4
+ createContextTracker,
5
+ isAssertionCallWithSupportedContext,
6
+ } from './utils/node-test.js';
7
+
8
+ const MESSAGE_ID = 'prefer-strict-assert';
9
+
10
+ const messages = {
11
+ [MESSAGE_ID]: 'Prefer `{{replacement}}` over the legacy loose `{{method}}`.',
12
+ };
13
+
14
+ // Legacy loose (`==`) assertion methods and their strict equivalents.
15
+ const LOOSE_TO_STRICT = new Map([
16
+ ['equal', 'strictEqual'],
17
+ ['notEqual', 'notStrictEqual'],
18
+ ['deepEqual', 'deepStrictEqual'],
19
+ ['notDeepEqual', 'notDeepStrictEqual'],
20
+ ]);
21
+
22
+ /** @param {import('eslint').Rule.RuleContext} context */
23
+ const create = context => {
24
+ const imports = resolveImports(context);
25
+ if (!imports.isAssertOrTestFile) {
26
+ return;
27
+ }
28
+
29
+ const tracker = createContextTracker(imports, {trackHooks: true});
30
+
31
+ context.on('CallExpression', node => {
32
+ tracker.update(node);
33
+
34
+ const assertion = parseAssertionCall(node, imports);
35
+ // In a strict-mode assert module the legacy methods already behave strictly, so leave them.
36
+ if (!assertion || assertion.isStrict || !isAssertionCallWithSupportedContext(node, tracker)) {
37
+ return;
38
+ }
39
+
40
+ const replacement = LOOSE_TO_STRICT.get(assertion.method);
41
+ if (!replacement) {
42
+ return;
43
+ }
44
+
45
+ const problem = {
46
+ node,
47
+ messageId: MESSAGE_ID,
48
+ data: {method: assertion.method, replacement},
49
+ };
50
+
51
+ // Autofix only the member forms (`assert.equal`, `t.assert.equal`). A bare named
52
+ // import (`equal`) cannot be rewritten to `strictEqual` without also importing it,
53
+ // so leave it reported but unfixed.
54
+ if (assertion.methodNode && assertion.methodNode !== node.callee) {
55
+ problem.fix = fixer => fixer.replaceText(assertion.methodNode, replacement);
56
+ }
57
+
58
+ return problem;
59
+ });
60
+
61
+ context.onExit('CallExpression', node => {
62
+ tracker.leave(node);
63
+ });
64
+ };
65
+
66
+ /** @type {import('eslint').Rule.RuleModule} */
67
+ const config = {
68
+ create,
69
+ meta: {
70
+ type: 'suggestion',
71
+ docs: {
72
+ description: 'Prefer strict assertion methods over their legacy loose counterparts.',
73
+ recommended: 'unopinionated',
74
+ },
75
+ fixable: 'code',
76
+ schema: [],
77
+ messages,
78
+ languages: ['js/js'],
79
+ },
80
+ };
81
+
82
+ export default config;
@@ -0,0 +1,162 @@
1
+ import {findVariable} from '@eslint-community/eslint-utils';
2
+ import {
3
+ resolveImports,
4
+ createContextTracker,
5
+ parseAssertionCall,
6
+ } from './utils/node-test.js';
7
+
8
+ const MESSAGE_ID_ERROR = 'prefer-test-context-assert/error';
9
+ const MESSAGE_ID_SUGGESTION = 'prefer-test-context-assert/suggestion';
10
+
11
+ const messages = {
12
+ [MESSAGE_ID_ERROR]: 'Prefer the test context `{{context}}.assert.{{method}}()` over the imported `node:assert`, so the runner ties the assertion to this test.',
13
+ [MESSAGE_ID_SUGGESTION]: 'Replace with `{{context}}.assert.{{method}}()`.',
14
+ };
15
+
16
+ // Under `node:assert/strict` these loose methods behave as their strict counterparts.
17
+ // `t.assert.*` exposes the non-strict functions, so preserve the behavior when converting.
18
+ const LOOSE_TO_STRICT = new Map([
19
+ ['equal', 'strictEqual'],
20
+ ['notEqual', 'notStrictEqual'],
21
+ ['deepEqual', 'deepStrictEqual'],
22
+ ['notDeepEqual', 'notDeepStrictEqual'],
23
+ ]);
24
+
25
+ function isImportedAssertCallee(callee, imports) {
26
+ if (
27
+ callee.type === 'Identifier'
28
+ && (
29
+ imports.assertNamed.has(callee.name)
30
+ || imports.assertNamespace.has(callee.name)
31
+ )
32
+ ) {
33
+ return true;
34
+ }
35
+
36
+ if (
37
+ callee.type === 'MemberExpression'
38
+ && !callee.computed
39
+ && callee.object.type === 'Identifier'
40
+ && (
41
+ imports.assertNamespace.has(callee.object.name)
42
+ || imports.assertNamed.get(callee.object.name) === 'strict'
43
+ )
44
+ ) {
45
+ return true;
46
+ }
47
+
48
+ return (
49
+ callee.type === 'MemberExpression'
50
+ && !callee.computed
51
+ && callee.object.type === 'MemberExpression'
52
+ && !callee.object.computed
53
+ && callee.object.object.type === 'Identifier'
54
+ && callee.object.property.type === 'Identifier'
55
+ && callee.object.property.name === 'strict'
56
+ && imports.assertNamespace.has(callee.object.object.name)
57
+ );
58
+ }
59
+
60
+ function getAssertMethod(node, imports) {
61
+ const assertion = parseAssertionCall(node, imports);
62
+ if (!assertion || !isImportedAssertCallee(node.callee, imports)) {
63
+ return;
64
+ }
65
+
66
+ return (assertion.isStrict && LOOSE_TO_STRICT.get(assertion.method)) || assertion.method;
67
+ }
68
+
69
+ function isInsideCallback(node, callback, sourceCode) {
70
+ const [callbackStart, callbackEnd] = sourceCode.getRange(callback);
71
+ const [nodeStart] = sourceCode.getRange(node);
72
+
73
+ return nodeStart >= callbackStart && nodeStart < callbackEnd;
74
+ }
75
+
76
+ function isContextParameterInScope(name, callback, node, sourceCode) {
77
+ const parameter = callback.params[0];
78
+ if (parameter?.type !== 'Identifier') {
79
+ return false;
80
+ }
81
+
82
+ const variable = findVariable(sourceCode.getScope(node), name);
83
+ return variable?.defs.some(definition => definition.name === parameter) === true;
84
+ }
85
+
86
+ /** @param {import('eslint').Rule.RuleContext} context */
87
+ const create = context => {
88
+ const {sourceCode} = context;
89
+ const imports = resolveImports(context);
90
+ // Needs both: an imported `node:assert` to convert from, and `node:test` to provide a context.
91
+ if (!imports.hasAssert || !imports.isTestFile) {
92
+ return;
93
+ }
94
+
95
+ const tracker = createContextTracker(imports);
96
+
97
+ context.on('CallExpression', node => {
98
+ tracker.update(node);
99
+
100
+ const contextName = tracker.current();
101
+ if (!contextName) {
102
+ return;
103
+ }
104
+
105
+ const callback = tracker.currentCallback();
106
+ if (
107
+ !callback
108
+ || !isInsideCallback(node, callback, sourceCode)
109
+ || !isContextParameterInScope(contextName, callback, node, sourceCode)
110
+ ) {
111
+ return;
112
+ }
113
+
114
+ const method = getAssertMethod(node, imports);
115
+ if (!method) {
116
+ return;
117
+ }
118
+
119
+ const data = {context: contextName, method};
120
+
121
+ const problem = {
122
+ node: node.callee,
123
+ messageId: MESSAGE_ID_ERROR,
124
+ data,
125
+ };
126
+
127
+ // Replacing the whole callee would drop any comments inside it.
128
+ if (sourceCode.getCommentsInside(node.callee).length === 0) {
129
+ problem.suggest = [
130
+ {
131
+ messageId: MESSAGE_ID_SUGGESTION,
132
+ data,
133
+ fix: fixer => fixer.replaceText(node.callee, `${contextName}.assert.${method}`),
134
+ },
135
+ ];
136
+ }
137
+
138
+ return problem;
139
+ });
140
+
141
+ context.onExit('CallExpression', node => {
142
+ tracker.leave(node);
143
+ });
144
+ };
145
+
146
+ /** @type {import('eslint').Rule.RuleModule} */
147
+ const config = {
148
+ create,
149
+ meta: {
150
+ type: 'suggestion',
151
+ docs: {
152
+ description: 'Prefer the test context `t.assert` over the imported `node:assert`.',
153
+ recommended: true,
154
+ },
155
+ hasSuggestions: true,
156
+ schema: [],
157
+ messages,
158
+ languages: ['js/js'],
159
+ },
160
+ };
161
+
162
+ export default config;