falsegreen-js 0.4.0 → 0.5.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,51 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.5.0] - 2026-06-28
10
+
11
+ ### Added
12
+ - `JS23` (high, J1): `expect.assertions(N)` with a numeric `N` higher than the unconditional,
13
+ reachable, non-nested `expect()` calls that can run. The guard can never be met, so the test
14
+ passes without ever exercising the count it claims. Fires only when `N` is a numeric literal
15
+ and the shortfall is provable: an `expect` in a loop, a branch, a `.then`/callback, or a
16
+ helper makes the count indeterminate and suppresses the finding. `expect.hasAssertions()`
17
+ carries no count and is skipped. This is the implemented sibling of the still-skipped JS16.
18
+ - `JS24` (low, J4): a Cypress query chain (`cy.get`/`cy.find`/`cy.contains`) used as a statement
19
+ with no terminating `.should`/`.and` and no `expect` inside a `.then` callback. The query
20
+ produces a subject that is never asserted, the cy.* analogue of JS13. Action commands
21
+ (`click`/`type`/`visit`/...) do work rather than just query, so a chain ending in one stays
22
+ clean, as does a chain that ends in `.should`/`.and` or asserts in `.then`.
23
+ - CLI `--enable <codes>` (and `--enable=...`): re-activates listed off or opt-in codes at their
24
+ catalog severity, flipping a default-off code on. It cannot raise a code above catalog
25
+ severity. `--disable` wins over `--enable`, so a code passed to both stays off.
26
+ - `examples/` tree (#47): a worked sample for every emitted code, a BAD test the scanner flags
27
+ paired with a CLEAN look-alike one token away that it leaves alone. Files are grouped by
28
+ RiskGroup (`effectiveness`, `execution`, `nondeterminism`, `dependency`), with `cypress.cy.ts`
29
+ for the Cypress code and `diagnostics.test.ts` for the opt-in maintainability group. C16 keeps
30
+ a separate frozen-clock file because the fake-timer signal is file-wide. `vitest.config.ts`
31
+ excludes `examples/**` from collection, and `test/examples.test.ts` scans each file with
32
+ `analyze(parse(...))` to assert every code fires in its file, with a drift guard that fails if a
33
+ new default-on code lands without an example. The config-audit-only PL series scans Jest/Vitest
34
+ config rather than a test file, so it has no test-file example.
35
+
36
+ ### Changed
37
+ - `JS8` now also catches the `jest.spyOn`/`vi.spyOn` form: a spy with a canned return
38
+ (`mockReturnValue`/`mockResolvedValue`/`mockImplementation`) whose spied target root is also an
39
+ `expect` subject. The test asserts the canned value, not real behaviour. Conservative
40
+ same-binding guard: spying a collaborator (a different object) stays clean, and asserting on
41
+ the spy handle itself (`expect(spy).toHaveBeenCalled()`) is not treated as the subject.
42
+ - `JS3` gains a distinct detail when the snapshot is an empty inline baseline:
43
+ `toMatchInlineSnapshot()` with no argument, or an empty or whitespace-only string baseline,
44
+ passes by writing itself on the first run. A populated inline snapshot keeps the existing
45
+ detail; the snapshot-only detection logic is unchanged.
46
+
47
+ ### Docs
48
+ - `CONTRIBUTING.md` documents the FP-boundary decisions that previously lived only in
49
+ source comments: the admission criteria for a new code (statically provable, FP-guarded,
50
+ ships with a CLEAN look-alike one token from the BAD, carries a catalog entry, clears the
51
+ panel and principal-reviewer gate) and the standing per-code rules for C44, C6, JS5, C16,
52
+ JS23, JS24, and JS8 (#51).
53
+
9
54
  ## [0.4.0] - 2026-06-28
10
55
 
11
56
  ### Fixed
@@ -176,7 +221,8 @@ All notable changes to this project are documented here. The format is based on
176
221
  - pre-commit hook (`.pre-commit-hooks.yaml`), CI matrix (Node 18/20/22), and an npm
177
222
  trusted-publishing release workflow.
178
223
 
179
- [Unreleased]: https://github.com/vinicq/falsegreen-js/compare/v0.4.0...HEAD
224
+ [Unreleased]: https://github.com/vinicq/falsegreen-js/compare/v0.5.0...HEAD
225
+ [0.5.0]: https://github.com/vinicq/falsegreen-js/compare/v0.4.0...v0.5.0
180
226
  [0.4.0]: https://github.com/vinicq/falsegreen-js/compare/v0.3.0...v0.4.0
181
227
  [0.3.0]: https://github.com/vinicq/falsegreen-js/compare/v0.2.0...v0.3.0
182
228
  [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.
@@ -152,6 +160,8 @@ line up in the research. `JS*` codes are ecosystem-specific.
152
160
  | JS18 | low | test takes a `done` callback instead of async/await — a mistimed `done` passes early |
153
161
  | JS21 | high | matcher referenced but never called (`expect(x).toBe` with no `()`) — the assertion never runs |
154
162
  | JS22 | high | empty `it.each`/`test.each` table — generated with zero cases, never runs |
163
+ | JS23 | high | `expect.assertions(N)` with fewer unconditional `expect()` calls than N — the guard can never be met |
164
+ | 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 |
155
165
 
156
166
  Each code carries a judgment tag (J1-J6) shared with the
157
167
  [falsegreen-skill](https://github.com/vinicq/falsegreen-skill) semantic framework.
@@ -186,8 +196,11 @@ Some catalog codes were reviewed and left out, on purpose:
186
196
  - **JS20** (a Promise compared without `resolves`/`rejects`): telling that a value is a
187
197
  Promise needs type information the AST does not carry, so it would be too noisy.
188
198
  - **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.
199
+ - **JS16** (`async` test with no `expect.assertions(n)`): the *absence* of a guard is not a
200
+ smell on its own; flagging it would fire on most async tests. The implemented sibling is
201
+ `JS23`, which fires on a present-but-unsatisfiable guard: `expect.assertions(N)` with a
202
+ numeric `N` higher than the unconditional `expect()` calls that can run, so the count can
203
+ never be met.
191
204
  - **JS14** (a giant inline snapshot): a readability and review-noise concern, not a
192
205
  false-green one. The snapshot still protects, so it belongs to the diagnostic group and is
193
206
  better served by `eslint-plugin-jest` (`no-large-snapshots`) as an opt-in lint rule.
@@ -226,7 +239,7 @@ Optional. `falsegreen.json`, `.falsegreenrc.json`, or a `"falsegreen"` key in
226
239
  }
227
240
  ```
228
241
 
229
- Precedence: CLI `--disable` > config `disable`/`severity` > catalog default.
242
+ 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
243
 
231
244
  ## Scope and honesty
232
245
 
package/dist/cases.js CHANGED
@@ -59,6 +59,8 @@ 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" },
62
64
  // --- diagnostic group (maintainability; default off, opt-in via --diagnostics
63
65
  // or config severity). These are NOT false-green: the test still protects. They
64
66
  // are a "plus" for test-code health, mirroring falsegreen's D/M group. -------
@@ -155,6 +157,8 @@ export const FIX_HINTS = {
155
157
  JS18: "use async/await instead of the done callback",
156
158
  JS21: "call the matcher (add ()) so the assertion executes",
157
159
  JS22: "add at least one row to the it.each/test.each table",
160
+ JS23: "make the unconditional expect() count match expect.assertions(N), or remove the guard",
161
+ JS24: "end the cy query in .should()/.and(), or assert in .then()",
158
162
  D1: "give each assertion a message, or split the test",
159
163
  D3: "remove the duplicate assertion",
160
164
  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
@@ -78,6 +78,58 @@ function expectRooted(e) {
78
78
  }
79
79
  return false;
80
80
  }
81
+ // --- JS24: Cypress query chain with no terminating assertion ---------------
82
+ const CY_QUERY_COMMANDS = new Set(["get", "find", "contains"]);
83
+ /** True if any `expect(...)` call appears under `scope` (any matcher form, or a
84
+ * bare expect). Used to keep a cy chain clean when it asserts inside a .then. */
85
+ function containsExpectCall(scope) {
86
+ let found = false;
87
+ const walk = (n) => {
88
+ if (found)
89
+ return;
90
+ if (ts.isCallExpression(n) && expectRooted(n)) {
91
+ found = true;
92
+ return;
93
+ }
94
+ ts.forEachChild(n, walk);
95
+ };
96
+ walk(scope);
97
+ return found;
98
+ }
99
+ /** True if a call chain rooted at `cy` ends in a query command (get/find/contains)
100
+ * and carries no terminating `.should`/`.and` and no `expect(...)` inside a `.then`
101
+ * callback — so it produces a subject that is never asserted. Action commands
102
+ * (click/type/visit/...) as the outermost call do something, so they stay clean.
103
+ * `expr` is the outermost call of the statement. */
104
+ function isUnassertedCyQuery(expr) {
105
+ if (rootIdent(expr) !== "cy")
106
+ return false;
107
+ // outermost command must be a query, not an action (action does work, not just query)
108
+ if (!ts.isPropertyAccessExpression(expr.expression))
109
+ return false;
110
+ if (!CY_QUERY_COMMANDS.has(expr.expression.name.text))
111
+ return false;
112
+ // scan the whole chain for a terminating assertion: any .should/.and, or an
113
+ // expect(...) inside a .then(cb) callback. Either keeps it clean.
114
+ let cur = expr;
115
+ const visit = (n) => {
116
+ if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression)) {
117
+ const m = n.expression.name.text;
118
+ if (m === "should" || m === "and")
119
+ return true;
120
+ if (m === "then") {
121
+ const cb = n.arguments.find((a) => ts.isArrowFunction(a) || ts.isFunctionExpression(a));
122
+ if (cb && containsExpectCall(cb))
123
+ return true;
124
+ }
125
+ }
126
+ let asserted = false;
127
+ ts.forEachChild(n, (c) => { if (visit(c))
128
+ asserted = true; });
129
+ return asserted;
130
+ };
131
+ return !visit(cur);
132
+ }
81
133
  function literalTruthiness(e) {
82
134
  if (!e)
83
135
  return null;
@@ -328,6 +380,139 @@ function matchersUnder(scope) {
328
380
  ts.forEachChild(scope, walk);
329
381
  return out;
330
382
  }
383
+ /** True if any snapshot matcher under `scope` is an inline snapshot with no
384
+ * baseline yet: `toMatchInlineSnapshot()` / `toThrowErrorMatchingInlineSnapshot()`
385
+ * with no argument, or an empty/whitespace-only string-literal baseline. On the
386
+ * first run the runner writes the snapshot from the output itself, so it passes
387
+ * by construction. A populated inline snapshot has a real baseline and is not this. */
388
+ function hasEmptyInlineSnapshot(scope) {
389
+ let found = false;
390
+ const walk = (n) => {
391
+ if (found)
392
+ return;
393
+ if (ts.isCallExpression(n)) {
394
+ const chain = expectChain(n);
395
+ if (chain && (chain.matcher === "toMatchInlineSnapshot" ||
396
+ chain.matcher === "toThrowErrorMatchingInlineSnapshot")) {
397
+ const a0 = chain.args[0];
398
+ if (a0 === undefined) {
399
+ found = true;
400
+ return;
401
+ }
402
+ if ((ts.isStringLiteral(a0) || ts.isNoSubstitutionTemplateLiteral(a0)) &&
403
+ a0.text.trim() === "") {
404
+ found = true;
405
+ return;
406
+ }
407
+ }
408
+ }
409
+ ts.forEachChild(n, walk);
410
+ };
411
+ ts.forEachChild(scope, walk);
412
+ return found;
413
+ }
414
+ /** The numeric N of an `expect.assertions(N)` call (N a numeric literal), else
415
+ * null. `expect.hasAssertions()` carries no count and is not this. */
416
+ function expectAssertionsCount(call) {
417
+ if (calleeName(call.expression) !== "expect.assertions")
418
+ return null;
419
+ const a0 = call.arguments[0];
420
+ if (a0 && ts.isNumericLiteral(a0))
421
+ return Number(a0.text);
422
+ return null;
423
+ }
424
+ /** JS23 expect accounting. `unconditional` is the count of expect-chain matcher
425
+ * calls guaranteed to run: a direct ExpressionStatement on the test body's spine
426
+ * (optionally awaited). `indeterminate` is true when any other expect-chain call
427
+ * exists in the body (in a loop, branch, try, switch, callback, .then, or an
428
+ * expression operand) — its run count cannot be proven, so a shortfall is not
429
+ * provable and JS23 must be suppressed (FP-averse: a false positive is worse than
430
+ * a miss). Stops at nested functions only for the spine count, but the
431
+ * indeterminate scan walks the whole body (a .then callback IS indeterminate). */
432
+ function expectAccounting(body) {
433
+ const spine = new Set();
434
+ let unconditional = 0;
435
+ for (const st of body.statements) {
436
+ if (!ts.isExpressionStatement(st))
437
+ continue;
438
+ let e = st.expression;
439
+ if (ts.isAwaitExpression(e))
440
+ e = e.expression;
441
+ if (ts.isCallExpression(e) && expectChain(e)) {
442
+ unconditional++;
443
+ spine.add(e);
444
+ }
445
+ }
446
+ // Anything that is not the proven-unconditional expect spine makes the count
447
+ // indeterminate: an off-spine expect chain (loop/branch/.then/operand), or any
448
+ // other call — a helper or setup call may carry assertions the count cannot see.
449
+ // Do not descend into a spine chain (its inner expect()/matcher calls are
450
+ // accounted, not separate helpers).
451
+ let indeterminate = false;
452
+ const walk = (n) => {
453
+ if (indeterminate)
454
+ return;
455
+ if (spine.has(n))
456
+ return; // whole spine chain already counted
457
+ if (ts.isCallExpression(n)) {
458
+ const nm = calleeName(n.expression);
459
+ if (nm !== "expect.assertions" && nm !== "expect.hasAssertions") {
460
+ indeterminate = true;
461
+ return;
462
+ }
463
+ }
464
+ ts.forEachChild(n, walk);
465
+ };
466
+ walk(body);
467
+ return { unconditional, indeterminate };
468
+ }
469
+ /** JS8 (spyOn form): collect `{root -> line}` for every jest.spyOn/vi.spyOn target
470
+ * whose return was canned (mockReturnValue/mockResolvedValue/mockRejectedValue/
471
+ * mockImplementation) under `scope`. Test-local on purpose: a spyOn is hoisted only
472
+ * within its own test body, unlike the module-wide jest.mock form. */
473
+ function cannedSpyTargets(scope) {
474
+ const out = new Map();
475
+ const sf = scope.getSourceFile();
476
+ const walk = (n) => {
477
+ if (ts.isCallExpression(n) &&
478
+ /^(mockReturnValue|mockResolvedValue|mockRejectedValue|mockImplementation)$/.test(calleeName(n.expression).split(".").pop() ?? "")) {
479
+ let base = n.expression;
480
+ while (ts.isPropertyAccessExpression(base) || ts.isCallExpression(base)) {
481
+ if (ts.isCallExpression(base)) {
482
+ const cn = calleeName(base.expression);
483
+ if (cn === "jest.spyOn" || cn === "vi.spyOn") {
484
+ const tr = base.arguments[0] && rootIdent(base.arguments[0]);
485
+ if (tr)
486
+ out.set(tr, lineOf(sf, n));
487
+ break;
488
+ }
489
+ }
490
+ base = ts.isCallExpression(base) ? base.expression
491
+ : base.expression;
492
+ }
493
+ }
494
+ ts.forEachChild(n, walk);
495
+ };
496
+ walk(scope);
497
+ return out;
498
+ }
499
+ /** Root identifiers used as an expect subject under `scope` (`expect(a.b).m()` -> "a"). */
500
+ function expectSubjectRoots(scope) {
501
+ const out = new Set();
502
+ const walk = (n) => {
503
+ if (ts.isCallExpression(n)) {
504
+ const chain = expectChain(n);
505
+ if (chain && chain.subject) {
506
+ const r = rootIdent(chain.subject);
507
+ if (r)
508
+ out.add(r);
509
+ }
510
+ }
511
+ ts.forEachChild(n, walk);
512
+ };
513
+ walk(scope);
514
+ return out;
515
+ }
331
516
  function getTestCallback(call) {
332
517
  for (const arg of call.arguments) {
333
518
  if (ts.isArrowFunction(arg) || ts.isFunctionExpression(arg))
@@ -578,7 +763,9 @@ export function analyze(sf) {
578
763
  else if (hasAssertion(cb)) {
579
764
  const ms = matchersUnder(cb);
580
765
  if (ms.length > 0 && ms.every((m) => SNAPSHOT_MATCHERS.has(m))) {
581
- push(line, "JS3", "the only assertion is a snapshot");
766
+ push(line, "JS3", hasEmptyInlineSnapshot(cb)
767
+ ? "the only assertion is an empty inline snapshot — it passes by writing itself on first run"
768
+ : "the only assertion is a snapshot");
582
769
  }
583
770
  // C21: the test has at least one assertion in its own scope and none of
584
771
  // them is guaranteed to run unconditionally (all behind a condition, a
@@ -596,6 +783,42 @@ export function analyze(sf) {
596
783
  push(line, "C21", "every assertion is guarded by a condition");
597
784
  }
598
785
  }
786
+ // JS23: expect.assertions(N) with N a numeric literal, but fewer
787
+ // unconditional reachable non-nested expect() matcher calls than N — the
788
+ // guard can never be satisfied. FP-averse: any expect in a loop, branch,
789
+ // callback, or helper is indeterminate, so the count is undercounted and a
790
+ // shortfall is only reported when it is provable. expect.hasAssertions()
791
+ // carries no count and is skipped. Distinct from the deliberately-skipped JS16.
792
+ let assertionsGuard = null;
793
+ forEachNoNesting(cb.body, (n) => {
794
+ if (ts.isCallExpression(n)) {
795
+ const cnt = expectAssertionsCount(n);
796
+ if (cnt !== null)
797
+ assertionsGuard = { line: lineOf(sf, n), n: cnt };
798
+ }
799
+ });
800
+ if (assertionsGuard !== null) {
801
+ const guard = assertionsGuard;
802
+ const acc = expectAccounting(cb.body);
803
+ if (!acc.indeterminate && acc.unconditional < guard.n) {
804
+ push(guard.line, "JS23", `expect.assertions(${guard.n}) but only ${acc.unconditional} unconditional expect() call(s) run`);
805
+ }
806
+ }
807
+ // JS8 (spyOn form): test-local. A spyOn target with a canned return that is
808
+ // also an expect subject IN THE SAME test body is a self-mock — the test
809
+ // asserts the canned value. Scoped to cb.body so a spy in one test never
810
+ // matches an assertion in another (the jest.mock module form, hoisted
811
+ // file-wide, is handled separately after the walk).
812
+ const spied = cannedSpyTargets(cb.body);
813
+ if (spied.size > 0) {
814
+ const subjects = expectSubjectRoots(cb.body);
815
+ for (const [tr, ln] of spied) {
816
+ if (subjects.has(tr)) {
817
+ push(ln, "JS8", `${tr} is spied with a canned return and asserted directly`);
818
+ break;
819
+ }
820
+ }
821
+ }
599
822
  // D7: anonymous test (empty or missing description)
600
823
  const desc = node.arguments[0];
601
824
  const emptyStr = (d) => d !== undefined &&
@@ -821,9 +1044,18 @@ export function analyze(sf) {
821
1044
  const isVueComponentQuery = qleaf === "findComponent" || qleaf === "findAllComponents";
822
1045
  const isVueSelectorQuery = (qleaf === "find" || qleaf === "findAll") &&
823
1046
  node.arguments.length > 0 && ts.isStringLiteral(node.arguments[0]);
824
- if (isRtlQuery || isVueComponentQuery || isVueSelectorQuery) {
1047
+ // A cy-rooted chain belongs to JS24, not JS13: cy.get("ul").find("li") ends in
1048
+ // .find("li") and would otherwise trip the Vue-selector heuristic (double-report).
1049
+ if ((isRtlQuery || isVueComponentQuery || isVueSelectorQuery) && rootIdent(node) !== "cy") {
825
1050
  push(lineOf(sf, node), "JS13", `${qleaf}() result is not asserted`);
826
1051
  }
1052
+ // JS24: the cy.* analogue of JS13 — a Cypress query chain (cy.get/find/
1053
+ // contains) as a statement with no terminating .should/.and and no expect
1054
+ // in a .then callback. Only query commands produce a subject; action
1055
+ // commands (click/type/visit/...) do work and stay clean.
1056
+ if (isUnassertedCyQuery(node)) {
1057
+ push(lineOf(sf, node), "JS24", `cy query (${qleaf}) result is not asserted`);
1058
+ }
827
1059
  }
828
1060
  // A runner `.each` table: it.each / test.each / describe.each (and fit/xit
829
1061
  // variants). Gate the each-table codes on a runner root so a plain helper
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.5.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",