falsegreen-js 0.2.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/dist/cli.js CHANGED
@@ -1,38 +1,112 @@
1
1
  #!/usr/bin/env node
2
- import { JUDGMENTS, groupOf } from "./cases.js";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
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";
3
7
  import { scanPaths, scanFile, stagedFiles, loadConfig, } from "./scan.js";
4
- const VERSION = "0.2.0";
8
+ import { auditConfig } from "./audit.js";
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();
5
23
  const TOOL_URI = "https://github.com/vinicq/falsegreen-js";
6
24
  const HELP = `falsegreen-js ${VERSION} - find false-positive JS/TS tests (static AST scan)
7
25
 
8
26
  Usage:
9
27
  falsegreen-js [paths...] files/dirs; no args = scan cwd
10
28
  falsegreen-js --staged only test files staged in git
11
- falsegreen-js --json JSON output
29
+ falsegreen-js --format FMT text | json | sarif | junit (default text)
30
+ falsegreen-js --json alias for --format json
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
34
+ falsegreen-js --config-audit audit Jest/Vitest config (project-layer PL codes)
12
35
  falsegreen-js --diagnostics also report the opt-in maintainability group (D*/M*)
13
36
  falsegreen-js --disable C7,JS3 turn off specific codes
14
37
  falsegreen-js --version
15
38
  falsegreen-js --help
16
39
 
40
+ Each finding carries its pyramid level (unit/integration/e2e, read from imports)
41
+ and a one-line fix hint; the summary breaks findings down by level.
17
42
  Exit codes: 0 clean, 10 low-confidence only, 20 high-confidence present.
18
43
  Suppress inline: expect(x).toBe(x); // falsegreen: ignore[C7]
19
44
  Covers: .js .jsx .ts .tsx .mjs .cjs .mts .cts`;
45
+ const FORMATS = new Set(["text", "json", "sarif", "junit"]);
20
46
  function parseArgs(argv) {
21
47
  const paths = [];
22
48
  let json = false, staged = false, help = false, version = false, diagnostics = false;
49
+ let configAudit = false;
50
+ let format;
51
+ let output;
52
+ let baseline;
53
+ let writeBaselinePath;
23
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;
24
58
  for (let i = 0; i < argv.length; i++) {
25
59
  const a = argv[i];
26
60
  if (a === "--json")
27
61
  json = true;
28
62
  else if (a === "--staged")
29
63
  staged = true;
64
+ else if (a === "--config-audit")
65
+ configAudit = true;
30
66
  else if (a === "--diagnostics")
31
67
  diagnostics = true;
32
68
  else if (a === "--help" || a === "-h")
33
69
  help = true;
34
70
  else if (a === "--version" || a === "-V")
35
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
+ }
88
+ else if (a === "--output")
89
+ output = argv[++i] ?? "";
90
+ else if (a.startsWith("--output="))
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
+ }
36
110
  else if (a === "--disable") {
37
111
  const v = argv[++i] ?? "";
38
112
  v.split(",").map((s) => s.trim()).filter(Boolean).forEach((c) => disable.add(c));
@@ -48,7 +122,34 @@ function parseArgs(argv) {
48
122
  else
49
123
  paths.push(a);
50
124
  }
51
- return { paths, json, staged, help, version, diagnostics, disable };
125
+ const fmt = format ?? (json ? "json" : "text");
126
+ return {
127
+ paths, fmt, staged, help, version, diagnostics, configAudit, disable,
128
+ output, baseline, writeBaselinePath,
129
+ };
130
+ }
131
+ /** Turn --output into a concrete file path. A directory (existing dir, a
132
+ * trailing separator, or an extension-less name like ".falsegreen") receives
133
+ * "report.<ext>" for the chosen format; anything else is treated as a file.
134
+ * Missing parent directories are created either way. */
135
+ export function resolveOutputPath(p, fmt) {
136
+ const ext = OUTPUT_EXT[fmt];
137
+ const trimmed = p.replace(/[/\\]+$/, "");
138
+ const base = path.basename(trimmed);
139
+ let isDir = /[/\\]$/.test(p) || path.extname(base) === "";
140
+ try {
141
+ if (fs.statSync(p).isDirectory())
142
+ isDir = true;
143
+ }
144
+ catch { /* missing path */ }
145
+ if (isDir) {
146
+ fs.mkdirSync(p, { recursive: true });
147
+ return path.join(p, `report.${ext}`);
148
+ }
149
+ const parent = path.dirname(p);
150
+ if (parent)
151
+ fs.mkdirSync(parent, { recursive: true });
152
+ return p;
52
153
  }
