falsegreen-js 0.3.0 → 0.4.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 +94 -1
- package/README.md +56 -10
- package/dist/cases.d.ts +51 -3
- package/dist/cases.js +87 -43
- package/dist/cfg.d.ts +32 -0
- package/dist/cfg.js +237 -0
- package/dist/cli.d.ts +27 -1
- package/dist/cli.js +124 -41
- package/dist/oracles.d.ts +52 -0
- package/dist/oracles.js +87 -0
- package/dist/report.d.ts +31 -0
- package/dist/report.js +177 -0
- package/dist/rules.js +304 -88
- package/dist/scan.js +0 -0
- package/dist/types.js +2 -2
- package/package.json +34 -9
package/dist/cfg.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intra-test structured reachability, backing C20 (dead code) and C21 (no
|
|
3
|
+
* unconditional assertion). JS test bodies are structured (no goto), so this is a
|
|
4
|
+
* recursive walk over the statement tree, not a full control-flow graph.
|
|
5
|
+
*
|
|
6
|
+
* Two questions, both FP-averse (a false positive is worse than a miss):
|
|
7
|
+
* assertionsInDeadCode - assertions at a position control can never reach.
|
|
8
|
+
* hasUnconditionalAssertion - is at least one assertion guaranteed to run?
|
|
9
|
+
*
|
|
10
|
+
* The assertion predicate and literal-truthiness helper are passed in (they live in
|
|
11
|
+
* rules.ts) so this module imports nothing from there and there is no cycle.
|
|
12
|
+
*/
|
|
13
|
+
import ts from "typescript";
|
|
14
|
+
/** A call to process.exit(...) — control leaves the process, so it terminates. */
|
|
15
|
+
function isProcessExit(e) {
|
|
16
|
+
return (ts.isCallExpression(e) &&
|
|
17
|
+
ts.isPropertyAccessExpression(e.expression) &&
|
|
18
|
+
e.expression.name.text === "exit" &&
|
|
19
|
+
ts.isIdentifier(e.expression.expression) &&
|
|
20
|
+
e.expression.expression.text === "process");
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A statement does not fall through to the next statement in its list iff control
|
|
24
|
+
* cannot continue past it. `breakStops` controls whether break/continue count as
|
|
25
|
+
* not-falling-through: true within a normal statement list (the sibling after a
|
|
26
|
+
* break is unreachable), false when asking whether a switch escapes its own exit
|
|
27
|
+
* (a case ending in `break` continues after the switch, so it does fall through).
|
|
28
|
+
*/
|
|
29
|
+
function stmtNoFallThrough(stmt, breakStops) {
|
|
30
|
+
if (ts.isReturnStatement(stmt) || ts.isThrowStatement(stmt))
|
|
31
|
+
return true;
|
|
32
|
+
if (ts.isBreakStatement(stmt) || ts.isContinueStatement(stmt))
|
|
33
|
+
return breakStops;
|
|
34
|
+
if (ts.isExpressionStatement(stmt) && isProcessExit(stmt.expression))
|
|
35
|
+
return true;
|
|
36
|
+
if (ts.isBlock(stmt))
|
|
37
|
+
return listNoFallThrough(stmt.statements, breakStops);
|
|
38
|
+
if (ts.isLabeledStatement(stmt))
|
|
39
|
+
return stmtNoFallThrough(stmt.statement, breakStops);
|
|
40
|
+
if (ts.isIfStatement(stmt)) {
|
|
41
|
+
return (stmt.elseStatement !== undefined &&
|
|
42
|
+
stmtNoFallThrough(stmt.thenStatement, breakStops) &&
|
|
43
|
+
stmtNoFallThrough(stmt.elseStatement, breakStops));
|
|
44
|
+
}
|
|
45
|
+
if (ts.isTryStatement(stmt))
|
|
46
|
+
return tryNoFallThrough(stmt, breakStops);
|
|
47
|
+
if (ts.isSwitchStatement(stmt))
|
|
48
|
+
return switchNoFallThrough(stmt);
|
|
49
|
+
// loops may run zero times, so they always fall through; var/expr/etc fall through.
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
function listNoFallThrough(stmts, breakStops) {
|
|
53
|
+
return stmts.some((s) => stmtNoFallThrough(s, breakStops));
|
|
54
|
+
}
|
|
55
|
+
function tryNoFallThrough(t, breakStops) {
|
|
56
|
+
// finally always runs; if it escapes, the whole try escapes.
|
|
57
|
+
if (t.finallyBlock && listNoFallThrough(t.finallyBlock.statements, breakStops))
|
|
58
|
+
return true;
|
|
59
|
+
if (!listNoFallThrough(t.tryBlock.statements, breakStops))
|
|
60
|
+
return false;
|
|
61
|
+
// try-block escapes; if there is a catch, a caught throw escapes only if catch does too.
|
|
62
|
+
if (!t.catchClause)
|
|
63
|
+
return true;
|
|
64
|
+
return listNoFallThrough(t.catchClause.block.statements, breakStops);
|
|
65
|
+
}
|
|
66
|
+
function switchNoFallThrough(sw) {
|
|
67
|
+
// code after the switch is unreachable iff every clause escapes the function
|
|
68
|
+
// (return/throw/process.exit — a `break` exits to AFTER the switch, so it does
|
|
69
|
+
// NOT escape) and a default clause is present (else a no-match falls through).
|
|
70
|
+
let hasDefault = false;
|
|
71
|
+
for (const clause of sw.caseBlock.clauses) {
|
|
72
|
+
if (clause.kind === ts.SyntaxKind.DefaultClause)
|
|
73
|
+
hasDefault = true;
|
|
74
|
+
if (!listNoFallThrough(clause.statements, /* breakStops */ false))
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
return hasDefault;
|
|
78
|
+
}
|
|
79
|
+
/** Collect assertion nodes under `node`, never entering a nested function scope
|
|
80
|
+
* (a return/assert inside a callback belongs to that callback, not this test). */
|
|
81
|
+
function collectAssertionsNoNesting(node, isAssertion, out) {
|
|
82
|
+
const visit = (n) => {
|
|
83
|
+
if (isAssertion(n))
|
|
84
|
+
out.push(n);
|
|
85
|
+
if (!ts.isFunctionLike(n))
|
|
86
|
+
ts.forEachChild(n, visit);
|
|
87
|
+
};
|
|
88
|
+
ts.forEachChild(node, visit);
|
|
89
|
+
if (isAssertion(node))
|
|
90
|
+
out.push(node);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Assertions sitting at a position control can never reach (C20). Walk each
|
|
94
|
+
* statement list keeping `reachable` (true at the head); once a statement does not
|
|
95
|
+
* fall through, the rest of the list is dead. Recurse into nested lists of a
|
|
96
|
+
* reachable statement with a fresh reachable=true. Stop at nested functions.
|
|
97
|
+
*/
|
|
98
|
+
export function assertionsInDeadCode(body, isAssertion) {
|
|
99
|
+
const dead = [];
|
|
100
|
+
const walkList = (stmts) => {
|
|
101
|
+
let reachable = true;
|
|
102
|
+
for (const st of stmts) {
|
|
103
|
+
if (!reachable) {
|
|
104
|
+
collectAssertionsNoNesting(st, isAssertion, dead);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
recurse(st);
|
|
108
|
+
if (stmtNoFallThrough(st, /* breakStops */ true))
|
|
109
|
+
reachable = false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
// Descend into the nested statement lists of a reachable statement, each a fresh
|
|
114
|
+
// list. Never enters a function body (its returns are its own).
|
|
115
|
+
const recurse = (st) => {
|
|
116
|
+
if (ts.isBlock(st))
|
|
117
|
+
return walkList(st.statements);
|
|
118
|
+
if (ts.isIfStatement(st)) {
|
|
119
|
+
recurse(st.thenStatement);
|
|
120
|
+
if (st.elseStatement)
|
|
121
|
+
recurse(st.elseStatement);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (ts.isForStatement(st) || ts.isForOfStatement(st) || ts.isForInStatement(st) ||
|
|
125
|
+
ts.isWhileStatement(st) || ts.isDoStatement(st)) {
|
|
126
|
+
return recurse(st.statement);
|
|
127
|
+
}
|
|
128
|
+
if (ts.isLabeledStatement(st))
|
|
129
|
+
return recurse(st.statement);
|
|
130
|
+
if (ts.isTryStatement(st)) {
|
|
131
|
+
walkList(st.tryBlock.statements);
|
|
132
|
+
if (st.catchClause)
|
|
133
|
+
walkList(st.catchClause.block.statements);
|
|
134
|
+
if (st.finallyBlock)
|
|
135
|
+
walkList(st.finallyBlock.statements);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (ts.isSwitchStatement(st)) {
|
|
139
|
+
for (const clause of st.caseBlock.clauses)
|
|
140
|
+
walkList(clause.statements);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
// expression/variable/etc: no nested statement list to walk (nested functions
|
|
144
|
+
// are skipped on purpose).
|
|
145
|
+
};
|
|
146
|
+
walkList(body.statements);
|
|
147
|
+
return dead;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Is at least one assertion guaranteed to run (so C21 must NOT fire)? Walks the
|
|
151
|
+
* "spine" of always-executed positions: top-level statements, blocks on the spine,
|
|
152
|
+
* the taken branch of an if/?: with a literal-constant condition, finally blocks,
|
|
153
|
+
* and - conservatively - a try block. A non-const if, any loop, switch, catch, or
|
|
154
|
+
* short-circuit is not guaranteed. Anything unmodeled is treated as guaranteed
|
|
155
|
+
* (suppress C21) to stay false-positive-averse.
|
|
156
|
+
*/
|
|
157
|
+
export function hasUnconditionalAssertion(body, isAssertion, litTruth, deadAsserts = new Set()) {
|
|
158
|
+
// An assertion already flagged C20 (dead code) is unreachable, so it is never the
|
|
159
|
+
// guaranteed-spine assertion. Walk a leaf statement's expression for a dead node so
|
|
160
|
+
// a dead top-level assertion does not mask a live conditional one (C21 must still
|
|
161
|
+
// fire). Scoped to the leaf expression only — a single ExpressionStatement/return
|
|
162
|
+
// has no sibling live asserts, so finding any dead node means this leaf is dead (#62).
|
|
163
|
+
const exprHasDeadAssertion = (e) => {
|
|
164
|
+
let dead = false;
|
|
165
|
+
const visit = (n) => {
|
|
166
|
+
if (deadAsserts.has(n)) {
|
|
167
|
+
dead = true;
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
ts.forEachChild(n, visit);
|
|
171
|
+
};
|
|
172
|
+
visit(e);
|
|
173
|
+
return dead;
|
|
174
|
+
};
|
|
175
|
+
// An assertion that is itself the spine expression (not behind a short-circuit
|
|
176
|
+
// or ternary): expect(...).m(), await expect(...), (expect(...)).
|
|
177
|
+
const directAssertion = (e) => {
|
|
178
|
+
if (ts.isAwaitExpression(e) || ts.isParenthesizedExpression(e)) {
|
|
179
|
+
return directAssertion(e.expression);
|
|
180
|
+
}
|
|
181
|
+
if (isAssertion(e))
|
|
182
|
+
return true;
|
|
183
|
+
// chai/Cypress fluent call form: result.should.equal(1) / cy.get(x).should(...).
|
|
184
|
+
// isAssertion recognizes the `.should` property-access node, but on the spine the
|
|
185
|
+
// enclosing CallExpression is what we see, so scan its callee chain for `.should`.
|
|
186
|
+
// Only the property/call spine is walked, never `&&`/`?:` operands, so a
|
|
187
|
+
// short-circuited assertion still does not count as guaranteed.
|
|
188
|
+
if (ts.isCallExpression(e)) {
|
|
189
|
+
let base = e.expression;
|
|
190
|
+
while (ts.isPropertyAccessExpression(base) || ts.isCallExpression(base)) {
|
|
191
|
+
if (ts.isPropertyAccessExpression(base) && base.name.text === "should")
|
|
192
|
+
return true;
|
|
193
|
+
base = base.expression;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return false;
|
|
197
|
+
};
|
|
198
|
+
const stmtGuaranteed = (st) => {
|
|
199
|
+
if (ts.isExpressionStatement(st)) {
|
|
200
|
+
if (exprHasDeadAssertion(st.expression))
|
|
201
|
+
return false;
|
|
202
|
+
return directAssertion(st.expression);
|
|
203
|
+
}
|
|
204
|
+
if (ts.isBlock(st))
|
|
205
|
+
return listGuaranteed(st.statements);
|
|
206
|
+
if (ts.isLabeledStatement(st))
|
|
207
|
+
return stmtGuaranteed(st.statement);
|
|
208
|
+
if (ts.isReturnStatement(st))
|
|
209
|
+
return st.expression !== undefined
|
|
210
|
+
&& !exprHasDeadAssertion(st.expression) && directAssertion(st.expression);
|
|
211
|
+
if (ts.isIfStatement(st)) {
|
|
212
|
+
const t = litTruth(st.expression);
|
|
213
|
+
if (t === true)
|
|
214
|
+
return stmtGuaranteed(st.thenStatement);
|
|
215
|
+
if (t === false)
|
|
216
|
+
return st.elseStatement !== undefined && stmtGuaranteed(st.elseStatement);
|
|
217
|
+
return false; // non-const condition: neither branch is guaranteed
|
|
218
|
+
}
|
|
219
|
+
if (ts.isTryStatement(st)) {
|
|
220
|
+
// conservative: a try block on the spine counts as guaranteed (so
|
|
221
|
+
// try{expect}finally{...} is not flagged); finally always runs; catch does not.
|
|
222
|
+
if (listGuaranteed(st.tryBlock.statements))
|
|
223
|
+
return true;
|
|
224
|
+
if (st.finallyBlock && listGuaranteed(st.finallyBlock.statements))
|
|
225
|
+
return true;
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
// do/while is the one loop whose body always runs at least once, so an
|
|
229
|
+
// assertion in it IS unconditional (the condition only controls repetition).
|
|
230
|
+
if (ts.isDoStatement(st))
|
|
231
|
+
return stmtGuaranteed(st.statement);
|
|
232
|
+
// for/while/for-of/for-in/switch/catch: their body is not guaranteed to run.
|
|
233
|
+
return false;
|
|
234
|
+
};
|
|
235
|
+
const listGuaranteed = (stmts) => stmts.some(stmtGuaranteed);
|
|
236
|
+
return listGuaranteed(body.statements);
|
|
237
|
+
}
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,10 +1,36 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Finding } from "./types.js";
|
|
3
|
+
import { OutputFormat } from "./report.js";
|
|
3
4
|
/** Turn --output into a concrete file path. A directory (existing dir, a
|
|
4
5
|
* trailing separator, or an extension-less name like ".falsegreen") receives
|
|
5
6
|
* "report.<ext>" for the chosen format; anything else is treated as a file.
|
|
6
7
|
* Missing parent directories are created either way. */
|
|
7
|
-
export declare function resolveOutputPath(p: string, fmt:
|
|
8
|
+
export declare function resolveOutputPath(p: string, fmt: OutputFormat): string;
|
|
9
|
+
/** The JSON report object. Each finding carries both the primary `riskGroup`
|
|
10
|
+
* (closed taxonomy) and the legacy `group` (transition-compat), plus its fix
|
|
11
|
+
* hint. The tool block records the oracle-registry version that classified it. */
|
|
12
|
+
export declare function buildReport(findings: Finding[]): {
|
|
13
|
+
tool: string;
|
|
14
|
+
version: string;
|
|
15
|
+
oracleRegistryVersion: number;
|
|
16
|
+
judgments: Record<string, string>;
|
|
17
|
+
findings: {
|
|
18
|
+
riskGroup: import("./cases.js").RiskGroup;
|
|
19
|
+
group: "diagnostic" | "false-positive" | "coupling" | "project";
|
|
20
|
+
fix: string;
|
|
21
|
+
file: string;
|
|
22
|
+
line: number;
|
|
23
|
+
code: string;
|
|
24
|
+
detail: string;
|
|
25
|
+
confidence: import("./cases.js").Confidence;
|
|
26
|
+
title: string;
|
|
27
|
+
level: import("./cases.js").PyramidLevel;
|
|
28
|
+
}[];
|
|
29
|
+
};
|
|
30
|
+
/** Render findings in the chosen format. JSON keeps the full report object
|
|
31
|
+
* (riskGroup/group/fix/oracleRegistryVersion/judgments); SARIF/JUnit follow the
|
|
32
|
+
* Python sibling's contract. */
|
|
33
|
+
export declare function render(findings: Finding[], fmt: OutputFormat): string;
|
|
8
34
|
export declare function renderText(findings: Finding[]): string;
|
|
9
35
|
/** True when this module is the process entry point. Package managers expose the
|
|
10
36
|
* `bin` through a symlink (node_modules/.bin/falsegreen-js), so process.argv[1]
|
package/dist/cli.js
CHANGED
|
@@ -1,19 +1,36 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
-
import { pathToFileURL } from "node:url";
|
|
5
|
-
import { JUDGMENTS, CASES, groupOf, FIX_HINTS } from "./cases.js";
|
|
4
|
+
import { pathToFileURL, fileURLToPath } from "node:url";
|
|
5
|
+
import { JUDGMENTS, CASES, groupOf, riskGroupOf, FIX_HINTS } from "./cases.js";
|
|
6
|
+
import { ORACLE_REGISTRY_VERSION } from "./oracles.js";
|
|
6
7
|
import { scanPaths, scanFile, stagedFiles, loadConfig, } from "./scan.js";
|
|
7
8
|
import { auditConfig } from "./audit.js";
|
|
8
|
-
|
|
9
|
+
import { OUTPUT_EXT, renderSarif, renderJunit, loadBaseline, writeBaseline, applyBaseline, } from "./report.js";
|
|
10
|
+
const DEFAULT_BASELINE = ".falsegreen-baseline.json";
|
|
11
|
+
/** Single source of truth for the version: package.json, resolved at runtime so
|
|
12
|
+
* `--version` and the JSON report never drift from the published package. */
|
|
13
|
+
function readVersion() {
|
|
14
|
+
try {
|
|
15
|
+
const pkg = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
16
|
+
return JSON.parse(fs.readFileSync(pkg, "utf-8")).version ?? "0.0.0";
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return "0.0.0";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const VERSION = readVersion();
|
|
9
23
|
const TOOL_URI = "https://github.com/vinicq/falsegreen-js";
|
|
10
24
|
const HELP = `falsegreen-js ${VERSION} - find false-positive JS/TS tests (static AST scan)
|
|
11
25
|
|
|
12
26
|
Usage:
|
|
13
27
|
falsegreen-js [paths...] files/dirs; no args = scan cwd
|
|
14
28
|
falsegreen-js --staged only test files staged in git
|
|
15
|
-
falsegreen-js --json
|
|
29
|
+
falsegreen-js --format FMT text | json | sarif | junit (default text)
|
|
30
|
+
falsegreen-js --json alias for --format json
|
|
16
31
|
falsegreen-js --output PATH write to a file, or report.<ext> into a directory
|
|
32
|
+
falsegreen-js --baseline [PATH] suppress findings in PATH (default ${DEFAULT_BASELINE})
|
|
33
|
+
falsegreen-js --write-baseline [PATH] record current findings as a baseline, exit 0
|
|
17
34
|
falsegreen-js --config-audit audit Jest/Vitest config (project-layer PL codes)
|
|
18
35
|
falsegreen-js --diagnostics also report the opt-in maintainability group (D*/M*)
|
|
19
36
|
falsegreen-js --disable C7,JS3 turn off specific codes
|
|
@@ -25,12 +42,19 @@ and a one-line fix hint; the summary breaks findings down by level.
|
|
|
25
42
|
Exit codes: 0 clean, 10 low-confidence only, 20 high-confidence present.
|
|
26
43
|
Suppress inline: expect(x).toBe(x); // falsegreen: ignore[C7]
|
|
27
44
|
Covers: .js .jsx .ts .tsx .mjs .cjs .mts .cts`;
|
|
45
|
+
const FORMATS = new Set(["text", "json", "sarif", "junit"]);
|
|
28
46
|
function parseArgs(argv) {
|
|
29
47
|
const paths = [];
|
|
30
48
|
let json = false, staged = false, help = false, version = false, diagnostics = false;
|
|
31
49
|
let configAudit = false;
|
|
50
|
+
let format;
|
|
32
51
|
let output;
|
|
52
|
+
let baseline;
|
|
53
|
+
let writeBaselinePath;
|
|
33
54
|
const disable = new Set();
|
|
55
|
+
// An optional-value flag (--baseline / --write-baseline) consumes the next
|
|
56
|
+
// token only when it is a value, not another flag.
|
|
57
|
+
const optionalValue = (next) => (next !== undefined && !next.startsWith("-")) ? next : undefined;
|
|
34
58
|
for (let i = 0; i < argv.length; i++) {
|
|
35
59
|
const a = argv[i];
|
|
36
60
|
if (a === "--json")
|
|
@@ -45,10 +69,44 @@ function parseArgs(argv) {
|
|
|
45
69
|
help = true;
|
|
46
70
|
else if (a === "--version" || a === "-V")
|
|
47
71
|
version = true;
|
|
72
|
+
else if (a === "--format") {
|
|
73
|
+
const v = argv[++i] ?? "";
|
|
74
|
+
if (!FORMATS.has(v)) {
|
|
75
|
+
process.stderr.write(`falsegreen-js: invalid --format ${v} (text|json|sarif|junit)\n`);
|
|
76
|
+
process.exit(2);
|
|
77
|
+
}
|
|
78
|
+
format = v;
|
|
79
|
+
}
|
|
80
|
+
else if (a.startsWith("--format=")) {
|
|
81
|
+
const v = a.slice("--format=".length);
|
|
82
|
+
if (!FORMATS.has(v)) {
|
|
83
|
+
process.stderr.write(`falsegreen-js: invalid --format ${v} (text|json|sarif|junit)\n`);
|
|
84
|
+
process.exit(2);
|
|
85
|
+
}
|
|
86
|
+
format = v;
|
|
87
|
+
}
|
|
48
88
|
else if (a === "--output")
|
|
49
89
|
output = argv[++i] ?? "";
|
|
50
90
|
else if (a.startsWith("--output="))
|
|
51
91
|
output = a.slice("--output=".length);
|
|
92
|
+
else if (a === "--baseline") {
|
|
93
|
+
const v = optionalValue(argv[i + 1]);
|
|
94
|
+
if (v !== undefined)
|
|
95
|
+
i++;
|
|
96
|
+
baseline = v ?? DEFAULT_BASELINE;
|
|
97
|
+
}
|
|
98
|
+
else if (a.startsWith("--baseline=")) {
|
|
99
|
+
baseline = a.slice("--baseline=".length) || DEFAULT_BASELINE;
|
|
100
|
+
}
|
|
101
|
+
else if (a === "--write-baseline") {
|
|
102
|
+
const v = optionalValue(argv[i + 1]);
|
|
103
|
+
if (v !== undefined)
|
|
104
|
+
i++;
|
|
105
|
+
writeBaselinePath = v ?? DEFAULT_BASELINE;
|
|
106
|
+
}
|
|
107
|
+
else if (a.startsWith("--write-baseline=")) {
|
|
108
|
+
writeBaselinePath = a.slice("--write-baseline=".length) || DEFAULT_BASELINE;
|
|
109
|
+
}
|
|
52
110
|
else if (a === "--disable") {
|
|
53
111
|
const v = argv[++i] ?? "";
|
|
54
112
|
v.split(",").map((s) => s.trim()).filter(Boolean).forEach((c) => disable.add(c));
|
|
@@ -64,14 +122,18 @@ function parseArgs(argv) {
|
|
|
64
122
|
else
|
|
65
123
|
paths.push(a);
|
|
66
124
|
}
|
|
67
|
-
|
|
125
|
+
const fmt = format ?? (json ? "json" : "text");
|
|
126
|
+
return {
|
|
127
|
+
paths, fmt, staged, help, version, diagnostics, configAudit, disable,
|
|
128
|
+
output, baseline, writeBaselinePath,
|
|
129
|
+
};
|
|
68
130
|
}
|
|
69
131
|
/** Turn --output into a concrete file path. A directory (existing dir, a
|
|
70
132
|
* trailing separator, or an extension-less name like ".falsegreen") receives
|
|
71
133
|
* "report.<ext>" for the chosen format; anything else is treated as a file.
|
|
72
134
|
* Missing parent directories are created either way. */
|
|
73
135
|
export function resolveOutputPath(p, fmt) {
|
|
74
|
-
const ext = fmt
|
|
136
|
+
const ext = OUTPUT_EXT[fmt];
|
|
75
137
|
const trimmed = p.replace(/[/\\]+$/, "");
|
|
76
138
|
const base = path.basename(trimmed);
|
|
77
139
|
let isDir = /[/\\]$/.test(p) || path.extname(base) === "";
|
|
@@ -96,6 +158,35 @@ function exitCode(findings) {
|
|
|
96
158
|
return 10;
|
|
97
159
|
return 0;
|
|
98
160
|
}
|
|
161
|
+
/** The JSON report object. Each finding carries both the primary `riskGroup`
|
|
162
|
+
* (closed taxonomy) and the legacy `group` (transition-compat), plus its fix
|
|
163
|
+
* hint. The tool block records the oracle-registry version that classified it. */
|
|
164
|
+
export function buildReport(findings) {
|
|
165
|
+
return {
|
|
166
|
+
tool: "falsegreen-js",
|
|
167
|
+
version: VERSION,
|
|
168
|
+
oracleRegistryVersion: ORACLE_REGISTRY_VERSION,
|
|
169
|
+
judgments: JUDGMENTS,
|
|
170
|
+
findings: findings.map((f) => ({
|
|
171
|
+
...f,
|
|
172
|
+
riskGroup: riskGroupOf(f.code),
|
|
173
|
+
group: groupOf(f.code),
|
|
174
|
+
fix: FIX_HINTS[f.code] ?? "",
|
|
175
|
+
})),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
/** Render findings in the chosen format. JSON keeps the full report object
|
|
179
|
+
* (riskGroup/group/fix/oracleRegistryVersion/judgments); SARIF/JUnit follow the
|
|
180
|
+
* Python sibling's contract. */
|
|
181
|
+
export function render(findings, fmt) {
|
|
182
|
+
if (fmt === "json")
|
|
183
|
+
return JSON.stringify(buildReport(findings), null, 2);
|
|
184
|
+
if (fmt === "sarif")
|
|
185
|
+
return renderSarif(findings, TOOL_URI, VERSION);
|
|
186
|
+
if (fmt === "junit")
|
|
187
|
+
return renderJunit(findings);
|
|
188
|
+
return renderText(findings);
|
|
189
|
+
}
|
|
99
190
|
export function renderText(findings) {
|
|
100
191
|
if (findings.length === 0)
|
|
101
192
|
return "falsegreen-js: no false-positive patterns found.";
|
|
@@ -141,6 +232,19 @@ export function renderText(findings) {
|
|
|
141
232
|
}
|
|
142
233
|
return lines.join("\n");
|
|
143
234
|
}
|
|
235
|
+
function scan(opt) {
|
|
236
|
+
const config = loadConfig();
|
|
237
|
+
const scanOpts = { config, cliDisable: opt.disable, diagnostics: opt.diagnostics };
|
|
238
|
+
if (opt.staged)
|
|
239
|
+
return stagedFiles().flatMap((f) => scanFile(f, scanOpts));
|
|
240
|
+
return scanPaths(opt.paths.length ? opt.paths : ["."], scanOpts);
|
|
241
|
+
}
|
|
242
|
+
function emit(rendered, opt) {
|
|
243
|
+
if (opt.output)
|
|
244
|
+
fs.writeFileSync(resolveOutputPath(opt.output, opt.fmt), rendered + "\n");
|
|
245
|
+
else
|
|
246
|
+
process.stdout.write(rendered + "\n");
|
|
247
|
+
}
|
|
144
248
|
function main() {
|
|
145
249
|
const opt = parseArgs(process.argv.slice(2));
|
|
146
250
|
if (opt.help) {
|
|
@@ -151,6 +255,13 @@ function main() {
|
|
|
151
255
|
process.stdout.write(VERSION + "\n");
|
|
152
256
|
process.exit(0);
|
|
153
257
|
}
|
|
258
|
+
// --write-baseline records the current scan and exits clean, ahead of every
|
|
259
|
+
// other mode (mirrors the Python sibling: it ratchets the file scan only).
|
|
260
|
+
if (opt.writeBaselinePath !== undefined) {
|
|
261
|
+
const n = writeBaseline(opt.writeBaselinePath, scan(opt));
|
|
262
|
+
process.stderr.write(`falsegreen-js: wrote ${n} fingerprint(s) to ${opt.writeBaselinePath}\n`);
|
|
263
|
+
process.exit(0);
|
|
264
|
+
}
|
|
154
265
|
if (opt.configAudit) {
|
|
155
266
|
const base = opt.paths.find((p) => { try {
|
|
156
267
|
return fs.statSync(p).isDirectory();
|
|
@@ -159,44 +270,16 @@ function main() {
|
|
|
159
270
|
return false;
|
|
160
271
|
} }) ?? ".";
|
|
161
272
|
const findings = auditConfig(base);
|
|
162
|
-
|
|
163
|
-
? JSON.stringify({
|
|
164
|
-
tool: "falsegreen-js", version: VERSION, judgments: JUDGMENTS,
|
|
165
|
-
findings: findings.map((f) => ({ ...f, group: groupOf(f.code), fix: FIX_HINTS[f.code] ?? "" })),
|
|
166
|
-
}, null, 2)
|
|
167
|
-
: renderText(findings);
|
|
168
|
-
if (opt.output)
|
|
169
|
-
fs.writeFileSync(resolveOutputPath(opt.output, opt.json ? "json" : "text"), rendered + "\n");
|
|
170
|
-
else
|
|
171
|
-
process.stdout.write(rendered + "\n");
|
|
273
|
+
emit(render(findings, opt.fmt), opt);
|
|
172
274
|
process.exit(findings.length ? 10 : 0);
|
|
173
275
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
if (opt.
|
|
178
|
-
findings =
|
|
179
|
-
}
|
|
180
|
-
else {
|
|
181
|
-
findings = scanPaths(opt.paths.length ? opt.paths : ["."], scanOpts);
|
|
182
|
-
}
|
|
183
|
-
const rendered = opt.json
|
|
184
|
-
? JSON.stringify({
|
|
185
|
-
tool: "falsegreen-js",
|
|
186
|
-
version: VERSION,
|
|
187
|
-
judgments: JUDGMENTS,
|
|
188
|
-
findings: findings.map((f) => ({
|
|
189
|
-
...f, group: groupOf(f.code), fix: FIX_HINTS[f.code] ?? "",
|
|
190
|
-
})),
|
|
191
|
-
}, null, 2)
|
|
192
|
-
: renderText(findings);
|
|
193
|
-
if (opt.output) {
|
|
194
|
-
const dest = resolveOutputPath(opt.output, opt.json ? "json" : "text");
|
|
195
|
-
fs.writeFileSync(dest, rendered + "\n");
|
|
196
|
-
}
|
|
197
|
-
else {
|
|
198
|
-
process.stdout.write(rendered + "\n");
|
|
276
|
+
let findings = scan(opt);
|
|
277
|
+
// The baseline filter runs before the exit code, so CI fails only on findings
|
|
278
|
+
// that are not already recorded.
|
|
279
|
+
if (opt.baseline !== undefined) {
|
|
280
|
+
findings = applyBaseline(findings, loadBaseline(opt.baseline));
|
|
199
281
|
}
|
|
282
|
+
emit(render(findings, opt.fmt), opt);
|
|
200
283
|
process.exit(exitCode(findings));
|
|
201
284
|
}
|
|
202
285
|
/** True when this module is the process entry point. Package managers expose the
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Oracle registry — the assertion-API vocabulary falsegreen-js understands, kept
|
|
3
|
+
* as a single versioned table instead of scattered Sets across the rules.
|
|
4
|
+
*
|
|
5
|
+
* Each family is classified by how its failure reaches the runner. That kind is
|
|
6
|
+
* what tells the async analysis whether a call settles synchronously, returns a
|
|
7
|
+
* promise that must be awaited, or only produces a value:
|
|
8
|
+
*
|
|
9
|
+
* sync-fail throws synchronously on mismatch — a bare statement is
|
|
10
|
+
* enough to fail the test (jest/vitest expect().matcher(),
|
|
11
|
+
* node:assert, chai assert, sinon.assert).
|
|
12
|
+
* promise returns a promise; the failure only surfaces if it is
|
|
13
|
+
* awaited or returned (expect().resolves/.rejects, supertest
|
|
14
|
+
* .expect() in its awaited form).
|
|
15
|
+
* runner-registered registered with the runner; the framework collects the
|
|
16
|
+
* result even from a fluent chain (AVA/node:test t.is,
|
|
17
|
+
* Cypress cy.should, chai .should).
|
|
18
|
+
* value-only produces a value but does not assert on its own; it must
|
|
19
|
+
* feed an assertion (Testing Library getBy* and findBy*).
|
|
20
|
+
*
|
|
21
|
+
* Bump ORACLE_REGISTRY_VERSION when the classification changes, so a JSON report
|
|
22
|
+
* records which vocabulary produced it.
|
|
23
|
+
*/
|
|
24
|
+
export declare const ORACLE_REGISTRY_VERSION = 2;
|
|
25
|
+
export type OracleKind = "sync-fail" | "promise" | "runner-registered" | "value-only";
|
|
26
|
+
/**
|
|
27
|
+
* Roots whose `<root>.<method>()` call counts as an assertion across runners:
|
|
28
|
+
* AVA / node:test / tap (t), Cypress (cy), chai assert, sinon.assert, QUnit.
|
|
29
|
+
* These are runner-registered: the framework records the outcome.
|
|
30
|
+
*/
|
|
31
|
+
export declare const ASSERT_ROOTS: Set<string>;
|
|
32
|
+
/** Assertion method names used by AVA / tap / node:test / chai assert / QUnit. */
|
|
33
|
+
export declare const ASSERT_METHODS: Set<string>;
|
|
34
|
+
/** Matchers whose baseline is generated from the output itself (snapshot family). */
|
|
35
|
+
export declare const SNAPSHOT_MATCHERS: Set<string>;
|
|
36
|
+
/** Equality matchers (Jest/Vitest). */
|
|
37
|
+
export declare const EQUALITY_MATCHERS: Set<string>;
|
|
38
|
+
/**
|
|
39
|
+
* Testing Library async leaves that return a promise and must be awaited before
|
|
40
|
+
* the assertion can settle (value-only / promise: findBy* resolve to an element,
|
|
41
|
+
* waitFor* resolve when their callback stops throwing).
|
|
42
|
+
*/
|
|
43
|
+
export declare const ASYNC_AWAIT_LEAVES: Set<string>;
|
|
44
|
+
/** Vue/Svelte async test helpers that return a promise and must be awaited. */
|
|
45
|
+
export declare const VUE_SVELTE_ASYNC: Set<string>;
|
|
46
|
+
/**
|
|
47
|
+
* Classify a call name (`root.leaf` or a bare identifier) by oracle kind. Returns
|
|
48
|
+
* null when the name is not a known oracle. Conservative: only the vocabulary
|
|
49
|
+
* above is classified; project-specific helpers are handled by naming convention
|
|
50
|
+
* in the rules, not here.
|
|
51
|
+
*/
|
|
52
|
+
export declare function oracleKind(name: string): OracleKind | null;
|
package/dist/oracles.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Oracle registry — the assertion-API vocabulary falsegreen-js understands, kept
|
|
3
|
+
* as a single versioned table instead of scattered Sets across the rules.
|
|
4
|
+
*
|
|
5
|
+
* Each family is classified by how its failure reaches the runner. That kind is
|
|
6
|
+
* what tells the async analysis whether a call settles synchronously, returns a
|
|
7
|
+
* promise that must be awaited, or only produces a value:
|
|
8
|
+
*
|
|
9
|
+
* sync-fail throws synchronously on mismatch — a bare statement is
|
|
10
|
+
* enough to fail the test (jest/vitest expect().matcher(),
|
|
11
|
+
* node:assert, chai assert, sinon.assert).
|
|
12
|
+
* promise returns a promise; the failure only surfaces if it is
|
|
13
|
+
* awaited or returned (expect().resolves/.rejects, supertest
|
|
14
|
+
* .expect() in its awaited form).
|
|
15
|
+
* runner-registered registered with the runner; the framework collects the
|
|
16
|
+
* result even from a fluent chain (AVA/node:test t.is,
|
|
17
|
+
* Cypress cy.should, chai .should).
|
|
18
|
+
* value-only produces a value but does not assert on its own; it must
|
|
19
|
+
* feed an assertion (Testing Library getBy* and findBy*).
|
|
20
|
+
*
|
|
21
|
+
* Bump ORACLE_REGISTRY_VERSION when the classification changes, so a JSON report
|
|
22
|
+
* records which vocabulary produced it.
|
|
23
|
+
*/
|
|
24
|
+
export const ORACLE_REGISTRY_VERSION = 2;
|
|
25
|
+
/**
|
|
26
|
+
* Roots whose `<root>.<method>()` call counts as an assertion across runners:
|
|
27
|
+
* AVA / node:test / tap (t), Cypress (cy), chai assert, sinon.assert, QUnit.
|
|
28
|
+
* These are runner-registered: the framework records the outcome.
|
|
29
|
+
*/
|
|
30
|
+
export const ASSERT_ROOTS = new Set(["assert", "t", "cy", "tap", "qunit", "sinon", "chai", "should"]);
|
|
31
|
+
/** Assertion method names used by AVA / tap / node:test / chai assert / QUnit. */
|
|
32
|
+
export const ASSERT_METHODS = new Set([
|
|
33
|
+
"is", "not", "ok", "notOk", "true", "false", "truthy", "falsy",
|
|
34
|
+
"equal", "notEqual", "deepEqual", "notDeepEqual", "strictEqual",
|
|
35
|
+
"same", "notSame", "throws", "notThrows", "throwsAsync", "regex", "notRegex",
|
|
36
|
+
"pass", "fail", "assert", "expect", "include", "match",
|
|
37
|
+
]);
|
|
38
|
+
/** Matchers whose baseline is generated from the output itself (snapshot family). */
|
|
39
|
+
export const SNAPSHOT_MATCHERS = new Set([
|
|
40
|
+
"toMatchSnapshot", "toMatchInlineSnapshot",
|
|
41
|
+
"toThrowErrorMatchingSnapshot", "toThrowErrorMatchingInlineSnapshot",
|
|
42
|
+
// visual snapshots (Playwright): the baseline is generated from the output too
|
|
43
|
+
"toHaveScreenshot", "toMatchScreenshot",
|
|
44
|
+
]);
|
|
45
|
+
/** Equality matchers (Jest/Vitest). */
|
|
46
|
+
export const EQUALITY_MATCHERS = new Set(["toBe", "toEqual", "toStrictEqual"]);
|
|
47
|
+
/**
|
|
48
|
+
* Testing Library async leaves that return a promise and must be awaited before
|
|
49
|
+
* the assertion can settle (value-only / promise: findBy* resolve to an element,
|
|
50
|
+
* waitFor* resolve when their callback stops throwing).
|
|
51
|
+
*/
|
|
52
|
+
export const ASYNC_AWAIT_LEAVES = new Set(["waitFor", "waitForElementToBeRemoved"]);
|
|
53
|
+
/** Vue/Svelte async test helpers that return a promise and must be awaited. */
|
|
54
|
+
export const VUE_SVELTE_ASYNC = new Set(["flushPromises", "nextTick", "$nextTick", "tick"]);
|
|
55
|
+
/**
|
|
56
|
+
* Classify a call name (`root.leaf` or a bare identifier) by oracle kind. Returns
|
|
57
|
+
* null when the name is not a known oracle. Conservative: only the vocabulary
|
|
58
|
+
* above is classified; project-specific helpers are handled by naming convention
|
|
59
|
+
* in the rules, not here.
|
|
60
|
+
*/
|
|
61
|
+
export function oracleKind(name) {
|
|
62
|
+
const parts = name.split(".");
|
|
63
|
+
const root = parts[0];
|
|
64
|
+
const leaf = parts[parts.length - 1];
|
|
65
|
+
// expect(x).resolves/.rejects.matcher() — promise; plain expect().matcher() is sync-fail.
|
|
66
|
+
if (root === "expect")
|
|
67
|
+
return name.includes(".resolves") || name.includes(".rejects") ? "promise" : "sync-fail";
|
|
68
|
+
if (root === "assert")
|
|
69
|
+
return "sync-fail";
|
|
70
|
+
if (root === "sinon" && name.includes("assert"))
|
|
71
|
+
return "sync-fail";
|
|
72
|
+
if (ASSERT_ROOTS.has(root) && ASSERT_METHODS.has(leaf))
|
|
73
|
+
return "runner-registered";
|
|
74
|
+
if (leaf === "should")
|
|
75
|
+
return "runner-registered";
|
|
76
|
+
if (leaf.startsWith("findBy") || leaf.startsWith("findAllBy"))
|
|
77
|
+
return "value-only";
|
|
78
|
+
// @testing-library/user-event v14+: every action (click/type/keyboard/…) returns
|
|
79
|
+
// a promise that must be awaited before the resulting state can be asserted.
|
|
80
|
+
if (root === "userEvent")
|
|
81
|
+
return "promise";
|
|
82
|
+
if (ASYNC_AWAIT_LEAVES.has(leaf))
|
|
83
|
+
return "promise";
|
|
84
|
+
if (VUE_SVELTE_ASYNC.has(leaf))
|
|
85
|
+
return "promise";
|
|
86
|
+
return null;
|
|
87
|
+
}
|