@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,294 @@
1
+ """
2
+ runner.py — Core subprocess bridge from Python to the Node.js qcBot CLI.
3
+
4
+ All heavy lifting (rule engine, analysis, report building) stays in Node.js.
5
+ This module only shells out and parses the JSON result back into Python objects.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ import tempfile
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+
20
+ # ── Data classes ──────────────────────────────────────────────────────────────
21
+
22
+ @dataclass
23
+ class Finding:
24
+ rule_id: str
25
+ severity: str # "critical" | "warning" | "info"
26
+ title: str
27
+ category: str
28
+ line: int | None
29
+ description: str
30
+ file: str = ""
31
+
32
+ @staticmethod
33
+ def from_dict(d: dict, file: str = "") -> "Finding":
34
+ return Finding(
35
+ rule_id=d.get("ruleId", ""),
36
+ severity=d.get("severity", "info"),
37
+ title=d.get("title", ""),
38
+ category=d.get("category", ""),
39
+ line=d.get("line"),
40
+ description=d.get("description", ""),
41
+ file=file,
42
+ )
43
+
44
+
45
+ @dataclass
46
+ class FileResult:
47
+ name: str
48
+ path: str
49
+ score: int
50
+ findings: list[Finding] = field(default_factory=list)
51
+
52
+ @property
53
+ def critical_count(self) -> int:
54
+ return sum(1 for f in self.findings if f.severity == "critical")
55
+
56
+ @staticmethod
57
+ def from_dict(d: dict) -> "FileResult":
58
+ findings = [Finding.from_dict(f, file=d.get("name", "")) for f in d.get("findings", [])]
59
+ return FileResult(
60
+ name=d.get("name", ""),
61
+ path=d.get("path", ""),
62
+ score=d.get("score", 0),
63
+ findings=findings,
64
+ )
65
+
66
+
67
+ @dataclass
68
+ class Summary:
69
+ critical: int
70
+ warnings: int
71
+ total: int
72
+ files: int
73
+
74
+ @staticmethod
75
+ def from_dict(d: dict) -> "Summary":
76
+ return Summary(
77
+ critical=d.get("critical", 0),
78
+ warnings=d.get("warnings", 0),
79
+ total=d.get("total", 0),
80
+ files=d.get("files", 0),
81
+ )
82
+
83
+
84
+ @dataclass
85
+ class PwQualityResult:
86
+ """Structured result returned by check()."""
87
+ project_name: str
88
+ stack_id: str
89
+ run_at: str
90
+ score: int
91
+ grade: str
92
+ passed: bool
93
+ threshold: int
94
+ summary: Summary
95
+ files: list[FileResult]
96
+ report_path: str | None = None
97
+ json_path: str | None = None
98
+
99
+ @property
100
+ def findings(self) -> list[Finding]:
101
+ """Flat list of all findings across all files."""
102
+ return [f for fr in self.files for f in fr.findings]
103
+
104
+ @property
105
+ def critical_findings(self) -> list[Finding]:
106
+ return [f for f in self.findings if f.severity == "critical"]
107
+
108
+ @staticmethod
109
+ def from_dict(d: dict) -> "PwQualityResult":
110
+ return PwQualityResult(
111
+ project_name=d.get("projectName", ""),
112
+ stack_id=d.get("stackId", ""),
113
+ run_at=d.get("runAt", ""),
114
+ score=d.get("score", 0),
115
+ grade=d.get("grade", ""),
116
+ passed=d.get("passed", False),
117
+ threshold=d.get("threshold", 80),
118
+ summary=Summary.from_dict(d.get("summary", {})),
119
+ files=[FileResult.from_dict(f) for f in d.get("files", [])],
120
+ )
121
+
122
+
123
+ class PwQualityError(RuntimeError):
124
+ """Raised when the qcBot CLI exits with an unexpected error."""
125
+ pass
126
+
127
+
128
+ # ── Node.js bootstrap ─────────────────────────────────────────────────────────
129
+
130
+ def _find_node() -> str:
131
+ """Return the path to node, or raise PwQualityError."""
132
+ node = shutil.which("node")
133
+ if not node:
134
+ raise PwQualityError(
135
+ "Node.js not found. Install Node.js 18+ from https://nodejs.org "
136
+ "and make sure it is on your PATH."
137
+ )
138
+ # Version check (need ≥ 18)
139
+ try:
140
+ out = subprocess.check_output([node, "--version"], text=True).strip()
141
+ major = int(out.lstrip("v").split(".")[0])
142
+ if major < 18:
143
+ raise PwQualityError(
144
+ f"Node.js 18+ required; found {out}. "
145
+ "Upgrade at https://nodejs.org"
146
+ )
147
+ except (ValueError, subprocess.CalledProcessError):
148
+ pass # can't parse — proceed anyway
149
+ return node
150
+
151
+
152
+ def _find_npx() -> str:
153
+ """Return path to npx, or raise PwQualityError."""
154
+ npx = shutil.which("npx")
155
+ if not npx:
156
+ raise PwQualityError(
157
+ "npx not found. Install Node.js 18+ (it bundles npx) "
158
+ "from https://nodejs.org"
159
+ )
160
+ return npx
161
+
162
+
163
+ # ── Public API ────────────────────────────────────────────────────────────────
164
+
165
+ def check(
166
+ path: str | os.PathLike,
167
+ *,
168
+ stack: str = "playwright",
169
+ threshold: int = 80,
170
+ parallel: int = 4,
171
+ report: bool = False,
172
+ output_dir: str | None = None,
173
+ no_cross_file: bool = False,
174
+ project: str | None = None,
175
+ npm_package: str = "@cqs/qcbot",
176
+ verbose: bool = False,
177
+ ) -> PwQualityResult:
178
+ """
179
+ Analyse test files in *path* and return a :class:`PwQualityResult`.
180
+
181
+ Parameters
182
+ ----------
183
+ path: Directory or glob containing test files to analyse.
184
+ stack: Rule set id (e.g. 'playwright', 'typescript', 'pytest_api').
185
+ threshold: Minimum passing score 0–100.
186
+ parallel: Maximum files analysed simultaneously.
187
+ report: If True, also write a self-contained HTML report.
188
+ output_dir: Where to write HTML/JSON reports (default: ./qcbot-report).
189
+ no_cross_file: Skip cross-file duplicate detection.
190
+ project: Project name shown in the HTML report header.
191
+ npm_package: The npm package name (override for private registry).
192
+ verbose: Print the raw CLI output to stdout while running.
193
+
194
+ Returns
195
+ -------
196
+ PwQualityResult with score, passed, findings, per-file breakdown, etc.
197
+
198
+ Raises
199
+ ------
200
+ PwQualityError if Node.js is not found or the CLI crashes unexpectedly.
201
+ """
202
+ _find_node()
203
+ npx = _find_npx()
204
+ abs_path = str(Path(path).resolve())
205
+
206
+ # We always write JSON so we can parse the result back
207
+ tmp_json = tempfile.NamedTemporaryFile(suffix=".json", delete=False)
208
+ tmp_json.close()
209
+ json_file = tmp_json.name
210
+
211
+ cmd = [
212
+ npx, "--yes", npm_package,
213
+ "check", abs_path,
214
+ "--stack", stack,
215
+ "--threshold", str(threshold),
216
+ "--parallel", str(parallel),
217
+ "--json", json_file,
218
+ "--summary", # keep terminal output quiet; Python caller has the object
219
+ ]
220
+ if report:
221
+ cmd.append("--report")
222
+ if output_dir:
223
+ cmd += ["--output", output_dir]
224
+ if no_cross_file:
225
+ cmd.append("--no-cross-file")
226
+ if project:
227
+ cmd += ["--project", project]
228
+
229
+ try:
230
+ proc = subprocess.run(
231
+ cmd,
232
+ capture_output=not verbose,
233
+ text=True,
234
+ )
235
+ except FileNotFoundError as exc:
236
+ raise PwQualityError(f"Failed to run npx: {exc}") from exc
237
+
238
+ # Exit codes: 0 = passed, 1 = failed (score < threshold) — both are valid
239
+ if proc.returncode not in (0, 1):
240
+ stderr = proc.stderr or ""
241
+ raise PwQualityError(
242
+ f"qcBot exited with code {proc.returncode}.\n{stderr[:800]}"
243
+ )
244
+
245
+ try:
246
+ raw = json.loads(Path(json_file).read_text(encoding="utf-8"))
247
+ except (json.JSONDecodeError, FileNotFoundError) as exc:
248
+ raise PwQualityError(f"Could not read JSON report: {exc}") from exc
249
+ finally:
250
+ try:
251
+ os.unlink(json_file)
252
+ except OSError:
253
+ pass
254
+
255
+ result = PwQualityResult.from_dict(raw)
256
+
257
+ if report:
258
+ out = output_dir or "./qcbot-report"
259
+ result.report_path = str(Path(out) / "report.html")
260
+ result.json_path = str(Path(out) / "report.json")
261
+
262
+ return result
263
+
264
+
265
+ def stacks(npm_package: str = "@cqs/qcbot") -> list[dict]:
266
+ """
267
+ Return the list of supported stacks from the CLI.
268
+
269
+ Returns a list of dicts: [{"id": "playwright", "name": "...", "hint": "..."}, ...]
270
+ """
271
+ _find_node()
272
+ npx = _find_npx()
273
+ try:
274
+ out = subprocess.check_output(
275
+ [npx, "--yes", npm_package, "stacks"],
276
+ text=True,
277
+ stderr=subprocess.DEVNULL,
278
+ )
279
+ except subprocess.CalledProcessError as exc:
280
+ raise PwQualityError(f"Could not list stacks: {exc}") from exc
281
+
282
+ results = []
283
+ for line in out.splitlines():
284
+ line = line.strip()
285
+ if not line or line.startswith("Supported"):
286
+ continue
287
+ # Strip ANSI codes
288
+ import re
289
+ clean = re.sub(r"\x1b\[[0-9;]*m", "", line).strip()
290
+ if clean:
291
+ parts = clean.split()
292
+ if parts:
293
+ results.append({"id": parts[0], "label": " ".join(parts[1:])})
294
+ return results
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.backends.legacy:build"
4
+
5
+ [project]
6
+ name = "qcbot"
7
+ version = "1.0.0"
8
+ description = "Playwright & multi-stack test quality analyser — Python interface"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ keywords = ["playwright", "test quality", "code analysis", "ci"]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.9",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Topic :: Software Development :: Quality Assurance",
23
+ "Topic :: Software Development :: Testing",
24
+ ]
25
+
26
+ # Node.js / npx are runtime prerequisites but cannot be declared here.
27
+ # The package checks for them at call time and raises a clear error.
28
+ dependencies = []
29
+
30
+ [project.optional-dependencies]
31
+ cli = ["click>=8.0"]
32
+
33
+ [project.scripts]
34
+ qcbot-py = "pw_quality.cli:main"
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/cqs/qcbot"
38
+ Documentation = "https://github.com/cqs/qcbot/blob/main/cli/README.md"
39
+
40
+ [tool.setuptools.packages.find]
41
+ where = ["."]
42
+ include = ["pw_quality*"]
package/src/cli.js ADDED
@@ -0,0 +1,294 @@
1
+ /**
2
+ * cli.js — Main entry point for the qcBot CLI tool.
3
+ *
4
+ * Usage:
5
+ * qcbot check ./tests --stack playwright --threshold 80 --report
6
+ */
7
+
8
+ import { program } from "commander";
9
+ import { readFile, writeFile, mkdir } from "fs/promises";
10
+ import { existsSync } from "fs";
11
+ import { resolve, join, relative, basename } from "path";
12
+ import { glob } from "glob";
13
+ import { runLocalAnalysis } from "../../src/analyzers/index.js";
14
+ import { runCrossFileAnalysis } from "../../src/analyzers/crossFileAnalyzer.js";
15
+ import { AUDIT_STACKS } from "../../src/stacks/definitions.js";
16
+ import { buildHtmlReport } from "./reportBuilder.js";
17
+ import { runRemediation } from "./remediation.js";
18
+ import { registerPrReviewCommand } from "./prBot.js";
19
+
20
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
21
+
22
+ const RESET = "\x1b[0m";
23
+ const BOLD = "\x1b[1m";
24
+ const RED = "\x1b[31m";
25
+ const YELLOW = "\x1b[33m";
26
+ const GREEN = "\x1b[32m";
27
+ const TEAL = "\x1b[36m";
28
+ const GRAY = "\x1b[90m";
29
+ const DIM = "\x1b[2m";
30
+
31
+ function gradeColor(score) {
32
+ if (score >= 90) return GREEN;
33
+ if (score >= 75) return TEAL;
34
+ if (score >= 60) return YELLOW;
35
+ return RED;
36
+ }
37
+ function gradeLabel(score) {
38
+ if (score >= 90) return "Excellent";
39
+ if (score >= 75) return "Good";
40
+ if (score >= 60) return "Fair";
41
+ return "Needs work";
42
+ }
43
+
44
+ function sevColor(sev) {
45
+ if (sev === "critical") return RED;
46
+ if (sev === "warning") return YELLOW;
47
+ return TEAL;
48
+ }
49
+
50
+ function bar(score, width = 20) {
51
+ const filled = Math.round((score / 100) * width);
52
+ const c = gradeColor(score);
53
+ return c + "█".repeat(filled) + GRAY + "░".repeat(width - filled) + RESET;
54
+ }
55
+
56
+ // ─── Spinner ──────────────────────────────────────────────────────────────────
57
+
58
+ function spinner(text) {
59
+ const frames = ["⠋","⠙","⠹","⠸","⠼","⠴","⠦","⠧","⠇","⠏"];
60
+ let i = 0;
61
+ const id = setInterval(() => {
62
+ process.stdout.write(`\r${TEAL}${frames[i++ % frames.length]}${RESET} ${text}`);
63
+ }, 80);
64
+ return { stop: () => { clearInterval(id); process.stdout.write("\r\x1b[K"); } };
65
+ }
66
+
67
+ // ─── Core analysis ───────────────────────────────────────────────────────────
68
+
69
+ async function analyseFiles(filePaths, stackId, { parallel = 4 } = {}) {
70
+ const results = new Array(filePaths.length).fill(null);
71
+ const queue = filePaths.map((fp, idx) => ({ fp, idx }));
72
+ const workers = Array.from({ length: Math.min(parallel, queue.length) }, async () => {
73
+ while (queue.length) {
74
+ const { fp, idx } = queue.shift();
75
+ try {
76
+ const content = await readFile(fp, "utf8");
77
+ const name = basename(fp);
78
+ results[idx] = { name, path: fp, content, result: runLocalAnalysis(stackId, name, content) };
79
+ } catch (err) {
80
+ results[idx] = { name: basename(fp), path: fp, content: "", error: err.message };
81
+ }
82
+ }
83
+ });
84
+ await Promise.all(workers);
85
+ return results;
86
+ }
87
+
88
+ // ─── Command: check ──────────────────────────────────────────────────────────
89
+
90
+ async function runCheck(inputPath, opts) {
91
+ const stackId = opts.stack;
92
+ const threshold = Number(opts.threshold);
93
+ const outputDir = opts.output || "./qcbot-report";
94
+ const withReport = opts.report || opts.output;
95
+ const withJson = opts.json;
96
+ const withCross = opts.crossFile !== false;
97
+
98
+ // Validate stack
99
+ if (!AUDIT_STACKS[stackId]) {
100
+ console.error(`${RED}✗ Unknown stack "${stackId}". Valid values: ${Object.keys(AUDIT_STACKS).join(", ")}${RESET}`);
101
+ process.exit(1);
102
+ }
103
+
104
+ const stackDef = AUDIT_STACKS[stackId];
105
+ const pattern = stackDef.filePattern;
106
+
107
+ // Resolve input path
108
+ const absInput = resolve(process.cwd(), inputPath);
109
+ if (!existsSync(absInput)) {
110
+ console.error(`${RED}✗ Path not found: ${absInput}${RESET}`);
111
+ process.exit(1);
112
+ }
113
+
114
+ // Discover files
115
+ const spin1 = spinner(`Discovering ${stackDef.dropHint} files in ${TEAL}${relative(process.cwd(), absInput)}${RESET}…`);
116
+ const allFiles = await glob("**/*", { cwd: absInput, absolute: true, nodir: true });
117
+ const specFiles = allFiles.filter((f) => pattern.test(basename(f)));
118
+ spin1.stop();
119
+
120
+ if (specFiles.length === 0) {
121
+ console.error(`${YELLOW}⚠ No ${stackDef.dropHint} files found in ${absInput}${RESET}`);
122
+ process.exit(0);
123
+ }
124
+
125
+ console.log(`\n${BOLD}⚡ Code Quality Studio${RESET} ${GRAY}— ${stackDef.name}${RESET}`);
126
+ console.log(`${GRAY} ${specFiles.length} files · threshold ${threshold} · ${new Date().toLocaleString()}${RESET}\n`);
127
+
128
+ // Analyse files
129
+ const spin2 = spinner(`Analysing ${specFiles.length} files (up to ${opts.parallel || 4} in parallel)…`);
130
+ const fileResults = await analyseFiles(specFiles, stackId, { parallel: Number(opts.parallel) || 4 });
131
+ spin2.stop();
132
+
133
+ // Cross-file pass
134
+ if (withCross && fileResults.length >= 2) {
135
+ const spin3 = spinner("Running cross-file duplicate detection…");
136
+ const eligible = fileResults.filter((f) => f.result && f.content);
137
+ const crossMap = runCrossFileAnalysis(eligible);
138
+ for (const fr of fileResults) {
139
+ if (!fr.result || !crossMap[fr.name]) continue;
140
+ fr.result = {
141
+ ...fr.result,
142
+ findings: [...(fr.result.findings || []), ...crossMap[fr.name]],
143
+ };
144
+ }
145
+ spin3.stop();
146
+ }
147
+
148
+ // Aggregate
149
+ const analysed = fileResults.filter((f) => f.result);
150
+ const scores = analysed.map((f) => f.result.overallScore ?? 0);
151
+ const avgScore = scores.length ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length) : 0;
152
+ const allFindings = analysed.flatMap((f) => (f.result.findings || []).map((fi) => ({ ...fi, _file: f.name })));
153
+ const critical = allFindings.filter((f) => f.severity === "critical").length;
154
+ const warnings = allFindings.filter((f) => f.severity === "warning").length;
155
+ const passed = avgScore >= threshold;
156
+ const gc = gradeColor(avgScore);
157
+
158
+ // ── Print summary ─────────────────────────────────────────────────────────
159
+ console.log(`${BOLD}Results${RESET}`);
160
+ console.log("─".repeat(52));
161
+ console.log(` Score ${bar(avgScore)} ${gc}${BOLD}${avgScore}${RESET} ${gc}${gradeLabel(avgScore)}${RESET}`);
162
+ console.log(` Files ${BOLD}${analysed.length}${RESET} of ${fileResults.length} analysed`);
163
+ console.log(` Critical ${critical > 0 ? RED + BOLD : GREEN}${critical}${RESET} Warnings ${warnings > 0 ? YELLOW : GREEN}${warnings}${RESET} Total ${allFindings.length}`);
164
+ console.log(` Status ${passed ? GREEN + BOLD + "✓ PASSED" : RED + BOLD + "✗ FAILED"} (threshold ${threshold})${RESET}`);
165
+ console.log("─".repeat(52));
166
+
167
+ // ── Per-file table ────────────────────────────────────────────────────────
168
+ if (!opts.summary) {
169
+ console.log(`\n${BOLD}File breakdown${RESET}`);
170
+ for (const fr of analysed) {
171
+ const s = fr.result.overallScore ?? 0;
172
+ const c = gradeColor(s);
173
+ const fc = (fr.result.findings || []).filter((f) => f.severity === "critical").length;
174
+ console.log(
175
+ ` ${c}${String(s).padStart(3)}${RESET} ${bar(s, 14)} ${
176
+ fc > 0 ? RED + fc + " crit " + RESET : GREEN + "✓ " + RESET
177
+ }${GRAY}${fr.name}${RESET}`
178
+ );
179
+ }
180
+ }
181
+
182
+ // ── Top findings ──────────────────────────────────────────────────────────
183
+ if (!opts.summary) {
184
+ const top = allFindings
185
+ .filter((f) => f.severity === "critical" || f.severity === "warning")
186
+ .slice(0, opts.maxFindings ? Number(opts.maxFindings) : 10);
187
+
188
+ if (top.length > 0) {
189
+ console.log(`\n${BOLD}Top findings${RESET}`);
190
+ for (const f of top) {
191
+ const sc = sevColor(f.severity);
192
+ console.log(` ${sc}${f.severity.toUpperCase().padEnd(8)}${RESET} ${BOLD}${f.title}${RESET}`);
193
+ console.log(` ${GRAY}${f._file}${f.line != null ? ":" + f.line : ""} ${f.ruleId ?? ""}${RESET}`);
194
+ }
195
+ }
196
+ }
197
+
198
+ // ── JSON output ───────────────────────────────────────────────────────────
199
+ if (withJson) {
200
+ const jsonPayload = {
201
+ projectName: opts.project || basename(absInput),
202
+ stackId,
203
+ runAt: new Date().toISOString(),
204
+ score: avgScore,
205
+ grade: gradeLabel(avgScore),
206
+ passed,
207
+ threshold,
208
+ summary: { critical, warnings, total: allFindings.length, files: analysed.length },
209
+ files: analysed.map((f) => ({
210
+ name: f.name,
211
+ path: relative(process.cwd(), f.path),
212
+ score: f.result.overallScore,
213
+ findings: f.result.findings,
214
+ })),
215
+ };
216
+ const jsonFile = typeof withJson === "string" ? withJson : join(outputDir, "report.json");
217
+ await mkdir(outputDir, { recursive: true });
218
+ await writeFile(jsonFile, JSON.stringify(jsonPayload, null, 2), "utf8");
219
+ console.log(`\n${GREEN}✓${RESET} JSON report → ${TEAL}${jsonFile}${RESET}`);
220
+ }
221
+
222
+ // ── HTML report ───────────────────────────────────────────────────────────
223
+ if (withReport) {
224
+ const html = buildHtmlReport({
225
+ projectName: opts.project || basename(absInput),
226
+ stackId,
227
+ runAt: new Date().toLocaleString(),
228
+ files: analysed.map((f) => ({ name: relative(absInput, f.path), result: f.result })),
229
+ threshold,
230
+ passed,
231
+ });
232
+ await mkdir(outputDir, { recursive: true });
233
+ const htmlFile = join(outputDir, "report.html");
234
+ await writeFile(htmlFile, html, "utf8");
235
+ console.log(`\n${GREEN}✓${RESET} HTML report → ${TEAL}${resolve(htmlFile)}${RESET}`);
236
+ console.log(` ${DIM}Open in browser: open ${resolve(htmlFile)}${RESET}`);
237
+ }
238
+
239
+ console.log();
240
+
241
+ // ── Exit code ─────────────────────────────────────────────────────────────
242
+ process.exit(passed ? 0 : 1);
243
+ }
244
+
245
+ // ─── CLI definition ──────────────────────────────────────────────────────────
246
+
247
+ program
248
+ .name("qcbot")
249
+ .description("Playwright & multi-stack test quality analyser — rules + AI + shareable HTML reports")
250
+ .version("1.0.0");
251
+
252
+ program
253
+ .command("check <path>")
254
+ .description("Analyse test files in <path> and report quality findings")
255
+ .option("-s, --stack <id>", "Stack to analyse (playwright, typescript, java_api, …)", "playwright")
256
+ .option("-t, --threshold <n>", "Minimum passing score 0-100 — exit code 1 if below", "80")
257
+ .option("-p, --parallel <n>", "Max files to analyse simultaneously", "4")
258
+ .option("--report", "Generate self-contained HTML report in ./qcbot-report/")
259
+ .option("--json [file]", "Write findings JSON (default: ./qcbot-report/report.json)")
260
+ .option("--output <dir>", "Custom output directory for report files", "./qcbot-report")
261
+ .option("--no-cross-file", "Skip cross-file duplicate detection")
262
+ .option("--summary", "Print summary only (no per-file breakdown or top findings)")
263
+ .option("--max-findings <n>", "Max top findings to show in terminal output", "10")
264
+ .option("--project <name>", "Project name for the report header")
265
+ .action(runCheck);
266
+
267
+ program
268
+ .command("stacks")
269
+ .description("List all supported stacks and their file patterns")
270
+ .action(() => {
271
+ console.log(`\n${BOLD}Supported stacks${RESET}\n`);
272
+ for (const [id, s] of Object.entries(AUDIT_STACKS)) {
273
+ console.log(` ${TEAL}${BOLD}${id.padEnd(20)}${RESET} ${s.name.padEnd(30)} ${GRAY}${s.dropHint}${RESET}`);
274
+ }
275
+ console.log();
276
+ });
277
+
278
+ registerPrReviewCommand(program);
279
+
280
+ program
281
+ .command("remediate <path>")
282
+ .description("Agentic AI remediation — analyses files and auto-fixes critical/warning findings")
283
+ .option("-s, --stack <id>", "Stack to analyse", "playwright")
284
+ .option("--severity <level>", "Which findings to fix: critical | warning | all", "critical")
285
+ .option("--provider <name>", "AI provider: anthropic | google", "anthropic")
286
+ .option("--api-key <key>", "API key (or set ANTHROPIC_API_KEY / GOOGLE_AI_API_KEY)")
287
+ .option("--model <id>", "Override model ID")
288
+ .option("--dry-run", "Show what would be changed without writing any files")
289
+ .option("--commit", "Create a git commit after applying fixes")
290
+ .option("--branch <name>", "Create and switch to a new git branch before fixing")
291
+ .option("--test-files-only", "Safety flag: restrict fixes to recognised test files only (*.spec.*, *.test.*, test_*.py, *Test.java …). Default: on. Pass --no-test-files-only to disable.", true)
292
+ .action(runRemediation);
293
+
294
+ program.parse(process.argv);