@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.
@@ -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 ?? ""} &nbsp;·&nbsp; Generated ${now} &nbsp;·&nbsp; 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 &nbsp;<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); });