@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/src/prBot.js ADDED
@@ -0,0 +1,301 @@
1
+ /**
2
+ * prBot.js — GitHub PR review bot for qcBot.
3
+ *
4
+ * Run as a GitHub Actions step:
5
+ * npx @cqs/qcbot pr-review --threshold 80
6
+ *
7
+ * Or manually:
8
+ * npx @cqs/qcbot pr-review \
9
+ * --token ghp_xxx \
10
+ * --repo owner/repo \
11
+ * --pr 42 \
12
+ * --threshold 80
13
+ *
14
+ * Env vars read automatically from GitHub Actions context:
15
+ * GITHUB_TOKEN, GITHUB_REPOSITORY, GITHUB_REF (refs/pull/42/merge)
16
+ */
17
+
18
+ import { readFile } from "fs/promises";
19
+ import { existsSync } from "fs";
20
+ import { resolve, basename } from "path";
21
+ import { program } from "commander";
22
+ import { runLocalAnalysis } from "../../src/analyzers/index.js";
23
+ import { runCrossFileAnalysis } from "../../src/analyzers/crossFileAnalyzer.js";
24
+ import { AUDIT_STACKS } from "../../src/stacks/definitions.js";
25
+
26
+ const RESET = "\x1b[0m";
27
+ const GREEN = "\x1b[32m";
28
+ const RED = "\x1b[31m";
29
+ const TEAL = "\x1b[36m";
30
+ const GRAY = "\x1b[90m";
31
+ const BOLD = "\x1b[1m";
32
+
33
+ // ── GitHub API helpers ────────────────────────────────────────────────────
34
+
35
+ async function ghFetch(path, token, options = {}) {
36
+ const res = await fetch(`https://api.github.com${path}`, {
37
+ ...options,
38
+ headers: {
39
+ Authorization: `Bearer ${token}`,
40
+ Accept: "application/vnd.github+json",
41
+ "X-GitHub-Api-Version": "2022-11-28",
42
+ "Content-Type": "application/json",
43
+ ...(options.headers || {}),
44
+ },
45
+ body: options.body ? JSON.stringify(options.body) : undefined,
46
+ });
47
+ if (!res.ok) {
48
+ const txt = await res.text();
49
+ throw new Error(`GitHub API ${res.status}: ${txt.slice(0, 300)}`);
50
+ }
51
+ return res.json();
52
+ }
53
+
54
+ async function getPrFiles(owner, repo, prNumber, token) {
55
+ // GitHub returns max 30 files per page; handle pagination
56
+ const files = [];
57
+ let page = 1;
58
+ while (true) {
59
+ const chunk = await ghFetch(
60
+ `/repos/${owner}/${repo}/pulls/${prNumber}/files?per_page=100&page=${page}`,
61
+ token,
62
+ );
63
+ files.push(...chunk);
64
+ if (chunk.length < 100) break;
65
+ page++;
66
+ }
67
+ return files;
68
+ }
69
+
70
+ async function getPrCommit(owner, repo, prNumber, token) {
71
+ const pr = await ghFetch(`/repos/${owner}/${repo}/pulls/${prNumber}`, token);
72
+ return pr.head.sha;
73
+ }
74
+
75
+ async function postReview(owner, repo, prNumber, token, { commitId, body, comments, event }) {
76
+ return ghFetch(`/repos/${owner}/${repo}/pulls/${prNumber}/reviews`, token, {
77
+ method: "POST",
78
+ body: { commit_id: commitId, body, comments, event },
79
+ });
80
+ }
81
+
82
+ // ── Score helpers ─────────────────────────────────────────────────────────
83
+
84
+ function gradeLabel(score) {
85
+ if (score >= 90) return "Excellent ✅";
86
+ if (score >= 75) return "Good ✅";
87
+ if (score >= 60) return "Fair ⚠️";
88
+ return "Needs work ❌";
89
+ }
90
+
91
+ function buildReviewBody({ score, passed, threshold, files, stackId }) {
92
+ const grade = gradeLabel(score);
93
+ const allFindings = files.flatMap((f) => (f.result?.findings || []).map((fi) => ({ ...fi, _file: f.name })));
94
+ const critical = allFindings.filter((f) => f.severity === "critical").length;
95
+ const warnings = allFindings.filter((f) => f.severity === "warning").length;
96
+
97
+ const lines = [
98
+ `## ⚡ qcBot Report — ${passed ? "PASSED ✅" : "FAILED ❌"}`,
99
+ ``,
100
+ `| Metric | Value |`,
101
+ `|--------|-------|`,
102
+ `| Overall score | **${score}** — ${grade} |`,
103
+ `| Threshold | ${threshold} |`,
104
+ `| Files analysed | ${files.length} |`,
105
+ `| Critical findings | ${critical} |`,
106
+ `| Warnings | ${warnings} |`,
107
+ `| Stack | \`${stackId}\` |`,
108
+ ``,
109
+ passed
110
+ ? `✅ This PR meets the quality threshold of **${threshold}**. Great work!`
111
+ : `❌ This PR's quality score (**${score}**) is below the threshold of **${threshold}**. Please address the critical findings before merging.`,
112
+ ``,
113
+ ];
114
+
115
+ if (critical > 0) {
116
+ lines.push(`### 🔴 Critical findings to fix`);
117
+ allFindings
118
+ .filter((f) => f.severity === "critical")
119
+ .slice(0, 10)
120
+ .forEach((f) => {
121
+ lines.push(`- **${f.title}** — \`${f._file}\`${f.line ? `:${f.line}` : ""} (\`${f.ruleId || ""}\`)`);
122
+ });
123
+ lines.push("");
124
+ }
125
+
126
+ lines.push(`---`);
127
+ lines.push(`*Generated by [qcbot](https://github.com/cqs/qcbot) · Stack: \`${stackId}\` · [Learn more about the rules](https://github.com/cqs/qcbot/blob/main/cli/README.md)*`);
128
+ return lines.join("\n");
129
+ }
130
+
131
+ // ── Main command ──────────────────────────────────────────────────────────
132
+
133
+ async function runPrReview(opts) {
134
+ // Resolve credentials — prefer explicit opts, fall back to GitHub Actions env
135
+ const token = opts.token || process.env.GITHUB_TOKEN;
136
+ if (!token) {
137
+ console.error(`${RED}✗ No GitHub token. Pass --token or set GITHUB_TOKEN.${RESET}`);
138
+ process.exit(1);
139
+ }
140
+
141
+ let owner, repo;
142
+ if (opts.repo) {
143
+ [owner, repo] = opts.repo.split("/");
144
+ } else if (process.env.GITHUB_REPOSITORY) {
145
+ [owner, repo] = process.env.GITHUB_REPOSITORY.split("/");
146
+ } else {
147
+ console.error(`${RED}✗ No repo. Pass --repo owner/repo or set GITHUB_REPOSITORY.${RESET}`);
148
+ process.exit(1);
149
+ }
150
+
151
+ let prNumber = opts.pr;
152
+ if (!prNumber && process.env.GITHUB_REF) {
153
+ // refs/pull/42/merge
154
+ const m = process.env.GITHUB_REF.match(/refs\/pull\/(\d+)/);
155
+ if (m) prNumber = parseInt(m[1], 10);
156
+ }
157
+ if (!prNumber) {
158
+ console.error(`${RED}✗ No PR number. Pass --pr 42 or run inside a GitHub Actions pull_request event.${RESET}`);
159
+ process.exit(1);
160
+ }
161
+
162
+ const stackId = opts.stack || "playwright";
163
+ const threshold = Number(opts.threshold) || 80;
164
+
165
+ if (!AUDIT_STACKS[stackId]) {
166
+ console.error(`${RED}✗ Unknown stack "${stackId}"${RESET}`);
167
+ process.exit(1);
168
+ }
169
+
170
+ const stackDef = AUDIT_STACKS[stackId];
171
+ const pattern = stackDef.filePattern;
172
+
173
+ console.log(`\n${BOLD}⚡ qcBot PR Review${RESET}`);
174
+ console.log(`${GRAY} ${owner}/${repo} · PR #${prNumber} · stack: ${stackId} · threshold: ${threshold}${RESET}\n`);
175
+
176
+ // 1. Get list of files changed in the PR
177
+ console.log(`${TEAL}→${RESET} Fetching PR files…`);
178
+ const prFiles = await getPrFiles(owner, repo, prNumber, token);
179
+ const specFiles = prFiles.filter((f) => f.status !== "removed" && pattern.test(basename(f.filename)));
180
+
181
+ if (specFiles.length === 0) {
182
+ console.log(`${GRAY} No ${stackDef.dropHint} files changed in this PR — skipping quality review.${RESET}\n`);
183
+ process.exit(0);
184
+ }
185
+
186
+ console.log(`${TEAL}→${RESET} ${specFiles.length} test file(s) changed in this PR`);
187
+
188
+ // 2. Read files from disk and analyse
189
+ const cwd = process.cwd();
190
+ const fileResults = [];
191
+ for (const pf of specFiles) {
192
+ const absPath = resolve(cwd, pf.filename);
193
+ if (!existsSync(absPath)) {
194
+ console.log(`${GRAY} Skipping ${pf.filename} (not on disk — likely deleted)${RESET}`);
195
+ continue;
196
+ }
197
+ try {
198
+ const content = await readFile(absPath, "utf8");
199
+ const name = pf.filename;
200
+ const result = runLocalAnalysis(stackId, basename(name), content);
201
+ fileResults.push({ name, content, result });
202
+ } catch (err) {
203
+ console.log(`${GRAY} Skipping ${pf.filename}: ${err.message}${RESET}`);
204
+ }
205
+ }
206
+
207
+ if (fileResults.length === 0) {
208
+ console.log(`${GRAY} No files could be read — nothing to review.${RESET}\n`);
209
+ process.exit(0);
210
+ }
211
+
212
+ // 3. Cross-file pass
213
+ if (fileResults.length >= 2) {
214
+ const crossMap = runCrossFileAnalysis(fileResults);
215
+ for (const fr of fileResults) {
216
+ if (crossMap[fr.name]) {
217
+ fr.result = { ...fr.result, findings: [...(fr.result.findings || []), ...crossMap[basename(fr.name)]] };
218
+ }
219
+ }
220
+ }
221
+
222
+ // 4. Aggregate
223
+ const scores = fileResults.map((f) => f.result?.overallScore ?? 0);
224
+ const avgScore = Math.round(scores.reduce((a, b) => a + b, 0) / scores.length);
225
+ const passed = avgScore >= threshold;
226
+
227
+ // 5. Build inline comments — one per critical/warning finding
228
+ const MAX_INLINE = 30; // GitHub caps reviews at 30 comments
229
+ const inlineComments = [];
230
+ for (const fr of fileResults) {
231
+ const findings = (fr.result?.findings || []).filter((f) => f.severity === "critical" || f.severity === "warning");
232
+ for (const finding of findings) {
233
+ if (inlineComments.length >= MAX_INLINE) break;
234
+ // Only post inline if we have a line number and the file appears in the diff
235
+ const prFile = prFiles.find((pf) => pf.filename === fr.name);
236
+ if (!prFile || !finding.line) continue;
237
+
238
+ const sevEmoji = finding.severity === "critical" ? "🔴" : "⚠️";
239
+ const body = [
240
+ `${sevEmoji} **${finding.severity.toUpperCase()}**: ${finding.title}`,
241
+ ``,
242
+ finding.description,
243
+ ``,
244
+ finding.fix ? `**Fix:** ${finding.fix.replace(/\n/g, "\n\n")}` : "",
245
+ ``,
246
+ finding.ruleId ? `*Rule: \`${finding.ruleId}\`*` : "",
247
+ finding.reference && finding.reference.startsWith("http") ? `[Learn more ↗](${finding.reference})` : "",
248
+ ].filter(Boolean).join("\n");
249
+
250
+ inlineComments.push({
251
+ path: fr.name,
252
+ line: finding.line,
253
+ side: "RIGHT",
254
+ body,
255
+ });
256
+ }
257
+ }
258
+
259
+ // 6. Get the head commit SHA for the review
260
+ const commitId = await getPrCommit(owner, repo, prNumber, token);
261
+
262
+ // 7. Build review body
263
+ const reviewBody = buildReviewBody({ score: avgScore, passed, threshold, files: fileResults, stackId });
264
+
265
+ // 8. Post the review
266
+ const event = passed ? "APPROVE" : (inlineComments.length > 0 ? "REQUEST_CHANGES" : "COMMENT");
267
+ console.log(`${TEAL}→${RESET} Posting GitHub review (${event}) with ${inlineComments.length} inline comment(s)…`);
268
+
269
+ try {
270
+ await postReview(owner, repo, prNumber, token, {
271
+ commitId,
272
+ body: reviewBody,
273
+ comments: inlineComments,
274
+ event: opts.dryRun ? "COMMENT" : event,
275
+ });
276
+ console.log(`\n${GREEN}✓ Review posted${RESET}`);
277
+ console.log(` Score: ${avgScore} Status: ${passed ? "PASSED ✅" : "FAILED ❌"}`);
278
+ console.log(` Inline comments: ${inlineComments.length}\n`);
279
+ } catch (err) {
280
+ console.error(`${RED}✗ Failed to post review: ${err.message}${RESET}`);
281
+ // Don't fail the CI step just because review posting failed
282
+ // The exit code below reflects the quality gate
283
+ }
284
+
285
+ process.exit(passed ? 0 : 1);
286
+ }
287
+
288
+ // ── Commander registration (called from cli.js) ───────────────────────────
289
+
290
+ export function registerPrReviewCommand(program) {
291
+ program
292
+ .command("pr-review")
293
+ .description("Post a quality review on a GitHub Pull Request")
294
+ .option("--token <token>", "GitHub token (default: $GITHUB_TOKEN)")
295
+ .option("--repo <owner/repo>", "Repository slug (default: $GITHUB_REPOSITORY)")
296
+ .option("--pr <number>", "PR number (default: auto-detected from $GITHUB_REF)", parseInt)
297
+ .option("-s, --stack <id>", "Stack to analyse", "playwright")
298
+ .option("-t, --threshold <n>", "Minimum passing score", "80")
299
+ .option("--dry-run", "Post as COMMENT instead of APPROVE/REQUEST_CHANGES")
300
+ .action(runPrReview);
301
+ }
@@ -0,0 +1,333 @@
1
+ /**
2
+ * remediation.js — Agentic auto-remediation for qcBot.
3
+ *
4
+ * Usage:
5
+ * qcbot remediate ./tests --stack playwright --api-key $ANTHROPIC_API_KEY
6
+ * qcbot remediate ./tests --dry-run # shows what would be changed, no writes
7
+ * qcbot remediate ./tests --commit # git-commits the fixes after applying
8
+ *
9
+ * How it works:
10
+ * 1. Run full analysis (same as `check`)
11
+ * 2. For each file with critical findings, call Claude to produce a fixed version
12
+ * 3. Show a before/after summary and write the fixed file (unless --dry-run)
13
+ * 4. If --commit, run `git add -A && git commit -m "fix: auto-remediate quality findings"`
14
+ */
15
+
16
+ import { readFile, writeFile } from "fs/promises";
17
+ import { existsSync } from "fs";
18
+ import { resolve, basename, relative } from "path";
19
+ import { glob } from "glob";
20
+ import { execSync } from "child_process";
21
+ import { runLocalAnalysis } from "../../src/analyzers/index.js";
22
+ import { AUDIT_STACKS } from "../../src/stacks/definitions.js";
23
+
24
+ const RESET = "\x1b[0m";
25
+ const BOLD = "\x1b[1m";
26
+ const RED = "\x1b[31m";
27
+ const GREEN = "\x1b[32m";
28
+ const YELLOW = "\x1b[33m";
29
+ const TEAL = "\x1b[36m";
30
+ const GRAY = "\x1b[90m";
31
+ const DIM = "\x1b[2m";
32
+
33
+ // ── AI fix caller ─────────────────────────────────────────────────────────
34
+
35
+ const FIX_SYSTEM_PROMPT = `You are a senior test automation engineer specialising in code quality.
36
+ You will receive a test file with one or more quality findings. Your task is to fix ALL the findings in the file.
37
+
38
+ Return ONLY the complete, corrected file content — no explanations, no markdown fences, no commentary.
39
+ The output must be the raw file content that can be written directly to disk.
40
+
41
+ Rules:
42
+ - Fix only the identified issues; keep all other code unchanged
43
+ - Maintain the same indentation style as the original
44
+ - Do not add comments explaining what you changed
45
+ - Do not remove tests or functionality
46
+ - If you cannot fix a finding without breaking functionality, leave that specific code unchanged`;
47
+
48
+ function buildRemediationPrompt(fileName, content, findings) {
49
+ const findingList = findings
50
+ .map((f, i) => `${i + 1}. [${f.severity.toUpperCase()}] ${f.title}${f.line ? ` (line ${f.line})` : ""}\n ${f.description}\n Fix: ${f.fix || "Follow best practices"}`)
51
+ .join("\n\n");
52
+
53
+ return `File: ${fileName}
54
+
55
+ Findings to fix:
56
+ ${findingList}
57
+
58
+ Original file content:
59
+ ${content}
60
+
61
+ Return the complete fixed file content only.`;
62
+ }
63
+
64
+ async function callAnthropicFix(apiKey, model, prompt) {
65
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
66
+ method: "POST",
67
+ headers: {
68
+ "Content-Type": "application/json",
69
+ "x-api-key": apiKey,
70
+ "anthropic-version": "2023-06-01",
71
+ },
72
+ body: JSON.stringify({
73
+ model: model || "claude-sonnet-4-6",
74
+ max_tokens: 8192,
75
+ system: FIX_SYSTEM_PROMPT,
76
+ messages: [{ role: "user", content: prompt }],
77
+ }),
78
+ });
79
+ if (!res.ok) {
80
+ const err = await res.text();
81
+ throw new Error(`Anthropic API error ${res.status}: ${err.slice(0, 200)}`);
82
+ }
83
+ const data = await res.json();
84
+ return data.content?.map((b) => b.text || "").join("") || "";
85
+ }
86
+
87
+ async function callGoogleFix(apiKey, model, prompt) {
88
+ const modelId = model || "gemini-1.5-pro";
89
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(modelId)}:generateContent?key=${encodeURIComponent(apiKey)}`;
90
+ const res = await fetch(url, {
91
+ method: "POST",
92
+ headers: { "Content-Type": "application/json" },
93
+ body: JSON.stringify({
94
+ systemInstruction: { parts: [{ text: FIX_SYSTEM_PROMPT }] },
95
+ contents: [{ role: "user", parts: [{ text: prompt }] }],
96
+ generationConfig: { temperature: 0.1, maxOutputTokens: 8192 },
97
+ }),
98
+ });
99
+ if (!res.ok) {
100
+ const err = await res.text();
101
+ throw new Error(`Google AI error ${res.status}: ${err.slice(0, 200)}`);
102
+ }
103
+ const data = await res.json();
104
+ return data.candidates?.[0]?.content?.parts?.map((p) => p.text).join("") ?? "";
105
+ }
106
+
107
+ async function getAiFix({ provider, apiKey, model, fileName, content, findings }) {
108
+ const prompt = buildRemediationPrompt(fileName, content, findings);
109
+ switch (provider) {
110
+ case "anthropic": return callAnthropicFix(apiKey, model, prompt);
111
+ case "google": return callGoogleFix(apiKey, model, prompt);
112
+ default: throw new Error(`Provider "${provider}" not supported for remediation. Use --provider anthropic or --provider google.`);
113
+ }
114
+ }
115
+
116
+ // ── Diff summary ──────────────────────────────────────────────────────────
117
+
118
+ function quickDiff(original, fixed) {
119
+ const origLines = original.split("\n");
120
+ const fixedLines = fixed.split("\n");
121
+ let added = 0, removed = 0;
122
+ const maxLen = Math.max(origLines.length, fixedLines.length);
123
+ for (let i = 0; i < maxLen; i++) {
124
+ if (origLines[i] !== fixedLines[i]) {
125
+ if (origLines[i] !== undefined) removed++;
126
+ if (fixedLines[i] !== undefined) added++;
127
+ }
128
+ }
129
+ return { added, removed, changed: added + removed > 0 };
130
+ }
131
+
132
+ // ── Spinner ───────────────────────────────────────────────────────────────
133
+
134
+ function spinner(text) {
135
+ const frames = ["⠋","⠙","⠹","⠸","⠼","⠴","⠦","⠧","⠇","⠏"];
136
+ let i = 0;
137
+ const id = setInterval(() => {
138
+ process.stdout.write(`\r${TEAL}${frames[i++ % frames.length]}${RESET} ${text}`);
139
+ }, 80);
140
+ return { stop: () => { clearInterval(id); process.stdout.write("\r\x1b[K"); } };
141
+ }
142
+
143
+ // ── Test-file guard ───────────────────────────────────────────────────────
144
+ // Patterns that identify recognised test / spec files across all supported stacks.
145
+ // Used by --test-files-only (default ON) to prevent the agent from rewriting
146
+ // production source code even if the user accidentally points it at a mixed directory.
147
+
148
+ const TEST_FILE_GUARD_PATTERNS = [
149
+ /\.spec\.[jt]sx?$/i, // *.spec.ts, *.spec.js, *.spec.tsx, *.spec.jsx
150
+ /\.test\.[jt]sx?$/i, // *.test.ts, *.test.js …
151
+ /\.spec\.py$/i, // Python playwright / pytest specs
152
+ /test_[^/\\]+\.py$/i, // pytest test_login.py
153
+ /[^/\\]+_test\.py$/i, // pytest login_test.py
154
+ /[^/\\]+Test\.java$/i, // JUnit LoginTest.java
155
+ /[^/\\]+Spec\.java$/i, // Spock LoginSpec.java
156
+ /[^/\\]+IT\.java$/i, // Maven integration tests
157
+ /\.feature$/i, // Karate / Cucumber .feature files
158
+ /[^/\\]+_spec\.rb$/i, // RSpec
159
+ /[^/\\]+\.steps\.[jt]sx?$/i, // Step definitions
160
+ ];
161
+
162
+ function isTestFile(filePath) {
163
+ return TEST_FILE_GUARD_PATTERNS.some((re) => re.test(filePath));
164
+ }
165
+
166
+ // ── Main ──────────────────────────────────────────────────────────────────
167
+
168
+ export async function runRemediation(inputPath, opts) {
169
+ const provider = opts.provider || "anthropic";
170
+ const apiKey = opts.apiKey || process.env.ANTHROPIC_API_KEY || process.env.GOOGLE_AI_API_KEY;
171
+ const model = opts.model || null;
172
+ const stackId = opts.stack || "playwright";
173
+ const dryRun = Boolean(opts.dryRun);
174
+ const doCommit = Boolean(opts.commit);
175
+ const doBranch = opts.branch || null;
176
+ const severity = opts.severity || "critical"; // which findings to fix: critical | warning | all
177
+ // Safety flag — default true. When enabled, only files matching TEST_FILE_GUARD_PATTERNS
178
+ // are eligible for remediation, regardless of what stackDef.filePattern matches.
179
+ const testFilesOnly = opts.testFilesOnly !== false;
180
+
181
+ if (!apiKey) {
182
+ console.error(`${RED}✗ No API key. Pass --api-key or set ANTHROPIC_API_KEY / GOOGLE_AI_API_KEY.${RESET}`);
183
+ process.exit(1);
184
+ }
185
+ if (!AUDIT_STACKS[stackId]) {
186
+ console.error(`${RED}✗ Unknown stack "${stackId}"${RESET}`);
187
+ process.exit(1);
188
+ }
189
+
190
+ const stackDef = AUDIT_STACKS[stackId];
191
+ const absInput = resolve(process.cwd(), inputPath);
192
+ if (!existsSync(absInput)) {
193
+ console.error(`${RED}✗ Path not found: ${absInput}${RESET}`);
194
+ process.exit(1);
195
+ }
196
+
197
+ console.log(`\n${BOLD}⚡ qcbot remediate${RESET}${dryRun ? ` ${YELLOW}(dry-run — no files will be written)${RESET}` : ""}`);
198
+ console.log(`${GRAY} provider: ${provider} · model: ${model || "default"} · severity: ${severity}${RESET}\n`);
199
+
200
+ // 1. Discover files
201
+ const spin1 = spinner(`Discovering ${stackDef.dropHint} files…`);
202
+ const allFiles = await glob("**/*", { cwd: absInput, absolute: true, nodir: true });
203
+ let specFiles = allFiles.filter((f) => stackDef.filePattern.test(basename(f)));
204
+
205
+ // Safety guard: when --test-files-only is active (default), remove any file that
206
+ // does not match a recognised test/spec naming pattern. This prevents the agent
207
+ // from accidentally rewriting production source code if the user points it at a
208
+ // mixed directory. Disable with --no-test-files-only only if you are certain the
209
+ // directory contains only test files.
210
+ if (testFilesOnly) {
211
+ const before = specFiles.length;
212
+ specFiles = specFiles.filter((f) => isTestFile(f));
213
+ const skipped = before - specFiles.length;
214
+ if (skipped > 0) {
215
+ console.log(`${YELLOW}⚠ --test-files-only: skipped ${skipped} file(s) that don't match test-file naming patterns.${RESET}`);
216
+ console.log(`${GRAY} (pass --no-test-files-only to include them — use with caution)${RESET}\n`);
217
+ }
218
+ } else {
219
+ console.log(`${YELLOW}⚠ --no-test-files-only active — AI may rewrite non-test files. Ensure you're targeting the right directory.${RESET}\n`);
220
+ }
221
+ spin1.stop();
222
+
223
+ if (specFiles.length === 0) {
224
+ console.log(`${YELLOW}⚠ No test files found in ${absInput}${RESET}\n`);
225
+ process.exit(0);
226
+ }
227
+
228
+ // 2. Analyse all files
229
+ const spin2 = spinner(`Analysing ${specFiles.length} files…`);
230
+ const fileResults = [];
231
+ for (const fp of specFiles) {
232
+ try {
233
+ const content = await readFile(fp, "utf8");
234
+ const name = basename(fp);
235
+ const result = runLocalAnalysis(stackId, name, content);
236
+ const findings = (result.findings || []).filter((f) =>
237
+ severity === "all" ? true :
238
+ severity === "critical" ? f.severity === "critical" :
239
+ f.severity === "critical" || f.severity === "warning"
240
+ );
241
+ fileResults.push({ fp, name, content, result, findings });
242
+ } catch (err) {
243
+ console.warn(`${GRAY} Skipping ${basename(fp)}: ${err.message}${RESET}`);
244
+ }
245
+ }
246
+ spin2.stop();
247
+
248
+ const toFix = fileResults.filter((f) => f.findings.length > 0);
249
+
250
+ console.log(`${BOLD}Analysis complete${RESET}`);
251
+ console.log(` ${specFiles.length} files scanned · ${toFix.length} file(s) need remediation\n`);
252
+
253
+ if (toFix.length === 0) {
254
+ console.log(`${GREEN}✓ No ${severity} findings to remediate. All good!${RESET}\n`);
255
+ process.exit(0);
256
+ }
257
+
258
+ // 3. Create branch if requested
259
+ if (doBranch && !dryRun) {
260
+ try {
261
+ execSync(`git checkout -b ${doBranch}`, { stdio: "pipe" });
262
+ console.log(`${GREEN}✓ Created branch: ${doBranch}${RESET}`);
263
+ } catch (err) {
264
+ console.warn(`${YELLOW}⚠ Could not create branch: ${err.message}${RESET}`);
265
+ }
266
+ }
267
+
268
+ // 4. Remediate each file
269
+ const results = { fixed: [], failed: [], skipped: [] };
270
+
271
+ for (let i = 0; i < toFix.length; i++) {
272
+ const fr = toFix[i];
273
+ const relPath = relative(process.cwd(), fr.fp);
274
+ console.log(`\n[${i + 1}/${toFix.length}] ${BOLD}${fr.name}${RESET}`);
275
+ console.log(` ${fr.findings.length} finding(s): ${fr.findings.map((f) => `${f.severity}:${f.ruleId || f.title}`).join(", ")}`);
276
+
277
+ const spin3 = spinner(`Calling ${provider} to fix ${fr.name}…`);
278
+ let fixedContent;
279
+ try {
280
+ fixedContent = await getAiFix({ provider, apiKey, model, fileName: fr.name, content: fr.content, findings: fr.findings });
281
+ spin3.stop();
282
+ } catch (err) {
283
+ spin3.stop();
284
+ console.log(` ${RED}✗ AI fix failed: ${err.message}${RESET}`);
285
+ results.failed.push({ file: relPath, error: err.message });
286
+ continue;
287
+ }
288
+
289
+ // Strip any accidental markdown fences the AI may have added
290
+ fixedContent = fixedContent.replace(/^```[\w]*\n?/m, "").replace(/\n?```\s*$/m, "").trim() + "\n";
291
+
292
+ const diff = quickDiff(fr.content, fixedContent);
293
+ if (!diff.changed) {
294
+ console.log(` ${GRAY}No changes produced — AI returned identical content${RESET}`);
295
+ results.skipped.push(relPath);
296
+ continue;
297
+ }
298
+
299
+ console.log(` ${GREEN}+${diff.added}${RESET} lines added ${RED}-${diff.removed}${RESET} lines removed`);
300
+
301
+ if (dryRun) {
302
+ console.log(` ${DIM}[dry-run] Would write ${fr.fp}${RESET}`);
303
+ results.fixed.push(relPath);
304
+ } else {
305
+ await writeFile(fr.fp, fixedContent, "utf8");
306
+ console.log(` ${GREEN}✓ Written${RESET} → ${relPath}`);
307
+ results.fixed.push(relPath);
308
+ }
309
+ }
310
+
311
+ // 5. Summary
312
+ console.log(`\n${"─".repeat(52)}`);
313
+ console.log(`${BOLD}Remediation complete${dryRun ? " (dry-run)" : ""}${RESET}`);
314
+ console.log(` ${GREEN}✓ Fixed:${RESET} ${results.fixed.length} file(s)`);
315
+ if (results.skipped.length) console.log(` ${GRAY} Skipped: ${results.skipped.length} file(s) (AI produced no change)${RESET}`);
316
+ if (results.failed.length) console.log(` ${RED}✗ Failed: ${results.failed.length} file(s)${RESET}`);
317
+
318
+ // 6. Git commit
319
+ if (doCommit && !dryRun && results.fixed.length > 0) {
320
+ console.log(`\n${TEAL}→${RESET} Committing fixes…`);
321
+ try {
322
+ execSync("git add -A", { stdio: "pipe" });
323
+ const msg = `fix: auto-remediate ${results.fixed.length} file(s) via qcBot (${severity} findings)\n\nFiles fixed:\n${results.fixed.map((f) => ` - ${f}`).join("\n")}\n\nGenerated by qcbot remediate`;
324
+ execSync(`git commit -m "${msg.replace(/"/g, '\\"')}"`, { stdio: "pipe" });
325
+ console.log(`${GREEN}✓ Committed${RESET}`);
326
+ } catch (err) {
327
+ console.error(`${RED}✗ Git commit failed: ${err.message}${RESET}`);
328
+ }
329
+ }
330
+
331
+ console.log();
332
+ process.exit(results.failed.length > 0 ? 1 : 0);
333
+ }