53
154
  function exitCode(findings) {
54
155
  if (findings.some((f) => f.confidence === "high"))
@@ -57,7 +158,36 @@ function exitCode(findings) {
57
158
  return 10;
58
159
  return 0;
59
160
  }
60
- function renderText(findings) {
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
+ }
190
+ export function renderText(findings) {
61
191
  if (findings.length === 0)
62
192
  return "falsegreen-js: no false-positive patterns found.";
63
193
  const byFile = new Map();
@@ -76,11 +206,45 @@ function renderText(findings) {
76
206
  low++;
77
207
  lines.push(` ${tag} ${f.code.padEnd(4)} L${f.line} ${f.title}` +
78
208
  (f.detail ? `\n ${f.detail}` : ""));
209
+ const hint = FIX_HINTS[f.code];
210
+ lines.push(` level: ${f.level}` + (hint ? ` fix: ${hint}` : ""));
79
211
  }
80
212
  }
81
213
  lines.push(`\n${high} high, ${low} low. ${TOOL_URI}`);
214
+ // Test-pyramid breakdown + the most common fixes, over every finding shown.
215
+ const byLevel = new Map();
216
+ const byCode = new Map();
217
+ for (const f of findings) {
218
+ byLevel.set(f.level, (byLevel.get(f.level) ?? 0) + 1);
219
+ byCode.set(f.code, (byCode.get(f.code) ?? 0) + 1);
220
+ }
221
+ const order = ["unit", "integration", "e2e"];
222
+ const levels = [
223
+ ...order.filter((l) => byLevel.has(l)),
224
+ ...[...byLevel.keys()].filter((l) => !order.includes(l)).sort(),
225
+ ];
226
+ lines.push("By level: " + levels.map((l) => `${l}:${byLevel.get(l)}`).join(", "));
227
+ const top = [...byCode.entries()]
228
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 3);
229
+ lines.push("Top fixes:");
230
+ for (const [code, n] of top) {
231
+ lines.push(` ${code} (${n}): ${FIX_HINTS[code] ?? CASES[code].title}`);
232
+ }
82
233
  return lines.join("\n");
83
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
+ }
84
248
  function main() {
85
249
  const opt = parseArgs(process.argv.slice(2));
86
250
  if (opt.help) {
@@ -91,26 +255,49 @@ function main() {
91
255
  process.stdout.write(VERSION + "\n");
92
256
  process.exit(0);
93
257
  }
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);
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);
102
264
  }
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");
265
+ if (opt.configAudit) {
266
+ const base = opt.paths.find((p) => { try {
267
+ return fs.statSync(p).isDirectory();
268
+ }
269
+ catch {
270
+ return false;
271
+ } }) ?? ".";
272
+ const findings = auditConfig(base);
273
+ emit(render(findings, opt.fmt), opt);
274
+ process.exit(findings.length ? 10 : 0);
110
275
  }
111
- else {
112
- process.stdout.write(renderText(findings) + "\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));
113
281
  }
282
+ emit(render(findings, opt.fmt), opt);
114
283
  process.exit(exitCode(findings));
115
284
  }
