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 +47 -1
- package/README.md +17 -4
- package/dist/cases.js +4 -0
- package/dist/cli.js +14 -2
- package/dist/rules.js +234 -2
- package/dist/scan.d.ts +1 -0
- package/dist/scan.js +0 -0
- package/package.json +1 -1
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.
|
|
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
|
|
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 = {
|
|
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",
|
|
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
|
-
|
|
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
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.
|
|
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",
|