@vasanth-mv/pqs-cli 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +126 -0
- package/bin/pqs.entry.js +693 -0
- package/bin/pw-quality.js +18 -0
- package/bin/qc-check.js +18 -0
- package/bin/qcbot.js +18 -0
- package/build.mjs +55 -0
- package/dist/pqs.js +7056 -0
- package/package.json +32 -0
- package/pqs-cheatsheet.html +688 -0
- package/pqs-cli-1.0.0.tgz +0 -0
- package/pqs-cli-1.1.0.tgz +0 -0
- package/pqs-cli-overview.pptx +0 -0
- package/pqs-report.json +14343 -0
- package/pw-quality-cli.pptx +0 -0
- package/python/README.md +236 -0
- package/python/pw_quality/__init__.py +21 -0
- package/python/pw_quality/cli.py +177 -0
- package/python/pw_quality/runner.py +294 -0
- package/python/pyproject.toml +42 -0
- package/src/cli.js +294 -0
- package/src/prBot.js +301 -0
- package/src/remediation.js +333 -0
- package/src/reportBuilder.js +187 -0
- package/~$pqs-cli-overview.pptx +0 -0
package/bin/pqs.entry.js
ADDED
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pqs — Code Quality Studio CLI
|
|
3
|
+
* Entry point bundled by build.mjs → dist/pqs.js
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync, readdirSync, statSync, existsSync, writeFileSync } from "fs";
|
|
6
|
+
import { resolve, extname, basename, relative, join } from "path";
|
|
7
|
+
import { tmpdir } from "os";
|
|
8
|
+
import { execSync } from "child_process";
|
|
9
|
+
import process from "process";
|
|
10
|
+
|
|
11
|
+
/* global __PQS_VERSION__ */
|
|
12
|
+
const PQS_VERSION = typeof __PQS_VERSION__ !== "undefined" ? __PQS_VERSION__ : "1.0.0";
|
|
13
|
+
|
|
14
|
+
// ── Analyzers (imported directly — bypass localStorage in index.js) ──────────
|
|
15
|
+
import { analysePlaywright } from "../../src/analyzers/playwright.js";
|
|
16
|
+
import { analyseJavaApiLocally } from "../../src/analyzers/javaApi.js";
|
|
17
|
+
import { analyseTypeScriptLocally } from "../../src/analyzers/typescript.js";
|
|
18
|
+
import { analysePlaywrightJavaLocally } from "../../src/analyzers/playwrightJava.js";
|
|
19
|
+
import { analysePlaywrightPythonLocally } from "../../src/analyzers/playwrightPython.js";
|
|
20
|
+
import { analyseTsFrontendLocally } from "../../src/analyzers/tsFrontend.js";
|
|
21
|
+
import { analysePythonApiLocally } from "../../src/analyzers/pythonApi.js";
|
|
22
|
+
import { analysePythonFrontendLocally }from "../../src/analyzers/pythonFrontend.js";
|
|
23
|
+
import { analyseJavaCoreLocally } from "../../src/analyzers/javaCore.js";
|
|
24
|
+
import { analyseRestAssuredLocally } from "../../src/analyzers/restAssured.js";
|
|
25
|
+
import { analyseKarateLocally } from "../../src/analyzers/karate.js";
|
|
26
|
+
import { analysePytestApiLocally } from "../../src/analyzers/pytestApi.js";
|
|
27
|
+
import { analysePostmanLocally } from "../../src/analyzers/postman.js";
|
|
28
|
+
import { analyseSeleniumJavaLocally } from "../../src/analyzers/seleniumJava.js";
|
|
29
|
+
import { analyseSeleniumCsharpLocally }from "../../src/analyzers/seleniumCsharp.js";
|
|
30
|
+
import { analyseCypressLocally } from "../../src/analyzers/cypress.js";
|
|
31
|
+
import { analyseAppiumJavaLocally } from "../../src/analyzers/appiumJava.js";
|
|
32
|
+
import { analyseToscaXmlLocally } from "../../src/analyzers/toscaXml.js";
|
|
33
|
+
import { AUDIT_STACKS } from "../../src/stacks/definitions.js";
|
|
34
|
+
|
|
35
|
+
// ── Runner map ────────────────────────────────────────────────────────────────
|
|
36
|
+
const RUNNERS = {
|
|
37
|
+
playwright: analysePlaywright,
|
|
38
|
+
java_api: analyseJavaApiLocally,
|
|
39
|
+
typescript: analyseTypeScriptLocally,
|
|
40
|
+
playwright_java: analysePlaywrightJavaLocally,
|
|
41
|
+
playwright_python: analysePlaywrightPythonLocally,
|
|
42
|
+
ts_frontend: analyseTsFrontendLocally,
|
|
43
|
+
python_api: analysePythonApiLocally,
|
|
44
|
+
python_frontend: analysePythonFrontendLocally,
|
|
45
|
+
java_frontend: analyseJavaCoreLocally,
|
|
46
|
+
restassured: analyseRestAssuredLocally,
|
|
47
|
+
karate: analyseKarateLocally,
|
|
48
|
+
pytest_api: analysePytestApiLocally,
|
|
49
|
+
postman: analysePostmanLocally,
|
|
50
|
+
selenium_java: analyseSeleniumJavaLocally,
|
|
51
|
+
selenium_csharp: analyseSeleniumCsharpLocally,
|
|
52
|
+
cypress: analyseCypressLocally,
|
|
53
|
+
appium_java: analyseAppiumJavaLocally,
|
|
54
|
+
tosca_xml: analyseToscaXmlLocally,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// ── ANSI colours ─────────────────────────────────────────────────────────────
|
|
58
|
+
let useColor = process.stdout.isTTY !== false;
|
|
59
|
+
const C = {
|
|
60
|
+
reset: () => useColor ? "\x1b[0m" : "",
|
|
61
|
+
bold: () => useColor ? "\x1b[1m" : "",
|
|
62
|
+
dim: () => useColor ? "\x1b[2m" : "",
|
|
63
|
+
red: () => useColor ? "\x1b[31m" : "",
|
|
64
|
+
yellow: () => useColor ? "\x1b[33m" : "",
|
|
65
|
+
blue: () => useColor ? "\x1b[34m" : "",
|
|
66
|
+
cyan: () => useColor ? "\x1b[36m" : "",
|
|
67
|
+
green: () => useColor ? "\x1b[32m" : "",
|
|
68
|
+
magenta: () => useColor ? "\x1b[35m" : "",
|
|
69
|
+
gray: () => useColor ? "\x1b[90m" : "",
|
|
70
|
+
bgRed: () => useColor ? "\x1b[41m" : "",
|
|
71
|
+
};
|
|
72
|
+
const b = (s) => `${C.bold()}${s}${C.reset()}`;
|
|
73
|
+
const dim = (s) => `${C.dim()}${s}${C.reset()}`;
|
|
74
|
+
|
|
75
|
+
// ── Arg parser ────────────────────────────────────────────────────────────────
|
|
76
|
+
function parseArgs(argv) {
|
|
77
|
+
const args = { paths: [], stack: null, severity: "all", category: null, output: "pretty", help: false, listStacks: false, readReport: null, open: false };
|
|
78
|
+
let i = 0;
|
|
79
|
+
while (i < argv.length) {
|
|
80
|
+
const a = argv[i];
|
|
81
|
+
if (a === "--help" || a === "-h") { args.help = true; }
|
|
82
|
+
else if (a === "--list-stacks") { args.listStacks = true; }
|
|
83
|
+
else if (a === "--no-color") { useColor = false; }
|
|
84
|
+
else if ((a === "--stack" || a === "-s") && argv[i+1]) { args.stack = argv[++i]; }
|
|
85
|
+
else if ((a === "--severity" || a === "-S") && argv[i+1]) { args.severity = argv[++i]; }
|
|
86
|
+
else if ((a === "--category" || a === "-c") && argv[i+1]) { args.category = argv[++i]; }
|
|
87
|
+
else if ((a === "--output" || a === "-o") && argv[i+1]) { args.output = argv[++i]; }
|
|
88
|
+
else if ((a === "--read-report" || a === "-r") && argv[i+1]) { args.readReport = argv[++i]; }
|
|
89
|
+
else if (a === "--open") { args.open = true; }
|
|
90
|
+
else if (!a.startsWith("-")) { args.paths.push(a); }
|
|
91
|
+
i++;
|
|
92
|
+
}
|
|
93
|
+
return args;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── File collector ────────────────────────────────────────────────────────────
|
|
97
|
+
function collectFiles(inputPath, stackId) {
|
|
98
|
+
const pattern = AUDIT_STACKS[stackId]?.filePattern;
|
|
99
|
+
const abs = resolve(inputPath);
|
|
100
|
+
if (!existsSync(abs)) { console.error(` Path not found: ${abs}`); process.exit(1); }
|
|
101
|
+
const stat = statSync(abs);
|
|
102
|
+
if (stat.isFile()) return [abs];
|
|
103
|
+
|
|
104
|
+
const out = [];
|
|
105
|
+
function walk(dir) {
|
|
106
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
107
|
+
const full = join(dir, entry.name);
|
|
108
|
+
if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules") walk(full);
|
|
109
|
+
else if (entry.isFile() && (!pattern || pattern.test(entry.name))) out.push(full);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
walk(abs);
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Auto-detect stack ─────────────────────────────────────────────────────────
|
|
117
|
+
function detectStack(files) {
|
|
118
|
+
// Score each stack by how many files match its pattern
|
|
119
|
+
const scores = {};
|
|
120
|
+
for (const [id, stack] of Object.entries(AUDIT_STACKS)) {
|
|
121
|
+
if (!stack.filePattern) continue;
|
|
122
|
+
scores[id] = files.filter(f => stack.filePattern.test(basename(f))).length;
|
|
123
|
+
}
|
|
124
|
+
const best = Object.entries(scores).sort((a, b) => b[1] - a[1])[0];
|
|
125
|
+
return best && best[1] > 0 ? best[0] : "playwright";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Grade helper ──────────────────────────────────────────────────────────────
|
|
129
|
+
function grade(score) {
|
|
130
|
+
if (score >= 90) return { letter: "A", color: C.green() };
|
|
131
|
+
if (score >= 75) return { letter: "B", color: C.cyan() };
|
|
132
|
+
if (score >= 60) return { letter: "C", color: C.yellow() };
|
|
133
|
+
if (score >= 40) return { letter: "D", color: C.magenta() };
|
|
134
|
+
return { letter: "F", color: C.red() };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Severity badge ────────────────────────────────────────────────────────────
|
|
138
|
+
function sevBadge(sev) {
|
|
139
|
+
if (sev === "critical") return `${C.bold()}${C.red()} CRIT ${C.reset()}`;
|
|
140
|
+
if (sev === "warning") return `${C.bold()}${C.yellow()} WARN ${C.reset()}`;
|
|
141
|
+
return `${C.blue()} INFO ${C.reset()}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── Help text ─────────────────────────────────────────────────────────────────
|
|
145
|
+
function printHelp() {
|
|
146
|
+
console.log(`
|
|
147
|
+
${b("pqs")} — Code Quality Studio CLI ${dim(`v${PQS_VERSION}`)}
|
|
148
|
+
|
|
149
|
+
${b("USAGE")}
|
|
150
|
+
pqs [path...] Analyse files/folders (defaults to current directory)
|
|
151
|
+
pqs --list-stacks List all available stacks
|
|
152
|
+
pqs --read-report <file> Print summary of a saved JSON report
|
|
153
|
+
|
|
154
|
+
${b("OPTIONS")}
|
|
155
|
+
-s, --stack <id> Force a stack (see --list-stacks for IDs)
|
|
156
|
+
-S, --severity <level> Filter findings: all | critical | warning | info
|
|
157
|
+
-c, --category <id> Filter by category id
|
|
158
|
+
-o, --output <fmt> Output format: pretty | json | summary
|
|
159
|
+
-r, --read-report <file> Read a saved JSON report and print summary
|
|
160
|
+
--open Open the HTML report in the browser after analysis
|
|
161
|
+
--no-color Disable ANSI colours
|
|
162
|
+
-h, --help Show this help
|
|
163
|
+
|
|
164
|
+
${b("EXAMPLES")}
|
|
165
|
+
pqs ./tests/
|
|
166
|
+
pqs ./tests/ --stack cypress --severity critical
|
|
167
|
+
pqs ./tests/ --output json > report.json
|
|
168
|
+
pqs ./tests/ --open # run + open HTML in browser
|
|
169
|
+
pqs --read-report report.json --open # open saved report in browser
|
|
170
|
+
pqs ./e2e/ -s selenium_java -S warning
|
|
171
|
+
pqs . --list-stacks
|
|
172
|
+
`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── List stacks ───────────────────────────────────────────────────────────────
|
|
176
|
+
function printStacks() {
|
|
177
|
+
console.log(`\n${b("Available stacks")}\n`);
|
|
178
|
+
const groups = {};
|
|
179
|
+
for (const [id, s] of Object.entries(AUDIT_STACKS)) {
|
|
180
|
+
if (!groups[s.group]) groups[s.group] = [];
|
|
181
|
+
groups[s.group].push({ id, s });
|
|
182
|
+
}
|
|
183
|
+
for (const [group, items] of Object.entries(groups)) {
|
|
184
|
+
console.log(` ${C.cyan()}${C.bold()}${group}${C.reset()}`);
|
|
185
|
+
for (const { id, s } of items) {
|
|
186
|
+
console.log(` ${C.bold()}${id.padEnd(20)}${C.reset()} ${s.icon} ${s.name} ${dim(s.fileAccept)}`);
|
|
187
|
+
}
|
|
188
|
+
console.log();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Pretty output ─────────────────────────────────────────────────────────────
|
|
193
|
+
function printPretty(stackId, results, args) {
|
|
194
|
+
const stack = AUDIT_STACKS[stackId];
|
|
195
|
+
const allFindings = results.flatMap(r => r.result.findings ?? []);
|
|
196
|
+
const shown = allFindings.filter(f =>
|
|
197
|
+
(args.severity === "all" || f.severity === args.severity) &&
|
|
198
|
+
(!args.category || f.category === args.category)
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
const crit = allFindings.filter(f => f.severity === "critical").length;
|
|
202
|
+
const warn = allFindings.filter(f => f.severity === "warning").length;
|
|
203
|
+
const info = allFindings.filter(f => f.severity === "info").length;
|
|
204
|
+
const avgScore = results.length
|
|
205
|
+
? Math.round(results.reduce((s, r) => s + (r.result.overallScore ?? 0), 0) / results.length)
|
|
206
|
+
: 0;
|
|
207
|
+
const { letter, color: gc } = grade(avgScore);
|
|
208
|
+
|
|
209
|
+
console.log();
|
|
210
|
+
console.log(`${C.cyan()}${C.bold()} Code Quality Studio${C.reset()} ${dim("─")} ${stack.icon} ${b(stack.name)}`);
|
|
211
|
+
console.log(` ${dim("─".repeat(52))}`);
|
|
212
|
+
console.log(` Files analysed : ${b(String(results.length))}`);
|
|
213
|
+
console.log(` Overall score : ${gc}${C.bold()}${avgScore}/100 Grade ${letter}${C.reset()}`);
|
|
214
|
+
console.log(` Findings : ${C.red()}${C.bold()}${crit} critical${C.reset()} ${C.yellow()}${warn} warning${C.reset()} ${C.blue()}${info} info${C.reset()}`);
|
|
215
|
+
console.log(` ${dim("─".repeat(52))}`);
|
|
216
|
+
|
|
217
|
+
// Per-file summary
|
|
218
|
+
console.log(`\n${b(" FILES")}\n`);
|
|
219
|
+
for (const { file, result } of results) {
|
|
220
|
+
const g = grade(result.overallScore ?? 0);
|
|
221
|
+
const fc = result.findings.filter(f => f.severity === "critical").length;
|
|
222
|
+
const fw = result.findings.filter(f => f.severity === "warning").length;
|
|
223
|
+
const fi = result.findings.filter(f => f.severity === "info").length;
|
|
224
|
+
const fname = basename(file);
|
|
225
|
+
const badge = `${g.color}${C.bold()}${(result.overallScore ?? 0).toString().padStart(3)}${C.reset()}`;
|
|
226
|
+
const counts = [
|
|
227
|
+
fc ? `${C.red()}${fc}c${C.reset()}` : null,
|
|
228
|
+
fw ? `${C.yellow()}${fw}w${C.reset()}` : null,
|
|
229
|
+
fi ? `${C.blue()}${fi}i${C.reset()}` : null,
|
|
230
|
+
].filter(Boolean).join(" ");
|
|
231
|
+
console.log(` ${badge} ${fname.padEnd(40)} ${counts || dim("clean")}`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (shown.length === 0) {
|
|
235
|
+
console.log(`\n ${C.green()}${C.bold()}✓ No findings match the current filter.${C.reset()}\n`);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Findings grouped by file
|
|
240
|
+
const byFile = {};
|
|
241
|
+
for (const f of shown) {
|
|
242
|
+
if (!byFile[f._file]) byFile[f._file] = [];
|
|
243
|
+
byFile[f._file].push(f);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
console.log(`\n${b(" FINDINGS")} ${dim(`(${shown.length} shown)`)}\n`);
|
|
247
|
+
|
|
248
|
+
for (const [file, findings] of Object.entries(byFile)) {
|
|
249
|
+
const fname = basename(file);
|
|
250
|
+
console.log(` ${C.cyan()}${C.bold()}${fname}${C.reset()} ${dim(relative(process.cwd(), file))}`);
|
|
251
|
+
|
|
252
|
+
// Group by severity for clean display
|
|
253
|
+
for (const sev of ["critical", "warning", "info"]) {
|
|
254
|
+
const sevFindings = findings.filter(f => f.severity === sev);
|
|
255
|
+
if (!sevFindings.length) continue;
|
|
256
|
+
for (const f of sevFindings) {
|
|
257
|
+
const lineRef = f.line ? `${C.gray()}:${f.line}${C.reset()}` : "";
|
|
258
|
+
const ruleTag = dim(`[${f.ruleId}]`);
|
|
259
|
+
console.log(` ${sevBadge(sev)} ${b(f.title)} ${ruleTag}${lineRef}`);
|
|
260
|
+
console.log(` ${dim(f.description)}`);
|
|
261
|
+
if (f.fix && !f.fix.includes("\n")) {
|
|
262
|
+
console.log(` ${C.green()}Fix:${C.reset()} ${dim(f.fix)}`);
|
|
263
|
+
}
|
|
264
|
+
console.log();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Category score summary
|
|
270
|
+
if (results.length === 1 && results[0].result.categoryScores) {
|
|
271
|
+
const cats = stack.categories ?? [];
|
|
272
|
+
console.log(` ${b("CATEGORY SCORES")}\n`);
|
|
273
|
+
for (const cat of cats) {
|
|
274
|
+
const s = results[0].result.categoryScores[cat.id] ?? 100;
|
|
275
|
+
const { letter: gl } = grade(s);
|
|
276
|
+
const bar = "█".repeat(Math.round(s / 10)).padEnd(10, "░");
|
|
277
|
+
const gc2 = grade(s);
|
|
278
|
+
console.log(` ${gc2.color}${bar}${C.reset()} ${s.toString().padStart(3)} ${cat.icon} ${cat.label}`);
|
|
279
|
+
}
|
|
280
|
+
console.log();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── Summary output ────────────────────────────────────────────────────────────
|
|
285
|
+
function printSummary(stackId, results) {
|
|
286
|
+
const stack = AUDIT_STACKS[stackId];
|
|
287
|
+
const allFindings = results.flatMap(r => r.result.findings ?? []);
|
|
288
|
+
const crit = allFindings.filter(f => f.severity === "critical").length;
|
|
289
|
+
const warn = allFindings.filter(f => f.severity === "warning").length;
|
|
290
|
+
const avgScore = results.length
|
|
291
|
+
? Math.round(results.reduce((s, r) => s + (r.result.overallScore ?? 0), 0) / results.length)
|
|
292
|
+
: 0;
|
|
293
|
+
console.log(`pqs ${stack.icon} ${stack.name} · ${results.length} files · score ${avgScore} · ${crit} critical · ${warn} warning`);
|
|
294
|
+
if (crit > 0) process.exitCode = 1;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── JSON output ───────────────────────────────────────────────────────────────
|
|
298
|
+
function printJson(stackId, results, args) {
|
|
299
|
+
const stack = AUDIT_STACKS[stackId];
|
|
300
|
+
const allFindings = results.flatMap(r => r.result.findings ?? []).filter(f =>
|
|
301
|
+
(args.severity === "all" || f.severity === args.severity) &&
|
|
302
|
+
(!args.category || f.category === args.category)
|
|
303
|
+
);
|
|
304
|
+
console.log(JSON.stringify({
|
|
305
|
+
stack: { id: stackId, name: stack.name },
|
|
306
|
+
files: results.length,
|
|
307
|
+
avgScore: results.length
|
|
308
|
+
? Math.round(results.reduce((s, r) => s + (r.result.overallScore ?? 0), 0) / results.length)
|
|
309
|
+
: 0,
|
|
310
|
+
summary: {
|
|
311
|
+
critical: allFindings.filter(f => f.severity === "critical").length,
|
|
312
|
+
warning: allFindings.filter(f => f.severity === "warning").length,
|
|
313
|
+
info: allFindings.filter(f => f.severity === "info").length,
|
|
314
|
+
},
|
|
315
|
+
results: results.map(({ file, result }) => ({
|
|
316
|
+
file,
|
|
317
|
+
overallScore: result.overallScore,
|
|
318
|
+
categoryScores: result.categoryScores,
|
|
319
|
+
findings: result.findings.filter(f =>
|
|
320
|
+
(args.severity === "all" || f.severity === args.severity) &&
|
|
321
|
+
(!args.category || f.category === args.category)
|
|
322
|
+
),
|
|
323
|
+
})),
|
|
324
|
+
}, null, 2));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ── HTML Report Generator ─────────────────────────────────────────────────────
|
|
328
|
+
function buildHtmlReport(report) {
|
|
329
|
+
const { stack, files, avgScore, summary, results = [] } = report;
|
|
330
|
+
const gradeColor = avgScore >= 90 ? "#16a34a" : avgScore >= 75 ? "#0891b2" : avgScore >= 60 ? "#d97706" : "#dc2626";
|
|
331
|
+
const gradeLetter = avgScore >= 90 ? "A" : avgScore >= 75 ? "B" : avgScore >= 60 ? "C" : avgScore >= 40 ? "D" : "F";
|
|
332
|
+
|
|
333
|
+
// Top files by critical count
|
|
334
|
+
const sortedFiles = [...results].sort((a, b) => {
|
|
335
|
+
const ac = (a.findings ?? []).filter(f => f.severity === "critical").length;
|
|
336
|
+
const bc = (b.findings ?? []).filter(f => f.severity === "critical").length;
|
|
337
|
+
return bc - ac;
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// Top rules
|
|
341
|
+
const ruleCounts = {};
|
|
342
|
+
for (const r of results) {
|
|
343
|
+
for (const f of (r.findings ?? [])) {
|
|
344
|
+
if (!ruleCounts[f.ruleId]) ruleCounts[f.ruleId] = { count: 0, title: f.title, sev: f.severity };
|
|
345
|
+
ruleCounts[f.ruleId].count++;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const topRules = Object.entries(ruleCounts).sort((a, b) => b[1].count - a[1].count).slice(0, 10);
|
|
349
|
+
|
|
350
|
+
// Category averages
|
|
351
|
+
const catTotals = {}; const catCounts2 = {};
|
|
352
|
+
for (const r of results) {
|
|
353
|
+
for (const [cat, score] of Object.entries(r.categoryScores ?? {})) {
|
|
354
|
+
catTotals[cat] = (catTotals[cat] ?? 0) + score;
|
|
355
|
+
catCounts2[cat] = (catCounts2[cat] ?? 0) + 1;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const catAvgs = Object.entries(catTotals)
|
|
359
|
+
.map(([cat, total]) => ({ cat, avg: Math.round(total / catCounts2[cat]) }))
|
|
360
|
+
.sort((a, b) => a.avg - b.avg);
|
|
361
|
+
|
|
362
|
+
const sevColor = sev => sev === "critical" ? "#dc2626" : sev === "warning" ? "#d97706" : "#2563eb";
|
|
363
|
+
const sevBg = sev => sev === "critical" ? "#fef2f2" : sev === "warning" ? "#fffbeb" : "#eff6ff";
|
|
364
|
+
const scoreCol = s => s >= 90 ? "#16a34a" : s >= 75 ? "#0891b2" : s >= 60 ? "#d97706" : "#dc2626";
|
|
365
|
+
|
|
366
|
+
const filesHtml = sortedFiles.map(r => {
|
|
367
|
+
const fc = (r.findings ?? []).filter(f => f.severity === "critical").length;
|
|
368
|
+
const fw = (r.findings ?? []).filter(f => f.severity === "warning").length;
|
|
369
|
+
const fi = (r.findings ?? []).filter(f => f.severity === "info").length;
|
|
370
|
+
const sc = r.overallScore ?? 0;
|
|
371
|
+
const fname = basename(r.file ?? "");
|
|
372
|
+
const findingsHtml = (r.findings ?? []).map(f => `
|
|
373
|
+
<div style="padding:8px 12px;border-bottom:1px solid #f1f5f9;display:flex;gap:10px;align-items:flex-start">
|
|
374
|
+
<span style="background:${sevBg(f.severity)};color:${sevColor(f.severity)};font-size:10px;font-weight:700;padding:2px 6px;border-radius:4px;white-space:nowrap;margin-top:2px">${f.severity.toUpperCase().slice(0,4)}</span>
|
|
375
|
+
<div>
|
|
376
|
+
<div style="font-weight:600;font-size:13px;color:#1e293b">${f.title ?? ""} <span style="font-size:11px;color:#94a3b8;font-weight:400">[${f.ruleId ?? ""}]${f.line ? ` :${f.line}` : ""}</span></div>
|
|
377
|
+
<div style="font-size:12px;color:#64748b;margin-top:2px">${f.description ?? ""}</div>
|
|
378
|
+
${f.fix ? `<div style="font-size:11px;color:#059669;margin-top:3px">💡 ${f.fix}</div>` : ""}
|
|
379
|
+
</div>
|
|
380
|
+
</div>`).join("");
|
|
381
|
+
return `
|
|
382
|
+
<details style="border:1px solid #e2e8f0;border-radius:8px;margin-bottom:8px;overflow:hidden">
|
|
383
|
+
<summary style="padding:12px 16px;cursor:pointer;display:flex;align-items:center;gap:12px;background:#f8fafc;list-style:none;user-select:none">
|
|
384
|
+
<span style="background:${scoreCol(sc)};color:#fff;font-weight:700;font-size:13px;padding:3px 10px;border-radius:6px;min-width:36px;text-align:center">${sc}</span>
|
|
385
|
+
<span style="font-weight:600;font-size:14px;color:#1e293b;flex:1">${fname}</span>
|
|
386
|
+
${fc ? `<span style="background:#fef2f2;color:#dc2626;font-size:12px;font-weight:700;padding:2px 8px;border-radius:4px">${fc} critical</span>` : ""}
|
|
387
|
+
${fw ? `<span style="background:#fffbeb;color:#d97706;font-size:12px;font-weight:700;padding:2px 8px;border-radius:4px">${fw} warn</span>` : ""}
|
|
388
|
+
${fi ? `<span style="background:#eff6ff;color:#2563eb;font-size:12px;font-weight:700;padding:2px 8px;border-radius:4px">${fi} info</span>` : ""}
|
|
389
|
+
</summary>
|
|
390
|
+
<div style="font-size:11px;color:#94a3b8;padding:4px 16px;background:#f8fafc;border-bottom:1px solid #e2e8f0">${r.file ?? ""}</div>
|
|
391
|
+
${findingsHtml || `<div style="padding:12px 16px;color:#64748b;font-size:13px">✓ No findings</div>`}
|
|
392
|
+
</details>`;
|
|
393
|
+
}).join("");
|
|
394
|
+
|
|
395
|
+
const rulesHtml = topRules.map(([ruleId, info]) => `
|
|
396
|
+
<tr>
|
|
397
|
+
<td style="padding:8px 12px;font-weight:700;color:${sevColor(info.sev)};font-size:18px">${info.count}</td>
|
|
398
|
+
<td style="padding:8px 12px;font-family:monospace;font-size:12px;color:#64748b">${ruleId}</td>
|
|
399
|
+
<td style="padding:8px 12px;font-size:13px;color:#1e293b">${info.title}</td>
|
|
400
|
+
<td style="padding:8px 12px"><span style="background:${sevBg(info.sev)};color:${sevColor(info.sev)};font-size:11px;font-weight:700;padding:2px 6px;border-radius:4px">${info.sev}</span></td>
|
|
401
|
+
</tr>`).join("");
|
|
402
|
+
|
|
403
|
+
const catsHtml = catAvgs.map(({ cat, avg }) => {
|
|
404
|
+
const pct = avg;
|
|
405
|
+
const col = scoreCol(avg);
|
|
406
|
+
return `
|
|
407
|
+
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px">
|
|
408
|
+
<div style="width:110px;font-size:12px;color:#64748b;text-align:right">${cat}</div>
|
|
409
|
+
<div style="flex:1;background:#e2e8f0;border-radius:4px;height:12px;overflow:hidden">
|
|
410
|
+
<div style="width:${pct}%;background:${col};height:100%;border-radius:4px;transition:width .3s"></div>
|
|
411
|
+
</div>
|
|
412
|
+
<div style="width:36px;font-weight:700;font-size:13px;color:${col}">${avg}</div>
|
|
413
|
+
</div>`;
|
|
414
|
+
}).join("");
|
|
415
|
+
|
|
416
|
+
const now = new Date().toLocaleString();
|
|
417
|
+
return `<!DOCTYPE html>
|
|
418
|
+
<html lang="en">
|
|
419
|
+
<head>
|
|
420
|
+
<meta charset="UTF-8">
|
|
421
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
422
|
+
<title>pqs Report — ${stack?.name ?? "Code Quality"}</title>
|
|
423
|
+
<style>
|
|
424
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
425
|
+
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f1f5f9;color:#1e293b;min-height:100vh}
|
|
426
|
+
.header{background:linear-gradient(135deg,#0f172a 0%,#1e293b 100%);color:#fff;padding:32px 40px}
|
|
427
|
+
.header h1{font-size:28px;font-weight:800;letter-spacing:-0.5px}
|
|
428
|
+
.header .sub{font-size:14px;color:#94a3b8;margin-top:4px}
|
|
429
|
+
.stats{display:flex;gap:16px;margin-top:24px;flex-wrap:wrap}
|
|
430
|
+
.stat{background:rgba(255,255,255,0.07);border:1px solid rgba(255,255,255,0.1);border-radius:10px;padding:16px 24px;min-width:140px}
|
|
431
|
+
.stat .val{font-size:32px;font-weight:800;line-height:1}
|
|
432
|
+
.stat .lbl{font-size:12px;color:#94a3b8;margin-top:4px;text-transform:uppercase;letter-spacing:.5px}
|
|
433
|
+
.body{max-width:1200px;margin:0 auto;padding:32px 24px}
|
|
434
|
+
.section{background:#fff;border-radius:12px;box-shadow:0 1px 3px rgba(0,0,0,.08);margin-bottom:24px;overflow:hidden}
|
|
435
|
+
.section-head{padding:16px 20px;border-bottom:1px solid #f1f5f9;font-weight:700;font-size:15px;color:#0f172a;display:flex;align-items:center;gap:8px}
|
|
436
|
+
.section-body{padding:20px}
|
|
437
|
+
table{width:100%;border-collapse:collapse}
|
|
438
|
+
tr:hover{background:#f8fafc}
|
|
439
|
+
details>summary::-webkit-details-marker{display:none}
|
|
440
|
+
@media(max-width:600px){.stats{gap:10px}.stat{min-width:120px;padding:12px 16px}}
|
|
441
|
+
</style>
|
|
442
|
+
</head>
|
|
443
|
+
<body>
|
|
444
|
+
<div class="header">
|
|
445
|
+
<h1>⚡ pqs Code Quality Report</h1>
|
|
446
|
+
<div class="sub">${stack?.name ?? ""} · Generated ${now} · pqs v${PQS_VERSION}</div>
|
|
447
|
+
<div class="stats">
|
|
448
|
+
<div class="stat"><div class="val" style="color:${gradeColor}">${avgScore}<span style="font-size:18px;margin-left:4px">${gradeLetter}</span></div><div class="lbl">Overall Score</div></div>
|
|
449
|
+
<div class="stat"><div class="val">${files}</div><div class="lbl">Files Analysed</div></div>
|
|
450
|
+
<div class="stat"><div class="val" style="color:#dc2626">${summary.critical}</div><div class="lbl">Critical</div></div>
|
|
451
|
+
<div class="stat"><div class="val" style="color:#d97706">${summary.warning}</div><div class="lbl">Warning</div></div>
|
|
452
|
+
<div class="stat"><div class="val" style="color:#2563eb">${summary.info}</div><div class="lbl">Info</div></div>
|
|
453
|
+
</div>
|
|
454
|
+
</div>
|
|
455
|
+
|
|
456
|
+
<div class="body">
|
|
457
|
+
|
|
458
|
+
${catAvgs.length ? `
|
|
459
|
+
<div class="section">
|
|
460
|
+
<div class="section-head">📊 Category Scores</div>
|
|
461
|
+
<div class="section-body">${catsHtml}</div>
|
|
462
|
+
</div>` : ""}
|
|
463
|
+
|
|
464
|
+
<div class="section">
|
|
465
|
+
<div class="section-head">🔴 Top Rule Violations</div>
|
|
466
|
+
<div class="section-body" style="padding:0">
|
|
467
|
+
<table>
|
|
468
|
+
<thead><tr style="background:#f8fafc;font-size:11px;text-transform:uppercase;color:#64748b">
|
|
469
|
+
<th style="padding:8px 12px;text-align:left">Count</th>
|
|
470
|
+
<th style="padding:8px 12px;text-align:left">Rule ID</th>
|
|
471
|
+
<th style="padding:8px 12px;text-align:left">Description</th>
|
|
472
|
+
<th style="padding:8px 12px;text-align:left">Severity</th>
|
|
473
|
+
</tr></thead>
|
|
474
|
+
<tbody>${rulesHtml}</tbody>
|
|
475
|
+
</table>
|
|
476
|
+
</div>
|
|
477
|
+
</div>
|
|
478
|
+
|
|
479
|
+
<div class="section">
|
|
480
|
+
<div class="section-head">📁 Files <span style="font-size:12px;color:#94a3b8;font-weight:400">click a file to expand findings</span></div>
|
|
481
|
+
<div class="section-body">${filesHtml}</div>
|
|
482
|
+
</div>
|
|
483
|
+
|
|
484
|
+
</div>
|
|
485
|
+
</body>
|
|
486
|
+
</html>`;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function openHtmlReport(htmlContent) {
|
|
490
|
+
const tmp = join(tmpdir(), `pqs-report-${Date.now()}.html`);
|
|
491
|
+
writeFileSync(tmp, htmlContent, "utf8");
|
|
492
|
+
const cmd = process.platform === "win32" ? `start "" "${tmp}"` :
|
|
493
|
+
process.platform === "darwin" ? `open "${tmp}"` : `xdg-open "${tmp}"`;
|
|
494
|
+
try { execSync(cmd); } catch { /* ignore */ }
|
|
495
|
+
console.log(`\n ${dim("HTML report:")} ${tmp}\n`);
|
|
496
|
+
return tmp;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ── Read Report Summary ───────────────────────────────────────────────────────
|
|
500
|
+
function printReportSummary(filePath) {
|
|
501
|
+
const abs = resolve(filePath);
|
|
502
|
+
if (!existsSync(abs)) {
|
|
503
|
+
console.error(` Report file not found: ${abs}`);
|
|
504
|
+
process.exit(1);
|
|
505
|
+
}
|
|
506
|
+
let report;
|
|
507
|
+
try { report = JSON.parse(readFileSync(abs, "utf8")); }
|
|
508
|
+
catch { console.error(` Could not parse JSON: ${abs}`); process.exit(1); }
|
|
509
|
+
|
|
510
|
+
const { stack, files, avgScore, summary, results = [] } = report;
|
|
511
|
+
const { letter, color: gc } = grade(avgScore);
|
|
512
|
+
|
|
513
|
+
console.log();
|
|
514
|
+
console.log(`${C.cyan()}${C.bold()} Code Quality Studio${C.reset()} ${dim("─")} ${b("Report Summary")}`);
|
|
515
|
+
console.log(` ${dim("─".repeat(52))}`);
|
|
516
|
+
console.log(` Stack : ${b(stack?.name ?? stack?.id ?? "unknown")}`);
|
|
517
|
+
console.log(` Files analysed : ${b(String(files))}`);
|
|
518
|
+
console.log(` Overall score : ${gc}${C.bold()}${avgScore}/100 Grade ${letter}${C.reset()}`);
|
|
519
|
+
console.log(` Findings : ${C.red()}${C.bold()}${summary.critical} critical${C.reset()} ${C.yellow()}${summary.warning} warning${C.reset()} ${C.blue()}${summary.info} info${C.reset()}`);
|
|
520
|
+
console.log(` ${dim("─".repeat(52))}`);
|
|
521
|
+
|
|
522
|
+
// ── Top 10 worst files ────────────────────────────────────────────────────
|
|
523
|
+
const sorted = [...results].sort((a, b) => {
|
|
524
|
+
const ac = (a.findings ?? []).filter(f => f.severity === "critical").length;
|
|
525
|
+
const bc = (b.findings ?? []).filter(f => f.severity === "critical").length;
|
|
526
|
+
return bc - ac || (a.overallScore ?? 0) - (b.overallScore ?? 0);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
console.log(`\n${b(" TOP FILES BY CRITICAL FINDINGS")}\n`);
|
|
530
|
+
const top = sorted.filter(r => (r.findings ?? []).some(f => f.severity === "critical")).slice(0, 10);
|
|
531
|
+
if (top.length === 0) {
|
|
532
|
+
console.log(` ${C.green()}${C.bold()}✓ No critical findings in any file!${C.reset()}`);
|
|
533
|
+
} else {
|
|
534
|
+
for (const r of top) {
|
|
535
|
+
const fc = (r.findings ?? []).filter(f => f.severity === "critical").length;
|
|
536
|
+
const fw = (r.findings ?? []).filter(f => f.severity === "warning").length;
|
|
537
|
+
const { color: sc } = grade(r.overallScore ?? 0);
|
|
538
|
+
const fname = basename(r.file ?? "unknown");
|
|
539
|
+
console.log(` ${sc}${C.bold()}${(r.overallScore ?? 0).toString().padStart(3)}${C.reset()} ${fname.padEnd(45)} ${C.red()}${fc}c${C.reset()} ${C.yellow()}${fw}w${C.reset()}`);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ── Top recurring rule violations ─────────────────────────────────────────
|
|
544
|
+
const ruleCounts = {};
|
|
545
|
+
for (const r of results) {
|
|
546
|
+
for (const f of (r.findings ?? [])) {
|
|
547
|
+
if (f.severity === "critical") {
|
|
548
|
+
ruleCounts[f.ruleId] = ruleCounts[f.ruleId] ?? { count: 0, title: f.title, sev: f.severity };
|
|
549
|
+
ruleCounts[f.ruleId].count++;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
const topRules = Object.entries(ruleCounts).sort((a, b) => b[1].count - a[1].count).slice(0, 8);
|
|
554
|
+
|
|
555
|
+
if (topRules.length > 0) {
|
|
556
|
+
console.log(`\n${b(" TOP CRITICAL RULES (most violations)")}\n`);
|
|
557
|
+
for (const [ruleId, info] of topRules) {
|
|
558
|
+
console.log(` ${C.red()}${C.bold()}${String(info.count).padStart(4)}x${C.reset()} ${dim(`[${ruleId}]`)} ${info.title}`);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ── Category score breakdown ──────────────────────────────────────────────
|
|
563
|
+
const catTotals = {};
|
|
564
|
+
const catCounts = {};
|
|
565
|
+
for (const r of results) {
|
|
566
|
+
if (!r.categoryScores) continue;
|
|
567
|
+
for (const [cat, score] of Object.entries(r.categoryScores)) {
|
|
568
|
+
catTotals[cat] = (catTotals[cat] ?? 0) + score;
|
|
569
|
+
catCounts[cat] = (catCounts[cat] ?? 0) + 1;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
const catAvgs = Object.entries(catTotals)
|
|
573
|
+
.map(([cat, total]) => ({ cat, avg: Math.round(total / catCounts[cat]) }))
|
|
574
|
+
.sort((a, b) => a.avg - b.avg);
|
|
575
|
+
|
|
576
|
+
if (catAvgs.length > 0) {
|
|
577
|
+
console.log(`\n${b(" CATEGORY AVERAGES")}\n`);
|
|
578
|
+
for (const { cat, avg } of catAvgs) {
|
|
579
|
+
const bar = "█".repeat(Math.round(avg / 10)).padEnd(10, "░");
|
|
580
|
+
const gc2 = grade(avg);
|
|
581
|
+
console.log(` ${gc2.color}${bar}${C.reset()} ${avg.toString().padStart(3)} ${cat}`);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
console.log();
|
|
586
|
+
console.log(` ${dim(`Report: ${abs}`)}`);
|
|
587
|
+
console.log();
|
|
588
|
+
return report;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
592
|
+
async function main() {
|
|
593
|
+
const args = parseArgs(process.argv.slice(2));
|
|
594
|
+
|
|
595
|
+
if (args.help) { printHelp(); process.exit(0); }
|
|
596
|
+
if (args.listStacks) { printStacks(); process.exit(0); }
|
|
597
|
+
if (args.readReport) {
|
|
598
|
+
const report = printReportSummary(args.readReport);
|
|
599
|
+
if (args.open && report) openHtmlReport(buildHtmlReport(report));
|
|
600
|
+
process.exit(0);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const inputPaths = args.paths.length ? args.paths : ["."];
|
|
604
|
+
|
|
605
|
+
// Collect all files first (for stack auto-detection)
|
|
606
|
+
let allFiles = inputPaths.flatMap(p => {
|
|
607
|
+
try { return collectFiles(p, args.stack ?? "playwright"); }
|
|
608
|
+
catch { return []; }
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
// Determine stack
|
|
612
|
+
let stackId = args.stack;
|
|
613
|
+
if (!stackId) {
|
|
614
|
+
stackId = detectStack(allFiles);
|
|
615
|
+
if (args.output === "pretty") {
|
|
616
|
+
console.log(`${dim(` Auto-detected stack: ${stackId}`)}`);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
if (!RUNNERS[stackId]) {
|
|
620
|
+
console.error(`Unknown stack: "${stackId}". Run pqs --list-stacks for valid IDs.`);
|
|
621
|
+
process.exit(1);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Re-collect with the correct stack file pattern
|
|
625
|
+
allFiles = inputPaths.flatMap(p => {
|
|
626
|
+
try { return collectFiles(p, stackId); }
|
|
627
|
+
catch { return []; }
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
if (allFiles.length === 0) {
|
|
631
|
+
const stack = AUDIT_STACKS[stackId];
|
|
632
|
+
console.error(` No ${stack.fileAccept} files found in: ${inputPaths.join(", ")}`);
|
|
633
|
+
process.exit(1);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Run analysis
|
|
637
|
+
const runner = RUNNERS[stackId];
|
|
638
|
+
const results = [];
|
|
639
|
+
for (const file of allFiles) {
|
|
640
|
+
let content;
|
|
641
|
+
try { content = readFileSync(file, "utf8"); }
|
|
642
|
+
catch { console.error(` Cannot read: ${file}`); continue; }
|
|
643
|
+
|
|
644
|
+
const result = runner(basename(file), content, { disabledRuleIds: new Set() });
|
|
645
|
+
// Tag each finding with its source file for later grouping
|
|
646
|
+
for (const f of result.findings ?? []) f._file = file;
|
|
647
|
+
results.push({ file, result });
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Output
|
|
651
|
+
if (args.output === "json") {
|
|
652
|
+
printJson(stackId, results, args);
|
|
653
|
+
} else if (args.output === "summary") {
|
|
654
|
+
printSummary(stackId, results);
|
|
655
|
+
} else {
|
|
656
|
+
printPretty(stackId, results, args);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Open HTML report in browser if --open flag set
|
|
660
|
+
if (args.open) {
|
|
661
|
+
const stack = AUDIT_STACKS[stackId];
|
|
662
|
+
const allFindings = results.flatMap(r => r.result.findings ?? []);
|
|
663
|
+
const report = {
|
|
664
|
+
stack: { id: stackId, name: stack.name },
|
|
665
|
+
files: results.length,
|
|
666
|
+
avgScore: results.length
|
|
667
|
+
? Math.round(results.reduce((s, r) => s + (r.result.overallScore ?? 0), 0) / results.length)
|
|
668
|
+
: 0,
|
|
669
|
+
summary: {
|
|
670
|
+
critical: allFindings.filter(f => f.severity === "critical").length,
|
|
671
|
+
warning: allFindings.filter(f => f.severity === "warning").length,
|
|
672
|
+
info: allFindings.filter(f => f.severity === "info").length,
|
|
673
|
+
},
|
|
674
|
+
results: results.map(({ file, result }) => ({
|
|
675
|
+
file,
|
|
676
|
+
overallScore: result.overallScore,
|
|
677
|
+
categoryScores: result.categoryScores,
|
|
678
|
+
findings: result.findings,
|
|
679
|
+
})),
|
|
680
|
+
};
|
|
681
|
+
openHtmlReport(buildHtmlReport(report));
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Exit with non-zero if any criticals found (useful for CI)
|
|
685
|
+
const hasCritical = results.some(r =>
|
|
686
|
+
r.result.findings?.some(f => f.severity === "critical")
|
|
687
|
+
);
|
|
688
|
+
if (hasCritical && args.severity !== "info" && args.severity !== "warning") {
|
|
689
|
+
process.exitCode = 1;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
main().catch(err => { console.error(err); process.exit(1); });
|