falsegreen-js 0.4.0 → 0.6.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 CHANGED
@@ -6,6 +6,103 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.6.0] - 2026-06-29
10
+
11
+ ### Added
12
+ - `JS25` (high, J1): the only assertion sits inside an array-iterator callback
13
+ (`forEach`/`map`/`filter`/`some`/`every`/`flatMap`). On an empty collection the
14
+ callback runs zero times, so nothing is checked and the test still goes green.
15
+ Fills the verified gap between C2/C2b (whose `hasAssertion` descends into the
16
+ callback and finds the assertion) and C21 (whose own-scope scan stops at the
17
+ callback and sees none). FP guards: an own-scope assertion, a non-empty
18
+ array-literal receiver, or an `expect.assertions`/`hasAssertions` guard suppress it.
19
+ - `JS30` (high, J2): literal-vs-literal assertion such as `expect(2).toBe(3)` or
20
+ chai `expect(x).to.equal(y)`, where both operands are literal nodes through an
21
+ equality matcher (`toBe`/`toEqual`/`toStrictEqual`/`toBeCloseTo`/`equal`/`equals`/
22
+ `eql`/`is`). The comparison is fixed at parse time, independent of any code. The
23
+ same-token case (`expect(1).toBe(1)`) stays with C5; object/array literals
24
+ (reference equality) and template literals with substitutions are excluded.
25
+ - `JS31` (low, J1): a `try/catch` whose try calls code that may throw and whose
26
+ catch neither asserts on the exception, re-raises, nor calls `fail()`. A unit that
27
+ stops throwing (a real regression) still passes green. Complement of JS11, which
28
+ owns the swallowed-assertion case; JS31 fires only when the try has a call but no
29
+ assertion and the catch is harmless.
30
+ - `JS27` (low, J3): `toHaveBeenCalled*` is the sole oracle on a locally-created
31
+ double (`jest.fn`/`vi.fn`/`spyOn`). The test confirms it called the double it set
32
+ up, not the unit's output or state. Gated to the unit level (a logger-spy call
33
+ check is legitimate at integration/e2e) and suppressed when any non-call-tracking
34
+ assertion is present. Sibling of JS8.
35
+ - `JS26` (low, J1): fake timers installed but never advanced. A `setTimeout`/
36
+ `setInterval` is armed under frozen fake timers and nothing in the test scope (nor
37
+ a sibling `before`/`after` hook) calls `runAllTimers`/`advanceTimersByTime`/`tick`,
38
+ so the scheduled callback never fires and the assertion reads un-mutated state. The
39
+ opposite of C16 (uncontrolled timer). Requires an assertion in scope; a flush in
40
+ the body or an enclosing hook suppresses it.
41
+ - `JS29` (low, J6): an `expect(...).resolves`/`.rejects` chain that is a bare
42
+ statement, not awaited or returned. The matcher settles asynchronously, so a
43
+ floating chain finishes green before it resolves. The statically-provable subset of
44
+ the skipped JS20, keyed on the explicit `.resolves`/`.rejects` marker so no type
45
+ inference is needed.
46
+ - `C8b` (low, J4): `toBeCloseTo` called with no precision argument, so the default
47
+ 2-digit tolerance applies. The js analogue of `assertAlmostEqual`/`pytest.approx`
48
+ with no tolerance, ported from `falsegreen`. Implicit-precision only; a
49
+ literal-vs-literal `toBeCloseTo` stays with JS30.
50
+ - `C11a` (low, J2): self-confirming literal. The expected value is bound from the
51
+ same call under test (`const e = foo(); expect(foo()).toBe(e)`), so the oracle
52
+ confirms the code against itself. Ported from `falsegreen`; the bound initializer
53
+ must provably be the SUT call (its source text equals the expect subject's), so a
54
+ literal or a different-call binding stays clean.
55
+
56
+ ### Changed
57
+ - `C8` (exact float) now fires only when the subject is a real (non-literal) value;
58
+ a literal-vs-literal float (`expect(0.1).toBe(0.3)`) is owned by the stronger JS30
59
+ lane, so the two no longer double-report.
60
+
61
+ ## [0.5.0] - 2026-06-28
62
+
63
+ ### Added
64
+ - `JS23` (high, J1): `expect.assertions(N)` with a numeric `N` higher than the unconditional,
65
+ reachable, non-nested `expect()` calls that can run. The guard can never be met, so the test
66
+ passes without ever exercising the count it claims. Fires only when `N` is a numeric literal
67
+ and the shortfall is provable: an `expect` in a loop, a branch, a `.then`/callback, or a
68
+ helper makes the count indeterminate and suppresses the finding. `expect.hasAssertions()`
69
+ carries no count and is skipped. This is the implemented sibling of the still-skipped JS16.
70
+ - `JS24` (low, J4): a Cypress query chain (`cy.get`/`cy.find`/`cy.contains`) used as a statement
71
+ with no terminating `.should`/`.and` and no `expect` inside a `.then` callback. The query
72
+ produces a subject that is never asserted, the cy.* analogue of JS13. Action commands
73
+ (`click`/`type`/`visit`/...) do work rather than just query, so a chain ending in one stays
74
+ clean, as does a chain that ends in `.should`/`.and` or asserts in `.then`.
75
+ - CLI `--enable <codes>` (and `--enable=...`): re-activates listed off or opt-in codes at their
76
+ catalog severity, flipping a default-off code on. It cannot raise a code above catalog
77
+ severity. `--disable` wins over `--enable`, so a code passed to both stays off.
78
+ - `examples/` tree (#47): a worked sample for every emitted code, a BAD test the scanner flags
79
+ paired with a CLEAN look-alike one token away that it leaves alone. Files are grouped by
80
+ RiskGroup (`effectiveness`, `execution`, `nondeterminism`, `dependency`), with `cypress.cy.ts`
81
+ for the Cypress code and `diagnostics.test.ts` for the opt-in maintainability group. C16 keeps
82
+ a separate frozen-clock file because the fake-timer signal is file-wide. `vitest.config.ts`
83
+ excludes `examples/**` from collection, and `test/examples.test.ts` scans each file with
84
+ `analyze(parse(...))` to assert every code fires in its file, with a drift guard that fails if a
85
+ new default-on code lands without an example. The config-audit-only PL series scans Jest/Vitest
86
+ config rather than a test file, so it has no test-file example.
87
+
88
+ ### Changed
89
+ - `JS8` now also catches the `jest.spyOn`/`vi.spyOn` form: a spy with a canned return
90
+ (`mockReturnValue`/`mockResolvedValue`/`mockImplementation`) whose spied target root is also an
91
+ `expect` subject. The test asserts the canned value, not real behaviour. Conservative
92
+ same-binding guard: spying a collaborator (a different object) stays clean, and asserting on
93
+ the spy handle itself (`expect(spy).toHaveBeenCalled()`) is not treated as the subject.
94
+ - `JS3` gains a distinct detail when the snapshot is an empty inline baseline:
95
+ `toMatchInlineSnapshot()` with no argument, or an empty or whitespace-only string baseline,
96
+ passes by writing itself on the first run. A populated inline snapshot keeps the existing
97
+ detail; the snapshot-only detection logic is unchanged.
98
+
99
+ ### Docs
100
+ - `CONTRIBUTING.md` documents the FP-boundary decisions that previously lived only in
101
+ source comments: the admission criteria for a new code (statically provable, FP-guarded,
102
+ ships with a CLEAN look-alike one token from the BAD, carries a catalog entry, clears the
103
+ panel and principal-reviewer gate) and the standing per-code rules for C44, C6, JS5, C16,
104
+ JS23, JS24, and JS8 (#51).
105
+
9
106
  ## [0.4.0] - 2026-06-28
10
107
 
11
108
  ### Fixed
@@ -176,7 +273,8 @@ All notable changes to this project are documented here. The format is based on
176
273
  - pre-commit hook (`.pre-commit-hooks.yaml`), CI matrix (Node 18/20/22), and an npm
177
274
  trusted-publishing release workflow.
178
275
 
179
- [Unreleased]: https://github.com/vinicq/falsegreen-js/compare/v0.4.0...HEAD
276
+ [Unreleased]: https://github.com/vinicq/falsegreen-js/compare/v0.5.0...HEAD
277
+ [0.5.0]: https://github.com/vinicq/falsegreen-js/compare/v0.4.0...v0.5.0
180
278
  [0.4.0]: https://github.com/vinicq/falsegreen-js/compare/v0.3.0...v0.4.0
181
279
  [0.3.0]: https://github.com/vinicq/falsegreen-js/compare/v0.2.0...v0.3.0
182
280
  [0.2.0]: https://github.com/vinicq/falsegreen-js/compare/v0.1.0...v0.2.0
package/README.md CHANGED
@@ -15,7 +15,14 @@ AST scan, no code execution. Sibling of [`falsegreen`](https://github.com/vinicq
15
15
 
16
16
  Covers `.js`, `.jsx`, `.ts`, `.tsx`, `.mjs`, `.cjs`, `.mts`, `.cts`.
17
17
 
18
- **The falsegreen family:** [falsegreen](https://github.com/vinicq/falsegreen) (Python/pytest) · **falsegreen-js** (JS/TS) · [robotframework-falsegreen](https://github.com/vinicq/robotframework-falsegreen) (Robot Framework) · [falsegreen-skill](https://github.com/vinicq/falsegreen-skill) (semantic LLM pass).
18
+ **The falsegreen family** (install the one for your stack):
19
+
20
+ | Tool | Stack | Install | Package |
21
+ |---|---|---|---|
22
+ | [falsegreen](https://github.com/vinicq/falsegreen) | Python / pytest | `pip install falsegreen` | [PyPI](https://pypi.org/project/falsegreen/) |
23
+ | **falsegreen-js** | JS / TS | `npm i -D falsegreen-js` (`npx falsegreen-js`) | [npm](https://www.npmjs.com/package/falsegreen-js) |
24
+ | [robotframework-falsegreen](https://github.com/vinicq/robotframework-falsegreen) | Robot Framework | `pip install robotframework-falsegreen` | [PyPI](https://pypi.org/project/robotframework-falsegreen/) |
25
+ | [falsegreen-skill](https://github.com/vinicq/falsegreen-skill) | semantic LLM pass | `npx falsegreen-skill analyze <path>` | [npm](https://www.npmjs.com/package/falsegreen-skill) |
19
26
 
20
27
  ## Why
21
28
 
@@ -44,6 +51,7 @@ npx falsegreen-js --output report.json # write to a file
44
51
  npx falsegreen-js --output .falsegreen/ # write report.<ext> into a directory
45
52
  npx falsegreen-js --config-audit # audit Jest/Vitest config (project-layer PL codes)
46
53
  npx falsegreen-js --disable C7,JS3
54
+ npx falsegreen-js --enable D8,M2 # re-activate off/opt-in codes at catalog severity
47
55
  ```
48
56
 
49
57
  Each finding is reported with its pyramid level (unit / integration / e2e, read from the file's imports) and a one-line fix hint, and the summary breaks the findings down by level and lists the most common fixes. `--output` takes a file or a directory: an extension-less or trailing-slash path (e.g. `.falsegreen/`) receives `report.<ext>` for the chosen format. Reports are run artifacts; keep the output directory gitignored.
@@ -129,7 +137,9 @@ line up in the research. `JS*` codes are ecosystem-specific.
129
137
  | C20 | high | assertion in unreachable code (after a `return`/`throw`/`process.exit`, a `break`, a both-arms-terminating `if`, or an exhaustive `switch`) — it never runs |
130
138
  | C23 | low | reads a real file at a literal path, or a hard-coded URL (mystery guest) |
131
139
  | C8 | low | exact equality on a float (use `toBeCloseTo`) |
140
+ | C8b | low | `toBeCloseTo` with no precision argument — the default 2-digit tolerance may be too loose |
132
141
  | C9 | low | `toThrow()` with no error type or message — accepts any error |
142
+ | C11a | low | self-confirming literal — the expected value is bound from the same call under test (`const e = foo(); expect(foo()).toBe(e)`) |
133
143
  | C16 | low | result depends on `Date.now`, `Math.random`, or a fixed timer |
134
144
  | C18 | low | compares `String(x)` / `JSON.stringify(x)` / `` `${x}` `` to a literal (formatting, not value) |
135
145
  | C21 | low | every assertion is conditional — none runs unconditionally |
@@ -152,6 +162,14 @@ line up in the research. `JS*` codes are ecosystem-specific.
152
162
  | JS18 | low | test takes a `done` callback instead of async/await — a mistimed `done` passes early |
153
163
  | JS21 | high | matcher referenced but never called (`expect(x).toBe` with no `()`) — the assertion never runs |
154
164
  | JS22 | high | empty `it.each`/`test.each` table — generated with zero cases, never runs |
165
+ | JS23 | high | `expect.assertions(N)` with fewer unconditional `expect()` calls than N — the guard can never be met |
166
+ | JS24 | low | Cypress query (`cy.get`/`cy.find`/`cy.contains`) as a loose statement with no terminating `.should`/`.and` and no `expect` in `.then` — its result is never asserted |
167
+ | JS25 | high | the only assertion sits inside an array-iterator callback (`forEach`/`map`/`filter`/`some`/`every`/`flatMap`) — runs zero times on an empty collection |
168
+ | JS26 | low | fake timers installed but never advanced (`runAllTimers`/`advanceTimersByTime`/`tick`) — the scheduled callback never fires, so the assertion reads un-mutated state |
169
+ | JS27 | low | `toHaveBeenCalled*` is the sole oracle on a locally-created double — verifies wiring, not behaviour |
170
+ | JS29 | low | `expect(...).resolves`/`.rejects` chain is a bare statement, not awaited or returned — the test finishes green before the matcher settles |
171
+ | JS30 | high | literal-vs-literal assertion (`expect(2).toBe(3)`, chai `expect(x).to.equal(y)`) — both operands are fixed at parse time |
172
+ | JS31 | low | `try/catch` swallows a possible throw with no assertion on the exception — a unit that stops throwing still passes green |
155
173
 
156
174
  Each code carries a judgment tag (J1-J6) shared with the
157
175
  [falsegreen-skill](https://github.com/vinicq/falsegreen-skill) semantic framework.
@@ -186,8 +204,11 @@ Some catalog codes were reviewed and left out, on purpose:
186
204
  - **JS20** (a Promise compared without `resolves`/`rejects`): telling that a value is a
187
205
  Promise needs type information the AST does not carry, so it would be too noisy.
188
206
  - **JS12** (a floating promise whose `expect` is never returned): already covered by JS7.
189
- - **JS16** (`async` test with no `expect.assertions(n)`): the absence of a guard is not a
190
- smell on its own; flagging it would fire on most async tests.
207
+ - **JS16** (`async` test with no `expect.assertions(n)`): the *absence* of a guard is not a
208
+ smell on its own; flagging it would fire on most async tests. The implemented sibling is
209
+ `JS23`, which fires on a present-but-unsatisfiable guard: `expect.assertions(N)` with a
210
+ numeric `N` higher than the unconditional `expect()` calls that can run, so the count can
211
+ never be met.
191
212
  - **JS14** (a giant inline snapshot): a readability and review-noise concern, not a
192
213
  false-green one. The snapshot still protects, so it belongs to the diagnostic group and is
193
214
  better served by `eslint-plugin-jest` (`no-large-snapshots`) as an opt-in lint rule.
@@ -226,7 +247,7 @@ Optional. `falsegreen.json`, `.falsegreenrc.json`, or a `"falsegreen"` key in
226
247
  }
227
248
  ```
228
249
 
229
- Precedence: CLI `--disable` > config `disable`/`severity` > catalog default.
250
+ Precedence: CLI `--disable` > CLI `--enable` > config `disable`/`severity` > catalog default. `--enable <codes>` re-activates listed off or opt-in codes at their catalog severity (it flips a default-off code on; it cannot raise a code above catalog). A code passed to both `--enable` and `--disable` stays off — `--disable` wins.
230
251
 
231
252
  ## Scope and honesty
232
253
 
package/dist/cases.js CHANGED
@@ -59,6 +59,16 @@ export const CASES = {
59
59
  JS18: { title: "test takes a done callback instead of async/await — a done called too early (or in a floating promise) passes before the assertions run", group: "execution", severity: "low", defaultOn: true, judgment: "J1" },
60
60
  JS21: { title: "matcher referenced but never called (expect(x).toBe with no ()) — the assertion never executes", group: "execution", severity: "high", defaultOn: true, judgment: "J1" },
61
61
  JS22: { title: "empty it.each/test.each table — the test is generated with zero cases and never runs", group: "execution", severity: "high", defaultOn: true, judgment: "J1" },
62
+ JS23: { title: "expect.assertions(N) with fewer unconditional expect() calls than N — the guard can never be met", group: "execution", severity: "high", defaultOn: true, judgment: "J1" },
63
+ JS24: { title: "Cypress query (cy.get/find/contains) with no terminating .should/.and and no expect in .then — its result is never asserted", group: "effectiveness", severity: "low", defaultOn: true, judgment: "J4" },
64
+ JS25: { title: "the only assertion sits inside an array-iterator callback (forEach/map/filter/some/every/flatMap) — runs zero times on an empty collection", group: "execution", severity: "high", defaultOn: true, judgment: "J1" },
65
+ JS26: { title: "fake timers installed but never advanced — the scheduled callback never fires, so the assertion runs against un-mutated state", group: "execution", severity: "low", defaultOn: true, judgment: "J1" },
66
+ JS27: { title: "toHaveBeenCalled* is the sole oracle on a locally-created double — verifies wiring, not behaviour", group: "dependency", severity: "low", defaultOn: true, judgment: "J3" },
67
+ JS29: { title: "expect(...).resolves/.rejects chain is a bare statement, not awaited or returned — the test finishes green before the matcher settles", group: "execution", severity: "low", defaultOn: true, judgment: "J6" },
68
+ JS30: { title: "literal-vs-literal assertion (expect(1).toBe(1)) — both operands are fixed at parse time, independent of any code", group: "effectiveness", severity: "high", defaultOn: true, judgment: "J2" },
69
+ JS31: { title: "try/catch swallows a SUT throw with no assertion on the exception — a unit that stops throwing still passes green", group: "execution", severity: "low", defaultOn: true, judgment: "J1" },
70
+ C8b: { title: "toBeCloseTo with no precision argument — the default 2-digit tolerance may be too loose for the value under test", group: "effectiveness", severity: "low", defaultOn: true, judgment: "J4" },
71
+ C11a: { title: "self-confirming literal — the expected value is bound from the same call/expression under test (const e = foo(); expect(foo()).toBe(e))", group: "effectiveness", severity: "low", defaultOn: true, judgment: "J2" },
62
72
  // --- diagnostic group (maintainability; default off, opt-in via --diagnostics
63
73
  // or config severity). These are NOT false-green: the test still protects. They
64
74
  // are a "plus" for test-code health, mirroring falsegreen's D/M group. -------
@@ -155,6 +165,16 @@ export const FIX_HINTS = {
155
165
  JS18: "use async/await instead of the done callback",
156
166
  JS21: "call the matcher (add ()) so the assertion executes",
157
167
  JS22: "add at least one row to the it.each/test.each table",
168
+ JS23: "make the unconditional expect() count match expect.assertions(N), or remove the guard",
169
+ JS24: "end the cy query in .should()/.and(), or assert in .then()",
170
+ JS25: "add an assertion outside the iterator callback so it runs even when the collection is empty",
171
+ JS26: "advance the fake timers (runAllTimers/advanceTimersByTime/tick) before asserting",
172
+ JS27: "assert the unit's output or state, not just that the double was called",
173
+ JS29: "await or return the resolves/rejects assertion so the matcher settles",
174
+ JS30: "assert a real value against an independent expected one, not literal against literal",
175
+ JS31: "assert on the caught error, re-throw, call fail(), or wrap the call in expect().toThrow()",
176
+ C8b: "pass a precision argument to toBeCloseTo(), or choose an explicit tolerance",
177
+ C11a: "bind the expected value independently of the call under test",
158
178
  D1: "give each assertion a message, or split the test",
159
179
  D3: "remove the duplicate assertion",
160
180
  D4: "add titled cases to it.each/test.each",
package/dist/cli.js CHANGED
@@ -34,6 +34,7 @@ Usage:
34
34
  falsegreen-js --config-audit audit Jest/Vitest config (project-layer PL codes)
35
35
  falsegreen-js --diagnostics also report the opt-in maintainability group (D*/M*)
36
36
  falsegreen-js --disable C7,JS3 turn off specific codes
37
+ falsegreen-js --enable D8,M2 re-activate off/opt-in codes at catalog severity (--disable wins)
37
38
  falsegreen-js --version
38
39
  falsegreen-js --help
39
40
 
@@ -52,6 +53,7 @@ function parseArgs(argv) {
52
53
  let baseline;
53
54
  let writeBaselinePath;
54
55
  const disable = new Set();
56
+ const enable = new Set();
55
57
  // An optional-value flag (--baseline / --write-baseline) consumes the next
56
58
  // token only when it is a value, not another flag.
57
59
  const optionalValue = (next) => (next !== undefined && !next.startsWith("-")) ? next : undefined;
@@ -115,6 +117,14 @@ function parseArgs(argv) {
115
117
  a.slice("--disable=".length).split(",").map((s) => s.trim())
116
118
  .filter(Boolean).forEach((c) => disable.add(c));
117
119
  }
120
+ else if (a === "--enable") {
121
+ const v = argv[++i] ?? "";
122
+ v.split(",").map((s) => s.trim()).filter(Boolean).forEach((c) => enable.add(c));
123
+ }
124
+ else if (a.startsWith("--enable=")) {
125
+ a.slice("--enable=".length).split(",").map((s) => s.trim())
126
+ .filter(Boolean).forEach((c) => enable.add(c));
127
+ }
118
128
  else if (a.startsWith("-")) {
119
129
  process.stderr.write(`falsegreen-js: unknown option ${a}\n`);
120
130
  process.exit(2);
@@ -124,7 +134,7 @@ function parseArgs(argv) {
124
134
  }
125
135
  const fmt = format ?? (json ? "json" : "text");
126
136
  return {
127
- paths, fmt, staged, help, version, diagnostics, configAudit, disable,
137
+ paths, fmt, staged, help, version, diagnostics, configAudit, disable, enable,
128
138
  output, baseline, writeBaselinePath,
129
139
  };
130
140
  }
@@ -234,7 +244,9 @@ export function renderText(findings) {
234
244
  }
235
245
  function scan(opt) {
236
246
  const config = loadConfig();
237
- const scanOpts = { config, cliDisable: opt.disable, diagnostics: opt.diagnostics };
247
+ const scanOpts = {
248
+ config, cliDisable: opt.disable, cliEnable: opt.enable, diagnostics: opt.diagnostics,
249
+ };
238
250
  if (opt.staged)
239
251
  return stagedFiles().flatMap((f) => scanFile(f, scanOpts));
240
252
  return scanPaths(opt.paths.length ? opt.paths : ["."], scanOpts);
package/dist/rules.js CHANGED
@@ -4,6 +4,7 @@ import { DIAGNOSTIC_THRESHOLDS } from "./cases.js";
4
4
  import { ASSERT_ROOTS, ASSERT_METHODS, SNAPSHOT_MATCHERS, EQUALITY_MATCHERS, VUE_SVELTE_ASYNC, oracleKind, } from "./oracles.js";
5
5
  import { lineOf } from "./parse.js";
6
6
  import { assertionsInDeadCode, hasUnconditionalAssertion } from "./cfg.js";
7
+ import { detectPyramidLevel } from "./level.js";
7
8
  // --- test framework vocabulary (runner-agnostic) ---------------------------
8
9
  // it/test/specify (Jest, Vitest, Mocha, Jasmine, AVA, node:test, Cypress,
9
10
  // Playwright, tap). describe/context/suite are suites. fit/fdescribe focus and
@@ -14,6 +15,23 @@ import { assertionsInDeadCode, hasUnconditionalAssertion } from "./cfg.js";
14
15
  const TEST_BLOCK_ROOTS = new Set(["it", "test", "specify"]);
15
16
  const SUITE_ROOTS = new Set(["describe", "context", "suite", "fdescribe", "xdescribe", "fcontext", "xcontext"]);
16
17
  const FOCUS_NAMES = new Set(["fit", "fdescribe", "fcontext"]);
18
+ // Array-iterator methods whose callback runs once per element — zero times on an
19
+ // empty collection. An assertion that lives ONLY inside one of these callbacks
20
+ // (JS25) runs zero times when the receiver is empty: green with nothing checked.
21
+ const ARRAY_ITERATOR_METHODS = new Set(["forEach", "map", "filter", "some", "every", "flatMap"]);
22
+ // Equality matchers across runners for the literal-vs-literal (JS30) and
23
+ // self-confirming-literal (C11a) lanes: Jest/Vitest toBe family + toBeCloseTo,
24
+ // plus chai/AVA equal/equals/eql/is. Broader than EQUALITY_MATCHERS (which gates
25
+ // the Jest-only C5/C7/C8 lanes) on purpose.
26
+ const EQ_MATCHERS_ANY = new Set([
27
+ "toBe", "toEqual", "toStrictEqual", "toBeCloseTo", "equal", "equals", "eql", "is",
28
+ ]);
29
+ // toHaveBeenCalled* family (JS27): matchers that only assert a double was invoked.
30
+ const CALL_TRACKING_MATCHERS = new Set([
31
+ "toHaveBeenCalled", "toHaveBeenCalledTimes", "toHaveBeenCalledWith",
32
+ "toHaveBeenLastCalledWith", "toHaveBeenNthCalledWith", "toBeCalled",
33
+ "toBeCalledTimes", "toBeCalledWith", "toHaveBeenCalledOnce",
34
+ ]);
17
35
  const SKIP_NAMES = new Set(["xit", "xdescribe", "xcontext", "xspecify"]);
18
36
  // --- C48 dark-patch: a test that flips a known test-mode flag then asserts ---
19
37
  // env keys (process.env.<KEY> = <test-mode value>) whose name means "we are under
@@ -78,6 +96,58 @@ function expectRooted(e) {
78
96
  }
79
97
  return false;
80
98
  }
99
+ // --- JS24: Cypress query chain with no terminating assertion ---------------
100
+ const CY_QUERY_COMMANDS = new Set(["get", "find", "contains"]);
101
+ /** True if any `expect(...)` call appears under `scope` (any matcher form, or a
102
+ * bare expect). Used to keep a cy chain clean when it asserts inside a .then. */
103
+ function containsExpectCall(scope) {
104
+ let found = false;
105
+ const walk = (n) => {
106
+ if (found)
107
+ return;
108
+ if (ts.isCallExpression(n) && expectRooted(n)) {
109
+ found = true;
110
+ return;
111
+ }
112
+ ts.forEachChild(n, walk);
113
+ };
114
+ walk(scope);
115
+ return found;
116
+ }
117
+ /** True if a call chain rooted at `cy` ends in a query command (get/find/contains)
118
+ * and carries no terminating `.should`/`.and` and no `expect(...)` inside a `.then`
119
+ * callback — so it produces a subject that is never asserted. Action commands
120
+ * (click/type/visit/...) as the outermost call do something, so they stay clean.
121
+ * `expr` is the outermost call of the statement. */
122
+ function isUnassertedCyQuery(expr) {
123
+ if (rootIdent(expr) !== "cy")
124
+ return false;
125
+ // outermost command must be a query, not an action (action does work, not just query)
126
+ if (!ts.isPropertyAccessExpression(expr.expression))
127
+ return false;
128
+ if (!CY_QUERY_COMMANDS.has(expr.expression.name.text))
129
+ return false;
130
+ // scan the whole chain for a terminating assertion: any .should/.and, or an
131
+ // expect(...) inside a .then(cb) callback. Either keeps it clean.
132
+ let cur = expr;
133
+ const visit = (n) => {
134
+ if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression)) {
135
+ const m = n.expression.name.text;
136
+ if (m === "should" || m === "and")
137
+ return true;
138
+ if (m === "then") {
139
+ const cb = n.arguments.find((a) => ts.isArrowFunction(a) || ts.isFunctionExpression(a));
140
+ if (cb && containsExpectCall(cb))
141
+ return true;
142
+ }
143
+ }
144
+ let asserted = false;
145
+ ts.forEachChild(n, (c) => { if (visit(c))
146
+ asserted = true; });
147
+ return asserted;
148
+ };
149
+ return !visit(cur);
150
+ }
81
151
  function literalTruthiness(e) {
82
152
  if (!e)
83
153
  return null;
@@ -151,6 +221,23 @@ function expectChain(call) {
151
221
  }
152
222
  return null;
153
223
  }
224
+ /** True if `call` is the terminal matcher call of an `expect(...).resolves`/
225
+ * `.rejects` chain (e.g. `expect(p).resolves.toBe(1)`). Walks the callee base for a
226
+ * `.resolves`/`.rejects` property access that bottoms out in an `expect(...)` call.
227
+ * Only the explicit resolves/rejects marker counts (JS29); a plain promise does not. */
228
+ function expectIsResolvesRejects(call) {
229
+ if (!ts.isPropertyAccessExpression(call.expression))
230
+ return false;
231
+ let base = call.expression.expression;
232
+ let sawSettle = false;
233
+ while (ts.isPropertyAccessExpression(base) || ts.isCallExpression(base)) {
234
+ if (ts.isPropertyAccessExpression(base) &&
235
+ (base.name.text === "resolves" || base.name.text === "rejects"))
236
+ sawSettle = true;
237
+ base = base.expression;
238
+ }
239
+ return sawSettle && expectRooted(call.expression);
240
+ }
154
241
  /** True if a call's result is observed: awaited, returned, assigned, the
155
242
  * implicit-return body of an arrow, or explicitly discarded with `void` (an
156
243
  * author signalling "I am dropping this on purpose"). A bare floating call (its
@@ -314,6 +401,22 @@ function isHarmlessCatch(block) {
314
401
  }
315
402
  return true;
316
403
  }
404
+ /** Stricter than isHarmlessCatch, for JS31: the catch truly swallows the throw,
405
+ * doing NOTHING with it. Only an empty body or `console.*` log-only statements
406
+ * qualify. An assignment (recovery flag like `supported = false`), a return, a
407
+ * fail()/throw, an assertion, or any other call is meaningful handling, not a
408
+ * silent swallow, so JS31 must not fire. Keeps JS31 precision-first. */
409
+ function catchSilentlySwallows(block) {
410
+ for (const stmt of block.statements) {
411
+ if (!ts.isExpressionStatement(stmt))
412
+ return false; // return/var/if/etc: handling
413
+ if (!ts.isCallExpression(stmt.expression))
414
+ return false; // a bare assignment/expr
415
+ if (!calleeName(stmt.expression.expression).startsWith("console."))
416
+ return false;
417
+ }
418
+ return true;
419
+ }
317
420
  /** Matcher names of every expect-chain assertion under scope (for snapshot-only). */
318
421
  function matchersUnder(scope) {
319
422
  const out = [];
@@ -328,6 +431,309 @@ function matchersUnder(scope) {
328
431
  ts.forEachChild(scope, walk);
329
432
  return out;
330
433
  }
434
+ /** True if any snapshot matcher under `scope` is an inline snapshot with no
435
+ * baseline yet: `toMatchInlineSnapshot()` / `toThrowErrorMatchingInlineSnapshot()`
436
+ * with no argument, or an empty/whitespace-only string-literal baseline. On the
437
+ * first run the runner writes the snapshot from the output itself, so it passes
438
+ * by construction. A populated inline snapshot has a real baseline and is not this. */
439
+ function hasEmptyInlineSnapshot(scope) {
440
+ let found = false;
441
+ const walk = (n) => {
442
+ if (found)
443
+ return;
444
+ if (ts.isCallExpression(n)) {
445
+ const chain = expectChain(n);
446
+ if (chain && (chain.matcher === "toMatchInlineSnapshot" ||
447
+ chain.matcher === "toThrowErrorMatchingInlineSnapshot")) {
448
+ const a0 = chain.args[0];
449
+ if (a0 === undefined) {
450
+ found = true;
451
+ return;
452
+ }
453
+ if ((ts.isStringLiteral(a0) || ts.isNoSubstitutionTemplateLiteral(a0)) &&
454
+ a0.text.trim() === "") {
455
+ found = true;
456
+ return;
457
+ }
458
+ }
459
+ }
460
+ ts.forEachChild(n, walk);
461
+ };
462
+ ts.forEachChild(scope, walk);
463
+ return found;
464
+ }
465
+ /** The numeric N of an `expect.assertions(N)` call (N a numeric literal), else
466
+ * null. `expect.hasAssertions()` carries no count and is not this. */
467
+ function expectAssertionsCount(call) {
468
+ if (calleeName(call.expression) !== "expect.assertions")
469
+ return null;
470
+ const a0 = call.arguments[0];
471
+ if (a0 && ts.isNumericLiteral(a0))
472
+ return Number(a0.text);
473
+ return null;
474
+ }
475
+ /** JS23 expect accounting. `unconditional` is the count of expect-chain matcher
476
+ * calls guaranteed to run: a direct ExpressionStatement on the test body's spine
477
+ * (optionally awaited). `indeterminate` is true when any other expect-chain call
478
+ * exists in the body (in a loop, branch, try, switch, callback, .then, or an
479
+ * expression operand) — its run count cannot be proven, so a shortfall is not
480
+ * provable and JS23 must be suppressed (FP-averse: a false positive is worse than
481
+ * a miss). Stops at nested functions only for the spine count, but the
482
+ * indeterminate scan walks the whole body (a .then callback IS indeterminate). */
483
+ function expectAccounting(body) {
484
+ const spine = new Set();
485
+ let unconditional = 0;
486
+ for (const st of body.statements) {
487
+ if (!ts.isExpressionStatement(st))
488
+ continue;
489
+ let e = st.expression;
490
+ if (ts.isAwaitExpression(e))
491
+ e = e.expression;
492
+ if (ts.isCallExpression(e) && expectChain(e)) {
493
+ unconditional++;
494
+ spine.add(e);
495
+ }
496
+ }
497
+ // Anything that is not the proven-unconditional expect spine makes the count
498
+ // indeterminate: an off-spine expect chain (loop/branch/.then/operand), or any
499
+ // other call — a helper or setup call may carry assertions the count cannot see.
500
+ // Do not descend into a spine chain (its inner expect()/matcher calls are
501
+ // accounted, not separate helpers).
502
+ let indeterminate = false;
503
+ const walk = (n) => {
504
+ if (indeterminate)
505
+ return;
506
+ if (spine.has(n))
507
+ return; // whole spine chain already counted
508
+ if (ts.isCallExpression(n)) {
509
+ const nm = calleeName(n.expression);
510
+ if (nm !== "expect.assertions" && nm !== "expect.hasAssertions") {
511
+ indeterminate = true;
512
+ return;
513
+ }
514
+ }
515
+ ts.forEachChild(n, walk);
516
+ };
517
+ walk(body);
518
+ return { unconditional, indeterminate };
519
+ }
520
+ /** JS8 (spyOn form): collect `{root -> line}` for every jest.spyOn/vi.spyOn target
521
+ * whose return was canned (mockReturnValue/mockResolvedValue/mockRejectedValue/
522
+ * mockImplementation) under `scope`. Test-local on purpose: a spyOn is hoisted only
523
+ * within its own test body, unlike the module-wide jest.mock form. */
524
+ function cannedSpyTargets(scope) {
525
+ const out = new Map();
526
+ const sf = scope.getSourceFile();
527
+ const walk = (n) => {
528
+ if (ts.isCallExpression(n) &&
529
+ /^(mockReturnValue|mockResolvedValue|mockRejectedValue|mockImplementation)$/.test(calleeName(n.expression).split(".").pop() ?? "")) {
530
+ let base = n.expression;
531
+ while (ts.isPropertyAccessExpression(base) || ts.isCallExpression(base)) {
532
+ if (ts.isCallExpression(base)) {
533
+ const cn = calleeName(base.expression);
534
+ if (cn === "jest.spyOn" || cn === "vi.spyOn") {
535
+ const tr = base.arguments[0] && rootIdent(base.arguments[0]);
536
+ if (tr)
537
+ out.set(tr, lineOf(sf, n));
538
+ break;
539
+ }
540
+ }
541
+ base = ts.isCallExpression(base) ? base.expression
542
+ : base.expression;
543
+ }
544
+ }
545
+ ts.forEachChild(n, walk);
546
+ };
547
+ walk(scope);
548
+ return out;
549
+ }
550
+ /** Root identifiers used as an expect subject under `scope` (`expect(a.b).m()` -> "a"). */
551
+ function expectSubjectRoots(scope) {
552
+ const out = new Set();
553
+ const walk = (n) => {
554
+ if (ts.isCallExpression(n)) {
555
+ const chain = expectChain(n);
556
+ if (chain && chain.subject) {
557
+ const r = rootIdent(chain.subject);
558
+ if (r)
559
+ out.add(r);
560
+ }
561
+ }
562
+ ts.forEachChild(n, walk);
563
+ };
564
+ walk(scope);
565
+ return out;
566
+ }
567
+ /** JS25: every assertion under `scope` sits inside an array-iterator callback
568
+ * (arr.forEach/map/filter/some/every/flatMap(cb)), and at least one such
569
+ * assertion exists. Walk the body; when entering an iterator callback, any
570
+ * assertion inside counts as "iterator-bound"; an assertion reached outside one
571
+ * is an own-scope assertion that disproves JS25. Returns whether the only
572
+ * assertions are iterator-bound (and there is at least one). FP-averse: a single
573
+ * own-scope assertion anywhere suppresses it. */
574
+ function assertionsOnlyInArrayIterator(scope) {
575
+ let iteratorBound = 0;
576
+ let ownScope = 0;
577
+ // The callback nodes of array-iterator calls under scope, so the walk knows when
578
+ // an assertion is inside one without re-deriving the call shape per node.
579
+ const iterCallbacks = new Set();
580
+ const collect = (n) => {
581
+ if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression) &&
582
+ ARRAY_ITERATOR_METHODS.has(n.expression.name.text)) {
583
+ for (const a of n.arguments) {
584
+ if (ts.isArrowFunction(a) || ts.isFunctionExpression(a))
585
+ iterCallbacks.add(a);
586
+ }
587
+ }
588
+ ts.forEachChild(n, collect);
589
+ };
590
+ collect(scope);
591
+ const walk = (n, insideIter) => {
592
+ const nowInside = insideIter || iterCallbacks.has(n);
593
+ if (isAssertionNode(n)) {
594
+ if (nowInside)
595
+ iteratorBound++;
596
+ else
597
+ ownScope++;
598
+ }
599
+ ts.forEachChild(n, (c) => walk(c, nowInside));
600
+ };
601
+ ts.forEachChild(scope, (c) => walk(c, false));
602
+ return ownScope === 0 && iteratorBound > 0;
603
+ }
604
+ /** True if `scope` carries an expect.assertions(N) / expect.hasAssertions() guard
605
+ * (JS23 territory) — used as a JS25 FP guard: the author already declared a count. */
606
+ function hasAssertionCountGuard(scope) {
607
+ let found = false;
608
+ const walk = (n) => {
609
+ if (found)
610
+ return;
611
+ if (ts.isCallExpression(n)) {
612
+ const nm = calleeName(n.expression);
613
+ if (nm === "expect.assertions" || nm === "expect.hasAssertions") {
614
+ found = true;
615
+ return;
616
+ }
617
+ }
618
+ ts.forEachChild(n, walk);
619
+ };
620
+ walk(scope);
621
+ return found;
622
+ }
623
+ /** True if any array-iterator call under `scope` iterates a non-empty array
624
+ * literal receiver (arr `[1, 2].forEach(...)`) — JS25 FP guard: a non-empty
625
+ * literal always runs the callback at least once, so the assertion does run. */
626
+ function iteratesNonEmptyArrayLiteral(scope) {
627
+ let found = false;
628
+ const walk = (n) => {
629
+ if (found)
630
+ return;
631
+ if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression) &&
632
+ ARRAY_ITERATOR_METHODS.has(n.expression.name.text)) {
633
+ const recv = n.expression.expression;
634
+ if (ts.isArrayLiteralExpression(recv) && recv.elements.length > 0) {
635
+ found = true;
636
+ return;
637
+ }
638
+ }
639
+ ts.forEachChild(n, walk);
640
+ };
641
+ walk(scope);
642
+ return found;
643
+ }
644
+ /** JS27: the matcher names of every expect-chain assertion under `scope`, each
645
+ * paired with the root identifier of its subject. Used to decide whether the ONLY
646
+ * oracle is a toHaveBeenCalled* check on a locally-created double. */
647
+ function expectMatcherSubjects(scope) {
648
+ const out = [];
649
+ const walk = (n) => {
650
+ if (ts.isCallExpression(n)) {
651
+ const chain = expectChain(n);
652
+ if (chain)
653
+ out.push({ matcher: chain.matcher, root: chain.subject ? rootIdent(chain.subject) : null });
654
+ }
655
+ ts.forEachChild(n, walk);
656
+ };
657
+ walk(scope);
658
+ return out;
659
+ }
660
+ /** Root identifiers in `scope` bound to a freshly-created test double:
661
+ * jest.fn()/vi.fn()/jest.spyOn()/vi.spyOn() in a const/let, or a mockReturnValue/
662
+ * mockImplementation-decorated handle. Used by JS27 to confirm the call-tracking
663
+ * subject is a local double, not a real collaborator. */
664
+ function localDoubleRoots(scope) {
665
+ const out = new Set();
666
+ const isDoubleInit = (e) => {
667
+ let cur = e;
668
+ while (ts.isCallExpression(cur) || ts.isPropertyAccessExpression(cur)) {
669
+ if (ts.isCallExpression(cur)) {
670
+ const nm = calleeName(cur.expression);
671
+ if (nm === "jest.fn" || nm === "vi.fn" || nm === "jest.spyOn" || nm === "vi.spyOn" ||
672
+ nm === "sinon.spy" || nm === "sinon.stub")
673
+ return true;
674
+ }
675
+ cur = ts.isCallExpression(cur) ? cur.expression : cur.expression;
676
+ }
677
+ return false;
678
+ };
679
+ const walk = (n) => {
680
+ if (ts.isVariableDeclaration(n) && n.initializer && ts.isIdentifier(n.name) &&
681
+ isDoubleInit(n.initializer)) {
682
+ out.add(n.name.text);
683
+ }
684
+ ts.forEachChild(n, walk);
685
+ };
686
+ walk(scope);
687
+ return out;
688
+ }
689
+ /** JS26: a fake-timer install and a setTimeout/setInterval arm both live under
690
+ * `scope`, but no flush/advance does (in scope or a sibling lifecycle hook). The
691
+ * scheduled callback never fires, so any assertion runs against un-mutated state. */
692
+ function timerInstalledNeverAdvanced(scope, timer, sf) {
693
+ // install present in scope, no flush in scope — order does not matter (a frozen
694
+ // timer that is never advanced is vacuous regardless of where the install sits).
695
+ if (!callMatchesUnder(scope, sf, FAKE_TIMER_INSTALL))
696
+ return false;
697
+ if (callMatchesUnder(scope, sf, FAKE_TIMER_FLUSH))
698
+ return false;
699
+ // a sibling hook may install or flush; reuse the JS-wide hook scan. If a hook
700
+ // flushes, the callback fires, so suppress.
701
+ if (hookControlsTimerFlush(timer, sf))
702
+ return false;
703
+ return true;
704
+ }
705
+ /** True if an enclosing describe/top-level afterEach/afterAll flushes timers — the
706
+ * JS26 suppression twin of hookControlsTimer (which also accepts a before-install).
707
+ * Here only a teardown flush matters: an install in a hook does not advance. */
708
+ function hookControlsTimerFlush(timer, sf) {
709
+ const scan = (statements) => {
710
+ for (const stmt of statements) {
711
+ if (!ts.isExpressionStatement(stmt) || !ts.isCallExpression(stmt.expression))
712
+ continue;
713
+ const hook = calleeName(stmt.expression.expression).split(".")[0];
714
+ const cb = getTestCallback(stmt.expression);
715
+ if (!cb)
716
+ continue;
717
+ if (callMatchesUnder(cb, sf, FAKE_TIMER_FLUSH) &&
718
+ (SETUP_HOOKS.has(hook) || TEARDOWN_HOOKS.has(hook)))
719
+ return true;
720
+ }
721
+ return false;
722
+ };
723
+ if (scan(sf.statements))
724
+ return true;
725
+ let pn = timer.parent;
726
+ while (pn) {
727
+ if ((ts.isArrowFunction(pn) || ts.isFunctionExpression(pn) || ts.isFunctionDeclaration(pn)) &&
728
+ pn.parent && ts.isCallExpression(pn.parent) && pn.parent.arguments.includes(pn)) {
729
+ const suiteRoot = calleeName(pn.parent.expression).split(".")[0];
730
+ if (SUITE_ROOTS.has(suiteRoot) && pn.body && ts.isBlock(pn.body) && scan(pn.body.statements))
731
+ return true;
732
+ }
733
+ pn = pn.parent;
734
+ }
735
+ return false;
736
+ }
331
737
  function getTestCallback(call) {
332
738
  for (const arg of call.arguments) {
333
739
  if (ts.isArrowFunction(arg) || ts.isFunctionExpression(arg))
@@ -480,6 +886,7 @@ export function analyze(sf) {
480
886
  const findings = [];
481
887
  const file = sf.fileName;
482
888
  const text = sf.getFullText();
889
+ const level = detectPyramidLevel(sf);
483
890
  // Fake-timer / flush presence suppresses the JS7 timer arm: if the test fakes
484
891
  // or drives timers anywhere, a setTimeout/setInterval callback is flushed
485
892
  // synchronously and its assertion does run. Covers Jest, Vitest and Sinon
@@ -578,7 +985,9 @@ export function analyze(sf) {
578
985
  else if (hasAssertion(cb)) {
579
986
  const ms = matchersUnder(cb);
580
987
  if (ms.length > 0 && ms.every((m) => SNAPSHOT_MATCHERS.has(m))) {
581
- push(line, "JS3", "the only assertion is a snapshot");
988
+ push(line, "JS3", hasEmptyInlineSnapshot(cb)
989
+ ? "the only assertion is an empty inline snapshot — it passes by writing itself on first run"
990
+ : "the only assertion is a snapshot");
582
991
  }
583
992
  // C21: the test has at least one assertion in its own scope and none of
584
993
  // them is guaranteed to run unconditionally (all behind a condition, a
@@ -596,6 +1005,104 @@ export function analyze(sf) {
596
1005
  push(line, "C21", "every assertion is guarded by a condition");
597
1006
  }
598
1007
  }
1008
+ // JS23: expect.assertions(N) with N a numeric literal, but fewer
1009
+ // unconditional reachable non-nested expect() matcher calls than N — the
1010
+ // guard can never be satisfied. FP-averse: any expect in a loop, branch,
1011
+ // callback, or helper is indeterminate, so the count is undercounted and a
1012
+ // shortfall is only reported when it is provable. expect.hasAssertions()
1013
+ // carries no count and is skipped. Distinct from the deliberately-skipped JS16.
1014
+ let assertionsGuard = null;
1015
+ forEachNoNesting(cb.body, (n) => {
1016
+ if (ts.isCallExpression(n)) {
1017
+ const cnt = expectAssertionsCount(n);
1018
+ if (cnt !== null)
1019
+ assertionsGuard = { line: lineOf(sf, n), n: cnt };
1020
+ }
1021
+ });
1022
+ if (assertionsGuard !== null) {
1023
+ const guard = assertionsGuard;
1024
+ const acc = expectAccounting(cb.body);
1025
+ if (!acc.indeterminate && acc.unconditional < guard.n) {
1026
+ push(guard.line, "JS23", `expect.assertions(${guard.n}) but only ${acc.unconditional} unconditional expect() call(s) run`);
1027
+ }
1028
+ }
1029
+ // JS8 (spyOn form): test-local. A spyOn target with a canned return that is
1030
+ // also an expect subject IN THE SAME test body is a self-mock — the test
1031
+ // asserts the canned value. Scoped to cb.body so a spy in one test never
1032
+ // matches an assertion in another (the jest.mock module form, hoisted
1033
+ // file-wide, is handled separately after the walk).
1034
+ const spied = cannedSpyTargets(cb.body);
1035
+ if (spied.size > 0) {
1036
+ const subjects = expectSubjectRoots(cb.body);
1037
+ for (const [tr, ln] of spied) {
1038
+ if (subjects.has(tr)) {
1039
+ push(ln, "JS8", `${tr} is spied with a canned return and asserted directly`);
1040
+ break;
1041
+ }
1042
+ }
1043
+ }
1044
+ // JS25: every assertion lives inside an array-iterator callback
1045
+ // (forEach/map/filter/some/every/flatMap) and none on the test's own
1046
+ // spine — so on an empty collection the callback never runs and zero
1047
+ // assertions execute. The verified hole between C2/C2b (hasAssertion
1048
+ // descends into callbacks, so it finds one) and C21 (its ownAsserts stop
1049
+ // at callbacks, so it sees none). FP guards: any own-scope assertion, a
1050
+ // non-empty array-literal receiver, or an expect.assertions/hasAssertions
1051
+ // guard suppress it.
1052
+ if (hasAssertion(cb) && assertionsOnlyInArrayIterator(cb.body) &&
1053
+ !iteratesNonEmptyArrayLiteral(cb.body) && !hasAssertionCountGuard(cb.body)) {
1054
+ push(line, "JS25", "the only assertion is inside an array-iterator callback; it runs zero times on an empty collection");
1055
+ }
1056
+ // JS27: every expect-chain matcher is in the toHaveBeenCalled* family AND
1057
+ // each such subject root is a locally-created double (jest.fn/vi.fn/spyOn).
1058
+ // The test confirms it called the double it set up, never the unit's output
1059
+ // or state (J3). FP guards: any non-call-tracking assertion suppresses it,
1060
+ // and it is gated to the unit level (a logger-spy call check is legit at
1061
+ // integration/e2e). Sibling of JS8.
1062
+ if (level === "unit") {
1063
+ const ms27 = expectMatcherSubjects(cb.body);
1064
+ if (ms27.length > 0 && ms27.every((m) => CALL_TRACKING_MATCHERS.has(m.matcher))) {
1065
+ const doubles = localDoubleRoots(cb.body);
1066
+ if (ms27.every((m) => m.root !== null && doubles.has(m.root))) {
1067
+ push(line, "JS27", "the only oracle is a toHaveBeenCalled* check on a local double; assert the unit's output or state");
1068
+ }
1069
+ }
1070
+ }
1071
+ // C11a: self-confirming literal — the expected value is bound from the
1072
+ // same call/expression under test. `const e = foo(); expect(foo()).toBe(e)`
1073
+ // can never fail: both sides evaluate the SUT, so the oracle confirms the
1074
+ // code against itself (J2). Static, low-FP corner of the circular-oracle
1075
+ // family. FP guard: the bound initializer must be provably the SUT call,
1076
+ // i.e. its source text equals the expect subject's source text exactly, and
1077
+ // it contains a call (a plain literal binding is not self-confirming).
1078
+ {
1079
+ const inits = new Map(); // var name -> initializer text
1080
+ forEachNoNesting(cb.body, (n) => {
1081
+ if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && n.initializer &&
1082
+ containsCall(n.initializer)) {
1083
+ inits.set(n.name.text, n.initializer.getText(sf).replace(/\s+/g, " ").trim());
1084
+ }
1085
+ });
1086
+ if (inits.size > 0) {
1087
+ forEachNoNesting(cb.body, (n) => {
1088
+ if (!ts.isCallExpression(n))
1089
+ return;
1090
+ const ch = expectChain(n);
1091
+ if (!ch || ch.negated || !ch.subject)
1092
+ return;
1093
+ if (!EQ_MATCHERS_ANY.has(ch.matcher))
1094
+ return;
1095
+ const a0 = ch.args[0];
1096
+ if (!a0 || !ts.isIdentifier(a0))
1097
+ return;
1098
+ const initText = inits.get(a0.text);
1099
+ if (initText && containsCall(ch.subject) &&
1100
+ ch.subject.getText(sf).replace(/\s+/g, " ").trim() === initText) {
1101
+ push(lineOf(sf, n), "C11a", "expected value is bound from the same call under test");
1102
+ }
1103
+ });
1104
+ }
1105
+ }
599
1106
  // D7: anonymous test (empty or missing description)
600
1107
  const desc = node.arguments[0];
601
1108
  const emptyStr = (d) => d !== undefined &&
@@ -737,8 +1244,9 @@ export function analyze(sf) {
737
1244
  // C7 self-compare
738
1245
  push(lineOf(sf, node), "C7", "expected value is the same expression as the subject");
739
1246
  }
740
- else if (EQUALITY_MATCHERS.has(chain.matcher) && arg && ts.isNumericLiteral(arg)) {
741
- // C8 exact float
1247
+ else if (EQUALITY_MATCHERS.has(chain.matcher) && arg && ts.isNumericLiteral(arg) && !isLiteral(subj)) {
1248
+ // C8 exact float — on a real (non-literal) subject. A literal-vs-literal
1249
+ // float (expect(0.1).toBe(0.3)) is JS30, the stronger both-literals lane.
742
1250
  const v = Number(arg.text);
743
1251
  if (!Number.isInteger(v) && v !== 0 && v !== 1) {
744
1252
  push(lineOf(sf, node), "C8", "exact equality on a float; use toBeCloseTo");
@@ -748,6 +1256,25 @@ export function analyze(sf) {
748
1256
  // C18 sensitive equality: compares the stringified form to a literal
749
1257
  push(lineOf(sf, node), "C18", "compares the stringified form of a value to a literal");
750
1258
  }
1259
+ // JS30: literal-vs-literal through an equality matcher — both operands are
1260
+ // fixed at parse time, so the comparison is independent of any production
1261
+ // code. Different-token only (the same-token case is C5's "both sides are
1262
+ // the same literal"); object/array literals (reference-equality, false-red)
1263
+ // and template literals with substitutions (C18 lane) are excluded by
1264
+ // isLiteral. Broader matcher set than C5/C7 (adds toBeCloseTo + chai/AVA
1265
+ // equal/equals/eql/is). Negation already filtered (chain.negated).
1266
+ if (EQ_MATCHERS_ANY.has(chain.matcher) && isLiteral(subj) && isLiteral(arg) &&
1267
+ subj.getText(sf) !== arg.getText(sf)) {
1268
+ push(lineOf(sf, node), "JS30", "both operands are literals; the comparison is fixed at parse time");
1269
+ }
1270
+ // C8b: toBeCloseTo called with no precision argument — only the expected
1271
+ // value, so the default 2-digit tolerance applies. The js analogue of
1272
+ // assertAlmostEqual/pytest.approx with no tolerance: implicit-precision only.
1273
+ // A literal-vs-literal toBeCloseTo is JS30's stronger lane, so skip it here.
1274
+ if (chain.matcher === "toBeCloseTo" && chain.args.length === 1 &&
1275
+ !(isLiteral(subj) && isLiteral(arg))) {
1276
+ push(lineOf(sf, node), "C8b", "toBeCloseTo with no precision; the default 2-digit tolerance may be too loose");
1277
+ }
751
1278
  }
752
1279
  // C16 nondeterminism
753
1280
  if (!fakeTimers) {
@@ -809,8 +1336,32 @@ export function analyze(sf) {
809
1336
  push(lineOf(sf, node), "JS7", `assertion deferred into a floating .${leaf}(); may not run before the test ends`);
810
1337
  }
811
1338
  }
1339
+ // JS26: fake timers installed but never advanced. The setTimeout/setInterval
1340
+ // is armed, fake timers freeze it, and nothing in the same test scope (nor a
1341
+ // sibling before/after hook) calls runAllTimers/advanceTimersByTime/tick — so
1342
+ // the scheduled callback never fires and any assertion runs against the
1343
+ // un-mutated initial state. Opposite of C16 (uncontrolled timer): here the
1344
+ // timer is controlled but not advanced. Requires an assertion in scope so a
1345
+ // pure setup arm with no oracle is not flagged. Kept low (legit "assert
1346
+ // nothing happened yet" exists). FP guard: any flush in the body or a
1347
+ // before/afterEach of the enclosing describe suppresses it.
1348
+ if (isTimer) {
1349
+ const scope = enclosingTestScope(node);
1350
+ if (timerInstalledNeverAdvanced(scope, node, sf) && hasAssertion(scope)) {
1351
+ push(lineOf(sf, node), "JS26", `${name} scheduled under fake timers that are never advanced; the callback never fires`);
1352
+ }
1353
+ }
812
1354
  }
813
1355
  }
1356
+ // JS29: an expect(...).resolves/.rejects chain that is a bare statement, not
1357
+ // awaited, returned, or collected. The matcher only settles asynchronously, so
1358
+ // a floating chain finishes green before it resolves. The statically-provable
1359
+ // subset of JS20 (no type inference needed: the explicit .resolves/.rejects
1360
+ // marker is the signal). FP guard: only the explicit resolves/rejects member
1361
+ // (a plain promise stays JS20-out); awaited/returned/collected suppresses it.
1362
+ if (expectIsResolvesRejects(node) && !isObservedAsync(node)) {
1363
+ push(lineOf(sf, node), "JS29", "resolves/rejects assertion is not awaited or returned; it settles after the test ends");
1364
+ }
814
1365
  // JS13: a sync query used as a loose statement (result never asserted).
815
1366
  // Testing Library getBy*/queryBy*, and Vue Test Utils findComponent /
816
1367
  // findAllComponents always, or find/findAll with a string selector (which
@@ -821,9 +1372,18 @@ export function analyze(sf) {
821
1372
  const isVueComponentQuery = qleaf === "findComponent" || qleaf === "findAllComponents";
822
1373
  const isVueSelectorQuery = (qleaf === "find" || qleaf === "findAll") &&
823
1374
  node.arguments.length > 0 && ts.isStringLiteral(node.arguments[0]);
824
- if (isRtlQuery || isVueComponentQuery || isVueSelectorQuery) {
1375
+ // A cy-rooted chain belongs to JS24, not JS13: cy.get("ul").find("li") ends in
1376
+ // .find("li") and would otherwise trip the Vue-selector heuristic (double-report).
1377
+ if ((isRtlQuery || isVueComponentQuery || isVueSelectorQuery) && rootIdent(node) !== "cy") {
825
1378
  push(lineOf(sf, node), "JS13", `${qleaf}() result is not asserted`);
826
1379
  }
1380
+ // JS24: the cy.* analogue of JS13 — a Cypress query chain (cy.get/find/
1381
+ // contains) as a statement with no terminating .should/.and and no expect
1382
+ // in a .then callback. Only query commands produce a subject; action
1383
+ // commands (click/type/visit/...) do work and stay clean.
1384
+ if (isUnassertedCyQuery(node)) {
1385
+ push(lineOf(sf, node), "JS24", `cy query (${qleaf}) result is not asserted`);
1386
+ }
827
1387
  }
828
1388
  // A runner `.each` table: it.each / test.each / describe.each (and fit/xit
829
1389
  // variants). Gate the each-table codes on a runner root so a plain helper
@@ -877,6 +1437,19 @@ export function analyze(sf) {
877
1437
  if (containsAssertion(node.tryBlock) && isHarmlessCatch(node.catchClause.block)) {
878
1438
  push(lineOf(sf, node), "JS11", "a failing assertion in try is swallowed by catch");
879
1439
  }
1440
+ else if (
1441
+ // JS31: the try calls production code that may throw, the catch neither
1442
+ // asserts on the exception, re-raises, nor calls fail() — so a unit that
1443
+ // STOPS throwing (a real regression) still passes green. Complement of JS11
1444
+ // (which owns the swallowed-assertion case): JS31 fires only when the try
1445
+ // has a call but NO assertion (otherwise JS11), and the catch is harmless.
1446
+ // FP guard: a catch that asserts on e / re-throws / calls fail() makes
1447
+ // isHarmlessCatch false; a toThrow/assert.throws oracle in the try would be
1448
+ // an assertion (so JS31 is skipped, the throw is covered).
1449
+ containsCall(node.tryBlock) &&
1450
+ catchSilentlySwallows(node.catchClause.block)) {
1451
+ push(lineOf(sf, node), "JS31", "try/catch swallows a possible throw with no assertion on the exception");
1452
+ }
880
1453
  }
881
1454
  // JS21: a matcher referenced but never called — `expect(x).toBe;` with no (),
882
1455
  // so the assertion object is built and dropped; nothing executes. The chain
package/dist/scan.d.ts CHANGED
@@ -9,6 +9,7 @@ export interface Config {
9
9
  export interface ScanOptions {
10
10
  config?: Config;
11
11
  cliDisable?: Set<string>;
12
+ cliEnable?: Set<string>;
12
13
  diagnostics?: boolean;
13
14
  }
14
15
  export declare function isTestFile(file: string): boolean;
package/dist/scan.js CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "falsegreen-js",
3
- "version": "0.4.0",
3
+ "version": "0.6.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
6
  "jest",