falsegreen-js 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +140 -1
- package/README.md +72 -13
- package/dist/cases.d.ts +51 -3
- package/dist/cases.js +91 -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 +136 -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 +538 -90
- package/dist/scan.d.ts +1 -0
- package/dist/scan.js +0 -0
- package/dist/types.js +2 -2
- package/package.json +34 -9
package/dist/cli.js
CHANGED
|
@@ -1,22 +1,40 @@
|
|
|
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
|
|
37
|
+
falsegreen-js --enable D8,M2 re-activate off/opt-in codes at catalog severity (--disable wins)
|
|
20
38
|
falsegreen-js --version
|
|
21
39
|
falsegreen-js --help
|
|
22
40
|
|
|
@@ -25,12 +43,20 @@ and a one-line fix hint; the summary breaks findings down by level.
|
|
|
25
43
|
Exit codes: 0 clean, 10 low-confidence only, 20 high-confidence present.
|
|
26
44
|
Suppress inline: expect(x).toBe(x); // falsegreen: ignore[C7]
|
|
27
45
|
Covers: .js .jsx .ts .tsx .mjs .cjs .mts .cts`;
|
|
46
|
+
const FORMATS = new Set(["text", "json", "sarif", "junit"]);
|
|
28
47
|
function parseArgs(argv) {
|
|
29
48
|
const paths = [];
|
|
30
49
|
let json = false, staged = false, help = false, version = false, diagnostics = false;
|
|
31
50
|
let configAudit = false;
|
|
51
|
+
let format;
|
|
32
52
|
let output;
|
|
53
|
+
let baseline;
|
|
54
|
+
let writeBaselinePath;
|
|
33
55
|
const disable = new Set();
|
|
56
|
+
const enable = new Set();
|
|
57
|
+
// An optional-value flag (--baseline / --write-baseline) consumes the next
|
|
58
|
+
// token only when it is a value, not another flag.
|
|
59
|
+
const optionalValue = (next) => (next !== undefined && !next.startsWith("-")) ? next : undefined;
|
|
34
60
|
for (let i = 0; i < argv.length; i++) {
|
|
35
61
|
const a = argv[i];
|
|
36
62
|
if (a === "--json")
|
|
@@ -45,10 +71,44 @@ function parseArgs(argv) {
|
|
|
45
71
|
help = true;
|
|
46
72
|
else if (a === "--version" || a === "-V")
|
|
47
73
|
version = true;
|
|
74
|
+
else if (a === "--format") {
|
|
75
|
+
const v = argv[++i] ?? "";
|
|
76
|
+
if (!FORMATS.has(v)) {
|
|
77
|
+
process.stderr.write(`falsegreen-js: invalid --format ${v} (text|json|sarif|junit)\n`);
|
|
78
|
+
process.exit(2);
|
|
79
|
+
}
|
|
80
|
+
format = v;
|
|
81
|
+
}
|
|
82
|
+
else if (a.startsWith("--format=")) {
|
|
83
|
+
const v = a.slice("--format=".length);
|
|
84
|
+
if (!FORMATS.has(v)) {
|
|
85
|
+
process.stderr.write(`falsegreen-js: invalid --format ${v} (text|json|sarif|junit)\n`);
|
|
86
|
+
process.exit(2);
|
|
87
|
+
}
|
|
88
|
+
format = v;
|
|
89
|
+
}
|
|
48
90
|
else if (a === "--output")
|
|
49
91
|
output = argv[++i] ?? "";
|
|
50
92
|
else if (a.startsWith("--output="))
|
|
51
93
|
output = a.slice("--output=".length);
|
|
94
|
+
else if (a === "--baseline") {
|
|
95
|
+
const v = optionalValue(argv[i + 1]);
|
|
96
|
+
if (v !== undefined)
|
|
97
|
+
i++;
|
|
98
|
+
baseline = v ?? DEFAULT_BASELINE;
|
|
99
|
+
}
|
|
100
|
+
else if (a.startsWith("--baseline=")) {
|
|
101
|
+
baseline = a.slice("--baseline=".length) || DEFAULT_BASELINE;
|
|
102
|
+
}
|
|
103
|
+
else if (a === "--write-baseline") {
|
|
104
|
+
const v = optionalValue(argv[i + 1]);
|
|
105
|
+
if (v !== undefined)
|
|
106
|
+
i++;
|
|
107
|
+
writeBaselinePath = v ?? DEFAULT_BASELINE;
|
|
108
|
+
}
|
|
109
|
+
else if (a.startsWith("--write-baseline=")) {
|
|
110
|
+
writeBaselinePath = a.slice("--write-baseline=".length) || DEFAULT_BASELINE;
|
|
111
|
+
}
|
|
52
112
|
else if (a === "--disable") {
|
|
53
113
|
const v = argv[++i] ?? "";
|
|
54
114
|
v.split(",").map((s) => s.trim()).filter(Boolean).forEach((c) => disable.add(c));
|
|
@@ -57,6 +117,14 @@ function parseArgs(argv) {
|
|
|
57
117
|
a.slice("--disable=".length).split(",").map((s) => s.trim())
|
|
58
118
|
.filter(Boolean).forEach((c) => disable.add(c));
|
|
59
119
|
}
|
|
120
|
+
else if (a === "--enable") {
|
|
121
|
+
const v = argv[++i] ?? "";
|
|
122
|
+
v.split(",").map((s) => s.trim()).filter(Boolean).forEach((c) => enable.add(c));
|
|
123
|
+
}
|
|
124
|
+
else if (a.startsWith("--enable=")) {
|
|
125
|
+
a.slice("--enable=".length).split(",").map((s) => s.trim())
|
|
126
|
+
.filter(Boolean).forEach((c) => enable.add(c));
|
|
127
|
+
}
|
|
60
128
|
else if (a.startsWith("-")) {
|
|
61
129
|
process.stderr.write(`falsegreen-js: unknown option ${a}\n`);
|
|
62
130
|
process.exit(2);
|
|
@@ -64,14 +132,18 @@ function parseArgs(argv) {
|
|
|
64
132
|
else
|
|
65
133
|
paths.push(a);
|
|
66
134
|
}
|
|
67
|
-
|
|
135
|
+
const fmt = format ?? (json ? "json" : "text");
|
|
136
|
+
return {
|
|
137
|
+
paths, fmt, staged, help, version, diagnostics, configAudit, disable, enable,
|
|
138
|
+
output, baseline, writeBaselinePath,
|
|
139
|
+
};
|
|
68
140
|
}
|
|
69
141
|
/** Turn --output into a concrete file path. A directory (existing dir, a
|
|
70
142
|
* trailing separator, or an extension-less name like ".falsegreen") receives
|
|
71
143
|
* "report.<ext>" for the chosen format; anything else is treated as a file.
|
|
72
144
|
* Missing parent directories are created either way. */
|
|
73
145
|
export function resolveOutputPath(p, fmt) {
|
|
74
|
-
const ext = fmt
|
|
146
|
+
const ext = OUTPUT_EXT[fmt];
|
|
75
147
|
const trimmed = p.replace(/[/\\]+$/, "");
|
|
76
148
|
const base = path.basename(trimmed);
|
|
77
149
|
let isDir = /[/\\]$/.test(p) || path.extname(base) === "";
|
|
@@ -96,6 +168,35 @@ function exitCode(findings) {
|
|
|
96
168
|
return 10;
|
|
97
169
|
return 0;
|
|
98
170
|
}
|
|
171
|
+
/** The JSON report object. Each finding carries both the primary `riskGroup`
|
|
172
|
+
* (closed taxonomy) and the legacy `group` (transition-compat), plus its fix
|
|
173
|
+
* hint. The tool block records the oracle-registry version that classified it. */
|
|
174
|
+
export function buildReport(findings) {
|
|
175
|
+
return {
|
|
176
|
+
tool: "falsegreen-js",
|
|
177
|
+
version: VERSION,
|
|
178
|
+
oracleRegistryVersion: ORACLE_REGISTRY_VERSION,
|
|
179
|
+
judgments: JUDGMENTS,
|
|
180
|
+
findings: findings.map((f) => ({
|
|
181
|
+
...f,
|
|
182
|
+
riskGroup: riskGroupOf(f.code),
|
|
183
|
+
group: groupOf(f.code),
|
|
184
|
+
fix: FIX_HINTS[f.code] ?? "",
|
|
185
|
+
})),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
/** Render findings in the chosen format. JSON keeps the full report object
|
|
189
|
+
* (riskGroup/group/fix/oracleRegistryVersion/judgments); SARIF/JUnit follow the
|
|
190
|
+
* Python sibling's contract. */
|
|
191
|
+
export function render(findings, fmt) {
|
|
192
|
+
if (fmt === "json")
|
|
193
|
+
return JSON.stringify(buildReport(findings), null, 2);
|
|
194
|
+
if (fmt === "sarif")
|
|
195
|
+
return renderSarif(findings, TOOL_URI, VERSION);
|
|
196
|
+
if (fmt === "junit")
|
|
197
|
+
return renderJunit(findings);
|
|
198
|
+
return renderText(findings);
|
|
199
|
+
}
|
|
99
200
|
export function renderText(findings) {
|
|
100
201
|
if (findings.length === 0)
|
|
101
202
|
return "falsegreen-js: no false-positive patterns found.";
|
|
@@ -141,6 +242,21 @@ export function renderText(findings) {
|
|
|
141
242
|
}
|
|
142
243
|
return lines.join("\n");
|
|
143
244
|
}
|
|
245
|
+
function scan(opt) {
|
|
246
|
+
const config = loadConfig();
|
|
247
|
+
const scanOpts = {
|
|
248
|
+
config, cliDisable: opt.disable, cliEnable: opt.enable, diagnostics: opt.diagnostics,
|
|
249
|
+
};
|
|
250
|
+
if (opt.staged)
|
|
251
|
+
return stagedFiles().flatMap((f) => scanFile(f, scanOpts));
|
|
252
|
+
return scanPaths(opt.paths.length ? opt.paths : ["."], scanOpts);
|
|
253
|
+
}
|
|
254
|
+
function emit(rendered, opt) {
|
|
255
|
+
if (opt.output)
|
|
256
|
+
fs.writeFileSync(resolveOutputPath(opt.output, opt.fmt), rendered + "\n");
|
|
257
|
+
else
|
|
258
|
+
process.stdout.write(rendered + "\n");
|
|
259
|
+
}
|
|
144
260
|
function main() {
|
|
145
261
|
const opt = parseArgs(process.argv.slice(2));
|
|
146
262
|
if (opt.help) {
|
|
@@ -151,6 +267,13 @@ function main() {
|
|
|
151
267
|
process.stdout.write(VERSION + "\n");
|
|
152
268
|
process.exit(0);
|
|
153
269
|
}
|
|
270
|
+
// --write-baseline records the current scan and exits clean, ahead of every
|
|
271
|
+
// other mode (mirrors the Python sibling: it ratchets the file scan only).
|
|
272
|
+
if (opt.writeBaselinePath !== undefined) {
|
|
273
|
+
const n = writeBaseline(opt.writeBaselinePath, scan(opt));
|
|
274
|
+
process.stderr.write(`falsegreen-js: wrote ${n} fingerprint(s) to ${opt.writeBaselinePath}\n`);
|
|
275
|
+
process.exit(0);
|
|
276
|
+
}
|
|
154
277
|
if (opt.configAudit) {
|
|
155
278
|
const base = opt.paths.find((p) => { try {
|
|
156
279
|
return fs.statSync(p).isDirectory();
|
|
@@ -159,44 +282,16 @@ function main() {
|
|
|
159
282
|
return false;
|
|
160
283
|
} }) ?? ".";
|
|
161
284
|
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");
|
|
285
|
+
emit(render(findings, opt.fmt), opt);
|
|
172
286
|
process.exit(findings.length ? 10 : 0);
|
|
173
287
|
}
|
|
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");
|
|
288
|
+
let findings = scan(opt);
|
|
289
|
+
// The baseline filter runs before the exit code, so CI fails only on findings
|
|
290
|
+
// that are not already recorded.
|
|
291
|
+
if (opt.baseline !== undefined) {
|
|
292
|
+
findings = applyBaseline(findings, loadBaseline(opt.baseline));
|
|
199
293
|
}
|
|
294
|
+
emit(render(findings, opt.fmt), opt);
|
|
200
295
|
process.exit(exitCode(findings));
|
|
201
296
|
}
|
|
202
297
|
/** 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
|
+
}
|
package/dist/report.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Finding } from "./types.js";
|
|
2
|
+
export type OutputFormat = "text" | "json" | "sarif" | "junit";
|
|
3
|
+
export declare const OUTPUT_EXT: Record<OutputFormat, string>;
|
|
4
|
+
/** A forward-slash relative URI (load-bearing for GitHub code scanning). */
|
|
5
|
+
export declare function relUri(file: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* SARIF 2.1.0 document. One rule per code present, one result per finding.
|
|
8
|
+
* Levels come from the finding's effective confidence; result tags carry the
|
|
9
|
+
* judgment, the risk group, and the level so GitHub code scanning can facet on
|
|
10
|
+
* any of them.
|
|
11
|
+
*/
|
|
12
|
+
export declare function renderSarif(findings: Finding[], toolUri: string, version: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* JUnit XML. One testcase per finding, ordered by (file, line). A high-severity
|
|
15
|
+
* finding is a <failure>; everything else is <skipped>. The suite attributes
|
|
16
|
+
* count tests, failures (high), skipped (non-high), and errors (always 0).
|
|
17
|
+
*/
|
|
18
|
+
export declare function renderJunit(findings: Finding[]): string;
|
|
19
|
+
/**
|
|
20
|
+
* Stable id: sha1(relpath + "\0" + code + "\0" + detail)[:16]. No line number,
|
|
21
|
+
* so the fingerprint survives unrelated line shifts in the file. The js
|
|
22
|
+
* fingerprint omits the source snippet the Python tool folds in, since the js
|
|
23
|
+
* Finding does not carry one.
|
|
24
|
+
*/
|
|
25
|
+
export declare function fingerprint(f: Finding): string;
|
|
26
|
+
/** Read a baseline file into a set of fingerprints (empty set if unreadable). */
|
|
27
|
+
export declare function loadBaseline(file: string): Set<string>;
|
|
28
|
+
/** Write all current findings as a baseline. Returns how many were recorded. */
|
|
29
|
+
export declare function writeBaseline(file: string, findings: Finding[]): number;
|
|
30
|
+
/** Drop findings whose content fingerprint is already in the baseline. */
|
|
31
|
+
export declare function applyBaseline(findings: Finding[], baseline: Set<string>): Finding[];
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output renderers and the baseline ratchet. Mirrors the Python sibling's
|
|
3
|
+
* contract (SARIF 2.1.0, JUnit XML, content fingerprint) so the two scanners
|
|
4
|
+
* produce interchangeable reports and a CI pipeline can swap one for the other.
|
|
5
|
+
*
|
|
6
|
+
* Divergence from falsegreen (Python): the js Finding carries no source snippet,
|
|
7
|
+
* so the content fingerprint hashes relpath + code + detail only (Python also
|
|
8
|
+
* folds in a normalized snippet). The fingerprint stays stable across unrelated
|
|
9
|
+
* line shifts in both tools; the js id is just coarser when two findings share
|
|
10
|
+
* the same code and detail in one file.
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { CASES, riskGroupOf } from "./cases.js";
|
|
16
|
+
export const OUTPUT_EXT = {
|
|
17
|
+
text: "txt", json: "json", sarif: "sarif", junit: "xml",
|
|
18
|
+
};
|
|
19
|
+
/** A forward-slash relative URI (load-bearing for GitHub code scanning). */
|
|
20
|
+
export function relUri(file) {
|
|
21
|
+
let rel = file;
|
|
22
|
+
try {
|
|
23
|
+
rel = path.relative(process.cwd(), file);
|
|
24
|
+
}
|
|
25
|
+
catch { /* different drive on Windows */ }
|
|
26
|
+
return rel.replace(/\\/g, "/");
|
|
27
|
+
}
|
|
28
|
+
/** SARIF level map: high -> error, low -> warning, off -> note. */
|
|
29
|
+
function sarifLevel(conf) {
|
|
30
|
+
if (conf === "high")
|
|
31
|
+
return "error";
|
|
32
|
+
if (conf === "low")
|
|
33
|
+
return "warning";
|
|
34
|
+
return "note";
|
|
35
|
+
}
|
|
36
|
+
function messageText(f) {
|
|
37
|
+
return CASES[f.code].title + (f.detail ? ` (${f.detail})` : "");
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* SARIF 2.1.0 document. One rule per code present, one result per finding.
|
|
41
|
+
* Levels come from the finding's effective confidence; result tags carry the
|
|
42
|
+
* judgment, the risk group, and the level so GitHub code scanning can facet on
|
|
43
|
+
* any of them.
|
|
44
|
+
*/
|
|
45
|
+
export function renderSarif(findings, toolUri, version) {
|
|
46
|
+
const codes = [];
|
|
47
|
+
for (const f of findings)
|
|
48
|
+
if (!codes.includes(f.code))
|
|
49
|
+
codes.push(f.code);
|
|
50
|
+
const rules = codes.map((code) => {
|
|
51
|
+
const c = CASES[code];
|
|
52
|
+
return {
|
|
53
|
+
id: code,
|
|
54
|
+
name: code,
|
|
55
|
+
shortDescription: { text: c.title },
|
|
56
|
+
defaultConfiguration: { level: sarifLevel(c.defaultOn ? c.severity : "off") },
|
|
57
|
+
helpUri: toolUri,
|
|
58
|
+
properties: { tags: [c.judgment] },
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
const results = findings.map((f) => ({
|
|
62
|
+
ruleId: f.code,
|
|
63
|
+
level: sarifLevel(f.confidence),
|
|
64
|
+
message: { text: messageText(f) },
|
|
65
|
+
properties: {
|
|
66
|
+
tags: [CASES[f.code].judgment, `risk:${riskGroupOf(f.code)}`, `level:${f.confidence}`],
|
|
67
|
+
},
|
|
68
|
+
locations: [{
|
|
69
|
+
physicalLocation: {
|
|
70
|
+
artifactLocation: { uri: relUri(f.file) },
|
|
71
|
+
region: { startLine: f.line },
|
|
72
|
+
},
|
|
73
|
+
}],
|
|
74
|
+
}));
|
|
75
|
+
const doc = {
|
|
76
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
77
|
+
version: "2.1.0",
|
|
78
|
+
runs: [{
|
|
79
|
+
tool: { driver: { name: "falsegreen-js", informationUri: toolUri, version, rules } },
|
|
80
|
+
results,
|
|
81
|
+
}],
|
|
82
|
+
};
|
|
83
|
+
return JSON.stringify(doc, null, 2);
|
|
84
|
+
}
|
|
85
|
+
/** XML attribute / text escaping for the JUnit renderer. */
|
|
86
|
+
function xmlEscape(s) {
|
|
87
|
+
return s
|
|
88
|
+
.replace(/&/g, "&")
|
|
89
|
+
.replace(/</g, "<")
|
|
90
|
+
.replace(/>/g, ">")
|
|
91
|
+
.replace(/"/g, """);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* JUnit XML. One testcase per finding, ordered by (file, line). A high-severity
|
|
95
|
+
* finding is a <failure>; everything else is <skipped>. The suite attributes
|
|
96
|
+
* count tests, failures (high), skipped (non-high), and errors (always 0).
|
|
97
|
+
*/
|
|
98
|
+
export function renderJunit(findings) {
|
|
99
|
+
const n = findings.length;
|
|
100
|
+
const nHigh = findings.filter((f) => f.confidence === "high").length;
|
|
101
|
+
const nNonHigh = n - nHigh;
|
|
102
|
+
const suiteAttrs = `name="falsegreen-js" tests="${n}" failures="${nHigh}" skipped="${nNonHigh}" errors="0"`;
|
|
103
|
+
const ordered = [...findings].sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line));
|
|
104
|
+
const cases = [];
|
|
105
|
+
for (const f of ordered) {
|
|
106
|
+
const title = messageText(f);
|
|
107
|
+
const loc = `${relUri(f.file)}:${f.line}`;
|
|
108
|
+
const caseAttrs = `classname="falsegreen-js.${xmlEscape(f.code)}" name="${xmlEscape(`${f.code} ${loc}`)}"`;
|
|
109
|
+
if (f.confidence === "high") {
|
|
110
|
+
cases.push(` <testcase ${caseAttrs}>\n` +
|
|
111
|
+
` <failure message="${xmlEscape(title)}">${xmlEscape(loc)}</failure>\n` +
|
|
112
|
+
` </testcase>`);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
cases.push(` <testcase ${caseAttrs}>\n` +
|
|
116
|
+
` <skipped message="${xmlEscape(`${title} ${loc}`)}"></skipped>\n` +
|
|
117
|
+
` </testcase>`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const body = cases.length ? `\n${cases.join("\n")}\n ` : "";
|
|
121
|
+
return `<?xml version="1.0" encoding="utf-8"?>\n` +
|
|
122
|
+
`<testsuites ${suiteAttrs}>\n` +
|
|
123
|
+
` <testsuite ${suiteAttrs}>${body}</testsuite>\n` +
|
|
124
|
+
`</testsuites>`;
|
|
125
|
+
}
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Baseline (ratchet): fingerprint by content, not line number
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
/**
|
|
130
|
+
* Stable id: sha1(relpath + "\0" + code + "\0" + detail)[:16]. No line number,
|
|
131
|
+
* so the fingerprint survives unrelated line shifts in the file. The js
|
|
132
|
+
* fingerprint omits the source snippet the Python tool folds in, since the js
|
|
133
|
+
* Finding does not carry one.
|
|
134
|
+
*/
|
|
135
|
+
export function fingerprint(f) {
|
|
136
|
+
const key = [relUri(f.file), f.code, f.detail || ""].join("\0");
|
|
137
|
+
return createHash("sha1").update(key, "utf-8").digest("hex").slice(0, 16);
|
|
138
|
+
}
|
|
139
|
+
/** Read a baseline file into a set of fingerprints (empty set if unreadable). */
|
|
140
|
+
export function loadBaseline(file) {
|
|
141
|
+
let data;
|
|
142
|
+
try {
|
|
143
|
+
data = JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return new Set();
|
|
147
|
+
}
|
|
148
|
+
const out = new Set();
|
|
149
|
+
const items = data.findings;
|
|
150
|
+
if (Array.isArray(items)) {
|
|
151
|
+
for (const item of items) {
|
|
152
|
+
const fp = item.fingerprint;
|
|
153
|
+
if (typeof fp === "string" && fp)
|
|
154
|
+
out.add(fp);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
/** Write all current findings as a baseline. Returns how many were recorded. */
|
|
160
|
+
export function writeBaseline(file, findings) {
|
|
161
|
+
const ordered = [...findings].sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line));
|
|
162
|
+
const items = ordered.map((f) => ({
|
|
163
|
+
fingerprint: fingerprint(f),
|
|
164
|
+
code: f.code,
|
|
165
|
+
file: relUri(f.file),
|
|
166
|
+
detail: f.detail,
|
|
167
|
+
}));
|
|
168
|
+
const parent = path.dirname(file);
|
|
169
|
+
if (parent)
|
|
170
|
+
fs.mkdirSync(parent, { recursive: true });
|
|
171
|
+
fs.writeFileSync(file, JSON.stringify({ version: 1, tool: "falsegreen-js", findings: items }, null, 2) + "\n");
|
|
172
|
+
return items.length;
|
|
173
|
+
}
|
|
174
|
+
/** Drop findings whose content fingerprint is already in the baseline. */
|
|
175
|
+
export function applyBaseline(findings, baseline) {
|
|
176
|
+
return findings.filter((f) => !baseline.has(fingerprint(f)));
|
|
177
|
+
}
|