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/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, "&lt;")
90
+ .replace(/>/g, "&gt;")
91
+ .replace(/"/g, "&quot;");
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
+ }