falsegreen-js 0.3.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/dist/rules.js CHANGED
@@ -1,32 +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
- // Roots whose `<root>.<method>()` call counts as an assertion across runners:
14
- // AVA (t), node:test/tap (t), Cypress (cy), chai assert, sinon.assert.
15
- const ASSERT_ROOTS = new Set(["assert", "t", "cy", "tap", "qunit", "sinon", "chai", "should"]);
16
- // Assertion method names used by AVA / tap / node:test / chai assert / QUnit.
17
- const ASSERT_METHODS = new Set([
18
- "is", "not", "ok", "notOk", "true", "false", "truthy", "falsy",
19
- "equal", "notEqual", "deepEqual", "notDeepEqual", "strictEqual",
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
- const SNAPSHOT_MATCHERS = new Set([
24
- "toMatchSnapshot", "toMatchInlineSnapshot",
25
- "toThrowErrorMatchingSnapshot", "toThrowErrorMatchingInlineSnapshot",
26
- // visual snapshots (Playwright): the baseline is generated from the output too
27
- "toHaveScreenshot", "toMatchScreenshot",
28
- ]);
29
- 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"]);
30
29
  // --- name helpers ----------------------------------------------------------
31
30
  function calleeName(expr) {
32
31
  if (ts.isIdentifier(expr))
@@ -119,35 +118,6 @@ function isStringify(e) {
119
118
  }
120
119
  return false;
121
120
  }
122
- const CONDITIONAL_ANCESTORS = new Set([
123
- ts.SyntaxKind.IfStatement, ts.SyntaxKind.ForStatement, ts.SyntaxKind.ForOfStatement,
124
- ts.SyntaxKind.ForInStatement, ts.SyntaxKind.WhileStatement, ts.SyntaxKind.DoStatement,
125
- ts.SyntaxKind.SwitchStatement, ts.SyntaxKind.CatchClause, ts.SyntaxKind.ConditionalExpression,
126
- ]);
127
- /** True if the function has at least one assertion and every one of them sits under a
128
- * conditional (if/for/while/switch/catch/?:) — so none runs unconditionally (C21). */
129
- function assertionsAllConditional(fn) {
130
- const asserts = [];
131
- const walk = (n) => { if (isAssertionNode(n))
132
- asserts.push(n); ts.forEachChild(n, walk); };
133
- ts.forEachChild(fn, walk);
134
- if (asserts.length === 0)
135
- return false;
136
- for (const a of asserts) {
137
- let p = a.parent;
138
- let conditional = false;
139
- while (p && p !== fn) {
140
- if (CONDITIONAL_ANCESTORS.has(p.kind)) {
141
- conditional = true;
142
- break;
143
- }
144
- p = p.parent;
145
- }
146
- if (!conditional)
147
- return false;
148
- }
149
- return true;
150
- }
151
121
  function containsCall(node) {
152
122
  let found = false;
153
123
  const walk = (n) => {
@@ -181,18 +151,33 @@ function expectChain(call) {
181
151
  }
182
152
  return null;
183
153
  }
184
- /** True if a call's result is observed: awaited, returned, assigned, or the
185
- * implicit-return body of an arrow. A bare floating call (its enclosing
186
- * statement is a plain ExpressionStatement) is NOT observed. Used to gate
187
- * supertest `.expect()` so a floating API request still surfaces as C2b. */
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. */
188
160
  function isObservedAsync(node) {
189
161
  let cur = node;
190
162
  let p = node.parent;
191
163
  while (p) {
192
164
  if (ts.isAwaitExpression(p) || ts.isReturnStatement(p))
193
165
  return true;
194
- if (ts.isVariableDeclaration(p) || ts.isBinaryExpression(p))
166
+ if (ts.isVariableDeclaration(p))
195
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
196
181
  if (ts.isArrowFunction(p) && p.body === cur)
197
182
  return true;
198
183
  if (ts.isExpressionStatement(p))
@@ -260,6 +245,54 @@ function hasAssertion(scope) {
260
245
  function containsAssertion(node) {
261
246
  return isAssertionNode(node) || hasAssertion(node);
262
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
+ }
263
296
  /** True if a catch block does nothing meaningful: empty, or only console.* /
264
297
  * comments, with no throw, no assertion, and no fail() — so it swallows errors. */
265
298
  function isHarmlessCatch(block) {
@@ -302,27 +335,137 @@ function getTestCallback(call) {
302
335
  }
303
336
  return null;
304
337
  }
305
- // --- JS5: async query/event not awaited (Testing Library) ------------------
306
- const ASYNC_AWAIT_LEAVES = new Set(["waitFor", "waitForElementToBeRemoved"]);
307
- // Vue/Svelte async test helpers that return a promise and must be awaited.
308
- const VUE_SVELTE_ASYNC = new Set(["flushPromises", "nextTick", "$nextTick", "tick"]);
309
- function isAsyncQueryCall(name) {
310
- const parts = name.split(".");
311
- const root = parts[0];
312
- const leaf = parts[parts.length - 1];
313
- if (root === "userEvent")
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))
314
404
  return true;
315
- if (leaf.startsWith("findBy") || leaf.startsWith("findAllBy"))
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)
316
451
  return true;
317
- return ASYNC_AWAIT_LEAVES.has(leaf);
452
+ return hookControlsTimer(timer, sf);
318
453
  }
319
454
  // --- C16: nondeterminism ----------------------------------------------------
320
455
  function c16Detail(call) {
321
456
  const name = calleeName(call.expression);
457
+ const leaf = name.split(".").pop() ?? "";
322
458
  if (name === "Math.random")
323
459
  return "Math.random() without a fixed seed";
324
460
  if (name === "Date.now" || name === "performance.now")
325
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
+ }
326
469
  if (name === "setTimeout" || name === "setInterval") {
327
470
  if (call.arguments.length >= 2 && ts.isNumericLiteral(call.arguments[1])) {
328
471
  return "fixed timer delay";
@@ -337,7 +480,12 @@ export function analyze(sf) {
337
480
  const findings = [];
338
481
  const file = sf.fileName;
339
482
  const text = sf.getFullText();
340
- const fakeTimers = /\b(useFakeTimers)\b/.test(text);
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);
341
489
  const push = (line, code, detail = "") => {
342
490
  findings.push(makeFinding(file, line, code, detail));
343
491
  };
@@ -380,8 +528,7 @@ export function analyze(sf) {
380
528
  // test-level block body checks (C2, C2b, JS3). Only `it`/`test`/`specify`
381
529
  // (and the focus/skip variants) — never `describe`/`suite`, whose body holds
382
530
  // nested tests, not assertions.
383
- const isTestBlock = TEST_BLOCK_ROOTS.has(root) || root === "fit" || root === "xit" ||
384
- ((TEST_BLOCK_ROOTS.has(root)) && (modifier === "only" || modifier === "skip" || modifier === "each"));
531
+ const isTestBlock = TEST_BLOCK_ROOTS.has(root) || root === "fit" || root === "xit";
385
532
  if (isTestBlock) {
386
533
  const cb = getTestCallback(node);
387
534
  // JS18: the test takes a `done` callback instead of async/await. A done
@@ -396,14 +543,31 @@ export function analyze(sf) {
396
543
  if (cb && cb.body && ts.isBlock(cb.body)) {
397
544
  const stmts = cb.body.statements;
398
545
  const line = lineOf(sf, node);
399
- // C20: an assertion after a return/throw in the test body is dead code
400
- let terminated = false;
401
- for (const st of stmts) {
402
- if (terminated && containsAssertion(st)) {
403
- push(lineOf(sf, st), "C20", "assertion after a return/throw never runs");
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");
404
570
  }
405
- if (ts.isReturnStatement(st) || ts.isThrowStatement(st))
406
- terminated = true;
407
571
  }
408
572
  if (stmts.length === 0) {
409
573
  push(line, "C2", "test body is empty");
@@ -416,7 +580,19 @@ export function analyze(sf) {
416
580
  if (ms.length > 0 && ms.every((m) => SNAPSHOT_MATCHERS.has(m))) {
417
581
  push(line, "JS3", "the only assertion is a snapshot");
418
582
  }
419
- if (assertionsAllConditional(cb)) {
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)) {
420
596
  push(line, "C21", "every assertion is guarded by a condition");
421
597
  }
422
598
  }
@@ -460,7 +636,7 @@ export function analyze(sf) {
460
636
  }
461
637
  }
462
638
  // JS6: empty describe/suite block
463
- if (SUITE_ROOTS.has(root) || (SUITE_ROOTS.has(root) && (modifier === "only" || modifier === "skip"))) {
639
+ if (SUITE_ROOTS.has(root)) {
464
640
  const cb = getTestCallback(node);
465
641
  if (cb && cb.body && ts.isBlock(cb.body) && cb.body.statements.length === 0) {
466
642
  push(lineOf(sf, node), "JS6", "suite body is empty");
@@ -507,6 +683,21 @@ export function analyze(sf) {
507
683
  if (subjIsComparison && boolMatcher) {
508
684
  push(lineOf(sf, node), "JS15", "comparison wrapped in a boolean; assert the values directly");
509
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
+ }
510
701
  // D8 (diagnostic, opt-in): a magic integer literal as the expected value.
511
702
  // Floats are C8's concern; D8 covers bare integers abs > 1.
512
703
  if ((chain.matcher === "toBe" || chain.matcher === "toEqual" || chain.matcher === "toStrictEqual") &&
@@ -576,18 +767,33 @@ export function analyze(sf) {
576
767
  push(lineOf(sf, node), "C23", "hard-coded URL (mystery guest)");
577
768
  }
578
769
  }
579
- // JS5: async query/event used without await. Testing Library (findBy*/waitFor/
580
- // user-event) plus Vue/Svelte async helpers (flushPromises/nextTick/tick) in
581
- // their promise form (no callback arg) used as a bare, non-awaited statement.
582
- if (isAsyncQueryCall(name) && ts.isExpressionStatement(node.parent)) {
583
- push(lineOf(sf, node), "JS5", `${name} is not awaited`);
584
- }
585
- else if (ts.isExpressionStatement(node.parent) && node.arguments.length === 0 &&
586
- VUE_SVELTE_ASYNC.has(name.split(".").pop() ?? "")) {
587
- const leaf = name.split(".").pop();
588
- push(lineOf(sf, node), "JS5", `${leaf}() is not awaited`);
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
+ }
589
785
  }
590
- // JS7: assertion inside a non-awaited setTimeout/setInterval/then callback
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.
591
797
  {
592
798
  const leaf = name.split(".").pop() ?? "";
593
799
  const isTimer = name === "setTimeout" || name === "setInterval";
@@ -596,11 +802,11 @@ export function analyze(sf) {
596
802
  const cb = node.arguments.find((a) => ts.isArrowFunction(a) || ts.isFunctionExpression(a));
597
803
  if (cb && containsAssertion(cb)) {
598
804
  const awaitedOrChained = !ts.isExpressionStatement(node.parent);
599
- if (isTimer && !fakeTimers) {
600
- push(lineOf(sf, node), "JS7", `assertion inside a non-awaited ${name}() callback`);
805
+ if (isTimer && !timerIsControlled(node, sf)) {
806
+ push(lineOf(sf, node), "JS7", `assertion deferred into ${name}; runs after the test ends`);
601
807
  }
602
808
  else if (isThen && !awaitedOrChained) {
603
- push(lineOf(sf, node), "JS7", `assertion inside a non-awaited .${leaf}() callback`);
809
+ push(lineOf(sf, node), "JS7", `assertion deferred into a floating .${leaf}(); may not run before the test ends`);
604
810
  }
605
811
  }
606
812
  }
@@ -681,6 +887,16 @@ export function analyze(sf) {
681
887
  expectRooted(node.expression)) {
682
888
  push(lineOf(sf, node), "JS21", `${node.name.text} is referenced but never called`);
683
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
+ }
684
900
  ts.forEachChild(node, visit);
685
901
  };
686
902
  visit(sf);
package/dist/scan.js CHANGED
Binary file
package/dist/types.js CHANGED
@@ -1,11 +1,11 @@
1
- import { CASES } from "./cases.js";
1
+ import { baseConfidence, CASES } from "./cases.js";
2
2
  export function makeFinding(file, line, code, detail = "", confidence) {
3
3
  return {
4
4
  file,
5
5
  line,
6
6
  code,
7
7
  detail,
8
- confidence: confidence ?? CASES[code].confidence,
8
+ confidence: confidence ?? baseConfidence(code),
9
9
  title: CASES[code].title,
10
10
  level: "unit",
11
11
  };
package/package.json CHANGED
@@ -1,24 +1,47 @@
1
1
  {
2
2
  "name": "falsegreen-js",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Find JavaScript/TypeScript unit tests that give false positives: green tests that protect nothing, and tests that pass while asserting the wrong thing. Deterministic AST scanner, sibling of falsegreen (Python).",
5
5
  "keywords": [
6
- "jest", "vitest", "mocha", "testing", "test-smells", "false-positive",
7
- "static-analysis", "typescript", "javascript", "tsx", "jsx", "code-quality",
6
+ "jest",
7
+ "vitest",
8
+ "mocha",
9
+ "testing",
10
+ "test-smells",
11
+ "false-positive",
12
+ "static-analysis",
13
+ "typescript",
14
+ "javascript",
15
+ "tsx",
16
+ "jsx",
17
+ "code-quality",
8
18
  "ai-generated-tests"
9
19
  ],
10
20
  "license": "MIT",
11
21
  "author": "Vinicius Queiroz",
12
22
  "type": "module",
13
- "engines": { "node": ">=18" },
14
- "bin": { "falsegreen-js": "dist/cli.js" },
23
+ "engines": {
24
+ "node": ">=18"
25
+ },
26
+ "bin": {
27
+ "falsegreen-js": "dist/cli.js"
28
+ },
15
29
  "main": "dist/scan.js",
16
30
  "types": "dist/scan.d.ts",
17
31
  "exports": {
18
- ".": { "types": "./dist/scan.d.ts", "import": "./dist/scan.js" }
32
+ ".": {
33
+ "types": "./dist/scan.d.ts",
34
+ "import": "./dist/scan.js"
35
+ }
19
36
  },
20
- "publishConfig": { "access": "public" },
21
- "files": ["dist", "CHANGELOG.md", "CREDITS.md"],
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "files": [
41
+ "dist",
42
+ "CHANGELOG.md",
43
+ "CREDITS.md"
44
+ ],
22
45
  "scripts": {
23
46
  "build": "tsc -p tsconfig.json",
24
47
  "test": "vitest run",
@@ -37,5 +60,7 @@
37
60
  "url": "https://github.com/vinicq/falsegreen-js.git"
38
61
  },
39
62
  "homepage": "https://github.com/vinicq/falsegreen-js#readme",
40
- "bugs": { "url": "https://github.com/vinicq/falsegreen-js/issues" }
63
+ "bugs": {
64
+ "url": "https://github.com/vinicq/falsegreen-js/issues"
65
+ }
41
66
  }