@noctcore/eslint-plugin-code-quality 0.1.0 → 0.2.0
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/README.md +10 -0
- package/THIRD_PARTY_NOTICES.md +38 -0
- package/dist/index.cjs +796 -91
- package/dist/index.d.cts +76 -0
- package/dist/index.d.ts +76 -0
- package/dist/index.js +785 -90
- package/docs/rules/fake-timers-must-be-restored.md +108 -0
- package/docs/rules/no-conditional-expect.md +82 -0
- package/docs/rules/no-real-network-in-unit-tests.md +75 -0
- package/docs/rules/no-vacuous-expect.md +89 -0
- package/package.json +4 -3
package/dist/index.js
CHANGED
|
@@ -7,60 +7,22 @@ var recommended = {
|
|
|
7
7
|
"noctcore-code-quality/no-narration-comments": "error",
|
|
8
8
|
"noctcore-code-quality/no-pr-reference-comments": "error",
|
|
9
9
|
"noctcore-code-quality/no-focused-tests": "error",
|
|
10
|
-
"noctcore-code-quality/skipped-tests-need-tracking": "error"
|
|
10
|
+
"noctcore-code-quality/skipped-tests-need-tracking": "error",
|
|
11
|
+
"noctcore-code-quality/no-vacuous-expect": "error",
|
|
12
|
+
"noctcore-code-quality/no-conditional-expect": "error",
|
|
13
|
+
"noctcore-code-quality/fake-timers-must-be-restored": "error",
|
|
14
|
+
"noctcore-code-quality/no-real-network-in-unit-tests": "error"
|
|
11
15
|
};
|
|
12
16
|
|
|
13
|
-
// src/rules/
|
|
17
|
+
// src/rules/fake-timers-must-be-restored.ts
|
|
18
|
+
import fs from "fs";
|
|
19
|
+
import path from "path";
|
|
14
20
|
import { AST_NODE_TYPES } from "@typescript-eslint/utils";
|
|
15
21
|
|
|
16
22
|
// src/createRule.ts
|
|
17
23
|
import { makeCreateRule } from "@noctcore/eslint-utils";
|
|
18
24
|
var createRule = makeCreateRule("code-quality");
|
|
19
25
|
|
|
20
|
-
// src/rules/interface-prefix-i.ts
|
|
21
|
-
var RULE_NAME = "interface-prefix-i";
|
|
22
|
-
function isAlreadyPrefixed(name) {
|
|
23
|
-
return /^I[A-Z]/.test(name);
|
|
24
|
-
}
|
|
25
|
-
function isInsideAmbientModule(node) {
|
|
26
|
-
let current = node.parent;
|
|
27
|
-
while (current) {
|
|
28
|
-
if (current.type === AST_NODE_TYPES.TSModuleDeclaration) {
|
|
29
|
-
return true;
|
|
30
|
-
}
|
|
31
|
-
current = current.parent;
|
|
32
|
-
}
|
|
33
|
-
return false;
|
|
34
|
-
}
|
|
35
|
-
var interfacePrefixIRule = createRule({
|
|
36
|
-
name: RULE_NAME,
|
|
37
|
-
meta: {
|
|
38
|
-
type: "suggestion",
|
|
39
|
-
docs: {
|
|
40
|
-
description: "Interface names must be prefixed with `I` followed by an uppercase letter. Module/global augmentations are exempt."
|
|
41
|
-
},
|
|
42
|
-
schema: [],
|
|
43
|
-
messages: {
|
|
44
|
-
missingPrefix: "Interface `{{name}}` must be prefixed with `I` (e.g. `I{{name}}`). Rename the declaration and its references."
|
|
45
|
-
}
|
|
46
|
-
},
|
|
47
|
-
defaultOptions: [],
|
|
48
|
-
create(context) {
|
|
49
|
-
return {
|
|
50
|
-
TSInterfaceDeclaration(node) {
|
|
51
|
-
const name = node.id.name;
|
|
52
|
-
if (isAlreadyPrefixed(name) || isInsideAmbientModule(node)) {
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
context.report({ node: node.id, messageId: "missingPrefix", data: { name } });
|
|
56
|
-
}
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
// src/rules/no-bare-date-now.ts
|
|
62
|
-
import { AST_NODE_TYPES as AST_NODE_TYPES2 } from "@typescript-eslint/utils";
|
|
63
|
-
|
|
64
26
|
// src/utils/allowMatch.ts
|
|
65
27
|
var REGEX_METACHARACTERS = /[.*+?^${}()|[\]\\]/gu;
|
|
66
28
|
function escapeLiteral(text) {
|
|
@@ -125,10 +87,216 @@ function matchesAny(filename, globs) {
|
|
|
125
87
|
return globs.some((glob) => compile(glob).test(normalized));
|
|
126
88
|
}
|
|
127
89
|
|
|
90
|
+
// src/rules/fake-timers-must-be-restored.ts
|
|
91
|
+
var RULE_NAME = "fake-timers-must-be-restored";
|
|
92
|
+
var DEFAULT_FAKE_TIMER_METHODS = ["useFakeTimers"];
|
|
93
|
+
var DEFAULT_RESTORE_TIMER_METHODS = ["useRealTimers"];
|
|
94
|
+
var DEFAULT_FOLLOW_IMPORTED_SUITES = true;
|
|
95
|
+
var DEFAULT_SHARED_SUITE_MODULES = [];
|
|
96
|
+
var RESOLVE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
|
|
97
|
+
var methodList = {
|
|
98
|
+
type: "array",
|
|
99
|
+
items: { type: "string", minLength: 1 },
|
|
100
|
+
uniqueItems: true,
|
|
101
|
+
minItems: 1
|
|
102
|
+
};
|
|
103
|
+
var optionSchema = {
|
|
104
|
+
type: "object",
|
|
105
|
+
additionalProperties: false,
|
|
106
|
+
properties: {
|
|
107
|
+
fakeTimerMethods: methodList,
|
|
108
|
+
restoreTimerMethods: methodList,
|
|
109
|
+
followImportedSuites: { type: "boolean" },
|
|
110
|
+
sharedSuiteModules: { type: "array", items: { type: "string", minLength: 1 }, uniqueItems: true }
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
var sourceCache = /* @__PURE__ */ new Map();
|
|
114
|
+
function readSource(file) {
|
|
115
|
+
try {
|
|
116
|
+
const stat = fs.statSync(file);
|
|
117
|
+
if (!stat.isFile()) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
const cached = sourceCache.get(file);
|
|
121
|
+
if (cached !== void 0 && cached.mtimeMs === stat.mtimeMs) {
|
|
122
|
+
return cached.text;
|
|
123
|
+
}
|
|
124
|
+
const text = fs.readFileSync(file, "utf8");
|
|
125
|
+
sourceCache.set(file, { mtimeMs: stat.mtimeMs, text });
|
|
126
|
+
return text;
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (error instanceof Error && "code" in error) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function resolveRelative(fromFile, specifier) {
|
|
135
|
+
const base = path.resolve(path.dirname(fromFile), specifier);
|
|
136
|
+
const stem = base.replace(/\.(?:[cm]?js|jsx)$/u, "");
|
|
137
|
+
const candidates = [
|
|
138
|
+
base,
|
|
139
|
+
...RESOLVE_EXTENSIONS.map((ext) => stem + ext),
|
|
140
|
+
...RESOLVE_EXTENSIONS.map((ext) => path.join(base, `index${ext}`))
|
|
141
|
+
];
|
|
142
|
+
for (const candidate of candidates) {
|
|
143
|
+
if (readSource(candidate) !== null) {
|
|
144
|
+
return candidate;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
function escapeRegExp(text) {
|
|
150
|
+
return text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
151
|
+
}
|
|
152
|
+
function importIsCalled(sourceCode, declaration) {
|
|
153
|
+
return sourceCode.getDeclaredVariables(declaration).some(
|
|
154
|
+
(variable) => variable.references.some((reference) => {
|
|
155
|
+
const parent = reference.identifier.parent;
|
|
156
|
+
if (parent.type === AST_NODE_TYPES.CallExpression && parent.callee === reference.identifier) {
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
return parent.type === AST_NODE_TYPES.MemberExpression && parent.object === reference.identifier && parent.parent.type === AST_NODE_TYPES.CallExpression && parent.parent.callee === parent;
|
|
160
|
+
})
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
function calledMethodName(node) {
|
|
164
|
+
const callee = node.callee;
|
|
165
|
+
if (callee.type === AST_NODE_TYPES.Identifier) {
|
|
166
|
+
return callee.name;
|
|
167
|
+
}
|
|
168
|
+
if (callee.type === AST_NODE_TYPES.MemberExpression && !callee.computed && callee.property.type === AST_NODE_TYPES.Identifier) {
|
|
169
|
+
return callee.property.name;
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
var fakeTimersMustBeRestoredRule = createRule({
|
|
174
|
+
name: RULE_NAME,
|
|
175
|
+
meta: {
|
|
176
|
+
type: "problem",
|
|
177
|
+
docs: {
|
|
178
|
+
description: "A test file that calls `useFakeTimers()` must also call `useRealTimers()`, so fake timers do not leak into later tests."
|
|
179
|
+
},
|
|
180
|
+
schema: [optionSchema],
|
|
181
|
+
messages: {
|
|
182
|
+
timersNotRestored: "`{{method}}()` is called without a matching restore call, so fake timers leak into other tests. Call `useRealTimers()` in an `afterEach`, here or in the shared suite this file runs."
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
defaultOptions: [
|
|
186
|
+
{
|
|
187
|
+
fakeTimerMethods: [...DEFAULT_FAKE_TIMER_METHODS],
|
|
188
|
+
restoreTimerMethods: [...DEFAULT_RESTORE_TIMER_METHODS],
|
|
189
|
+
followImportedSuites: DEFAULT_FOLLOW_IMPORTED_SUITES,
|
|
190
|
+
sharedSuiteModules: [...DEFAULT_SHARED_SUITE_MODULES]
|
|
191
|
+
}
|
|
192
|
+
],
|
|
193
|
+
create(context, [options]) {
|
|
194
|
+
const fakeMethods = new Set(options.fakeTimerMethods ?? DEFAULT_FAKE_TIMER_METHODS);
|
|
195
|
+
const restoreMethodList = options.restoreTimerMethods ?? DEFAULT_RESTORE_TIMER_METHODS;
|
|
196
|
+
const restoreMethods = new Set(restoreMethodList);
|
|
197
|
+
const followImportedSuites = options.followImportedSuites ?? DEFAULT_FOLLOW_IMPORTED_SUITES;
|
|
198
|
+
const sharedSuiteModules = options.sharedSuiteModules ?? DEFAULT_SHARED_SUITE_MODULES;
|
|
199
|
+
const restoreCallPattern = new RegExp(
|
|
200
|
+
`\\b(?:${restoreMethodList.map(escapeRegExp).join("|")})\\s*\\(`,
|
|
201
|
+
"u"
|
|
202
|
+
);
|
|
203
|
+
const fakeCalls = [];
|
|
204
|
+
const imports = [];
|
|
205
|
+
let hasRestore = false;
|
|
206
|
+
function restoredByImportedSuite() {
|
|
207
|
+
return imports.some((declaration) => {
|
|
208
|
+
const specifier = declaration.source.value;
|
|
209
|
+
if (!importIsCalled(context.sourceCode, declaration)) {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
if (matchesAny(specifier, sharedSuiteModules)) {
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
if (!followImportedSuites || !specifier.startsWith(".")) {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
const resolved = resolveRelative(context.filename, specifier);
|
|
219
|
+
const text = resolved === null ? null : readSource(resolved);
|
|
220
|
+
return text !== null && restoreCallPattern.test(text);
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
ImportDeclaration(node) {
|
|
225
|
+
if (node.importKind !== "type") {
|
|
226
|
+
imports.push(node);
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
CallExpression(node) {
|
|
230
|
+
const method = calledMethodName(node);
|
|
231
|
+
if (method === null) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (fakeMethods.has(method)) {
|
|
235
|
+
fakeCalls.push({ node, method });
|
|
236
|
+
}
|
|
237
|
+
if (restoreMethods.has(method)) {
|
|
238
|
+
hasRestore = true;
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
"Program:exit"() {
|
|
242
|
+
if (fakeCalls.length === 0 || hasRestore || restoredByImportedSuite()) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
for (const { node, method } of fakeCalls) {
|
|
246
|
+
context.report({ node, messageId: "timersNotRestored", data: { method } });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// src/rules/interface-prefix-i.ts
|
|
254
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES2 } from "@typescript-eslint/utils";
|
|
255
|
+
var RULE_NAME2 = "interface-prefix-i";
|
|
256
|
+
function isAlreadyPrefixed(name) {
|
|
257
|
+
return /^I[A-Z]/.test(name);
|
|
258
|
+
}
|
|
259
|
+
function isInsideAmbientModule(node) {
|
|
260
|
+
let current = node.parent;
|
|
261
|
+
while (current) {
|
|
262
|
+
if (current.type === AST_NODE_TYPES2.TSModuleDeclaration) {
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
current = current.parent;
|
|
266
|
+
}
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
var interfacePrefixIRule = createRule({
|
|
270
|
+
name: RULE_NAME2,
|
|
271
|
+
meta: {
|
|
272
|
+
type: "suggestion",
|
|
273
|
+
docs: {
|
|
274
|
+
description: "Interface names must be prefixed with `I` followed by an uppercase letter. Module/global augmentations are exempt."
|
|
275
|
+
},
|
|
276
|
+
schema: [],
|
|
277
|
+
messages: {
|
|
278
|
+
missingPrefix: "Interface `{{name}}` must be prefixed with `I` (e.g. `I{{name}}`). Rename the declaration and its references."
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
defaultOptions: [],
|
|
282
|
+
create(context) {
|
|
283
|
+
return {
|
|
284
|
+
TSInterfaceDeclaration(node) {
|
|
285
|
+
const name = node.id.name;
|
|
286
|
+
if (isAlreadyPrefixed(name) || isInsideAmbientModule(node)) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
context.report({ node: node.id, messageId: "missingPrefix", data: { name } });
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
128
295
|
// src/rules/no-bare-date-now.ts
|
|
129
|
-
|
|
296
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES3 } from "@typescript-eslint/utils";
|
|
297
|
+
var RULE_NAME3 = "no-bare-date-now";
|
|
130
298
|
var DEFAULT_ALLOW_IN = ["**/clock.ts", "**/clock/**"];
|
|
131
|
-
var
|
|
299
|
+
var optionSchema2 = {
|
|
132
300
|
type: "object",
|
|
133
301
|
additionalProperties: false,
|
|
134
302
|
properties: {
|
|
@@ -141,19 +309,19 @@ var optionSchema = {
|
|
|
141
309
|
};
|
|
142
310
|
function isDateNowCall(node) {
|
|
143
311
|
const callee = node.callee;
|
|
144
|
-
return callee.type ===
|
|
312
|
+
return callee.type === AST_NODE_TYPES3.MemberExpression && !callee.computed && callee.object.type === AST_NODE_TYPES3.Identifier && callee.object.name === "Date" && callee.property.type === AST_NODE_TYPES3.Identifier && callee.property.name === "now";
|
|
145
313
|
}
|
|
146
314
|
function isBareNewDate(node) {
|
|
147
|
-
return node.callee.type ===
|
|
315
|
+
return node.callee.type === AST_NODE_TYPES3.Identifier && node.callee.name === "Date" && node.arguments.length === 0;
|
|
148
316
|
}
|
|
149
317
|
var noBareDateNowRule = createRule({
|
|
150
|
-
name:
|
|
318
|
+
name: RULE_NAME3,
|
|
151
319
|
meta: {
|
|
152
320
|
type: "problem",
|
|
153
321
|
docs: {
|
|
154
322
|
description: "Disallow bare `Date.now()` / `new Date()` in business logic. Read wall-clock time through a shared `clock` util (`nowMs()` / `now()`) so time is mockable."
|
|
155
323
|
},
|
|
156
|
-
schema: [
|
|
324
|
+
schema: [optionSchema2],
|
|
157
325
|
messages: {
|
|
158
326
|
dateNow: "Use `nowMs()` from the shared `clock` util instead of bare `Date.now()`.",
|
|
159
327
|
newDate: "Use `now()` from the shared `clock` util instead of bare `new Date()`."
|
|
@@ -180,20 +348,169 @@ var noBareDateNowRule = createRule({
|
|
|
180
348
|
}
|
|
181
349
|
});
|
|
182
350
|
|
|
351
|
+
// src/rules/no-conditional-expect.ts
|
|
352
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES4 } from "@typescript-eslint/utils";
|
|
353
|
+
var RULE_NAME4 = "no-conditional-expect";
|
|
354
|
+
var DEFAULT_CHECK_LOOPS = false;
|
|
355
|
+
var BRANCH_TYPES = /* @__PURE__ */ new Set([
|
|
356
|
+
AST_NODE_TYPES4.IfStatement,
|
|
357
|
+
AST_NODE_TYPES4.SwitchCase,
|
|
358
|
+
AST_NODE_TYPES4.ConditionalExpression,
|
|
359
|
+
AST_NODE_TYPES4.LogicalExpression,
|
|
360
|
+
AST_NODE_TYPES4.CatchClause
|
|
361
|
+
]);
|
|
362
|
+
var LOOP_TYPES = /* @__PURE__ */ new Set([
|
|
363
|
+
AST_NODE_TYPES4.ForStatement,
|
|
364
|
+
AST_NODE_TYPES4.ForInStatement,
|
|
365
|
+
AST_NODE_TYPES4.ForOfStatement,
|
|
366
|
+
AST_NODE_TYPES4.WhileStatement,
|
|
367
|
+
AST_NODE_TYPES4.DoWhileStatement
|
|
368
|
+
]);
|
|
369
|
+
var RUNNERS = /* @__PURE__ */ new Set(["it", "test", "describe", "suite"]);
|
|
370
|
+
var ASSERTION_COUNT_GUARDS = /* @__PURE__ */ new Set(["assertions", "hasAssertions"]);
|
|
371
|
+
var optionSchema3 = {
|
|
372
|
+
type: "object",
|
|
373
|
+
additionalProperties: false,
|
|
374
|
+
properties: {
|
|
375
|
+
checkLoops: { type: "boolean" }
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
function isExpectCall(node) {
|
|
379
|
+
const callee = node.callee;
|
|
380
|
+
if (callee.type === AST_NODE_TYPES4.Identifier) {
|
|
381
|
+
return callee.name === "expect";
|
|
382
|
+
}
|
|
383
|
+
return callee.type === AST_NODE_TYPES4.MemberExpression && !callee.computed && callee.object.type === AST_NODE_TYPES4.Identifier && callee.property.type === AST_NODE_TYPES4.Identifier && callee.property.name === "expect";
|
|
384
|
+
}
|
|
385
|
+
function isAssertionCountGuard(node) {
|
|
386
|
+
const callee = node.callee;
|
|
387
|
+
return callee.type === AST_NODE_TYPES4.MemberExpression && !callee.computed && callee.object.type === AST_NODE_TYPES4.Identifier && callee.object.name === "expect" && callee.property.type === AST_NODE_TYPES4.Identifier && ASSERTION_COUNT_GUARDS.has(callee.property.name);
|
|
388
|
+
}
|
|
389
|
+
function runnerName(callee) {
|
|
390
|
+
let current = callee;
|
|
391
|
+
for (; ; ) {
|
|
392
|
+
if (current.type === AST_NODE_TYPES4.MemberExpression) {
|
|
393
|
+
current = current.object;
|
|
394
|
+
} else if (current.type === AST_NODE_TYPES4.CallExpression) {
|
|
395
|
+
current = current.callee;
|
|
396
|
+
} else if (current.type === AST_NODE_TYPES4.TaggedTemplateExpression) {
|
|
397
|
+
current = current.tag;
|
|
398
|
+
} else {
|
|
399
|
+
break;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return current.type === AST_NODE_TYPES4.Identifier ? current.name : null;
|
|
403
|
+
}
|
|
404
|
+
function isRunnerCallback(node) {
|
|
405
|
+
const parent = node.parent;
|
|
406
|
+
if (parent.type !== AST_NODE_TYPES4.CallExpression || !parent.arguments.some((argument) => argument === node)) {
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
const name = runnerName(parent.callee);
|
|
410
|
+
return name !== null && RUNNERS.has(name);
|
|
411
|
+
}
|
|
412
|
+
function isFunctionNode(node) {
|
|
413
|
+
return node.type === AST_NODE_TYPES4.ArrowFunctionExpression || node.type === AST_NODE_TYPES4.FunctionExpression || node.type === AST_NODE_TYPES4.FunctionDeclaration;
|
|
414
|
+
}
|
|
415
|
+
function isUnconditionalPart(node, child) {
|
|
416
|
+
switch (node.type) {
|
|
417
|
+
case AST_NODE_TYPES4.IfStatement:
|
|
418
|
+
case AST_NODE_TYPES4.ConditionalExpression:
|
|
419
|
+
return node.test === child;
|
|
420
|
+
case AST_NODE_TYPES4.LogicalExpression:
|
|
421
|
+
return node.left === child;
|
|
422
|
+
case AST_NODE_TYPES4.SwitchCase:
|
|
423
|
+
return node.test === child;
|
|
424
|
+
case AST_NODE_TYPES4.ForStatement:
|
|
425
|
+
return node.init === child;
|
|
426
|
+
case AST_NODE_TYPES4.ForInStatement:
|
|
427
|
+
case AST_NODE_TYPES4.ForOfStatement:
|
|
428
|
+
return node.right === child;
|
|
429
|
+
default:
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
var noConditionalExpectRule = createRule({
|
|
434
|
+
name: RULE_NAME4,
|
|
435
|
+
meta: {
|
|
436
|
+
type: "problem",
|
|
437
|
+
docs: {
|
|
438
|
+
description: "Disallow `expect()` inside a branch, `catch` or loop that may not run: a skipped assertion lets a broken test pass."
|
|
439
|
+
},
|
|
440
|
+
schema: [optionSchema3],
|
|
441
|
+
messages: {
|
|
442
|
+
conditionalExpect: "This `expect()` sits inside a branch that may not run, so the test can pass without asserting anything. Assert unconditionally, or add `expect.assertions(n)` to the test."
|
|
443
|
+
}
|
|
444
|
+
},
|
|
445
|
+
defaultOptions: [{ checkLoops: DEFAULT_CHECK_LOOPS }],
|
|
446
|
+
create(context, [options]) {
|
|
447
|
+
const checkLoops = options.checkLoops ?? DEFAULT_CHECK_LOOPS;
|
|
448
|
+
const pending = /* @__PURE__ */ new Map();
|
|
449
|
+
const guarded = /* @__PURE__ */ new Set();
|
|
450
|
+
function isConditional(type) {
|
|
451
|
+
return BRANCH_TYPES.has(type) || checkLoops && LOOP_TYPES.has(type);
|
|
452
|
+
}
|
|
453
|
+
function scan(node) {
|
|
454
|
+
let conditional = false;
|
|
455
|
+
let child = node;
|
|
456
|
+
let current = node.parent;
|
|
457
|
+
while (current !== void 0 && current !== null) {
|
|
458
|
+
if (isFunctionNode(current) && isRunnerCallback(current)) {
|
|
459
|
+
return { boundary: current, conditional };
|
|
460
|
+
}
|
|
461
|
+
if (isConditional(current.type) && !isUnconditionalPart(current, child)) {
|
|
462
|
+
conditional = true;
|
|
463
|
+
}
|
|
464
|
+
child = current;
|
|
465
|
+
current = current.parent;
|
|
466
|
+
}
|
|
467
|
+
return { boundary: null, conditional };
|
|
468
|
+
}
|
|
469
|
+
return {
|
|
470
|
+
CallExpression(node) {
|
|
471
|
+
if (isAssertionCountGuard(node)) {
|
|
472
|
+
guarded.add(scan(node).boundary);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
if (!isExpectCall(node)) {
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const { boundary, conditional } = scan(node);
|
|
479
|
+
if (!conditional) {
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const list = pending.get(boundary) ?? [];
|
|
483
|
+
list.push(node);
|
|
484
|
+
pending.set(boundary, list);
|
|
485
|
+
},
|
|
486
|
+
"Program:exit"() {
|
|
487
|
+
for (const [boundary, calls] of pending) {
|
|
488
|
+
if (guarded.has(boundary)) {
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
for (const call of calls) {
|
|
492
|
+
context.report({ node: call, messageId: "conditionalExpect" });
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
|
|
183
500
|
// src/rules/no-focused-tests.ts
|
|
184
|
-
import { AST_NODE_TYPES as
|
|
185
|
-
var
|
|
501
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES5 } from "@typescript-eslint/utils";
|
|
502
|
+
var RULE_NAME5 = "no-focused-tests";
|
|
186
503
|
var FOCUSABLE_RUNNERS = /* @__PURE__ */ new Set(["it", "describe", "test"]);
|
|
187
504
|
var FOCUSED_CALL_NAMES = /* @__PURE__ */ new Set(["fdescribe", "fit", "ddescribe"]);
|
|
188
505
|
function rootRunnerName(node) {
|
|
189
506
|
let current = node;
|
|
190
|
-
while (current.type ===
|
|
507
|
+
while (current.type === AST_NODE_TYPES5.MemberExpression && !current.computed) {
|
|
191
508
|
current = current.object;
|
|
192
509
|
}
|
|
193
|
-
return current.type ===
|
|
510
|
+
return current.type === AST_NODE_TYPES5.Identifier ? current.name : null;
|
|
194
511
|
}
|
|
195
512
|
var noFocusedTestsRule = createRule({
|
|
196
|
-
name:
|
|
513
|
+
name: RULE_NAME5,
|
|
197
514
|
meta: {
|
|
198
515
|
type: "problem",
|
|
199
516
|
docs: {
|
|
@@ -209,7 +526,7 @@ var noFocusedTestsRule = createRule({
|
|
|
209
526
|
create(context) {
|
|
210
527
|
return {
|
|
211
528
|
MemberExpression(node) {
|
|
212
|
-
if (node.property.type !==
|
|
529
|
+
if (node.property.type !== AST_NODE_TYPES5.Identifier || node.property.name !== "only") {
|
|
213
530
|
return;
|
|
214
531
|
}
|
|
215
532
|
const runner = rootRunnerName(node.object);
|
|
@@ -222,7 +539,7 @@ var noFocusedTestsRule = createRule({
|
|
|
222
539
|
}
|
|
223
540
|
},
|
|
224
541
|
CallExpression(node) {
|
|
225
|
-
if (node.callee.type ===
|
|
542
|
+
if (node.callee.type === AST_NODE_TYPES5.Identifier && FOCUSED_CALL_NAMES.has(node.callee.name)) {
|
|
226
543
|
const name = node.callee.name;
|
|
227
544
|
context.report({
|
|
228
545
|
node: node.callee,
|
|
@@ -247,7 +564,7 @@ function looksLikeJsDoc(comment) {
|
|
|
247
564
|
}
|
|
248
565
|
|
|
249
566
|
// src/rules/no-historical-comments.ts
|
|
250
|
-
var
|
|
567
|
+
var RULE_NAME6 = "no-historical-comments";
|
|
251
568
|
var HISTORICAL_PATTERNS = [
|
|
252
569
|
/\bbefore\s+the\s+fix\b/iu,
|
|
253
570
|
/\bafter\s+the\s+fix\b/iu,
|
|
@@ -262,7 +579,7 @@ var HISTORICAL_PATTERNS = [
|
|
|
262
579
|
/\bhistorical(?:ly)?\b/iu
|
|
263
580
|
];
|
|
264
581
|
var noHistoricalCommentsRule = createRule({
|
|
265
|
-
name:
|
|
582
|
+
name: RULE_NAME6,
|
|
266
583
|
meta: {
|
|
267
584
|
type: "suggestion",
|
|
268
585
|
docs: {
|
|
@@ -305,7 +622,7 @@ var noHistoricalCommentsRule = createRule({
|
|
|
305
622
|
});
|
|
306
623
|
|
|
307
624
|
// src/rules/no-narration-comments.ts
|
|
308
|
-
var
|
|
625
|
+
var RULE_NAME7 = "no-narration-comments";
|
|
309
626
|
var NARRATION_PATTERNS = [
|
|
310
627
|
/^here\s+we\b/iu,
|
|
311
628
|
/^now\s+we\b/iu,
|
|
@@ -317,7 +634,7 @@ var NARRATION_PATTERNS = [
|
|
|
317
634
|
/^let\s+me\b/iu
|
|
318
635
|
];
|
|
319
636
|
var noNarrationCommentsRule = createRule({
|
|
320
|
-
name:
|
|
637
|
+
name: RULE_NAME7,
|
|
321
638
|
meta: {
|
|
322
639
|
type: "suggestion",
|
|
323
640
|
docs: {
|
|
@@ -358,7 +675,7 @@ var noNarrationCommentsRule = createRule({
|
|
|
358
675
|
});
|
|
359
676
|
|
|
360
677
|
// src/rules/no-pr-reference-comments.ts
|
|
361
|
-
var
|
|
678
|
+
var RULE_NAME8 = "no-pr-reference-comments";
|
|
362
679
|
var PR_PATTERNS = [
|
|
363
680
|
{
|
|
364
681
|
pattern: /https?:\/\/github\.com\/[^\s)]+\/(?:pull|issues)\/\d+/iu,
|
|
@@ -378,7 +695,7 @@ var PR_PATTERNS = [
|
|
|
378
695
|
}
|
|
379
696
|
];
|
|
380
697
|
var noPrReferenceCommentsRule = createRule({
|
|
381
|
-
name:
|
|
698
|
+
name: RULE_NAME8,
|
|
382
699
|
meta: {
|
|
383
700
|
type: "suggestion",
|
|
384
701
|
docs: {
|
|
@@ -422,29 +739,29 @@ var noPrReferenceCommentsRule = createRule({
|
|
|
422
739
|
import "@typescript-eslint/utils";
|
|
423
740
|
|
|
424
741
|
// src/utils/ast.ts
|
|
425
|
-
import { AST_NODE_TYPES as
|
|
742
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES6 } from "@typescript-eslint/utils";
|
|
426
743
|
function isEmptyStringLiteral(node) {
|
|
427
|
-
return node.type ===
|
|
744
|
+
return node.type === AST_NODE_TYPES6.Literal && node.value === "";
|
|
428
745
|
}
|
|
429
746
|
function isStaticMemberAccess(node, objectName, propertyName) {
|
|
430
|
-
if (node.type !==
|
|
747
|
+
if (node.type !== AST_NODE_TYPES6.MemberExpression || node.object.type !== AST_NODE_TYPES6.Identifier || node.object.name !== objectName) {
|
|
431
748
|
return false;
|
|
432
749
|
}
|
|
433
750
|
if (node.computed) {
|
|
434
|
-
return node.property.type ===
|
|
751
|
+
return node.property.type === AST_NODE_TYPES6.Literal && node.property.value === propertyName;
|
|
435
752
|
}
|
|
436
|
-
return node.property.type ===
|
|
753
|
+
return node.property.type === AST_NODE_TYPES6.Identifier && node.property.name === propertyName;
|
|
437
754
|
}
|
|
438
755
|
|
|
439
756
|
// src/rules/no-process-exit.ts
|
|
440
|
-
var
|
|
757
|
+
var RULE_NAME9 = "no-process-exit";
|
|
441
758
|
var DEFAULT_ALLOW_IN2 = [
|
|
442
759
|
"**/scripts/**",
|
|
443
760
|
"**/bin/**",
|
|
444
761
|
"**/cli/**",
|
|
445
762
|
"**/*.config.{ts,js,mjs,cjs,cts,mts}"
|
|
446
763
|
];
|
|
447
|
-
var
|
|
764
|
+
var optionSchema4 = {
|
|
448
765
|
type: "object",
|
|
449
766
|
additionalProperties: false,
|
|
450
767
|
properties: {
|
|
@@ -459,13 +776,13 @@ function isProcessExit(node) {
|
|
|
459
776
|
return isStaticMemberAccess(node.callee, "process", "exit");
|
|
460
777
|
}
|
|
461
778
|
var noProcessExitRule = createRule({
|
|
462
|
-
name:
|
|
779
|
+
name: RULE_NAME9,
|
|
463
780
|
meta: {
|
|
464
781
|
type: "problem",
|
|
465
782
|
docs: {
|
|
466
783
|
description: "Disallow `process.exit()` outside bootstrap/shutdown paths and standalone CLIs. Application and service code must throw or reject so the lifecycle can shut down gracefully."
|
|
467
784
|
},
|
|
468
|
-
schema: [
|
|
785
|
+
schema: [optionSchema4],
|
|
469
786
|
messages: {
|
|
470
787
|
processExit: "`process.exit()` is reserved for bootstrap/shutdown and CLI entrypoints. Throw or reject and let the lifecycle handle teardown."
|
|
471
788
|
}
|
|
@@ -486,14 +803,207 @@ var noProcessExitRule = createRule({
|
|
|
486
803
|
}
|
|
487
804
|
});
|
|
488
805
|
|
|
806
|
+
// src/rules/no-real-network-in-unit-tests.ts
|
|
807
|
+
import path2 from "path";
|
|
808
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES7 } from "@typescript-eslint/utils";
|
|
809
|
+
var RULE_NAME10 = "no-real-network-in-unit-tests";
|
|
810
|
+
var DEFAULT_TEST_FILE_SUFFIXES = [
|
|
811
|
+
".test.ts",
|
|
812
|
+
".test.tsx",
|
|
813
|
+
".spec.ts",
|
|
814
|
+
".spec.tsx",
|
|
815
|
+
".test.js",
|
|
816
|
+
".test.jsx",
|
|
817
|
+
".spec.js",
|
|
818
|
+
".spec.jsx"
|
|
819
|
+
];
|
|
820
|
+
var DEFAULT_INTEGRATION_MARKERS = [
|
|
821
|
+
".integration.test.",
|
|
822
|
+
".integration.spec.",
|
|
823
|
+
".e2e.test.",
|
|
824
|
+
".e2e.spec.",
|
|
825
|
+
".e2e-spec.",
|
|
826
|
+
"/integration/",
|
|
827
|
+
"/e2e/"
|
|
828
|
+
];
|
|
829
|
+
var DEFAULT_NETWORK_CALLEES = ["fetch"];
|
|
830
|
+
var DEFAULT_HTTP_CLIENTS = ["axios"];
|
|
831
|
+
var HTTP_CLIENT_METHODS = /* @__PURE__ */ new Set([
|
|
832
|
+
"get",
|
|
833
|
+
"post",
|
|
834
|
+
"put",
|
|
835
|
+
"patch",
|
|
836
|
+
"delete",
|
|
837
|
+
"head",
|
|
838
|
+
"options",
|
|
839
|
+
"request"
|
|
840
|
+
]);
|
|
841
|
+
var GLOBAL_RECEIVERS = /* @__PURE__ */ new Set(["globalThis", "window", "self", "global"]);
|
|
842
|
+
var MODULE_MOCKERS = /* @__PURE__ */ new Set(["mock", "doMock", "unstable_mockModule"]);
|
|
843
|
+
var GLOBAL_STUBBERS = /* @__PURE__ */ new Set(["stubGlobal", "spyOn", "replaceProperty"]);
|
|
844
|
+
var stringList = {
|
|
845
|
+
type: "array",
|
|
846
|
+
items: { type: "string", minLength: 1 },
|
|
847
|
+
uniqueItems: true
|
|
848
|
+
};
|
|
849
|
+
var optionSchema5 = {
|
|
850
|
+
type: "object",
|
|
851
|
+
additionalProperties: false,
|
|
852
|
+
properties: {
|
|
853
|
+
testFileSuffixes: stringList,
|
|
854
|
+
integrationMarkers: stringList,
|
|
855
|
+
networkCallees: stringList,
|
|
856
|
+
httpClients: stringList
|
|
857
|
+
}
|
|
858
|
+
};
|
|
859
|
+
function toPosixRelative(filename, cwd) {
|
|
860
|
+
const relative = path2.isAbsolute(filename) ? path2.relative(cwd, filename) : filename;
|
|
861
|
+
return `/${relative.split(path2.sep).join("/")}`;
|
|
862
|
+
}
|
|
863
|
+
function staticPropertyName(member) {
|
|
864
|
+
if (!member.computed && member.property.type === AST_NODE_TYPES7.Identifier) {
|
|
865
|
+
return member.property.name;
|
|
866
|
+
}
|
|
867
|
+
if (member.property.type === AST_NODE_TYPES7.Literal && typeof member.property.value === "string") {
|
|
868
|
+
return member.property.value;
|
|
869
|
+
}
|
|
870
|
+
return null;
|
|
871
|
+
}
|
|
872
|
+
function stringArgument(node, index) {
|
|
873
|
+
const argument = node.arguments[index];
|
|
874
|
+
return argument?.type === AST_NODE_TYPES7.Literal && typeof argument.value === "string" ? argument.value : null;
|
|
875
|
+
}
|
|
876
|
+
var noRealNetworkInUnitTestsRule = createRule({
|
|
877
|
+
name: RULE_NAME10,
|
|
878
|
+
meta: {
|
|
879
|
+
type: "problem",
|
|
880
|
+
docs: {
|
|
881
|
+
description: "Unit tests must not perform real network I/O: mock the HTTP client, or move the test to an integration suite."
|
|
882
|
+
},
|
|
883
|
+
schema: [optionSchema5],
|
|
884
|
+
messages: {
|
|
885
|
+
realNetworkInUnitTest: "Real network call `{{callee}}` in a unit test. Mock it, or move this test to an integration file."
|
|
886
|
+
}
|
|
887
|
+
},
|
|
888
|
+
defaultOptions: [
|
|
889
|
+
{
|
|
890
|
+
testFileSuffixes: [...DEFAULT_TEST_FILE_SUFFIXES],
|
|
891
|
+
integrationMarkers: [...DEFAULT_INTEGRATION_MARKERS],
|
|
892
|
+
networkCallees: [...DEFAULT_NETWORK_CALLEES],
|
|
893
|
+
httpClients: [...DEFAULT_HTTP_CLIENTS]
|
|
894
|
+
}
|
|
895
|
+
],
|
|
896
|
+
create(context, [options]) {
|
|
897
|
+
const testSuffixes = options.testFileSuffixes ?? DEFAULT_TEST_FILE_SUFFIXES;
|
|
898
|
+
const integrationMarkers = options.integrationMarkers ?? DEFAULT_INTEGRATION_MARKERS;
|
|
899
|
+
const networkCallees = new Set(options.networkCallees ?? DEFAULT_NETWORK_CALLEES);
|
|
900
|
+
const httpClients = new Set(options.httpClients ?? DEFAULT_HTTP_CLIENTS);
|
|
901
|
+
const relPath = toPosixRelative(context.filename, context.cwd);
|
|
902
|
+
if (!testSuffixes.some((suffix) => relPath.endsWith(suffix))) {
|
|
903
|
+
return {};
|
|
904
|
+
}
|
|
905
|
+
if (integrationMarkers.some((marker) => relPath.includes(marker))) {
|
|
906
|
+
return {};
|
|
907
|
+
}
|
|
908
|
+
const findings = [];
|
|
909
|
+
const mocked = /* @__PURE__ */ new Set();
|
|
910
|
+
const importSources = /* @__PURE__ */ new Map();
|
|
911
|
+
function isLocalDouble(node, name) {
|
|
912
|
+
let scope = context.sourceCode.getScope(node);
|
|
913
|
+
while (scope !== null) {
|
|
914
|
+
const variable = scope.set.get(name);
|
|
915
|
+
if (variable !== void 0 && variable.defs.length > 0) {
|
|
916
|
+
return variable.defs.every((def) => def.type !== "ImportBinding");
|
|
917
|
+
}
|
|
918
|
+
scope = scope.upper;
|
|
919
|
+
}
|
|
920
|
+
return false;
|
|
921
|
+
}
|
|
922
|
+
function recordMock(node) {
|
|
923
|
+
const callee = node.callee;
|
|
924
|
+
if (callee.type !== AST_NODE_TYPES7.MemberExpression) {
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
const method = staticPropertyName(callee);
|
|
928
|
+
if (method !== null && MODULE_MOCKERS.has(method)) {
|
|
929
|
+
const moduleName = stringArgument(node, 0);
|
|
930
|
+
if (moduleName !== null) {
|
|
931
|
+
mocked.add(moduleName);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
if (method !== null && GLOBAL_STUBBERS.has(method)) {
|
|
935
|
+
const name = method === "stubGlobal" ? stringArgument(node, 0) : stringArgument(node, 1);
|
|
936
|
+
if (name !== null) {
|
|
937
|
+
mocked.add(name);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
function networkCallee(node) {
|
|
942
|
+
const callee = node.callee;
|
|
943
|
+
if (callee.type === AST_NODE_TYPES7.Identifier) {
|
|
944
|
+
const isNetworkName = networkCallees.has(callee.name) || httpClients.has(callee.name);
|
|
945
|
+
return isNetworkName && !isLocalDouble(callee, callee.name) ? callee.name : null;
|
|
946
|
+
}
|
|
947
|
+
if (callee.type !== AST_NODE_TYPES7.MemberExpression) {
|
|
948
|
+
return null;
|
|
949
|
+
}
|
|
950
|
+
const method = staticPropertyName(callee);
|
|
951
|
+
if (method === null || callee.object.type !== AST_NODE_TYPES7.Identifier) {
|
|
952
|
+
return null;
|
|
953
|
+
}
|
|
954
|
+
const receiver = callee.object.name;
|
|
955
|
+
if (GLOBAL_RECEIVERS.has(receiver) && networkCallees.has(method)) {
|
|
956
|
+
return method;
|
|
957
|
+
}
|
|
958
|
+
if (httpClients.has(receiver) && HTTP_CLIENT_METHODS.has(method) && !isLocalDouble(callee.object, receiver)) {
|
|
959
|
+
return `${receiver}.${method}`;
|
|
960
|
+
}
|
|
961
|
+
return null;
|
|
962
|
+
}
|
|
963
|
+
return {
|
|
964
|
+
ImportDeclaration(node) {
|
|
965
|
+
for (const specifier of node.specifiers) {
|
|
966
|
+
importSources.set(specifier.local.name, node.source.value);
|
|
967
|
+
}
|
|
968
|
+
},
|
|
969
|
+
CallExpression(node) {
|
|
970
|
+
recordMock(node);
|
|
971
|
+
const callee = networkCallee(node);
|
|
972
|
+
if (callee !== null) {
|
|
973
|
+
findings.push({ node, callee });
|
|
974
|
+
}
|
|
975
|
+
},
|
|
976
|
+
AssignmentExpression(node) {
|
|
977
|
+
const target = node.left;
|
|
978
|
+
if (target.type === AST_NODE_TYPES7.MemberExpression && target.object.type === AST_NODE_TYPES7.Identifier && GLOBAL_RECEIVERS.has(target.object.name)) {
|
|
979
|
+
const name = staticPropertyName(target);
|
|
980
|
+
if (name !== null) {
|
|
981
|
+
mocked.add(name);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
},
|
|
985
|
+
"Program:exit"() {
|
|
986
|
+
for (const { node, callee } of findings) {
|
|
987
|
+
const root = callee.split(".")[0] ?? callee;
|
|
988
|
+
const source = importSources.get(root);
|
|
989
|
+
if (mocked.has(root) || source !== void 0 && mocked.has(source)) {
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
context.report({ node, messageId: "realNetworkInUnitTest", data: { callee } });
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
});
|
|
998
|
+
|
|
489
999
|
// src/rules/no-template-trim-empty-ternary.ts
|
|
490
|
-
import { AST_NODE_TYPES as
|
|
491
|
-
var
|
|
1000
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES8 } from "@typescript-eslint/utils";
|
|
1001
|
+
var RULE_NAME11 = "no-template-trim-empty-ternary";
|
|
492
1002
|
function isTrimCallOnTemplate(node) {
|
|
493
|
-
return node.type ===
|
|
1003
|
+
return node.type === AST_NODE_TYPES8.CallExpression && node.callee.type === AST_NODE_TYPES8.MemberExpression && node.callee.property.type === AST_NODE_TYPES8.Identifier && node.callee.property.name === "trim" && node.callee.object.type === AST_NODE_TYPES8.TemplateLiteral;
|
|
494
1004
|
}
|
|
495
1005
|
function matchesTemplateTrimEmptyTest(test) {
|
|
496
|
-
if (test.type !==
|
|
1006
|
+
if (test.type !== AST_NODE_TYPES8.BinaryExpression) {
|
|
497
1007
|
return false;
|
|
498
1008
|
}
|
|
499
1009
|
if (test.operator !== "===" && test.operator !== "!==") {
|
|
@@ -502,7 +1012,7 @@ function matchesTemplateTrimEmptyTest(test) {
|
|
|
502
1012
|
return isTrimCallOnTemplate(test.left) && isEmptyStringLiteral(test.right) || isEmptyStringLiteral(test.left) && isTrimCallOnTemplate(test.right);
|
|
503
1013
|
}
|
|
504
1014
|
var noTemplateTrimEmptyTernaryRule = createRule({
|
|
505
|
-
name:
|
|
1015
|
+
name: RULE_NAME11,
|
|
506
1016
|
meta: {
|
|
507
1017
|
type: "suggestion",
|
|
508
1018
|
docs: {
|
|
@@ -526,10 +1036,10 @@ var noTemplateTrimEmptyTernaryRule = createRule({
|
|
|
526
1036
|
});
|
|
527
1037
|
|
|
528
1038
|
// src/utils/preferEarlyReturn.ts
|
|
529
|
-
import { AST_NODE_TYPES as
|
|
1039
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES9 } from "@typescript-eslint/utils";
|
|
530
1040
|
var MIN_CONSEQUENT_STATEMENTS = 2;
|
|
531
1041
|
function getFunctionBlockBody(node) {
|
|
532
|
-
if (node.body.type !==
|
|
1042
|
+
if (node.body.type !== AST_NODE_TYPES9.BlockStatement) {
|
|
533
1043
|
return null;
|
|
534
1044
|
}
|
|
535
1045
|
return node.body;
|
|
@@ -539,13 +1049,13 @@ function findWrappedHappyPathIf(body) {
|
|
|
539
1049
|
return null;
|
|
540
1050
|
}
|
|
541
1051
|
const lastStatement = body.body[body.body.length - 1];
|
|
542
|
-
if (lastStatement === void 0 || lastStatement.type !==
|
|
1052
|
+
if (lastStatement === void 0 || lastStatement.type !== AST_NODE_TYPES9.IfStatement) {
|
|
543
1053
|
return null;
|
|
544
1054
|
}
|
|
545
1055
|
if (lastStatement.alternate !== null) {
|
|
546
1056
|
return null;
|
|
547
1057
|
}
|
|
548
|
-
if (lastStatement.consequent.type !==
|
|
1058
|
+
if (lastStatement.consequent.type !== AST_NODE_TYPES9.BlockStatement) {
|
|
549
1059
|
return null;
|
|
550
1060
|
}
|
|
551
1061
|
if (lastStatement.consequent.body.length < MIN_CONSEQUENT_STATEMENTS) {
|
|
@@ -555,9 +1065,9 @@ function findWrappedHappyPathIf(body) {
|
|
|
555
1065
|
}
|
|
556
1066
|
|
|
557
1067
|
// src/rules/prefer-early-return.ts
|
|
558
|
-
var
|
|
1068
|
+
var RULE_NAME12 = "prefer-early-return";
|
|
559
1069
|
var preferEarlyReturnRule = createRule({
|
|
560
|
-
name:
|
|
1070
|
+
name: RULE_NAME12,
|
|
561
1071
|
meta: {
|
|
562
1072
|
type: "problem",
|
|
563
1073
|
docs: {
|
|
@@ -588,8 +1098,189 @@ var preferEarlyReturnRule = createRule({
|
|
|
588
1098
|
}
|
|
589
1099
|
});
|
|
590
1100
|
|
|
1101
|
+
// src/rules/no-vacuous-expect.ts
|
|
1102
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES10 } from "@typescript-eslint/utils";
|
|
1103
|
+
var RULE_NAME13 = "no-vacuous-expect";
|
|
1104
|
+
var DEFAULT_WEAK_MATCHERS = [
|
|
1105
|
+
"toBeDefined",
|
|
1106
|
+
"toBeTruthy",
|
|
1107
|
+
"toBeFalsy",
|
|
1108
|
+
"not.toBeUndefined"
|
|
1109
|
+
];
|
|
1110
|
+
var DEFAULT_ASSERTION_CALLEES = ["^assert", "^expect\\w", "\\.expect$"];
|
|
1111
|
+
var TYPEOF_RESULTS = /* @__PURE__ */ new Set([
|
|
1112
|
+
"undefined",
|
|
1113
|
+
"object",
|
|
1114
|
+
"boolean",
|
|
1115
|
+
"number",
|
|
1116
|
+
"bigint",
|
|
1117
|
+
"string",
|
|
1118
|
+
"symbol",
|
|
1119
|
+
"function"
|
|
1120
|
+
]);
|
|
1121
|
+
var EQUALITY_MATCHERS = /* @__PURE__ */ new Set(["toBe", "toEqual", "toStrictEqual"]);
|
|
1122
|
+
var TEST_RUNNERS = /* @__PURE__ */ new Set(["it", "test"]);
|
|
1123
|
+
var optionSchema6 = {
|
|
1124
|
+
type: "object",
|
|
1125
|
+
additionalProperties: false,
|
|
1126
|
+
properties: {
|
|
1127
|
+
weakMatchers: { type: "array", items: { type: "string", minLength: 1 }, uniqueItems: true },
|
|
1128
|
+
assertionCallees: {
|
|
1129
|
+
type: "array",
|
|
1130
|
+
items: { type: "string", minLength: 1 },
|
|
1131
|
+
uniqueItems: true
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
};
|
|
1135
|
+
function isExpectCall2(node) {
|
|
1136
|
+
return node.type === AST_NODE_TYPES10.CallExpression && node.callee.type === AST_NODE_TYPES10.Identifier && node.callee.name === "expect";
|
|
1137
|
+
}
|
|
1138
|
+
function matcherCall(node) {
|
|
1139
|
+
const callee = node.callee;
|
|
1140
|
+
if (callee.type !== AST_NODE_TYPES10.MemberExpression || callee.computed || callee.property.type !== AST_NODE_TYPES10.Identifier) {
|
|
1141
|
+
return null;
|
|
1142
|
+
}
|
|
1143
|
+
let negated = false;
|
|
1144
|
+
let current = callee.object;
|
|
1145
|
+
while (current.type === AST_NODE_TYPES10.MemberExpression) {
|
|
1146
|
+
if (!current.computed && current.property.type === AST_NODE_TYPES10.Identifier && current.property.name === "not") {
|
|
1147
|
+
negated = !negated;
|
|
1148
|
+
}
|
|
1149
|
+
current = current.object;
|
|
1150
|
+
}
|
|
1151
|
+
if (!isExpectCall2(current)) {
|
|
1152
|
+
return null;
|
|
1153
|
+
}
|
|
1154
|
+
const name = callee.property.name;
|
|
1155
|
+
return { root: current, matcher: negated ? `not.${name}` : name };
|
|
1156
|
+
}
|
|
1157
|
+
function calleePath(callee) {
|
|
1158
|
+
if (callee.type === AST_NODE_TYPES10.Identifier) {
|
|
1159
|
+
return callee.name;
|
|
1160
|
+
}
|
|
1161
|
+
if (callee.type === AST_NODE_TYPES10.MemberExpression && !callee.computed && callee.property.type === AST_NODE_TYPES10.Identifier) {
|
|
1162
|
+
const owner = callee.object.type === AST_NODE_TYPES10.Identifier ? callee.object.name : "";
|
|
1163
|
+
return `${owner}.${callee.property.name}`;
|
|
1164
|
+
}
|
|
1165
|
+
return null;
|
|
1166
|
+
}
|
|
1167
|
+
function runnerName2(callee) {
|
|
1168
|
+
let current = callee;
|
|
1169
|
+
for (; ; ) {
|
|
1170
|
+
if (current.type === AST_NODE_TYPES10.MemberExpression) {
|
|
1171
|
+
current = current.object;
|
|
1172
|
+
} else if (current.type === AST_NODE_TYPES10.CallExpression) {
|
|
1173
|
+
current = current.callee;
|
|
1174
|
+
} else if (current.type === AST_NODE_TYPES10.TaggedTemplateExpression) {
|
|
1175
|
+
current = current.tag;
|
|
1176
|
+
} else {
|
|
1177
|
+
break;
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
return current.type === AST_NODE_TYPES10.Identifier ? current.name : null;
|
|
1181
|
+
}
|
|
1182
|
+
function isTestCallback(node) {
|
|
1183
|
+
const parent = node.parent;
|
|
1184
|
+
if (parent.type !== AST_NODE_TYPES10.CallExpression || parent.arguments[1] !== node) {
|
|
1185
|
+
return false;
|
|
1186
|
+
}
|
|
1187
|
+
const name = runnerName2(parent.callee);
|
|
1188
|
+
return name !== null && TEST_RUNNERS.has(name);
|
|
1189
|
+
}
|
|
1190
|
+
function isTypeofExpression(node) {
|
|
1191
|
+
return node?.type === AST_NODE_TYPES10.UnaryExpression && node.operator === "typeof";
|
|
1192
|
+
}
|
|
1193
|
+
function isTypeofResult(node) {
|
|
1194
|
+
return node?.type === AST_NODE_TYPES10.Literal && typeof node.value === "string" && TYPEOF_RESULTS.has(node.value);
|
|
1195
|
+
}
|
|
1196
|
+
function isLiteralTautology(actual, expected) {
|
|
1197
|
+
return actual?.type === AST_NODE_TYPES10.Literal && expected?.type === AST_NODE_TYPES10.Literal && !("regex" in actual) && actual.value === expected.value;
|
|
1198
|
+
}
|
|
1199
|
+
var noVacuousExpectRule = createRule({
|
|
1200
|
+
name: RULE_NAME13,
|
|
1201
|
+
meta: {
|
|
1202
|
+
type: "problem",
|
|
1203
|
+
docs: {
|
|
1204
|
+
description: "Disallow vacuous expects (`typeof` checks, literal tautologies, a sole `toBeDefined`/`toBeTruthy`): a test must assert behaviour that a real regression would break."
|
|
1205
|
+
},
|
|
1206
|
+
schema: [optionSchema6],
|
|
1207
|
+
messages: {
|
|
1208
|
+
typeofExpect: "Do not assert on `typeof`; that only proves a binding exists. Assert a result, a thrown error or an observable effect.",
|
|
1209
|
+
tautologyExpect: "This expect compares a literal with itself and can never fail. Assert something a real regression would break.",
|
|
1210
|
+
soleWeakExpect: "A sole `{{matcher}}` does not pin behaviour. Assert on the value or the outcome, or delete the test."
|
|
1211
|
+
}
|
|
1212
|
+
},
|
|
1213
|
+
defaultOptions: [
|
|
1214
|
+
{
|
|
1215
|
+
weakMatchers: [...DEFAULT_WEAK_MATCHERS],
|
|
1216
|
+
assertionCallees: [...DEFAULT_ASSERTION_CALLEES]
|
|
1217
|
+
}
|
|
1218
|
+
],
|
|
1219
|
+
create(context, [options]) {
|
|
1220
|
+
const weakMatchers = new Set(options.weakMatchers ?? DEFAULT_WEAK_MATCHERS);
|
|
1221
|
+
const assertionCallees = (options.assertionCallees ?? DEFAULT_ASSERTION_CALLEES).map(
|
|
1222
|
+
(source) => new RegExp(source, "u")
|
|
1223
|
+
);
|
|
1224
|
+
const stack = [];
|
|
1225
|
+
function enter(node) {
|
|
1226
|
+
if (isTestCallback(node)) {
|
|
1227
|
+
stack.push({ node, assertions: 0, weak: null });
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
function exit(node) {
|
|
1231
|
+
const frame = stack.at(-1);
|
|
1232
|
+
if (frame?.node !== node) {
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
stack.pop();
|
|
1236
|
+
if (frame.assertions === 1 && frame.weak !== null) {
|
|
1237
|
+
context.report({
|
|
1238
|
+
node: frame.weak.node,
|
|
1239
|
+
messageId: "soleWeakExpect",
|
|
1240
|
+
data: { matcher: frame.weak.matcher }
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
return {
|
|
1245
|
+
ArrowFunctionExpression: enter,
|
|
1246
|
+
FunctionExpression: enter,
|
|
1247
|
+
"ArrowFunctionExpression:exit": exit,
|
|
1248
|
+
"FunctionExpression:exit": exit,
|
|
1249
|
+
CallExpression(node) {
|
|
1250
|
+
const frame = stack.at(-1);
|
|
1251
|
+
const call = matcherCall(node);
|
|
1252
|
+
if (call === null) {
|
|
1253
|
+
const path3 = calleePath(node.callee);
|
|
1254
|
+
if (frame !== void 0 && path3 !== null && assertionCallees.some((re) => re.test(path3))) {
|
|
1255
|
+
frame.assertions += 1;
|
|
1256
|
+
}
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
if (frame !== void 0) {
|
|
1260
|
+
frame.assertions += 1;
|
|
1261
|
+
if (weakMatchers.has(call.matcher)) {
|
|
1262
|
+
frame.weak = { node, matcher: call.matcher };
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
const negated = call.matcher.startsWith("not.");
|
|
1266
|
+
const base = negated ? call.matcher.slice("not.".length) : call.matcher;
|
|
1267
|
+
if (!EQUALITY_MATCHERS.has(base)) {
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
const actual = call.root.arguments[0];
|
|
1271
|
+
const expected = node.arguments[0];
|
|
1272
|
+
if (isTypeofExpression(actual) && isTypeofResult(expected)) {
|
|
1273
|
+
context.report({ node, messageId: "typeofExpect" });
|
|
1274
|
+
} else if (!negated && isLiteralTautology(actual, expected)) {
|
|
1275
|
+
context.report({ node, messageId: "tautologyExpect" });
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
};
|
|
1279
|
+
}
|
|
1280
|
+
});
|
|
1281
|
+
|
|
591
1282
|
// src/rules/skipped-tests-need-tracking.ts
|
|
592
|
-
var
|
|
1283
|
+
var RULE_NAME14 = "skipped-tests-need-tracking";
|
|
593
1284
|
var SKIP_PATTERNS = [
|
|
594
1285
|
{ pattern: /\b(?:it|test|describe)\.skip\s*\(/u, label: ".skip(" },
|
|
595
1286
|
{ pattern: /\b(?:it|test|describe)\.fixme\s*\(/u, label: ".fixme(" },
|
|
@@ -599,7 +1290,7 @@ var SKIP_PATTERNS = [
|
|
|
599
1290
|
];
|
|
600
1291
|
var DEFAULT_MARKERS = ["https?://\\S+", "TODO\\(@?\\S+\\)"];
|
|
601
1292
|
var DEFAULT_LOOKBACK = 30;
|
|
602
|
-
var
|
|
1293
|
+
var optionSchema7 = {
|
|
603
1294
|
type: "object",
|
|
604
1295
|
additionalProperties: false,
|
|
605
1296
|
properties: {
|
|
@@ -613,13 +1304,13 @@ var optionSchema3 = {
|
|
|
613
1304
|
}
|
|
614
1305
|
};
|
|
615
1306
|
var skippedTestsNeedTrackingRule = createRule({
|
|
616
|
-
name:
|
|
1307
|
+
name: RULE_NAME14,
|
|
617
1308
|
meta: {
|
|
618
1309
|
type: "problem",
|
|
619
1310
|
docs: {
|
|
620
1311
|
description: "Skipped tests (`.skip` / `.fixme` / `xit` / `xdescribe`) must carry a tracking marker (an issue URL or `TODO(@owner)`) on or above the line, so the debt has an owner instead of rotting silently."
|
|
621
1312
|
},
|
|
622
|
-
schema: [
|
|
1313
|
+
schema: [optionSchema7],
|
|
623
1314
|
messages: {
|
|
624
1315
|
needsTracking: "Skipped test `{{label}}` has no tracking marker. Add an issue URL or `TODO(@owner)` on the same line or above so the skip has an owner."
|
|
625
1316
|
}
|
|
@@ -671,6 +1362,10 @@ var rules = {
|
|
|
671
1362
|
"no-pr-reference-comments": noPrReferenceCommentsRule,
|
|
672
1363
|
"no-focused-tests": noFocusedTestsRule,
|
|
673
1364
|
"skipped-tests-need-tracking": skippedTestsNeedTrackingRule,
|
|
1365
|
+
"no-vacuous-expect": noVacuousExpectRule,
|
|
1366
|
+
"no-conditional-expect": noConditionalExpectRule,
|
|
1367
|
+
"fake-timers-must-be-restored": fakeTimersMustBeRestoredRule,
|
|
1368
|
+
"no-real-network-in-unit-tests": noRealNetworkInUnitTestsRule,
|
|
674
1369
|
// Available but omitted from `recommended` (opinionated / niche).
|
|
675
1370
|
"interface-prefix-i": interfacePrefixIRule,
|
|
676
1371
|
"no-template-trim-empty-ternary": noTemplateTrimEmptyTernaryRule
|
|
@@ -678,7 +1373,7 @@ var rules = {
|
|
|
678
1373
|
|
|
679
1374
|
// src/index.ts
|
|
680
1375
|
var NAMESPACE = "noctcore-code-quality";
|
|
681
|
-
var VERSION = "0.
|
|
1376
|
+
var VERSION = "0.2.0";
|
|
682
1377
|
var plugin = {
|
|
683
1378
|
meta: { name: "@noctcore/eslint-plugin-code-quality", version: VERSION },
|
|
684
1379
|
rules,
|