@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
|
@@ -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);
|