falsegreen-js 0.1.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 ADDED
@@ -0,0 +1,40 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format is based on
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-06-22
10
+
11
+ ### Added
12
+
13
+ - Initial release. Deterministic AST scanner for false-green test smells in JS/TS.
14
+ - More false-green codes: C9 (broad `toThrow`), C37 (duplicate `it.each` case),
15
+ JS13 (loose RTL query never asserted).
16
+ - Opt-in maintainability group (default off, enable with `--diagnostics` or config
17
+ `severity`): D1 (assertion roulette), D3 (duplicate assert), D4 (`it.each` without
18
+ titled cases), D6 (`console.*` in a test), D7 (anonymous test), M2 (long test body).
19
+ - Parser via the TypeScript compiler API, covering `.js`, `.jsx`, `.ts`, `.tsx`,
20
+ `.mjs`, `.cjs`, `.mts`, `.cts`.
21
+ - Runner-agnostic assertion/test vocabulary: Jest, Vitest, Mocha + Chai, Jasmine, AVA,
22
+ `node:test`, tap, Cypress, Playwright, Testing Library (jest-dom matchers).
23
+ - Detection codes:
24
+ - Shared concept with `falsegreen` (Python): C2, C2b, C5, C7, C8, C16, CC.
25
+ - Shared, additional: C18 (sensitive equality on a stringified value), C21 (every
26
+ assertion is conditional).
27
+ - JS/TS-specific: JS1 (focused test), JS2 (expect with no matcher), JS3 (snapshot-only),
28
+ JS4 (skipped test), JS5 (async query/event not awaited), JS6 (empty describe),
29
+ JS7 (assertion in a non-awaited timer/then callback), JS9 (assertion in a dead literal
30
+ branch), JS11 (try/catch swallows the assertion).
31
+ - Custom assertion helpers (`assert*`/`expect*` naming, e.g. `util.assertEqual`) are
32
+ recognized as assertions, reducing C2b false positives.
33
+ - CLI: paths, `--staged`, `--json`, `--disable`, `--version`, `--help`. Exit codes
34
+ 0/10/20. Inline suppression `// falsegreen: ignore[CODE]`. Config via `falsegreen.json`,
35
+ `.falsegreenrc.json`, or a `falsegreen` key in `package.json`.
36
+ - pre-commit hook (`.pre-commit-hooks.yaml`), CI matrix (Node 18/20/22), and an npm
37
+ trusted-publishing release workflow.
38
+
39
+ [Unreleased]: https://github.com/vinicq/falsegreen-js/compare/v0.1.0...HEAD
40
+ [0.1.0]: https://github.com/vinicq/falsegreen-js/releases/tag/v0.1.0
package/CREDITS.md ADDED
@@ -0,0 +1,59 @@
1
+ # References
2
+
3
+ falsegreen-js detects false-green test smells in JS/TS. Its catalog draws on the
4
+ academic test-smell literature and on existing detection tools. This file credits
5
+ those sources and maps each to the codes it informs.
6
+
7
+ ## Founding and conceptual
8
+
9
+ - **van Deursen, Moonen, van den Bergh, Kok (2001).** "Refactoring Test Code." XP 2001.
10
+ The original catalog of 11 test smells. Source of the general vocabulary.
11
+ - **Delplanque, Ducasse, Polito, Black, Etien (2019).** "Rotten Green Tests." ICSE 2019.
12
+ Green tests whose assertions never execute. The conceptual origin of the
13
+ "false-green" framing and of codes JS2, JS6, JS9, JS11 (assertion present but
14
+ unreachable). Cross-language extension: EMSE 2021.
15
+
16
+ ## JS/TS empirical and tooling
17
+
18
+ - **Jorge, D. N. (2023).** "Uma investigação sobre Test Smells em códigos de testes
19
+ JavaScript." PhD thesis, PPGCC/UFCG. Static analysis of 16 test smells over 65 JS
20
+ projects. Closest sibling study; maps to C2, C2b, C5, C16, JS4.
21
+ - **Silva, A. C. (2022).** "Identificação e Caracterização de Test Smells em JavaScript."
22
+ PUC Minas. Proposes the JS-specific smells **Only Test** and **Complex Snapshot** —
23
+ the academic precedent for **JS1** and **JS3**.
24
+ - **Oliveira et al. (2024).** "SNUTS.js: Sniffing Nasty Unit Test Smells in JavaScript."
25
+ SBES 2024. JS test-smell detector; informs C8 (sensitive equality) scope.
26
+ - **Oliveira et al. (2025).** "Identifying and Addressing Test Smells in JavaScript: A
27
+ Developer-Centric Study." SBES 2025. Motivation: developers miss subtle smells.
28
+
29
+ ## Detection-tool baselines
30
+
31
+ - **Peruma, Almalki, Newman, Mkaouer, Ouni, Palomba (2020).** "tsDetect: An Open Source
32
+ Test Smells Detection Tool." ESEC/FSE 2020. The de facto Java baseline (~96% precision).
33
+ - **marabesi/smelly-test.** JS/TS test-smell detector (TypeScript compiler API), the
34
+ closest tooling analogue; informs JS6 (empty describe).
35
+ - **Panichella, Panichella, Beller, Zaidman et al. (2022).** "Test Smells 20 Years Later."
36
+ EMSE. Methodological caution: catalog agreement is not perceived quality.
37
+
38
+ ## React / frontend (scope note)
39
+
40
+ - **Ferreira & Valente (2023).** "Detecting code smells in React-based Web apps." IST 155.
41
+ React **production** code smells (ReactSniffer). Cited to mark the boundary:
42
+ those are not test smells and are out of scope for a test-file scanner.
43
+
44
+ ## Code-to-source map
45
+
46
+ | Code | Primary source(s) |
47
+ |---|---|
48
+ | C2, C2b | van Deursen 2001; Jorge 2023 (Empty/Unknown Test) |
49
+ | C5, C7 | Redundant Assertion (Jorge 2023; tsDetect) |
50
+ | C8 | Sensitive Equality (SNUTS.js 2024) |
51
+ | C16 | Sleepy Test (Jorge 2023) |
52
+ | JS1 | Only Test (Silva 2022) |
53
+ | JS3 | Complex Snapshot (Silva 2022) |
54
+ | JS2, JS6, JS9, JS11 | Rotten Green Tests (Delplanque 2019) |
55
+ | JS4 | Ignored Test (Jorge 2023; tsDetect) |
56
+ | JS5 | Testing Library async guidance (community practice) |
57
+ | CC | community practice (commented-out assertion) |
58
+
59
+ Detailed per-source notes and the running research live in a separate private study.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vinicius Queiroz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,167 @@
1
+ # falsegreen-js
2
+
3
+ Find JavaScript/TypeScript unit tests that give false positives: green tests that
4
+ protect nothing, and tests that pass while asserting the wrong thing. Deterministic
5
+ AST scan, no code execution. Sibling of [`falsegreen`](https://github.com/vinicq/falsegreen)
6
+ (the Python scanner); same contract, JS/TS rule set.
7
+
8
+ Covers `.js`, `.jsx`, `.ts`, `.tsx`, `.mjs`, `.cjs`, `.mts`, `.cts`.
9
+
10
+ ## Why
11
+
12
+ A test can be green and still protect nothing: an empty body, an assertion that is
13
+ never reached, `expect(x).toBe(x)`, `expect(value)` with no matcher, a focused
14
+ `it.only` that silently parks the rest of the suite, a `findByText` that is never
15
+ awaited. AI-generated tests produce these in bulk. This tool flags the mechanical
16
+ patterns a parser can prove, before they reach review.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ npm install -D falsegreen-js
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ ```bash
27
+ npx falsegreen-js # scan cwd
28
+ npx falsegreen-js src test # scan paths
29
+ npx falsegreen-js --staged # only test files staged in git (pre-commit)
30
+ npx falsegreen-js --json # machine-readable output
31
+ npx falsegreen-js --disable C7,JS3
32
+ ```
33
+
34
+ Exit code: `0` clean, `10` low-confidence only, `20` high-confidence present. Wire it
35
+ into CI or a pre-commit hook and let exit `20` block the commit.
36
+
37
+ Suppress a single finding inline:
38
+
39
+ ```ts
40
+ expect(user.id).toBe(user.id); // falsegreen: ignore[C7]
41
+ expect(x); // falsegreen: ignore
42
+ ```
43
+
44
+ ## Runner coverage
45
+
46
+ Runner-agnostic. The assertion and test vocabulary spans Jest, Vitest, Mocha + Chai,
47
+ Jasmine, AVA, `node:test`, tap, Cypress, Playwright, and Testing Library
48
+ (`@testing-library/*` with `jest-dom` / `jasmine-dom` matchers and `user-event`).
49
+ `expect().matcher()`, chai `expect().to`, `assert`, `x.should`, and AVA `t.is` all
50
+ count as real assertions, so a Mocha or AVA test is not mistaken for one that never
51
+ checks anything.
52
+
53
+ Note: component files (`.vue`, `.svelte`, `.astro`, `.marko`) and templates (`.html`)
54
+ are not test files. Tests for those frameworks are written in `.spec`/`.test` files in
55
+ the eight extensions above, which is what the scanner reads.
56
+
57
+ ## Case catalog
58
+
59
+ Codes shared with `falsegreen` (Python) keep the same id, so cross-language results
60
+ line up in the research. `JS*` codes are ecosystem-specific.
61
+
62
+ | Code | Confidence | What it flags |
63
+ |---|---|---|
64
+ | C2 | high | test with no check at all (empty body) |
65
+ | C2b | low | test calls code but asserts nothing |
66
+ | C5 | high | always-true check (`expect(true).toBe(true)`, `assert(1)`) |
67
+ | C7 | high | compares a thing to itself (`expect(x).toBe(x)`) |
68
+ | C8 | low | exact equality on a float (use `toBeCloseTo`) |
69
+ | C9 | low | `toThrow()` with no error type or message — accepts any error |
70
+ | C16 | low | result depends on `Date.now`, `Math.random`, or a fixed timer |
71
+ | C18 | low | compares `String(x)` / `JSON.stringify(x)` / `` `${x}` `` to a literal (formatting, not value) |
72
+ | C21 | low | every assertion is conditional — none runs unconditionally |
73
+ | C37 | low | duplicate case in `it.each`/`test.each` — the same scenario runs twice |
74
+ | CC | low | commented-out assertion |
75
+ | JS1 | high | focused test (`it.only` / `fit`) silently skips the rest of the suite |
76
+ | JS2 | high | `expect(x)` with no matcher — the assertion never runs |
77
+ | JS3 | low | snapshot is the only assertion |
78
+ | JS4 | low | skipped test (`it.skip` / `xit` / `it.todo`) never runs |
79
+ | JS5 | low | async query/event not awaited (`findBy*` / `waitFor` / `user-event`) |
80
+ | JS6 | high | empty `describe`/`suite` — the suite is green but runs nothing |
81
+ | JS7 | low | assertion inside a non-awaited `setTimeout`/`then` callback — may run after the test ends |
82
+ | JS9 | high | assertion in a dead branch (`if(false)` / `if(true){}else`) — never runs |
83
+ | JS11 | low | `try/catch` swallows the assertion — a failing `expect` is caught, test stays green |
84
+ | JS13 | low | query (`getBy*`/`queryBy*`) as a loose statement — its result is never asserted |
85
+
86
+ Each code carries a judgment tag (J1-J6) shared with the
87
+ [falsegreen-skill](https://github.com/vinicq/falsegreen-skill) semantic framework.
88
+
89
+ ### Opt-in: maintainability group (default off)
90
+
91
+ These are **not** false-green — the test still protects something — so they are off by
92
+ default. Enable them with `--diagnostics`, or per code via config `severity`. They are a
93
+ "plus" for test-code health, mirroring falsegreen's diagnostic/coupling groups.
94
+
95
+ | Code | Group | What it flags |
96
+ |---|---|---|
97
+ | D1 | diagnostic | assertion roulette — many assertions in one test |
98
+ | D3 | diagnostic | duplicate assert — the same assertion repeated |
99
+ | D4 | diagnostic | `it.each`/`test.each` without titled cases (index-only) |
100
+ | D6 | diagnostic | `console.*` in a test body |
101
+ | D7 | diagnostic | anonymous test — empty or missing description |
102
+ | M2 | coupling | test body exceeds the line-count threshold |
103
+
104
+ ```bash
105
+ npx falsegreen-js --diagnostics # include D*/M* as warnings
106
+ ```
107
+
108
+ ### Roadmap (researched, not yet active)
109
+
110
+ Tracked in the research hub, pending implementation: JS8 (mocking the unit under test),
111
+ JS10 (async test with no `expect.assertions` and the assertion only in a `catch`), JS12
112
+ (`render(<C/>)`/`mount()` with no query or assertion), JS13 (a `getBy*`/`queryBy*` query
113
+ as a loose statement), and a general Mystery Guest / external-resource code.
114
+
115
+ ### What carries over from falsegreen, what does not
116
+
117
+ Ported (same concept): C2, C2b, C5, C7, C8, C16, CC.
118
+
119
+ Python-only, not applicable to JS/TS: pytest collection rules (C4 family), `pytest.raises`
120
+ breadth (C9/C19/C27/C28), fixtures and `os.environ`/global-state codes (C23/C24/C29),
121
+ sklearn/torch/tensorflow metric and seed codes (C33, parts of C16), xfail (C25), and the
122
+ xunit/`self.assert*` codes. These have no JS equivalent or need a different signal.
123
+
124
+ JS/TS-only (new here): JS1-JS5 above. The `describe.only`/skip, snapshot, no-matcher,
125
+ and not-awaited patterns are specific to the JS test runners and Testing Library.
126
+
127
+ ## Configuration
128
+
129
+ Optional. `falsegreen.json`, `.falsegreenrc.json`, or a `"falsegreen"` key in
130
+ `package.json`:
131
+
132
+ ```json
133
+ {
134
+ "disable": ["C8"],
135
+ "exclude": ["**/legacy/**"],
136
+ "severity": { "JS3": "off", "C16": "high" }
137
+ }
138
+ ```
139
+
140
+ Precedence: CLI `--disable` > config `disable`/`severity` > catalog default.
141
+
142
+ ## Scope and honesty
143
+
144
+ This is a static scanner. It owns what the structure proves. Two things it does not
145
+ decide: whether the expected value contradicts the intended behavior, and whether the
146
+ test re-implements the production logic. Those are semantic and belong to the
147
+ `falsegreen-skill` LLM pass. Precision over recall: a softened heuristic that misses a
148
+ case is preferred to one that flags correct code.
149
+
150
+ ## References
151
+
152
+ The catalog is grounded in the test-smell literature. Direct influences: the
153
+ rotten-green-test work that names this whole family (Delplanque et al., ICSE 2019),
154
+ the founding test-smell refactoring catalog (van Deursen et al., XP 2001), the
155
+ JS/TS empirical studies (Jorge, UFCG 2023; Silva, PUC Minas 2022 — the academic
156
+ precedent for the focused-test and snapshot codes; Oliveira et al., SBES 2024/2025),
157
+ and the detection-tool baselines (tsDetect, Peruma et al., 2020). Full list and the
158
+ code-to-source mapping in [CREDITS.md](CREDITS.md).
159
+
160
+ ## Status
161
+
162
+ `0.1.0`, early. The rule set is a deterministic core; the full JS/TS smell catalog is
163
+ tracked as research in the private audit hub. Issues and PRs welcome.
164
+
165
+ ## License
166
+
167
+ MIT, Vinicius Queiroz.
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Case catalog for falsegreen-js. Mirrors falsegreen (Python) where the smell is
3
+ * the same concept (shared C-codes, so cross-language paper comparison lines up),
4
+ * plus JS/TS-specific codes (JS-prefix) for ecosystem-only patterns.
5
+ *
6
+ * confidence: "high" => blocks (exit 20); "low" => warns (exit 10); "off" => silent.
7
+ * judgment: which semantic question (J1-J6, see falsegreen-skill) the code belongs to.
8
+ */
9
+ export type Confidence = "high" | "low" | "off";
10
+ export declare const JUDGMENTS: Record<string, string>;
11
+ export interface CaseDef {
12
+ title: string;
13
+ confidence: Confidence;
14
+ judgment: keyof typeof JUDGMENTS;
15
+ }
16
+ export declare const CASES: Record<string, CaseDef>;
17
+ /** Default thresholds for the diagnostic group (overridable later via config). */
18
+ export declare const DIAGNOSTIC_THRESHOLDS: {
19
+ assertionRoulette: number;
20
+ longTest: number;
21
+ };
22
+ export declare function groupOf(code: string): "false-positive" | "diagnostic" | "coupling";
package/dist/cases.js ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Case catalog for falsegreen-js. Mirrors falsegreen (Python) where the smell is
3
+ * the same concept (shared C-codes, so cross-language paper comparison lines up),
4
+ * plus JS/TS-specific codes (JS-prefix) for ecosystem-only patterns.
5
+ *
6
+ * confidence: "high" => blocks (exit 20); "low" => warns (exit 10); "off" => silent.
7
+ * judgment: which semantic question (J1-J6, see falsegreen-skill) the code belongs to.
8
+ */
9
+ export const JUDGMENTS = {
10
+ J1: "does the assertion actually run?",
11
+ J2: "is the oracle independent of the code?",
12
+ J3: "does it exercise the real unit, or a stand-in?",
13
+ J4: "does it check enough, and the right thing?",
14
+ J5: "is it coupled to internals it should not see?",
15
+ J6: "does it pass in isolation, or only via shared state?",
16
+ };
17
+ export const CASES = {
18
+ // --- shared concept with falsegreen (same code id) -----------------------
19
+ C2: { title: "test with no check at all (empty body)", confidence: "high", judgment: "J1" },
20
+ C2b: { title: "test calls things but checks nothing", confidence: "low", judgment: "J1" },
21
+ C5: { title: "always-true check (expect(true).toBe(true), assert(1))", confidence: "high", judgment: "J2" },
22
+ C7: { title: "compares a thing to itself (expect(x).toBe(x))", confidence: "high", judgment: "J2" },
23
+ C8: { title: "exact equality on a float (fails on rounding, not bugs)", confidence: "low", judgment: "J4" },
24
+ C16: { title: "result depends on time, randomness or a fixed timer", confidence: "low", judgment: "J1" },
25
+ C18: { title: "compares String()/JSON.stringify()/`${x}` of a value to a literal (checks formatting, not the value)", confidence: "low", judgment: "J2" },
26
+ C21: { title: "every assertion is conditional — none runs unconditionally", confidence: "low", judgment: "J1" },
27
+ C9: { title: "expect(...).toThrow() with no error type or message — accepts any error", confidence: "low", judgment: "J4" },
28
+ C37: { title: "duplicate case in it.each/test.each — the same scenario runs twice", confidence: "low", judgment: "J4" },
29
+ CC: { title: "commented-out assertion (check switched off)", confidence: "low", judgment: "J1" },
30
+ // --- JS/TS ecosystem-specific --------------------------------------------
31
+ JS1: { title: "focused test (it.only / fit / describe.only) silently skips the rest of the suite", confidence: "high", judgment: "J1" },
32
+ JS2: { title: "expect(x) with no matcher — the assertion is never executed", confidence: "high", judgment: "J1" },
33
+ JS3: { title: "snapshot is the only assertion (toMatchSnapshot generated from the output itself)", confidence: "low", judgment: "J2" },
34
+ JS4: { title: "skipped test (it.skip / xit / xdescribe / it.todo) never runs", confidence: "low", judgment: "J1" },
35
+ JS5: { title: "async query/event not awaited (findBy* / waitFor / user-event) — the assertion may never settle", confidence: "low", judgment: "J1" },
36
+ JS6: { title: "empty describe/suite block — the suite reports green but runs nothing", confidence: "high", judgment: "J1" },
37
+ JS7: { title: "assertion inside a non-awaited setTimeout/setInterval/then callback — it may run after the test ends", confidence: "low", judgment: "J1" },
38
+ JS9: { title: "assertion in a dead branch (if(false) / if(true){}else) — it never runs", confidence: "high", judgment: "J1" },
39
+ JS11: { title: "try/catch swallows the assertion — a failing expect is caught and the test stays green", confidence: "low", judgment: "J1" },
40
+ JS13: { title: "query (getBy*/queryBy*/wrapper.find) as a loose statement — its result is never asserted", confidence: "low", judgment: "J4" },
41
+ // --- diagnostic group (maintainability; default off, opt-in via --diagnostics
42
+ // or config severity). These are NOT false-green: the test still protects. They
43
+ // are a "plus" for test-code health, mirroring falsegreen's D/M group. -------
44
+ D1: { title: "assertion roulette — many assertions in one test; a failure does not say which", confidence: "off", judgment: "J4" },
45
+ D3: { title: "duplicate assert — the same assertion appears more than once in a test", confidence: "off", judgment: "J4" },
46
+ D4: { title: "it.each/test.each without titled cases — a failing case is identified only by its index", confidence: "off", judgment: "J4" },
47
+ D6: { title: "console.* in a test body — a debug artifact that bypasses the oracle", confidence: "off", judgment: "J4" },
48
+ D7: { title: "anonymous test — empty or missing description", confidence: "off", judgment: "J4" },
49
+ M2: { title: "test body exceeds the line-count threshold — hard to read and maintain", confidence: "off", judgment: "J5" },
50
+ };
51
+ /** Default thresholds for the diagnostic group (overridable later via config). */
52
+ export const DIAGNOSTIC_THRESHOLDS = { assertionRoulette: 5, longTest: 50 };
53
+ export function groupOf(code) {
54
+ if (code.startsWith("D"))
55
+ return "diagnostic";
56
+ if (code.startsWith("M"))
57
+ return "coupling";
58
+ return "false-positive";
59
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ import { JUDGMENTS, groupOf } from "./cases.js";
3
+ import { scanPaths, scanFile, stagedFiles, loadConfig, } from "./scan.js";
4
+ const VERSION = "0.1.0";
5
+ const TOOL_URI = "https://github.com/vinicq/falsegreen-js";
6
+ const HELP = `falsegreen-js ${VERSION} - find false-positive JS/TS tests (static AST scan)
7
+
8
+ Usage:
9
+ falsegreen-js [paths...] files/dirs; no args = scan cwd
10
+ falsegreen-js --staged only test files staged in git
11
+ falsegreen-js --json JSON output
12
+ falsegreen-js --diagnostics also report the opt-in maintainability group (D*/M*)
13
+ falsegreen-js --disable C7,JS3 turn off specific codes
14
+ falsegreen-js --version
15
+ falsegreen-js --help
16
+
17
+ Exit codes: 0 clean, 10 low-confidence only, 20 high-confidence present.
18
+ Suppress inline: expect(x).toBe(x); // falsegreen: ignore[C7]
19
+ Covers: .js .jsx .ts .tsx .mjs .cjs .mts .cts`;
20
+ function parseArgs(argv) {
21
+ const paths = [];
22
+ let json = false, staged = false, help = false, version = false, diagnostics = false;
23
+ const disable = new Set();
24
+ for (let i = 0; i < argv.length; i++) {
25
+ const a = argv[i];
26
+ if (a === "--json")
27
+ json = true;
28
+ else if (a === "--staged")
29
+ staged = true;
30
+ else if (a === "--diagnostics")
31
+ diagnostics = true;
32
+ else if (a === "--help" || a === "-h")
33
+ help = true;
34
+ else if (a === "--version" || a === "-V")
35
+ version = true;
36
+ else if (a === "--disable") {
37
+ const v = argv[++i] ?? "";
38
+ v.split(",").map((s) => s.trim()).filter(Boolean).forEach((c) => disable.add(c));
39
+ }
40
+ else if (a.startsWith("--disable=")) {
41
+ a.slice("--disable=".length).split(",").map((s) => s.trim())
42
+ .filter(Boolean).forEach((c) => disable.add(c));
43
+ }
44
+ else if (a.startsWith("-")) {
45
+ process.stderr.write(`falsegreen-js: unknown option ${a}\n`);
46
+ process.exit(2);
47
+ }
48
+ else
49
+ paths.push(a);
50
+ }
51
+ return { paths, json, staged, help, version, diagnostics, disable };
52
+ }
53
+ function exitCode(findings) {
54
+ if (findings.some((f) => f.confidence === "high"))
55
+ return 20;
56
+ if (findings.some((f) => f.confidence === "low"))
57
+ return 10;
58
+ return 0;
59
+ }
60
+ function renderText(findings) {
61
+ if (findings.length === 0)
62
+ return "falsegreen-js: no false-positive patterns found.";
63
+ const byFile = new Map();
64
+ for (const f of findings) {
65
+ (byFile.get(f.file) ?? byFile.set(f.file, []).get(f.file)).push(f);
66
+ }
67
+ const lines = [];
68
+ let high = 0, low = 0;
69
+ for (const [file, fs_] of byFile) {
70
+ lines.push(`\n${file}`);
71
+ for (const f of fs_.sort((a, b) => a.line - b.line)) {
72
+ const tag = f.confidence === "high" ? "HIGH" : "low ";
73
+ if (f.confidence === "high")
74
+ high++;
75
+ else
76
+ low++;
77
+ lines.push(` ${tag} ${f.code.padEnd(4)} L${f.line} ${f.title}` +
78
+ (f.detail ? `\n ${f.detail}` : ""));
79
+ }
80
+ }
81
+ lines.push(`\n${high} high, ${low} low. ${TOOL_URI}`);
82
+ return lines.join("\n");
83
+ }
84
+ function main() {
85
+ const opt = parseArgs(process.argv.slice(2));
86
+ if (opt.help) {
87
+ process.stdout.write(HELP + "\n");
88
+ process.exit(0);
89
+ }
90
+ if (opt.version) {
91
+ process.stdout.write(VERSION + "\n");
92
+ process.exit(0);
93
+ }
94
+ const config = loadConfig();
95
+ const scanOpts = { config, cliDisable: opt.disable, diagnostics: opt.diagnostics };
96
+ let findings;
97
+ if (opt.staged) {
98
+ findings = stagedFiles().flatMap((f) => scanFile(f, scanOpts));
99
+ }
100
+ else {
101
+ findings = scanPaths(opt.paths.length ? opt.paths : ["."], scanOpts);
102
+ }
103
+ if (opt.json) {
104
+ process.stdout.write(JSON.stringify({
105
+ tool: "falsegreen-js",
106
+ version: VERSION,
107
+ judgments: JUDGMENTS,
108
+ findings: findings.map((f) => ({ ...f, group: groupOf(f.code) })),
109
+ }, null, 2) + "\n");
110
+ }
111
+ else {
112
+ process.stdout.write(renderText(findings) + "\n");
113
+ }
114
+ process.exit(exitCode(findings));
115
+ }
116
+ main();
@@ -0,0 +1,7 @@
1
+ import ts from "typescript";
2
+ /** Map a file extension to the TypeScript ScriptKind so JSX/TSX parse correctly. */
3
+ export declare function scriptKindFor(file: string): ts.ScriptKind;
4
+ /** Parse source text into a SourceFile (setParentNodes=true so we can walk up). */
5
+ export declare function parse(file: string, text: string): ts.SourceFile;
6
+ /** 1-based line number of a node's start, from the SourceFile line map. */
7
+ export declare function lineOf(sf: ts.SourceFile, node: ts.Node): number;
package/dist/parse.js ADDED
@@ -0,0 +1,31 @@
1
+ import ts from "typescript";
2
+ import * as path from "node:path";
3
+ /** Map a file extension to the TypeScript ScriptKind so JSX/TSX parse correctly. */
4
+ export function scriptKindFor(file) {
5
+ const ext = path.extname(file).toLowerCase();
6
+ switch (ext) {
7
+ case ".tsx":
8
+ return ts.ScriptKind.TSX;
9
+ case ".jsx":
10
+ return ts.ScriptKind.JSX;
11
+ case ".ts":
12
+ case ".mts":
13
+ case ".cts":
14
+ return ts.ScriptKind.TS;
15
+ case ".js":
16
+ case ".mjs":
17
+ case ".cjs":
18
+ return ts.ScriptKind.JS;
19
+ default:
20
+ return ts.ScriptKind.TS;
21
+ }
22
+ }
23
+ /** Parse source text into a SourceFile (setParentNodes=true so we can walk up). */
24
+ export function parse(file, text) {
25
+ return ts.createSourceFile(file, text, ts.ScriptTarget.Latest,
26
+ /* setParentNodes */ true, scriptKindFor(file));
27
+ }
28
+ /** 1-based line number of a node's start, from the SourceFile line map. */
29
+ export function lineOf(sf, node) {
30
+ return sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
31
+ }
@@ -0,0 +1,3 @@
1
+ import ts from "typescript";
2
+ import { Finding } from "./types.js";
3
+ export declare function analyze(sf: ts.SourceFile): Finding[];
package/dist/rules.js ADDED
@@ -0,0 +1,499 @@
1
+ import ts from "typescript";
2
+ import { makeFinding } from "./types.js";
3
+ import { DIAGNOSTIC_THRESHOLDS } from "./cases.js";
4
+ import { lineOf } from "./parse.js";
5
+ // --- test framework vocabulary (runner-agnostic) ---------------------------
6
+ // it/test/specify (Jest, Vitest, Mocha, Jasmine, AVA, node:test, Cypress,
7
+ // Playwright, tap). describe/context/suite are suites. fit/fdescribe focus and
8
+ // xit/xdescribe skip come from Jasmine/Mocha.
9
+ const TEST_BLOCK_ROOTS = new Set(["it", "test", "specify"]);
10
+ const SUITE_ROOTS = new Set(["describe", "context", "suite", "fdescribe", "xdescribe", "fcontext", "xcontext"]);
11
+ const FOCUS_NAMES = new Set(["fit", "fdescribe", "fcontext"]);
12
+ const SKIP_NAMES = new Set(["xit", "xdescribe", "xcontext", "xspecify"]);
13
+ // Roots whose `<root>.<method>()` call counts as an assertion across runners:
14
+ // AVA (t), node:test/tap (t), Cypress (cy), chai assert, sinon.assert.
15
+ const ASSERT_ROOTS = new Set(["assert", "t", "cy", "tap", "qunit", "sinon", "chai", "should"]);
16
+ // Assertion method names used by AVA / tap / node:test / chai assert / QUnit.
17
+ const ASSERT_METHODS = new Set([
18
+ "is", "not", "ok", "notOk", "true", "false", "truthy", "falsy",
19
+ "equal", "notEqual", "deepEqual", "notDeepEqual", "strictEqual",
20
+ "same", "notSame", "throws", "notThrows", "throwsAsync", "regex", "notRegex",
21
+ "pass", "fail", "assert", "expect", "include", "match",
22
+ ]);
23
+ const SNAPSHOT_MATCHERS = new Set([
24
+ "toMatchSnapshot", "toMatchInlineSnapshot",
25
+ "toThrowErrorMatchingSnapshot", "toThrowErrorMatchingInlineSnapshot",
26
+ ]);
27
+ const EQUALITY_MATCHERS = new Set(["toBe", "toEqual", "toStrictEqual"]);
28
+ // --- name helpers ----------------------------------------------------------
29
+ function calleeName(expr) {
30
+ if (ts.isIdentifier(expr))
31
+ return expr.text;
32
+ if (ts.isPropertyAccessExpression(expr)) {
33
+ const base = calleeName(expr.expression);
34
+ return base ? base + "." + expr.name.text : expr.name.text;
35
+ }
36
+ if (ts.isCallExpression(expr))
37
+ return calleeName(expr.expression);
38
+ if (ts.isParenthesizedExpression(expr))
39
+ return calleeName(expr.expression);
40
+ return "";
41
+ }
42
+ function literalTruthiness(e) {
43
+ if (!e)
44
+ return null;
45
+ if (e.kind === ts.SyntaxKind.TrueKeyword)
46
+ return true;
47
+ if (e.kind === ts.SyntaxKind.FalseKeyword)
48
+ return false;
49
+ if (e.kind === ts.SyntaxKind.NullKeyword)
50
+ return false;
51
+ if (ts.isIdentifier(e) && e.text === "undefined")
52
+ return false;
53
+ if (ts.isNumericLiteral(e))
54
+ return Number(e.text) !== 0;
55
+ if (ts.isStringLiteral(e))
56
+ return e.text.length > 0;
57
+ return null;
58
+ }
59
+ function isLiteral(e) {
60
+ if (!e)
61
+ return false;
62
+ return (e.kind === ts.SyntaxKind.TrueKeyword ||
63
+ e.kind === ts.SyntaxKind.FalseKeyword ||
64
+ e.kind === ts.SyntaxKind.NullKeyword ||
65
+ ts.isNumericLiteral(e) ||
66
+ ts.isStringLiteral(e) ||
67
+ ts.isNoSubstitutionTemplateLiteral(e));
68
+ }
69
+ /** A value turned into text: String(x), JSON.stringify(x), x.toString(), `${x}`. */
70
+ function isStringify(e) {
71
+ if (!e)
72
+ return false;
73
+ if (ts.isTemplateExpression(e))
74
+ return true;
75
+ if (ts.isCallExpression(e)) {
76
+ const n = calleeName(e.expression);
77
+ const leaf = n.split(".").pop() ?? "";
78
+ return leaf === "String" || n === "JSON.stringify" || leaf === "toString" || leaf === "toJSON";
79
+ }
80
+ return false;
81
+ }
82
+ const CONDITIONAL_ANCESTORS = new Set([
83
+ ts.SyntaxKind.IfStatement, ts.SyntaxKind.ForStatement, ts.SyntaxKind.ForOfStatement,
84
+ ts.SyntaxKind.ForInStatement, ts.SyntaxKind.WhileStatement, ts.SyntaxKind.DoStatement,
85
+ ts.SyntaxKind.SwitchStatement, ts.SyntaxKind.CatchClause, ts.SyntaxKind.ConditionalExpression,
86
+ ]);
87
+ /** True if the function has at least one assertion and every one of them sits under a
88
+ * conditional (if/for/while/switch/catch/?:) — so none runs unconditionally (C21). */
89
+ function assertionsAllConditional(fn) {
90
+ const asserts = [];
91
+ const walk = (n) => { if (isAssertionNode(n))
92
+ asserts.push(n); ts.forEachChild(n, walk); };
93
+ ts.forEachChild(fn, walk);
94
+ if (asserts.length === 0)
95
+ return false;
96
+ for (const a of asserts) {
97
+ let p = a.parent;
98
+ let conditional = false;
99
+ while (p && p !== fn) {
100
+ if (CONDITIONAL_ANCESTORS.has(p.kind)) {
101
+ conditional = true;
102
+ break;
103
+ }
104
+ p = p.parent;
105
+ }
106
+ if (!conditional)
107
+ return false;
108
+ }
109
+ return true;
110
+ }
111
+ function containsCall(node) {
112
+ let found = false;
113
+ const walk = (n) => {
114
+ if (found)
115
+ return;
116
+ if (ts.isCallExpression(n)) {
117
+ found = true;
118
+ return;
119
+ }
120
+ ts.forEachChild(n, walk);
121
+ };
122
+ walk(node);
123
+ return found;
124
+ }
125
+ /** Decode `expect(subject)[.not][.resolves].matcher(args)` from a CallExpression. */
126
+ function expectChain(call) {
127
+ if (!ts.isPropertyAccessExpression(call.expression))
128
+ return null;
129
+ const matcher = call.expression.name.text;
130
+ let base = call.expression.expression;
131
+ let negated = false;
132
+ while (ts.isPropertyAccessExpression(base)) {
133
+ if (base.name.text === "not")
134
+ negated = true;
135
+ base = base.expression;
136
+ }
137
+ if (ts.isCallExpression(base) &&
138
+ ts.isIdentifier(base.expression) &&
139
+ base.expression.text === "expect") {
140
+ return { subject: base.arguments[0], matcher, args: call.arguments, negated };
141
+ }
142
+ return null;
143
+ }
144
+ // --- assertion presence ----------------------------------------------------
145
+ function isAssertionNode(node) {
146
+ if (ts.isCallExpression(node)) {
147
+ const name = calleeName(node.expression);
148
+ const parts = name.split(".");
149
+ const root = parts[0];
150
+ const leaf = parts[parts.length - 1];
151
+ // expect(x).matcher(...) — Jest, Vitest, Jasmine, Playwright, jest-dom, chai expect
152
+ if (root === "expect" && ts.isPropertyAccessExpression(node.expression))
153
+ return true;
154
+ if (root === "assert")
155
+ return true; // node:test, chai assert
156
+ if (root === "sinon" && name.includes("assert"))
157
+ return true;
158
+ // AVA (t.is), node:test/tap (t.ok), Cypress (cy....should), QUnit
159
+ if (ASSERT_ROOTS.has(root) && ASSERT_METHODS.has(leaf))
160
+ return true;
161
+ if (leaf === "should")
162
+ return true; // x.should() (chai/Cypress as a call)
163
+ // custom assertion helpers by naming convention: util.assertEqual(...),
164
+ // assertType(...), expectType(...), checkX(...). Bare expect() is excluded
165
+ // (that is JS2). Recognizing these avoids C2b false positives in projects
166
+ // that extract assertions into helpers.
167
+ if (leaf.startsWith("assert"))
168
+ return true;
169
+ if (leaf.startsWith("expect") && leaf !== "expect")
170
+ return true;
171
+ }
172
+ // chai/Cypress fluent: x.should.equal / cy.get().should — the `.should` access
173
+ if (ts.isPropertyAccessExpression(node) && node.name.text === "should")
174
+ return true;
175
+ return false;
176
+ }
177
+ function hasAssertion(scope) {
178
+ let found = false;
179
+ const walk = (n) => {
180
+ if (found)
181
+ return;
182
+ if (isAssertionNode(n)) {
183
+ found = true;
184
+ return;
185
+ }
186
+ ts.forEachChild(n, walk);
187
+ };
188
+ ts.forEachChild(scope, walk);
189
+ return found;
190
+ }
191
+ /** Like hasAssertion but tests the node itself too (for branch/try-block subtrees). */
192
+ function containsAssertion(node) {
193
+ return isAssertionNode(node) || hasAssertion(node);
194
+ }
195
+ /** True if a catch block does nothing meaningful: empty, or only console.* /
196
+ * comments, with no throw, no assertion, and no fail() — so it swallows errors. */
197
+ function isHarmlessCatch(block) {
198
+ for (const stmt of block.statements) {
199
+ if (ts.isThrowStatement(stmt))
200
+ return false;
201
+ if (containsAssertion(stmt))
202
+ return false;
203
+ if (ts.isExpressionStatement(stmt)) {
204
+ const name = calleeName(ts.isCallExpression(stmt.expression) ? stmt.expression.expression : stmt.expression);
205
+ const leaf = name.split(".").pop() ?? "";
206
+ if (name.startsWith("console."))
207
+ continue;
208
+ if (leaf === "fail")
209
+ return false;
210
+ continue; // other no-op-ish expression: still swallows
211
+ }
212
+ // any other statement (return/log/etc.) still does not re-raise
213
+ }
214
+ return true;
215
+ }
216
+ /** Matcher names of every expect-chain assertion under scope (for snapshot-only). */
217
+ function matchersUnder(scope) {
218
+ const out = [];
219
+ const walk = (n) => {
220
+ if (ts.isCallExpression(n)) {
221
+ const chain = expectChain(n);
222
+ if (chain)
223
+ out.push(chain.matcher);
224
+ }
225
+ ts.forEachChild(n, walk);
226
+ };
227
+ ts.forEachChild(scope, walk);
228
+ return out;
229
+ }
230
+ function getTestCallback(call) {
231
+ for (const arg of call.arguments) {
232
+ if (ts.isArrowFunction(arg) || ts.isFunctionExpression(arg))
233
+ return arg;
234
+ }
235
+ return null;
236
+ }
237
+ // --- JS5: async query/event not awaited (Testing Library) ------------------
238
+ const ASYNC_AWAIT_LEAVES = new Set(["waitFor", "waitForElementToBeRemoved"]);
239
+ function isAsyncQueryCall(name) {
240
+ const parts = name.split(".");
241
+ const root = parts[0];
242
+ const leaf = parts[parts.length - 1];
243
+ if (root === "userEvent")
244
+ return true;
245
+ if (leaf.startsWith("findBy") || leaf.startsWith("findAllBy"))
246
+ return true;
247
+ return ASYNC_AWAIT_LEAVES.has(leaf);
248
+ }
249
+ // --- C16: nondeterminism ----------------------------------------------------
250
+ function c16Detail(call) {
251
+ const name = calleeName(call.expression);
252
+ if (name === "Math.random")
253
+ return "Math.random() without a fixed seed";
254
+ if (name === "Date.now" || name === "performance.now")
255
+ return "reads the system clock";
256
+ if (name === "setTimeout" || name === "setInterval") {
257
+ if (call.arguments.length >= 2 && ts.isNumericLiteral(call.arguments[1])) {
258
+ return "fixed timer delay";
259
+ }
260
+ }
261
+ return null;
262
+ }
263
+ // ---------------------------------------------------------------------------
264
+ // Main: collect findings from a parsed source file.
265
+ // ---------------------------------------------------------------------------
266
+ export function analyze(sf) {
267
+ const findings = [];
268
+ const file = sf.fileName;
269
+ const text = sf.getFullText();
270
+ const fakeTimers = /\b(useFakeTimers)\b/.test(text);
271
+ const push = (line, code, detail = "") => {
272
+ findings.push(makeFinding(file, line, code, detail));
273
+ };
274
+ const visit = (node) => {
275
+ if (ts.isCallExpression(node)) {
276
+ const name = calleeName(node.expression);
277
+ const root = name.split(".")[0];
278
+ const modifier = name.split(".")[1] ?? "";
279
+ // JS1 focused / JS4 skipped
280
+ if (name.endsWith(".only") ||
281
+ FOCUS_NAMES.has(root) ||
282
+ (FOCUS_NAMES.has(name))) {
283
+ push(lineOf(sf, node), "JS1", `focused via ${name}`);
284
+ }
285
+ else if (name.endsWith(".skip") ||
286
+ name.endsWith(".todo") ||
287
+ SKIP_NAMES.has(root)) {
288
+ push(lineOf(sf, node), "JS4", `skipped via ${name}`);
289
+ }
290
+ // test-level block body checks (C2, C2b, JS3). Only `it`/`test`/`specify`
291
+ // (and the focus/skip variants) — never `describe`/`suite`, whose body holds
292
+ // nested tests, not assertions.
293
+ const isTestBlock = TEST_BLOCK_ROOTS.has(root) || root === "fit" || root === "xit" ||
294
+ ((TEST_BLOCK_ROOTS.has(root)) && (modifier === "only" || modifier === "skip" || modifier === "each"));
295
+ if (isTestBlock) {
296
+ const cb = getTestCallback(node);
297
+ if (cb && cb.body && ts.isBlock(cb.body)) {
298
+ const stmts = cb.body.statements;
299
+ const line = lineOf(sf, node);
300
+ if (stmts.length === 0) {
301
+ push(line, "C2", "test body is empty");
302
+ }
303
+ else if (!hasAssertion(cb) && containsCall(cb.body)) {
304
+ push(line, "C2b", "calls code but never asserts");
305
+ }
306
+ else if (hasAssertion(cb)) {
307
+ const ms = matchersUnder(cb);
308
+ if (ms.length > 0 && ms.every((m) => SNAPSHOT_MATCHERS.has(m))) {
309
+ push(line, "JS3", "the only assertion is a snapshot");
310
+ }
311
+ if (assertionsAllConditional(cb)) {
312
+ push(line, "C21", "every assertion is guarded by a condition");
313
+ }
314
+ }
315
+ // D7: anonymous test (empty or missing description)
316
+ const desc = node.arguments[0];
317
+ const emptyStr = (d) => d !== undefined &&
318
+ (ts.isStringLiteral(d) || ts.isNoSubstitutionTemplateLiteral(d)) && d.text.trim() === "";
319
+ if (desc === cb || desc === undefined || emptyStr(desc)) {
320
+ push(line, "D7", "test has no description");
321
+ }
322
+ // diagnostic group (maintainability; emitted always, filtered when off)
323
+ const asserts = [];
324
+ const consoles = [];
325
+ const walkD = (n) => {
326
+ if (isAssertionNode(n))
327
+ asserts.push(n);
328
+ if (ts.isCallExpression(n) && calleeName(n.expression).startsWith("console."))
329
+ consoles.push(n);
330
+ ts.forEachChild(n, walkD);
331
+ };
332
+ ts.forEachChild(cb, walkD);
333
+ if (asserts.length >= DIAGNOSTIC_THRESHOLDS.assertionRoulette) {
334
+ push(line, "D1", `${asserts.length} assertions in one test`);
335
+ }
336
+ const seenA = new Set();
337
+ for (const a of asserts) {
338
+ const t = a.getText(sf).replace(/\s+/g, " ").trim();
339
+ if (seenA.has(t)) {
340
+ push(line, "D3", "an assertion is repeated");
341
+ break;
342
+ }
343
+ seenA.add(t);
344
+ }
345
+ for (const c of consoles)
346
+ push(lineOf(sf, c), "D6", "console call in a test body");
347
+ const startL = sf.getLineAndCharacterOfPosition(cb.body.getStart(sf)).line;
348
+ const endL = sf.getLineAndCharacterOfPosition(cb.body.getEnd()).line;
349
+ if (endL - startL > DIAGNOSTIC_THRESHOLDS.longTest) {
350
+ push(line, "M2", `test body spans ${endL - startL} lines`);
351
+ }
352
+ }
353
+ }
354
+ // JS6: empty describe/suite block
355
+ if (SUITE_ROOTS.has(root) || (SUITE_ROOTS.has(root) && (modifier === "only" || modifier === "skip"))) {
356
+ const cb = getTestCallback(node);
357
+ if (cb && cb.body && ts.isBlock(cb.body) && cb.body.statements.length === 0) {
358
+ push(lineOf(sf, node), "JS6", "suite body is empty");
359
+ }
360
+ }
361
+ // JS2: bare expect(x) with no matcher
362
+ if (ts.isIdentifier(node.expression) &&
363
+ node.expression.text === "expect" &&
364
+ !ts.isPropertyAccessExpression(node.parent)) {
365
+ push(lineOf(sf, node), "JS2", "expect(...) is not chained to a matcher");
366
+ }
367
+ // assert(literal) -> C5
368
+ if ((root === "assert" || name === "assert.ok") && literalTruthiness(node.arguments[0]) === true) {
369
+ push(lineOf(sf, node), "C5", "assert on a constant truthy value");
370
+ }
371
+ // expect-chain matchers (C5, C7, C8)
372
+ const chain = expectChain(node);
373
+ if (chain && !chain.negated) {
374
+ const subj = chain.subject;
375
+ const arg = chain.args[0];
376
+ // C9 over-broad throw assertion: toThrow() with no error type or message
377
+ if ((chain.matcher === "toThrow" || chain.matcher === "toThrowError") && chain.args.length === 0) {
378
+ push(lineOf(sf, node), "C9", "toThrow() with no error type or message accepts any error");
379
+ }
380
+ // C5 always-true
381
+ if (chain.matcher === "toBeTruthy" && literalTruthiness(subj) === true) {
382
+ push(lineOf(sf, node), "C5", "toBeTruthy on a constant truthy literal");
383
+ }
384
+ else if ((chain.matcher === "toBeFalsy" || chain.matcher === "toBeNull" || chain.matcher === "toBeUndefined") &&
385
+ literalTruthiness(subj) === false) {
386
+ push(lineOf(sf, node), "C5", `${chain.matcher} on a constant falsy literal`);
387
+ }
388
+ else if (EQUALITY_MATCHERS.has(chain.matcher) && isLiteral(subj) && isLiteral(arg) &&
389
+ subj.getText(sf) === arg.getText(sf)) {
390
+ push(lineOf(sf, node), "C5", "both sides are the same literal");
391
+ }
392
+ else if (EQUALITY_MATCHERS.has(chain.matcher) && subj && arg &&
393
+ !isLiteral(subj) && !containsCall(subj) &&
394
+ subj.getText(sf) === arg.getText(sf)) {
395
+ // C7 self-compare
396
+ push(lineOf(sf, node), "C7", "expected value is the same expression as the subject");
397
+ }
398
+ else if (EQUALITY_MATCHERS.has(chain.matcher) && arg && ts.isNumericLiteral(arg)) {
399
+ // C8 exact float
400
+ const v = Number(arg.text);
401
+ if (!Number.isInteger(v) && v !== 0 && v !== 1) {
402
+ push(lineOf(sf, node), "C8", "exact equality on a float; use toBeCloseTo");
403
+ }
404
+ }
405
+ else if (EQUALITY_MATCHERS.has(chain.matcher) && isStringify(subj) && arg && ts.isStringLiteral(arg)) {
406
+ // C18 sensitive equality: compares the stringified form to a literal
407
+ push(lineOf(sf, node), "C18", "compares the stringified form of a value to a literal");
408
+ }
409
+ }
410
+ // C16 nondeterminism
411
+ if (!fakeTimers) {
412
+ const detail = c16Detail(node);
413
+ if (detail)
414
+ push(lineOf(sf, node), "C16", detail);
415
+ }
416
+ // JS5: Testing Library async query/event used without await
417
+ if (isAsyncQueryCall(name) && ts.isExpressionStatement(node.parent)) {
418
+ push(lineOf(sf, node), "JS5", `${name} is not awaited`);
419
+ }
420
+ // JS7: assertion inside a non-awaited setTimeout/setInterval/then callback
421
+ {
422
+ const leaf = name.split(".").pop() ?? "";
423
+ const isTimer = name === "setTimeout" || name === "setInterval";
424
+ const isThen = leaf === "then" || leaf === "catch" || leaf === "finally";
425
+ if (isTimer || isThen) {
426
+ const cb = node.arguments.find((a) => ts.isArrowFunction(a) || ts.isFunctionExpression(a));
427
+ if (cb && containsAssertion(cb)) {
428
+ const awaitedOrChained = !ts.isExpressionStatement(node.parent);
429
+ if (isTimer && !fakeTimers) {
430
+ push(lineOf(sf, node), "JS7", `assertion inside a non-awaited ${name}() callback`);
431
+ }
432
+ else if (isThen && !awaitedOrChained) {
433
+ push(lineOf(sf, node), "JS7", `assertion inside a non-awaited .${leaf}() callback`);
434
+ }
435
+ }
436
+ }
437
+ }
438
+ // JS13: a sync RTL query used as a loose statement (result never asserted)
439
+ if (ts.isExpressionStatement(node.parent)) {
440
+ const qleaf = name.split(".").pop() ?? "";
441
+ if (/^(getBy|getAllBy|queryBy|queryAllBy)/.test(qleaf)) {
442
+ push(lineOf(sf, node), "JS13", `${qleaf}() result is not asserted`);
443
+ }
444
+ }
445
+ // C37: duplicate case in it.each/test.each table
446
+ if (name.endsWith(".each") && node.arguments.length > 0 && ts.isArrayLiteralExpression(node.arguments[0])) {
447
+ const seen = new Set();
448
+ for (const el of node.arguments[0].elements) {
449
+ const t = el.getText(sf).replace(/\s+/g, " ").trim();
450
+ if (seen.has(t)) {
451
+ push(lineOf(sf, node), "C37", "duplicate case in the .each table");
452
+ break;
453
+ }
454
+ seen.add(t);
455
+ }
456
+ }
457
+ // D4: it.each/test.each with untitled cases (no %s/%i placeholder)
458
+ if (ts.isCallExpression(node.expression)) {
459
+ const innerName = calleeName(node.expression.expression);
460
+ const innerRoot = innerName.split(".")[0];
461
+ if (innerName.endsWith(".each") && (TEST_BLOCK_ROOTS.has(innerRoot) || SUITE_ROOTS.has(innerRoot))) {
462
+ const title = node.arguments[0];
463
+ if (title && ts.isStringLiteral(title) && !title.text.includes("%")) {
464
+ push(lineOf(sf, node), "D4", "each cases are not titled");
465
+ }
466
+ }
467
+ }
468
+ }
469
+ // JS9: assertion in a dead branch with a literal condition
470
+ if (ts.isIfStatement(node)) {
471
+ const cond = literalTruthiness(node.expression);
472
+ if (cond === false && containsAssertion(node.thenStatement)) {
473
+ push(lineOf(sf, node), "JS9", "assertion in an if(false) branch");
474
+ }
475
+ else if (cond === true && node.elseStatement && containsAssertion(node.elseStatement)) {
476
+ push(lineOf(sf, node), "JS9", "assertion in the else of an if(true)");
477
+ }
478
+ }
479
+ // JS11: try block asserts, catch swallows the failure
480
+ if (ts.isTryStatement(node) && node.catchClause) {
481
+ if (containsAssertion(node.tryBlock) && isHarmlessCatch(node.catchClause.block)) {
482
+ push(lineOf(sf, node), "JS11", "a failing assertion in try is swallowed by catch");
483
+ }
484
+ }
485
+ ts.forEachChild(node, visit);
486
+ };
487
+ visit(sf);
488
+ // CC: commented-out assertion (text scan over single-line comments)
489
+ // CC: a single-line `//` comment that is a commented-out assertion call.
490
+ // Requires the call paren (expect(/assert(/assert.x() or a .should chain) so it
491
+ // does not match JSDoc prose like ` * assert that ...`.
492
+ const lines = text.split(/\r?\n/);
493
+ const ccRe = /^\s*\/\/\s*(?:await\s+)?(?:expect\s*\(|assert(?:\.\w+)?\s*\(|[\w.]+\.should\b)/;
494
+ lines.forEach((ln, i) => {
495
+ if (ccRe.test(ln))
496
+ push(i + 1, "CC", "assertion is commented out");
497
+ });
498
+ return findings;
499
+ }
package/dist/scan.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { Confidence } from "./cases.js";
2
+ import { Finding } from "./types.js";
3
+ export declare const TEST_EXTENSIONS: string[];
4
+ export interface Config {
5
+ disable: Set<string>;
6
+ exclude: string[];
7
+ severity: Record<string, Confidence>;
8
+ }
9
+ export interface ScanOptions {
10
+ config?: Config;
11
+ cliDisable?: Set<string>;
12
+ diagnostics?: boolean;
13
+ }
14
+ export declare function isTestFile(file: string): boolean;
15
+ export declare function discover(paths: string[]): string[];
16
+ export declare function stagedFiles(): string[];
17
+ export declare function loadConfig(start?: string): Config;
18
+ export declare function effectiveConf(code: string, opts: ScanOptions): Confidence;
19
+ export declare function scanFile(file: string, opts?: ScanOptions): Finding[];
20
+ export declare function scanPaths(paths: string[], opts?: ScanOptions): Finding[];
package/dist/scan.js ADDED
Binary file
@@ -0,0 +1,10 @@
1
+ import { Confidence } from "./cases.js";
2
+ export interface Finding {
3
+ file: string;
4
+ line: number;
5
+ code: string;
6
+ detail: string;
7
+ confidence: Confidence;
8
+ title: string;
9
+ }
10
+ export declare function makeFinding(file: string, line: number, code: string, detail?: string, confidence?: Confidence): Finding;
package/dist/types.js ADDED
@@ -0,0 +1,11 @@
1
+ import { CASES } from "./cases.js";
2
+ export function makeFinding(file, line, code, detail = "", confidence) {
3
+ return {
4
+ file,
5
+ line,
6
+ code,
7
+ detail,
8
+ confidence: confidence ?? CASES[code].confidence,
9
+ title: CASES[code].title,
10
+ };
11
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "falsegreen-js",
3
+ "version": "0.1.0",
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
+ "keywords": [
6
+ "jest", "vitest", "mocha", "testing", "test-smells", "false-positive",
7
+ "static-analysis", "typescript", "javascript", "tsx", "jsx", "code-quality",
8
+ "ai-generated-tests"
9
+ ],
10
+ "license": "MIT",
11
+ "author": "Vinicius Queiroz",
12
+ "type": "module",
13
+ "engines": { "node": ">=18" },
14
+ "bin": { "falsegreen-js": "dist/cli.js" },
15
+ "main": "dist/scan.js",
16
+ "types": "dist/scan.d.ts",
17
+ "exports": {
18
+ ".": { "types": "./dist/scan.d.ts", "import": "./dist/scan.js" }
19
+ },
20
+ "publishConfig": { "access": "public" },
21
+ "files": ["dist", "CHANGELOG.md", "CREDITS.md"],
22
+ "scripts": {
23
+ "build": "tsc -p tsconfig.json",
24
+ "test": "vitest run",
25
+ "lint": "tsc -p tsconfig.json --noEmit",
26
+ "prepublishOnly": "npm run build"
27
+ },
28
+ "dependencies": {
29
+ "typescript": "^5.4.0"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^26.0.0",
33
+ "vitest": "^1.6.0"
34
+ },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "https://github.com/vinicq/falsegreen-js.git"
38
+ },
39
+ "homepage": "https://github.com/vinicq/falsegreen-js#readme",
40
+ "bugs": { "url": "https://github.com/vinicq/falsegreen-js/issues" }
41
+ }