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 +40 -0
- package/CREDITS.md +59 -0
- package/LICENSE +21 -0
- package/README.md +167 -0
- package/dist/cases.d.ts +22 -0
- package/dist/cases.js +59 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +116 -0
- package/dist/parse.d.ts +7 -0
- package/dist/parse.js +31 -0
- package/dist/rules.d.ts +3 -0
- package/dist/rules.js +499 -0
- package/dist/scan.d.ts +20 -0
- package/dist/scan.js +0 -0
- package/dist/types.d.ts +10 -0
- package/dist/types.js +11 -0
- package/package.json +41 -0
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.
|
package/dist/cases.d.ts
ADDED
|
@@ -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
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();
|
package/dist/parse.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/rules.d.ts
ADDED
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
|
package/dist/types.d.ts
ADDED
|
@@ -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
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
|
+
}
|