116
- main();
285
+ /** True when this module is the process entry point. Package managers expose the
286
+ * `bin` through a symlink (node_modules/.bin/falsegreen-js), so process.argv[1]
287
+ * is the symlink while import.meta.url is the real dist/cli.js path; resolve the
288
+ * realpath before comparing, or `npx falsegreen-js` would exit without scanning. */
289
+ export function isDirectRun(invokedPath, moduleUrl) {
290
+ if (!invokedPath)
291
+ return false;
292
+ let resolved = invokedPath;
293
+ try {
294
+ resolved = fs.realpathSync(invokedPath);
295
+ }
296
+ catch { /* keep raw path */ }
297
+ return moduleUrl === pathToFileURL(resolved).href;
298
+ }
299
+ // Run only when invoked as the CLI, so the module can be imported in tests
300
+ // without triggering a scan and process.exit.
301
+ if (isDirectRun(process.argv[1], import.meta.url)) {
302
+ main();
303
+ }
@@ -0,0 +1,10 @@
1
+ import ts from "typescript";
2
+ import { PyramidLevel } from "./cases.js";
3
+ /**
4
+ * Map a test file to a pyramid level from its import roots: "e2e" (browser
5
+ * driver / e2e framework), "integration" (HTTP client or database driver:
6
+ * API and DB tests), or "unit" (neither). Broadest wins. A real API/DB import
7
+ * in a test the author treats as a unit test is itself the smell, surfaced by
8
+ * the level mismatch.
9
+ */
10
+ export declare function detectPyramidLevel(sf: ts.SourceFile): PyramidLevel;
package/dist/level.js ADDED
@@ -0,0 +1,75 @@
1
+ import ts from "typescript";
2
+ // Browser drivers and end-to-end frameworks. A test file importing one of these
3
+ // drives a real browser or a full stack: it is an E2E test.
4
+ const E2E_ROOTS = new Set([
5
+ "cypress", "@playwright/test", "playwright", "playwright-core",
6
+ "selenium-webdriver", "webdriverio", "@wdio/globals", "puppeteer",
7
+ "puppeteer-core", "protractor", "nightwatch", "testcafe",
8
+ ]);
9
+ // HTTP clients / API mocks and real datastore drivers or ORMs. A test importing
10
+ // one of these crosses an I/O boundary (API or database): it is an integration
11
+ // test, where the response or the row is the oracle.
12
+ const INTEGRATION_ROOTS = new Set([
13
+ // API / HTTP
14
+ "supertest", "axios", "node-fetch", "cross-fetch", "got", "undici",
15
+ "superagent", "request", "nock", "msw", "pactum",
16
+ // database drivers / ORMs
17
+ "@prisma/client", "prisma", "typeorm", "sequelize", "mongoose", "mongodb",
18
+ "pg", "mysql", "mysql2", "redis", "ioredis", "knex", "better-sqlite3",
19
+ "sqlite3", "drizzle-orm", "testcontainers",
20
+ ]);
21
+ /** The package root of a module specifier, or null for a relative import.
22
+ * Scoped packages keep two segments (`@playwright/test`); others keep one. */
23
+ function packageRoot(spec) {
24
+ if (!spec || spec.startsWith(".") || spec.startsWith("/"))
25
+ return null;
26
+ const parts = spec.split("/");
27
+ return spec.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
28
+ }
29
+ /** Every module specifier imported or required in the file. */
30
+ function importRoots(sf) {
31
+ const roots = new Set();
32
+ const add = (spec) => {
33
+ const root = spec ? packageRoot(spec) : null;
34
+ if (root)
35
+ roots.add(root);
36
+ };
37
+ const visit = (node) => {
38
+ if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
39
+ add(node.moduleSpecifier.text);
40
+ }
41
+ else if (ts.isImportEqualsDeclaration(node) &&
42
+ ts.isExternalModuleReference(node.moduleReference) &&
43
+ ts.isStringLiteral(node.moduleReference.expression)) {
44
+ add(node.moduleReference.expression.text);
45
+ }
46
+ else if (ts.isCallExpression(node)) {
47
+ const fn = node.expression;
48
+ const isRequire = ts.isIdentifier(fn) && fn.text === "require";
49
+ const isDynImport = fn.kind === ts.SyntaxKind.ImportKeyword;
50
+ const arg = node.arguments[0];
51
+ if ((isRequire || isDynImport) && arg && ts.isStringLiteral(arg))
52
+ add(arg.text);
53
+ }
54
+ ts.forEachChild(node, visit);
55
+ };
56
+ visit(sf);
57
+ return roots;
58
+ }
59
+ /**
60
+ * Map a test file to a pyramid level from its import roots: "e2e" (browser
61
+ * driver / e2e framework), "integration" (HTTP client or database driver:
62
+ * API and DB tests), or "unit" (neither). Broadest wins. A real API/DB import
63
+ * in a test the author treats as a unit test is itself the smell, surfaced by
64
+ * the level mismatch.
65
+ */
66
+ export function detectPyramidLevel(sf) {
67
+ const roots = importRoots(sf);
68
+ for (const r of roots)
69
+ if (E2E_ROOTS.has(r))
70
+ return "e2e";
71
+ for (const r of roots)
72
+ if (INTEGRATION_ROOTS.has(r))
73
+ return "integration";
74
+ return "unit";
75
+ }
@@ -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;
@@ -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
+ }
@@ -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[];