falsegreen-js 0.2.0 → 0.4.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/CHANGELOG.md +134 -1
- package/README.md +131 -14
- package/dist/audit.d.ts +5 -0
- package/dist/audit.js +120 -0
- package/dist/cases.d.ts +61 -4
- package/dist/cases.js +138 -31
- package/dist/cfg.d.ts +32 -0
- package/dist/cfg.js +237 -0
- package/dist/cli.d.ts +38 -1
- package/dist/cli.js +210 -23
- package/dist/level.d.ts +10 -0
- package/dist/level.js +75 -0
- package/dist/oracles.d.ts +52 -0
- package/dist/oracles.js +87 -0
- package/dist/report.d.ts +31 -0
- package/dist/report.js +177 -0
- package/dist/rules.js +472 -68
- package/dist/scan.js +0 -0
- package/dist/types.d.ts +2 -1
- package/dist/types.js +3 -2
- package/package.json +34 -9
package/dist/rules.js
CHANGED
|
@@ -1,30 +1,31 @@
|
|
|
1
1
|
import ts from "typescript";
|
|
2
2
|
import { makeFinding } from "./types.js";
|
|
3
3
|
import { DIAGNOSTIC_THRESHOLDS } from "./cases.js";
|
|
4
|
+
import { ASSERT_ROOTS, ASSERT_METHODS, SNAPSHOT_MATCHERS, EQUALITY_MATCHERS, VUE_SVELTE_ASYNC, oracleKind, } from "./oracles.js";
|
|
4
5
|
import { lineOf } from "./parse.js";
|
|
6
|
+
import { assertionsInDeadCode, hasUnconditionalAssertion } from "./cfg.js";
|
|
5
7
|
// --- test framework vocabulary (runner-agnostic) ---------------------------
|
|
6
8
|
// it/test/specify (Jest, Vitest, Mocha, Jasmine, AVA, node:test, Cypress,
|
|
7
9
|
// Playwright, tap). describe/context/suite are suites. fit/fdescribe focus and
|
|
8
|
-
// xit/xdescribe skip come from Jasmine/Mocha.
|
|
10
|
+
// xit/xdescribe skip come from Jasmine/Mocha. The assertion-API vocabulary
|
|
11
|
+
// (ASSERT_ROOTS/ASSERT_METHODS/SNAPSHOT_MATCHERS/EQUALITY_MATCHERS and the async
|
|
12
|
+
// leaves) lives in the oracle registry (oracles.ts), imported above. JS5 routes
|
|
13
|
+
// its async detection through oracleKind() instead of a hand-rolled name list.
|
|
9
14
|
const TEST_BLOCK_ROOTS = new Set(["it", "test", "specify"]);
|
|
10
15
|
const SUITE_ROOTS = new Set(["describe", "context", "suite", "fdescribe", "xdescribe", "fcontext", "xcontext"]);
|
|
11
16
|
const FOCUS_NAMES = new Set(["fit", "fdescribe", "fcontext"]);
|
|
12
17
|
const SKIP_NAMES = new Set(["xit", "xdescribe", "xcontext", "xspecify"]);
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
//
|
|
17
|
-
const
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"same", "notSame", "throws", "notThrows", "throwsAsync", "regex", "notRegex",
|
|
21
|
-
"pass", "fail", "assert", "expect", "include", "match",
|
|
18
|
+
// --- C48 dark-patch: a test that flips a known test-mode flag then asserts ---
|
|
19
|
+
// env keys (process.env.<KEY> = <test-mode value>) whose name means "we are under
|
|
20
|
+
// test". NODE_ENV only counts when set to "test" (production/development are real
|
|
21
|
+
// configs); CI is excluded (infra, not a product branch).
|
|
22
|
+
const ENV_TEST_MODE_KEYS = new Set([
|
|
23
|
+
"NODE_ENV", "JEST_WORKER_ID", "VITEST", "TEST", "TESTING", "TEST_MODE",
|
|
24
|
+
"TESTMODE", "UNDER_TEST", "IS_TEST",
|
|
22
25
|
]);
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
]);
|
|
27
|
-
const EQUALITY_MATCHERS = new Set(["toBe", "toEqual", "toStrictEqual"]);
|
|
26
|
+
// module/settings flag names (settings.TESTING = true, config.TEST_MODE = true).
|
|
27
|
+
const MODULE_TEST_MODE_RE = /^(TESTING|TEST_MODE|IS_TEST|UNDER_TEST|_TESTING)$/;
|
|
28
|
+
const TEST_MODE_TRUE_STRINGS = new Set(["1", "true", "test", "yes", "on"]);
|
|
28
29
|
// --- name helpers ----------------------------------------------------------
|
|
29
30
|
function calleeName(expr) {
|
|
30
31
|
if (ts.isIdentifier(expr))
|
|
@@ -39,6 +40,44 @@ function calleeName(expr) {
|
|
|
39
40
|
return calleeName(expr.expression);
|
|
40
41
|
return "";
|
|
41
42
|
}
|
|
43
|
+
/** Leftmost identifier of a property/call/element chain: `a.b().c` -> "a". */
|
|
44
|
+
function rootIdent(e) {
|
|
45
|
+
let cur = e;
|
|
46
|
+
while (cur) {
|
|
47
|
+
if (ts.isIdentifier(cur))
|
|
48
|
+
return cur.text;
|
|
49
|
+
if (ts.isPropertyAccessExpression(cur) || ts.isCallExpression(cur) || ts.isElementAccessExpression(cur)) {
|
|
50
|
+
cur = cur.expression;
|
|
51
|
+
}
|
|
52
|
+
else if (ts.isParenthesizedExpression(cur) || ts.isNonNullExpression(cur)) {
|
|
53
|
+
cur = cur.expression;
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
/** True if the expression chain bottoms out in an `expect(...)` call, walking
|
|
62
|
+
* through property access, calls, `.not`/`.resolves`/`.rejects`, and wrappers. */
|
|
63
|
+
function expectRooted(e) {
|
|
64
|
+
let cur = e;
|
|
65
|
+
while (cur) {
|
|
66
|
+
if (ts.isCallExpression(cur) && ts.isIdentifier(cur.expression) && cur.expression.text === "expect") {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
if (ts.isPropertyAccessExpression(cur) || ts.isCallExpression(cur) || ts.isElementAccessExpression(cur)) {
|
|
70
|
+
cur = cur.expression;
|
|
71
|
+
}
|
|
72
|
+
else if (ts.isParenthesizedExpression(cur) || ts.isNonNullExpression(cur)) {
|
|
73
|
+
cur = cur.expression;
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
42
81
|
function literalTruthiness(e) {
|
|
43
82
|
if (!e)
|
|
44
83
|
return null;
|
|
@@ -79,35 +118,6 @@ function isStringify(e) {
|
|
|
79
118
|
}
|
|
80
119
|
return false;
|
|
81
120
|
}
|
|
82
|
-
const CONDITIONAL_ANCESTORS = new Set([
|
|
83
|
-
ts.SyntaxKind.IfStatement, ts.SyntaxKind.ForStatement, ts.SyntaxKind.ForOfStatement,
|
|
84
|
-
ts.SyntaxKind.ForInStatement, ts.SyntaxKind.WhileStatement, ts.SyntaxKind.DoStatement,
|
|
85
|
-
ts.SyntaxKind.SwitchStatement, ts.SyntaxKind.CatchClause, ts.SyntaxKind.ConditionalExpression,
|
|
86
|
-
]);
|
|
87
|
-
/** True if the function has at least one assertion and every one of them sits under a
|
|
88
|
-
* conditional (if/for/while/switch/catch/?:) — so none runs unconditionally (C21). */
|
|
89
|
-
function assertionsAllConditional(fn) {
|
|
90
|
-
const asserts = [];
|
|
91
|
-
const walk = (n) => { if (isAssertionNode(n))
|
|
92
|
-
asserts.push(n); ts.forEachChild(n, walk); };
|
|
93
|
-
ts.forEachChild(fn, walk);
|
|
94
|
-
if (asserts.length === 0)
|
|
95
|
-
return false;
|
|
96
|
-
for (const a of asserts) {
|
|
97
|
-
let p = a.parent;
|
|
98
|
-
let conditional = false;
|
|
99
|
-
while (p && p !== fn) {
|
|
100
|
-
if (CONDITIONAL_ANCESTORS.has(p.kind)) {
|
|
101
|
-
conditional = true;
|
|
102
|
-
break;
|
|
103
|
-
}
|
|
104
|
-
p = p.parent;
|
|
105
|
-
}
|
|
106
|
-
if (!conditional)
|
|
107
|
-
return false;
|
|
108
|
-
}
|
|
109
|
-
return true;
|
|
110
|
-
}
|
|
111
121
|
function containsCall(node) {
|
|
112
122
|
let found = false;
|
|
113
123
|
const walk = (n) => {
|
|
@@ -141,6 +151,42 @@ function expectChain(call) {
|
|
|
141
151
|
}
|
|
142
152
|
return null;
|
|
143
153
|
}
|
|
154
|
+
/** True if a call's result is observed: awaited, returned, assigned, the
|
|
155
|
+
* implicit-return body of an arrow, or explicitly discarded with `void` (an
|
|
156
|
+
* author signalling "I am dropping this on purpose"). A bare floating call (its
|
|
157
|
+
* enclosing statement is a plain ExpressionStatement) is NOT observed. Used to
|
|
158
|
+
* gate supertest `.expect()` so a floating API request still surfaces as C2b,
|
|
159
|
+
* and JS5 so a dropped async query/event still surfaces. */
|
|
160
|
+
function isObservedAsync(node) {
|
|
161
|
+
let cur = node;
|
|
162
|
+
let p = node.parent;
|
|
163
|
+
while (p) {
|
|
164
|
+
if (ts.isAwaitExpression(p) || ts.isReturnStatement(p))
|
|
165
|
+
return true;
|
|
166
|
+
if (ts.isVariableDeclaration(p))
|
|
167
|
+
return true;
|
|
168
|
+
// A BinaryExpression only observes the call when it is a real assignment
|
|
169
|
+
// (`=` or a compound `+=` etc., kinds in FirstAssignment..LastAssignment) and
|
|
170
|
+
// the call is the right-hand side. A logical/comparison/arithmetic operator
|
|
171
|
+
// (`||`, `&&`, `===`, `+`, ...) does NOT observe it: `findBy*() || expect(...)`
|
|
172
|
+
// still floats the promise and must surface as JS5.
|
|
173
|
+
if (ts.isBinaryExpression(p)) {
|
|
174
|
+
const k = p.operatorToken.kind;
|
|
175
|
+
const isAssign = k >= ts.SyntaxKind.FirstAssignment && k <= ts.SyntaxKind.LastAssignment;
|
|
176
|
+
if (isAssign && p.right === cur)
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
if (ts.isVoidExpression(p))
|
|
180
|
+
return true; // `void expr` — discarded on purpose
|
|
181
|
+
if (ts.isArrowFunction(p) && p.body === cur)
|
|
182
|
+
return true;
|
|
183
|
+
if (ts.isExpressionStatement(p))
|
|
184
|
+
return false;
|
|
185
|
+
cur = p;
|
|
186
|
+
p = p.parent;
|
|
187
|
+
}
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
144
190
|
// --- assertion presence ----------------------------------------------------
|
|
145
191
|
function isAssertionNode(node) {
|
|
146
192
|
if (ts.isCallExpression(node)) {
|
|
@@ -151,6 +197,13 @@ function isAssertionNode(node) {
|
|
|
151
197
|
// expect(x).matcher(...) — Jest, Vitest, Jasmine, Playwright, jest-dom, chai expect
|
|
152
198
|
if (root === "expect" && ts.isPropertyAccessExpression(node.expression))
|
|
153
199
|
return true;
|
|
200
|
+
// <chain>.expect(...) — supertest / chai-http API tests: request(app).get("/").expect(200)
|
|
201
|
+
// is the assertion (it throws on mismatch), but only when the request is awaited or
|
|
202
|
+
// returned. A floating `request(app).get("/").expect(200);` can finish after the test
|
|
203
|
+
// ends, so it stays uncovered (C2b) instead of scanning clean.
|
|
204
|
+
if (ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "expect"
|
|
205
|
+
&& isObservedAsync(node))
|
|
206
|
+
return true;
|
|
154
207
|
if (root === "assert")
|
|
155
208
|
return true; // node:test, chai assert
|
|
156
209
|
if (root === "sinon" && name.includes("assert"))
|
|
@@ -192,6 +245,54 @@ function hasAssertion(scope) {
|
|
|
192
245
|
function containsAssertion(node) {
|
|
193
246
|
return isAssertionNode(node) || hasAssertion(node);
|
|
194
247
|
}
|
|
248
|
+
/** Visit descendants of `node` without entering nested function scopes (a helper
|
|
249
|
+
* def/arrow/method in the test body is its own scope, not the test's). */
|
|
250
|
+
function forEachNoNesting(node, visit) {
|
|
251
|
+
ts.forEachChild(node, (child) => {
|
|
252
|
+
visit(child);
|
|
253
|
+
if (!ts.isFunctionLike(child))
|
|
254
|
+
forEachNoNesting(child, visit);
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
/** The env key of a `process.env.KEY = ...` / `process.env["KEY"] = ...` target, else null. */
|
|
258
|
+
function envAssignKey(lhs) {
|
|
259
|
+
const isProcessEnv = (e) => ts.isPropertyAccessExpression(e) && e.name.text === "env" &&
|
|
260
|
+
ts.isIdentifier(e.expression) && e.expression.text === "process";
|
|
261
|
+
if (ts.isPropertyAccessExpression(lhs) && isProcessEnv(lhs.expression))
|
|
262
|
+
return lhs.name.text;
|
|
263
|
+
if (ts.isElementAccessExpression(lhs) && isProcessEnv(lhs.expression) &&
|
|
264
|
+
ts.isStringLiteralLike(lhs.argumentExpression))
|
|
265
|
+
return lhs.argumentExpression.text;
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
/** A value that puts a test-mode flag into test mode. NODE_ENV only counts as "test";
|
|
269
|
+
* every other key takes true/1/"1"/"true"/"test"/"yes"/"on". */
|
|
270
|
+
function isTestModeValue(rhs, key) {
|
|
271
|
+
if (key === "NODE_ENV")
|
|
272
|
+
return ts.isStringLiteralLike(rhs) && rhs.text === "test";
|
|
273
|
+
if (rhs.kind === ts.SyntaxKind.TrueKeyword)
|
|
274
|
+
return true;
|
|
275
|
+
if (ts.isNumericLiteral(rhs))
|
|
276
|
+
return rhs.text === "1";
|
|
277
|
+
if (ts.isStringLiteralLike(rhs))
|
|
278
|
+
return TEST_MODE_TRUE_STRINGS.has(rhs.text.trim().toLowerCase());
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
/** True if `bin` is a raw write that flips a known test-mode toggle into test mode:
|
|
282
|
+
* process.env.<KEY> = <test value>, or <obj>.TESTING = <truthy> (obj not `this`). */
|
|
283
|
+
function isTestModeToggleWrite(bin) {
|
|
284
|
+
if (bin.operatorToken.kind !== ts.SyntaxKind.EqualsToken)
|
|
285
|
+
return false;
|
|
286
|
+
const lhs = bin.left;
|
|
287
|
+
const envKey = envAssignKey(lhs);
|
|
288
|
+
if (envKey && ENV_TEST_MODE_KEYS.has(envKey) && isTestModeValue(bin.right, envKey))
|
|
289
|
+
return true;
|
|
290
|
+
if (ts.isPropertyAccessExpression(lhs) && MODULE_TEST_MODE_RE.test(lhs.name.text) &&
|
|
291
|
+
lhs.expression.kind !== ts.SyntaxKind.ThisKeyword &&
|
|
292
|
+
isTestModeValue(bin.right, lhs.name.text))
|
|
293
|
+
return true;
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
195
296
|
/** True if a catch block does nothing meaningful: empty, or only console.* /
|
|
196
297
|
* comments, with no throw, no assertion, and no fail() — so it swallows errors. */
|
|
197
298
|
function isHarmlessCatch(block) {
|
|
@@ -234,25 +335,137 @@ function getTestCallback(call) {
|
|
|
234
335
|
}
|
|
235
336
|
return null;
|
|
236
337
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
338
|
+
/** The function body that encloses `node` for timer-flush scoping: the nearest
|
|
339
|
+
* ancestor arrow/function that is the callback of an it/test/specify call (so a
|
|
340
|
+
* flush in one test never reaches a timer in another), falling back to the
|
|
341
|
+
* nearest enclosing function, then the whole source file. */
|
|
342
|
+
function enclosingTestScope(node) {
|
|
343
|
+
let fallback = null;
|
|
344
|
+
let p = node.parent;
|
|
345
|
+
while (p) {
|
|
346
|
+
if (ts.isArrowFunction(p) || ts.isFunctionExpression(p) || ts.isFunctionDeclaration(p)) {
|
|
347
|
+
if (!fallback)
|
|
348
|
+
fallback = p;
|
|
349
|
+
const call = p.parent;
|
|
350
|
+
if (call && ts.isCallExpression(call) && call.arguments.includes(p)) {
|
|
351
|
+
const root = calleeName(call.expression).split(".")[0];
|
|
352
|
+
if (TEST_BLOCK_ROOTS.has(root) || root === "fit" || root === "xit")
|
|
353
|
+
return p;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
p = p.parent;
|
|
357
|
+
}
|
|
358
|
+
return fallback ?? node.getSourceFile();
|
|
359
|
+
}
|
|
360
|
+
const FAKE_TIMER_INSTALL = /\b(useFakeTimers|installFakeTimers)\b/;
|
|
361
|
+
const FAKE_TIMER_FLUSH = /\b(runAllTimers|runOnlyPendingTimers|advanceTimersByTime|tick)\b/;
|
|
362
|
+
const SETUP_HOOKS = new Set(["beforeEach", "beforeAll"]);
|
|
363
|
+
const TEARDOWN_HOOKS = new Set(["afterEach", "afterAll"]);
|
|
364
|
+
/** True if any call under `scope` matches `re`. */
|
|
365
|
+
function callMatchesUnder(scope, sf, re) {
|
|
366
|
+
let found = false;
|
|
367
|
+
const walk = (n) => {
|
|
368
|
+
if (found)
|
|
369
|
+
return;
|
|
370
|
+
if (ts.isCallExpression(n) && re.test(calleeName(n.expression))) {
|
|
371
|
+
found = true;
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
ts.forEachChild(n, walk);
|
|
375
|
+
};
|
|
376
|
+
walk(scope);
|
|
377
|
+
return found;
|
|
378
|
+
}
|
|
379
|
+
/** Scan one statement list for a lifecycle hook that drives the timer: a
|
|
380
|
+
* fake-timer install in a beforeEach/beforeAll runs before every test in scope,
|
|
381
|
+
* a flush/advance in an afterEach/afterAll runs after. Order inside the hook does
|
|
382
|
+
* not matter — the runner sequences the hook around the test body. */
|
|
383
|
+
function hookStatementsControlTimer(statements, sf) {
|
|
384
|
+
for (const stmt of statements) {
|
|
385
|
+
if (!ts.isExpressionStatement(stmt) || !ts.isCallExpression(stmt.expression))
|
|
386
|
+
continue;
|
|
387
|
+
const hook = calleeName(stmt.expression.expression).split(".")[0];
|
|
388
|
+
const cb = getTestCallback(stmt.expression);
|
|
389
|
+
if (!cb)
|
|
390
|
+
continue;
|
|
391
|
+
if (SETUP_HOOKS.has(hook) && callMatchesUnder(cb, sf, FAKE_TIMER_INSTALL))
|
|
392
|
+
return true;
|
|
393
|
+
if (TEARDOWN_HOOKS.has(hook) && callMatchesUnder(cb, sf, FAKE_TIMER_FLUSH))
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
/** A timer can be driven from a sibling lifecycle hook rather than the test body.
|
|
399
|
+
* Top-level hooks (outside any describe) wrap every test in the file, and hooks
|
|
400
|
+
* in any enclosing describe/suite wrap every test nested under it. Check both. */
|
|
401
|
+
function hookControlsTimer(timer, sf) {
|
|
402
|
+
// Top-level hooks live directly in the source file and apply to every test.
|
|
403
|
+
if (hookStatementsControlTimer(sf.statements, sf))
|
|
244
404
|
return true;
|
|
245
|
-
|
|
405
|
+
let p = timer.parent;
|
|
406
|
+
while (p) {
|
|
407
|
+
// The body of an enclosing describe/suite callback: scan its top-level hook calls.
|
|
408
|
+
if ((ts.isArrowFunction(p) || ts.isFunctionExpression(p) || ts.isFunctionDeclaration(p)) &&
|
|
409
|
+
p.parent && ts.isCallExpression(p.parent) &&
|
|
410
|
+
p.parent.arguments.includes(p)) {
|
|
411
|
+
const suiteRoot = calleeName(p.parent.expression).split(".")[0];
|
|
412
|
+
if (SUITE_ROOTS.has(suiteRoot) && p.body && ts.isBlock(p.body)) {
|
|
413
|
+
if (hookStatementsControlTimer(p.body.statements, sf))
|
|
414
|
+
return true;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
p = p.parent;
|
|
418
|
+
}
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
/** Precise replacement for the old file-wide fake-timer suppression. A
|
|
422
|
+
* setTimeout/setInterval is "controlled" when, inside the same enclosing test
|
|
423
|
+
* callback, either a fake-timer install runs BEFORE it or a flush/advance call
|
|
424
|
+
* runs AFTER it; OR when an enclosing describe drives the timer through a sibling
|
|
425
|
+
* hook (install in beforeEach/beforeAll, flush in afterEach/afterAll). A flush
|
|
426
|
+
* before the arm (callback never ran) or a flush in a different test does not
|
|
427
|
+
* count. */
|
|
428
|
+
function timerIsControlled(timer, sf) {
|
|
429
|
+
const scope = enclosingTestScope(timer);
|
|
430
|
+
const armPos = timer.getStart(sf);
|
|
431
|
+
let controlled = false;
|
|
432
|
+
const walk = (n) => {
|
|
433
|
+
if (controlled)
|
|
434
|
+
return;
|
|
435
|
+
if (ts.isCallExpression(n)) {
|
|
436
|
+
const name = calleeName(n.expression);
|
|
437
|
+
const pos = n.getStart(sf);
|
|
438
|
+
if (FAKE_TIMER_INSTALL.test(name) && pos < armPos) {
|
|
439
|
+
controlled = true;
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
if (FAKE_TIMER_FLUSH.test(name) && pos > armPos) {
|
|
443
|
+
controlled = true;
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
ts.forEachChild(n, walk);
|
|
448
|
+
};
|
|
449
|
+
walk(scope);
|
|
450
|
+
if (controlled)
|
|
246
451
|
return true;
|
|
247
|
-
return
|
|
452
|
+
return hookControlsTimer(timer, sf);
|
|
248
453
|
}
|
|
249
454
|
// --- C16: nondeterminism ----------------------------------------------------
|
|
250
455
|
function c16Detail(call) {
|
|
251
456
|
const name = calleeName(call.expression);
|
|
457
|
+
const leaf = name.split(".").pop() ?? "";
|
|
252
458
|
if (name === "Math.random")
|
|
253
459
|
return "Math.random() without a fixed seed";
|
|
254
460
|
if (name === "Date.now" || name === "performance.now")
|
|
255
461
|
return "reads the system clock";
|
|
462
|
+
// crypto.randomUUID() / crypto.getRandomValues() (incl. globalThis/window/self.crypto and
|
|
463
|
+
// the bare node:crypto import) produce a fresh random value each run with no seed. Anchored
|
|
464
|
+
// to a crypto root so a user method named randomUUID()/getRandomValues() is NOT flagged.
|
|
465
|
+
const isCryptoRandom = (m) => name === "crypto." + m || name.endsWith(".crypto." + m) || name === m;
|
|
466
|
+
if (isCryptoRandom("randomUUID") || isCryptoRandom("getRandomValues")) {
|
|
467
|
+
return "crypto randomness without a seed";
|
|
468
|
+
}
|
|
256
469
|
if (name === "setTimeout" || name === "setInterval") {
|
|
257
470
|
if (call.arguments.length >= 2 && ts.isNumericLiteral(call.arguments[1])) {
|
|
258
471
|
return "fixed timer delay";
|
|
@@ -267,14 +480,39 @@ export function analyze(sf) {
|
|
|
267
480
|
const findings = [];
|
|
268
481
|
const file = sf.fileName;
|
|
269
482
|
const text = sf.getFullText();
|
|
270
|
-
|
|
483
|
+
// Fake-timer / flush presence suppresses the JS7 timer arm: if the test fakes
|
|
484
|
+
// or drives timers anywhere, a setTimeout/setInterval callback is flushed
|
|
485
|
+
// synchronously and its assertion does run. Covers Jest, Vitest and Sinon
|
|
486
|
+
// install calls plus the explicit advance/run calls (jest/vi runAllTimers,
|
|
487
|
+
// runOnlyPendingTimers, advanceTimersByTime, sinon clock.tick).
|
|
488
|
+
const fakeTimers = /\b(useFakeTimers|installFakeTimers|runAllTimers|runOnlyPendingTimers|advanceTimersByTime|tick)\b/.test(text);
|
|
271
489
|
const push = (line, code, detail = "") => {
|
|
272
490
|
findings.push(makeFinding(file, line, code, detail));
|
|
273
491
|
};
|
|
492
|
+
// JS8 (self-mock) file-level state
|
|
493
|
+
const mockedAt = new Map(); // module -> line of jest.mock/vi.mock
|
|
494
|
+
const importBinding = new Map(); // binding name -> module
|
|
495
|
+
const expectRoots = new Set(); // root identifier used as expect subject
|
|
274
496
|
const visit = (node) => {
|
|
497
|
+
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
498
|
+
const mod = node.moduleSpecifier.text;
|
|
499
|
+
const clause = node.importClause;
|
|
500
|
+
if (clause?.name)
|
|
501
|
+
importBinding.set(clause.name.text, mod);
|
|
502
|
+
const nb = clause?.namedBindings;
|
|
503
|
+
if (nb && ts.isNamespaceImport(nb))
|
|
504
|
+
importBinding.set(nb.name.text, mod);
|
|
505
|
+
if (nb && ts.isNamedImports(nb))
|
|
506
|
+
for (const el of nb.elements)
|
|
507
|
+
importBinding.set(el.name.text, mod);
|
|
508
|
+
}
|
|
275
509
|
if (ts.isCallExpression(node)) {
|
|
276
510
|
const name = calleeName(node.expression);
|
|
277
511
|
const root = name.split(".")[0];
|
|
512
|
+
if ((name === "jest.mock" || name === "vi.mock" || name === "jest.doMock" || name === "vi.doMock") &&
|
|
513
|
+
node.arguments[0] && ts.isStringLiteral(node.arguments[0])) {
|
|
514
|
+
mockedAt.set(node.arguments[0].text, lineOf(sf, node));
|
|
515
|
+
}
|
|
278
516
|
const modifier = name.split(".")[1] ?? "";
|
|
279
517
|
// JS1 focused / JS4 skipped
|
|
280
518
|
if (name.endsWith(".only") ||
|
|
@@ -290,13 +528,47 @@ export function analyze(sf) {
|
|
|
290
528
|
// test-level block body checks (C2, C2b, JS3). Only `it`/`test`/`specify`
|
|
291
529
|
// (and the focus/skip variants) — never `describe`/`suite`, whose body holds
|
|
292
530
|
// nested tests, not assertions.
|
|
293
|
-
const isTestBlock = TEST_BLOCK_ROOTS.has(root) || root === "fit" || root === "xit"
|
|
294
|
-
((TEST_BLOCK_ROOTS.has(root)) && (modifier === "only" || modifier === "skip" || modifier === "each"));
|
|
531
|
+
const isTestBlock = TEST_BLOCK_ROOTS.has(root) || root === "fit" || root === "xit";
|
|
295
532
|
if (isTestBlock) {
|
|
296
533
|
const cb = getTestCallback(node);
|
|
534
|
+
// JS18: the test takes a `done` callback instead of async/await. A done
|
|
535
|
+
// called too early, or inside a floating promise, lets the test pass
|
|
536
|
+
// before the assertions run.
|
|
537
|
+
if (cb && cb.parameters.length > 0) {
|
|
538
|
+
const p0 = cb.parameters[0].name;
|
|
539
|
+
if (ts.isIdentifier(p0) && p0.text === "done") {
|
|
540
|
+
push(lineOf(sf, node), "JS18", "uses a done callback; prefer async/await");
|
|
541
|
+
}
|
|
542
|
+
}
|
|
297
543
|
if (cb && cb.body && ts.isBlock(cb.body)) {
|
|
298
544
|
const stmts = cb.body.statements;
|
|
299
545
|
const line = lineOf(sf, node);
|
|
546
|
+
// C20: an assertion at a position control can never reach (after a
|
|
547
|
+
// return/throw/process.exit/break, both-arms-terminating if, exhaustive
|
|
548
|
+
// switch). Structured reachability over the whole body, not just the top
|
|
549
|
+
// level; stops at nested functions (their returns are their own).
|
|
550
|
+
const deadAsserts = assertionsInDeadCode(cb.body, isAssertionNode);
|
|
551
|
+
for (const a of deadAsserts) {
|
|
552
|
+
push(lineOf(sf, a), "C20", "assertion in unreachable code (after a return/throw/exit) never runs");
|
|
553
|
+
}
|
|
554
|
+
const deadAssertSet = new Set(deadAsserts);
|
|
555
|
+
// C48: dark patch — the test flips a known test-mode flag (process.env or a
|
|
556
|
+
// module/settings flag) into test mode and then asserts, exercising the
|
|
557
|
+
// product's test-only branch instead of real behaviour. v1: raw writes only.
|
|
558
|
+
const toggles = [];
|
|
559
|
+
const assertPositions = [];
|
|
560
|
+
forEachNoNesting(cb.body, (n) => {
|
|
561
|
+
if (ts.isBinaryExpression(n) && isTestModeToggleWrite(n)) {
|
|
562
|
+
toggles.push({ pos: n.getStart(sf), line: lineOf(sf, n) });
|
|
563
|
+
}
|
|
564
|
+
if (isAssertionNode(n))
|
|
565
|
+
assertPositions.push(n.getStart(sf));
|
|
566
|
+
});
|
|
567
|
+
for (const w of toggles) {
|
|
568
|
+
if (assertPositions.some((ap) => ap > w.pos)) {
|
|
569
|
+
push(w.line, "C48", "test sets a test-mode flag then asserts — drive real behaviour, not the test-only branch");
|
|
570
|
+
}
|
|
571
|
+
}
|
|
300
572
|
if (stmts.length === 0) {
|
|
301
573
|
push(line, "C2", "test body is empty");
|
|
302
574
|
}
|
|
@@ -308,7 +580,19 @@ export function analyze(sf) {
|
|
|
308
580
|
if (ms.length > 0 && ms.every((m) => SNAPSHOT_MATCHERS.has(m))) {
|
|
309
581
|
push(line, "JS3", "the only assertion is a snapshot");
|
|
310
582
|
}
|
|
311
|
-
|
|
583
|
+
// C21: the test has at least one assertion in its own scope and none of
|
|
584
|
+
// them is guaranteed to run unconditionally (all behind a condition, a
|
|
585
|
+
// loop, a switch, or a catch). Assertions that live only inside a nested
|
|
586
|
+
// callback are not counted here (unmodeled execution → suppress, FP-averse).
|
|
587
|
+
// Assertions already flagged C20 (dead code) are excluded: C20 owns them, so
|
|
588
|
+
// a dead-code-only test reports C20 alone, not a contradictory C20 + C21 (#62).
|
|
589
|
+
const ownAsserts = [];
|
|
590
|
+
forEachNoNesting(cb.body, (n) => {
|
|
591
|
+
if (isAssertionNode(n) && !deadAssertSet.has(n))
|
|
592
|
+
ownAsserts.push(n);
|
|
593
|
+
});
|
|
594
|
+
if (ownAsserts.length > 0 &&
|
|
595
|
+
!hasUnconditionalAssertion(cb.body, isAssertionNode, literalTruthiness, deadAssertSet)) {
|
|
312
596
|
push(line, "C21", "every assertion is guarded by a condition");
|
|
313
597
|
}
|
|
314
598
|
}
|
|
@@ -352,7 +636,7 @@ export function analyze(sf) {
|
|
|
352
636
|
}
|
|
353
637
|
}
|
|
354
638
|
// JS6: empty describe/suite block
|
|
355
|
-
if (SUITE_ROOTS.has(root)
|
|
639
|
+
if (SUITE_ROOTS.has(root)) {
|
|
356
640
|
const cb = getTestCallback(node);
|
|
357
641
|
if (cb && cb.body && ts.isBlock(cb.body) && cb.body.statements.length === 0) {
|
|
358
642
|
push(lineOf(sf, node), "JS6", "suite body is empty");
|
|
@@ -370,6 +654,11 @@ export function analyze(sf) {
|
|
|
370
654
|
}
|
|
371
655
|
// expect-chain matchers (C5, C7, C8)
|
|
372
656
|
const chain = expectChain(node);
|
|
657
|
+
if (chain && chain.subject) {
|
|
658
|
+
const r = rootIdent(chain.subject);
|
|
659
|
+
if (r)
|
|
660
|
+
expectRoots.add(r);
|
|
661
|
+
}
|
|
373
662
|
if (chain && !chain.negated) {
|
|
374
663
|
const subj = chain.subject;
|
|
375
664
|
const arg = chain.args[0];
|
|
@@ -394,6 +683,21 @@ export function analyze(sf) {
|
|
|
394
683
|
if (subjIsComparison && boolMatcher) {
|
|
395
684
|
push(lineOf(sf, node), "JS15", "comparison wrapped in a boolean; assert the values directly");
|
|
396
685
|
}
|
|
686
|
+
// C44 numeric tautology: `expect(x.length).toBeGreaterThanOrEqual(0)`.
|
|
687
|
+
// A `.length` is never negative and never NaN, so `>= 0` holds for every
|
|
688
|
+
// input and verifies nothing — the JS/TS mirror of the Python `len(x) >= 0`.
|
|
689
|
+
// The subject must be a DIRECT property access ending in `.length`: a derived
|
|
690
|
+
// expression that merely mentions `.length` (e.g. `a.length - b.length`) can
|
|
691
|
+
// be negative, so it is a real check and is not flagged. Bounds that can still
|
|
692
|
+
// fail (`>= 1`, `> 0`) are not tautologies either. Finiteness/NaN guards
|
|
693
|
+
// (`toBeLessThan(Infinity)`, `toBeGreaterThan(-Infinity)`) are intentionally
|
|
694
|
+
// NOT flagged: they are false for NaN (and `Infinity`), so they catch
|
|
695
|
+
// divide-by-zero and invalid-number bugs.
|
|
696
|
+
if (chain.matcher === "toBeGreaterThanOrEqual" &&
|
|
697
|
+
arg && ts.isNumericLiteral(arg) && Number(arg.text) === 0 &&
|
|
698
|
+
subj && ts.isPropertyAccessExpression(subj) && subj.name.text === "length") {
|
|
699
|
+
push(lineOf(sf, node), "C44", "length is never negative; this comparison is always true");
|
|
700
|
+
}
|
|
397
701
|
// D8 (diagnostic, opt-in): a magic integer literal as the expected value.
|
|
398
702
|
// Floats are C8's concern; D8 covers bare integers abs > 1.
|
|
399
703
|
if ((chain.matcher === "toBe" || chain.matcher === "toEqual" || chain.matcher === "toStrictEqual") &&
|
|
@@ -403,6 +707,18 @@ export function analyze(sf) {
|
|
|
403
707
|
push(lineOf(sf, node), "D8", `magic number ${arg.text} in the assertion`);
|
|
404
708
|
}
|
|
405
709
|
}
|
|
710
|
+
// C6 weak check: truthiness/defined-only, or length > 0, on a real (non-literal) value
|
|
711
|
+
if (subj && !isLiteral(subj) && !subjIsComparison) {
|
|
712
|
+
if (chain.matcher === "toBeTruthy" || chain.matcher === "toBeFalsy" || chain.matcher === "toBeDefined") {
|
|
713
|
+
push(lineOf(sf, node), "C6", "only checks the value is present, not the expected result");
|
|
714
|
+
}
|
|
715
|
+
else if (arg && ts.isNumericLiteral(arg) &&
|
|
716
|
+
((chain.matcher === "toBeGreaterThan" && Number(arg.text) === 0) ||
|
|
717
|
+
(chain.matcher === "toBeGreaterThanOrEqual" && Number(arg.text) === 1)) &&
|
|
718
|
+
/\.length\b/.test(subj.getText(sf))) {
|
|
719
|
+
push(lineOf(sf, node), "C6", "only checks it is not empty");
|
|
720
|
+
}
|
|
721
|
+
}
|
|
406
722
|
// C5 always-true
|
|
407
723
|
if (chain.matcher === "toBeTruthy" && literalTruthiness(subj) === true) {
|
|
408
724
|
push(lineOf(sf, node), "C5", "toBeTruthy on a constant truthy literal");
|
|
@@ -439,11 +755,45 @@ export function analyze(sf) {
|
|
|
439
755
|
if (detail)
|
|
440
756
|
push(lineOf(sf, node), "C16", detail);
|
|
441
757
|
}
|
|
442
|
-
//
|
|
443
|
-
|
|
444
|
-
|
|
758
|
+
// C23 mystery guest: real file at a literal path, or a hard-coded URL
|
|
759
|
+
{
|
|
760
|
+
const leaf = name.split(".").pop() ?? "";
|
|
761
|
+
const a0 = node.arguments[0];
|
|
762
|
+
const lit = a0 && ts.isStringLiteral(a0) ? a0.text : null;
|
|
763
|
+
if (lit && /^(readFileSync|readFile|openSync|createReadStream)$/.test(leaf) && /[\\/]/.test(lit)) {
|
|
764
|
+
push(lineOf(sf, node), "C23", "reads a real file at a literal path");
|
|
765
|
+
}
|
|
766
|
+
else if (lit && (leaf === "fetch" || name === "fetch" || leaf === "get") && /^https?:\/\//i.test(lit)) {
|
|
767
|
+
push(lineOf(sf, node), "C23", "hard-coded URL (mystery guest)");
|
|
768
|
+
}
|
|
445
769
|
}
|
|
446
|
-
//
|
|
770
|
+
// JS5: async query/event whose settled state is dropped. Detection runs
|
|
771
|
+
// through the oracle registry: a `promise` or `value-only` call (Testing
|
|
772
|
+
// Library findBy*/waitFor*, user-event, Vue/Svelte flushPromises/nextTick/
|
|
773
|
+
// tick) that is not awaited, returned, assigned, or `void`-discarded leaves
|
|
774
|
+
// the following assertion reading a stale moment. The Vue/Svelte helpers are
|
|
775
|
+
// only their promise form when called with no callback argument; nextTick(cb)
|
|
776
|
+
// is the callback form and settles on its own.
|
|
777
|
+
{
|
|
778
|
+
const kind = oracleKind(name);
|
|
779
|
+
const leaf = name.split(".").pop() ?? "";
|
|
780
|
+
const isVueSvelte = VUE_SVELTE_ASYNC.has(leaf);
|
|
781
|
+
const promiseForm = !isVueSvelte || node.arguments.length === 0;
|
|
782
|
+
if ((kind === "promise" || kind === "value-only") && promiseForm && !isObservedAsync(node)) {
|
|
783
|
+
push(lineOf(sf, node), "JS5", isVueSvelte ? `${leaf}() is not awaited` : `${name} is not awaited`);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
// JS7: a deferred assertion. One code, two mechanisms tagged in `detail`:
|
|
787
|
+
// timer arm — assertion in a setTimeout/setInterval callback that is
|
|
788
|
+
// never flushed. Suppression is precise (timerIsControlled):
|
|
789
|
+
// scoped to the enclosing it/test callback and order-aware —
|
|
790
|
+
// a fake-timer install BEFORE the arm, or a flush/advance
|
|
791
|
+
// AFTER it, in the same callback. A flush before the arm
|
|
792
|
+
// (callback never ran) or a flush in a different test does
|
|
793
|
+
// not count, so it still runs after the test reports green.
|
|
794
|
+
// promise arm — assertion in a floating .then/.catch/.finally (the call
|
|
795
|
+
// is a bare statement, not awaited/returned/chained), so it
|
|
796
|
+
// may not run before the test ends.
|
|
447
797
|
{
|
|
448
798
|
const leaf = name.split(".").pop() ?? "";
|
|
449
799
|
const isTimer = name === "setTimeout" || name === "setInterval";
|
|
@@ -452,24 +802,44 @@ export function analyze(sf) {
|
|
|
452
802
|
const cb = node.arguments.find((a) => ts.isArrowFunction(a) || ts.isFunctionExpression(a));
|
|
453
803
|
if (cb && containsAssertion(cb)) {
|
|
454
804
|
const awaitedOrChained = !ts.isExpressionStatement(node.parent);
|
|
455
|
-
if (isTimer && !
|
|
456
|
-
push(lineOf(sf, node), "JS7", `assertion
|
|
805
|
+
if (isTimer && !timerIsControlled(node, sf)) {
|
|
806
|
+
push(lineOf(sf, node), "JS7", `assertion deferred into ${name}; runs after the test ends`);
|
|
457
807
|
}
|
|
458
808
|
else if (isThen && !awaitedOrChained) {
|
|
459
|
-
push(lineOf(sf, node), "JS7", `assertion
|
|
809
|
+
push(lineOf(sf, node), "JS7", `assertion deferred into a floating .${leaf}(); may not run before the test ends`);
|
|
460
810
|
}
|
|
461
811
|
}
|
|
462
812
|
}
|
|
463
813
|
}
|
|
464
|
-
// JS13: a sync
|
|
814
|
+
// JS13: a sync query used as a loose statement (result never asserted).
|
|
815
|
+
// Testing Library getBy*/queryBy*, and Vue Test Utils findComponent /
|
|
816
|
+
// findAllComponents always, or find/findAll with a string selector (which
|
|
817
|
+
// distinguishes wrapper.find('.btn') from Array.prototype.find(fn)).
|
|
465
818
|
if (ts.isExpressionStatement(node.parent)) {
|
|
466
819
|
const qleaf = name.split(".").pop() ?? "";
|
|
467
|
-
|
|
820
|
+
const isRtlQuery = /^(getBy|getAllBy|queryBy|queryAllBy)/.test(qleaf);
|
|
821
|
+
const isVueComponentQuery = qleaf === "findComponent" || qleaf === "findAllComponents";
|
|
822
|
+
const isVueSelectorQuery = (qleaf === "find" || qleaf === "findAll") &&
|
|
823
|
+
node.arguments.length > 0 && ts.isStringLiteral(node.arguments[0]);
|
|
824
|
+
if (isRtlQuery || isVueComponentQuery || isVueSelectorQuery) {
|
|
468
825
|
push(lineOf(sf, node), "JS13", `${qleaf}() result is not asserted`);
|
|
469
826
|
}
|
|
470
827
|
}
|
|
828
|
+
// A runner `.each` table: it.each / test.each / describe.each (and fit/xit
|
|
829
|
+
// variants). Gate the each-table codes on a runner root so a plain helper
|
|
830
|
+
// like `_.each([], fn)` or `lodash.each([])` is never mistaken for a test table.
|
|
831
|
+
const eachRoot = name.split(".")[0];
|
|
832
|
+
const isRunnerEach = name.endsWith(".each") &&
|
|
833
|
+
(TEST_BLOCK_ROOTS.has(eachRoot) || SUITE_ROOTS.has(eachRoot) ||
|
|
834
|
+
eachRoot === "fit" || eachRoot === "xit");
|
|
835
|
+
// JS22: empty it.each/test.each table — zero cases are generated, so the
|
|
836
|
+
// test is collected but never runs and the suite stays green.
|
|
837
|
+
if (isRunnerEach && node.arguments.length > 0 &&
|
|
838
|
+
ts.isArrayLiteralExpression(node.arguments[0]) && node.arguments[0].elements.length === 0) {
|
|
839
|
+
push(lineOf(sf, node), "JS22", "empty .each table — the test runs zero times");
|
|
840
|
+
}
|
|
471
841
|
// C37: duplicate case in it.each/test.each table
|
|
472
|
-
if (
|
|
842
|
+
if (isRunnerEach && node.arguments.length > 0 && ts.isArrayLiteralExpression(node.arguments[0])) {
|
|
473
843
|
const seen = new Set();
|
|
474
844
|
for (const el of node.arguments[0].elements) {
|
|
475
845
|
const t = el.getText(sf).replace(/\s+/g, " ").trim();
|
|
@@ -508,18 +878,52 @@ export function analyze(sf) {
|
|
|
508
878
|
push(lineOf(sf, node), "JS11", "a failing assertion in try is swallowed by catch");
|
|
509
879
|
}
|
|
510
880
|
}
|
|
881
|
+
// JS21: a matcher referenced but never called — `expect(x).toBe;` with no (),
|
|
882
|
+
// so the assertion object is built and dropped; nothing executes. The chain
|
|
883
|
+
// must be a bare statement (a call would make node.parent a CallExpression).
|
|
884
|
+
if (ts.isPropertyAccessExpression(node) &&
|
|
885
|
+
node.name.text.startsWith("to") &&
|
|
886
|
+
ts.isExpressionStatement(node.parent) &&
|
|
887
|
+
expectRooted(node.expression)) {
|
|
888
|
+
push(lineOf(sf, node), "JS21", `${node.name.text} is referenced but never called`);
|
|
889
|
+
}
|
|
890
|
+
// C16: `new Date()` with no argument reads the system clock (nondeterministic).
|
|
891
|
+
// `new Date(literal)` / `new Date(expr)` constructs a fixed instant, so it stays
|
|
892
|
+
// clean; the file-wide fake-timer suppression applies here too.
|
|
893
|
+
if (!fakeTimers &&
|
|
894
|
+
ts.isNewExpression(node) &&
|
|
895
|
+
ts.isIdentifier(node.expression) &&
|
|
896
|
+
node.expression.text === "Date" &&
|
|
897
|
+
(node.arguments === undefined || node.arguments.length === 0)) {
|
|
898
|
+
push(lineOf(sf, node), "C16", "new Date() reads the system clock");
|
|
899
|
+
}
|
|
511
900
|
ts.forEachChild(node, visit);
|
|
512
901
|
};
|
|
513
902
|
visit(sf);
|
|
903
|
+
// JS8: a mocked module's imported binding is asserted directly -> testing the mock,
|
|
904
|
+
// not the real unit. Conservative: same module both mocked and imported, and that
|
|
905
|
+
// import used as an expect subject.
|
|
906
|
+
for (const [binding, mod] of importBinding) {
|
|
907
|
+
if (mockedAt.has(mod) && expectRoots.has(binding)) {
|
|
908
|
+
findings.push(makeFinding(file, mockedAt.get(mod), "JS8", `${binding} (from ${mod}) is mocked and asserted directly`));
|
|
909
|
+
break;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
514
912
|
// CC: commented-out assertion (text scan over single-line comments)
|
|
515
913
|
// CC: a single-line `//` comment that is a commented-out assertion call.
|
|
516
914
|
// Requires the call paren (expect(/assert(/assert.x() or a .should chain) so it
|
|
517
915
|
// does not match JSDoc prose like ` * assert that ...`.
|
|
518
916
|
const lines = text.split(/\r?\n/);
|
|
519
917
|
const ccRe = /^\s*\/\/\s*(?:await\s+)?(?:expect\s*\(|assert(?:\.\w+)?\s*\(|[\w.]+\.should\b)/;
|
|
918
|
+
// JS17: a commented-out test block (// it('...', / // test(...) / // describe(...)),
|
|
919
|
+
// optionally with .skip/.only/.each. A disabled test that no longer runs and no
|
|
920
|
+
// longer shows up as skipped.
|
|
921
|
+
const js17Re = /^\s*\/\/\s*(?:it|test|describe|context|specify)(?:\.\w+)?\s*\(/;
|
|
520
922
|
lines.forEach((ln, i) => {
|
|
521
923
|
if (ccRe.test(ln))
|
|
522
924
|
push(i + 1, "CC", "assertion is commented out");
|
|
925
|
+
else if (js17Re.test(ln))
|
|
926
|
+
push(i + 1, "JS17", "test block is commented out");
|
|
523
927
|
});
|
|
524
928
|
return findings;
|
|
525
929
|
}
|