qodfy 0.2.9 → 0.3.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 +24 -3
- package/dist/index.js +1086 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,9 +18,27 @@ Scan a specific folder:
|
|
|
18
18
|
npx qodfy scan --path apps/web
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
+
Print machine-readable JSON:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx qodfy scan --json
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Write JSON, Markdown, or HTML reports:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npx qodfy scan --json --output qodfy-report.json
|
|
31
|
+
npx qodfy scan --report qodfy-report.md
|
|
32
|
+
npx qodfy scan --html qodfy-report.html
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The Markdown report is the **Qodfy Launch Report**: a senior-engineer-style review with a launch status, executive summary, top priorities, what looks good, and per-issue context (what Qodfy found, why it matters, evidence, suggested fix, and an AI fix prompt).
|
|
36
|
+
|
|
37
|
+
The HTML report is a standalone, self-contained file (inline CSS, no external CDN, no JavaScript) that you can open directly in a browser to share with teammates or clients.
|
|
38
|
+
|
|
21
39
|
## What Qodfy Checks Today
|
|
22
40
|
|
|
23
|
-
Qodfy
|
|
41
|
+
Qodfy scans locally and looks for common launch-readiness risks:
|
|
24
42
|
|
|
25
43
|
- Next.js project detection
|
|
26
44
|
- missing `.env.example`
|
|
@@ -67,6 +85,10 @@ Fix critical issues first, then warnings, then cleanup items.
|
|
|
67
85
|
```bash
|
|
68
86
|
qodfy scan
|
|
69
87
|
qodfy scan --path <project-path>
|
|
88
|
+
qodfy scan --json
|
|
89
|
+
qodfy scan --json --output qodfy-report.json
|
|
90
|
+
qodfy scan --report qodfy-report.md
|
|
91
|
+
qodfy scan --html qodfy-report.html
|
|
70
92
|
qodfy --help
|
|
71
93
|
qodfy --version
|
|
72
94
|
```
|
|
@@ -79,13 +101,12 @@ Qodfy starts at `100`.
|
|
|
79
101
|
- Warning: `-8`
|
|
80
102
|
- Info: no major score penalty
|
|
81
103
|
|
|
82
|
-
The score is intentionally simple
|
|
104
|
+
The score is intentionally simple and will become more precise as the rule set improves.
|
|
83
105
|
|
|
84
106
|
## Roadmap
|
|
85
107
|
|
|
86
108
|
Near-term priorities:
|
|
87
109
|
|
|
88
|
-
- JSON and Markdown output
|
|
89
110
|
- `.env.example` coverage for `process.env.*`
|
|
90
111
|
- exposed secret detection
|
|
91
112
|
- Stripe webhook signature checks
|
package/dist/index.js
CHANGED
|
@@ -11,34 +11,64 @@ import {
|
|
|
11
11
|
scanProject,
|
|
12
12
|
validScanChecks
|
|
13
13
|
} from "@qodfy/core";
|
|
14
|
-
var CLI_VERSION = "0.
|
|
14
|
+
var CLI_VERSION = "0.3.0";
|
|
15
15
|
var DEFAULT_MAX_ISSUES = 5;
|
|
16
16
|
var program = new Command();
|
|
17
17
|
program.name("qodfy").description("Launch readiness scanner for AI-built apps.").version(CLI_VERSION);
|
|
18
|
-
program.command("scan").description("Scan a project for launch readiness issues.").option("-p, --path <path>", "Project path to scan", process.cwd()).option("--max-issues <number>", "Maximum number of issues to display", String(DEFAULT_MAX_ISSUES)).option("--prompts", "Show safe copy-paste fix prompts for displayed issues").option("--prompt <issue-id>", "Show the safe AI fix prompt for one issue").option("--checks <checks>", "Comma-separated checks to run").option("--all", "Run all checks without prompting").option("--no-interactive", "Skip interactive prompts and run the recommended scan").action(async (options) => {
|
|
18
|
+
program.command("scan").description("Scan a project for launch readiness issues.").option("-p, --path <path>", "Project path to scan", process.cwd()).option("--max-issues <number>", "Maximum number of issues to display", String(DEFAULT_MAX_ISSUES)).option("--prompts", "Show safe copy-paste fix prompts for displayed issues").option("--prompt <issue-id>", "Show the safe AI fix prompt for one issue").option("--checks <checks>", "Comma-separated checks to run").option("--all", "Run all checks without prompting").option("--no-interactive", "Skip interactive prompts and run the recommended scan").option("--json", "Print a machine-readable JSON report").option("--output <file>", "Write the JSON report to a file").option("--report <file>", "Write a human-readable Markdown report to a file").option("--html <file>", "Write a standalone HTML report to a file").action(async (options) => {
|
|
19
|
+
const outputOptionsResult = validateScanOutputOptions(options);
|
|
20
|
+
if (!outputOptionsResult.ok) {
|
|
21
|
+
printScanError(outputOptionsResult.reason, !isOutputMode(options));
|
|
22
|
+
process.exitCode = 1;
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
19
25
|
const pathResult = await resolveProjectPath(options.path);
|
|
20
26
|
if (!pathResult.ok) {
|
|
21
|
-
printScanError(pathResult.reason);
|
|
27
|
+
printScanError(pathResult.reason, !isOutputMode(options));
|
|
22
28
|
process.exitCode = 1;
|
|
23
29
|
return;
|
|
24
30
|
}
|
|
25
31
|
try {
|
|
26
32
|
const scanModeResult = await resolveScanMode(options);
|
|
27
33
|
if (!scanModeResult.ok) {
|
|
28
|
-
printScanError(scanModeResult.reason);
|
|
34
|
+
printScanError(scanModeResult.reason, !isOutputMode(options));
|
|
29
35
|
process.exitCode = 1;
|
|
30
36
|
return;
|
|
31
37
|
}
|
|
32
|
-
if (scanModeResult.notice) {
|
|
38
|
+
if (scanModeResult.notice && !isOutputMode(options)) {
|
|
33
39
|
console.log(pc.dim(scanModeResult.notice));
|
|
34
40
|
console.log("");
|
|
35
41
|
}
|
|
36
|
-
|
|
42
|
+
if (!isOutputMode(options)) {
|
|
43
|
+
console.log(pc.cyan("Qodfy is scanning your project...\n"));
|
|
44
|
+
}
|
|
37
45
|
const report = await scanProject({
|
|
38
46
|
projectPath: pathResult.projectPath,
|
|
39
47
|
checks: scanModeResult.checks,
|
|
40
48
|
includeLowConfidence: Boolean(scanModeResult.includeLowConfidence)
|
|
41
49
|
});
|
|
50
|
+
const outputReport = createOutputReport(report, scanModeResult);
|
|
51
|
+
if (options.json) {
|
|
52
|
+
const jsonReport = `${JSON.stringify(outputReport, null, 2)}
|
|
53
|
+
`;
|
|
54
|
+
if (options.output) {
|
|
55
|
+
await writeReportFile(options.output, jsonReport);
|
|
56
|
+
console.log(`Qodfy JSON report saved to ${options.output}`);
|
|
57
|
+
} else {
|
|
58
|
+
process.stdout.write(jsonReport);
|
|
59
|
+
}
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (options.report) {
|
|
63
|
+
await writeReportFile(options.report, renderMarkdownReport(outputReport));
|
|
64
|
+
console.log(`Qodfy report saved to ${options.report}`);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (options.html) {
|
|
68
|
+
await writeReportFile(options.html, renderHtmlReport(outputReport));
|
|
69
|
+
console.log(`Qodfy HTML report saved to ${options.html}`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
42
72
|
if (options.prompt) {
|
|
43
73
|
printPromptFromReport(report, options.prompt);
|
|
44
74
|
return;
|
|
@@ -55,7 +85,7 @@ program.command("scan").description("Scan a project for launch readiness issues.
|
|
|
55
85
|
if (isPromptCancelError(error)) {
|
|
56
86
|
console.log("Scan cancelled.");
|
|
57
87
|
} else {
|
|
58
|
-
printScanError(getErrorMessage(error));
|
|
88
|
+
printScanError(getErrorMessage(error), !isOutputMode(options));
|
|
59
89
|
}
|
|
60
90
|
process.exitCode = 1;
|
|
61
91
|
}
|
|
@@ -113,6 +143,48 @@ var categoryLabels = {
|
|
|
113
143
|
project: "Project"
|
|
114
144
|
};
|
|
115
145
|
await program.parseAsync();
|
|
146
|
+
function validateScanOutputOptions(options) {
|
|
147
|
+
if (options.output && !options.json) {
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
reason: "Use --output together with --json, for example: qodfy scan --json --output qodfy-report.json."
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
if (options.json && options.report) {
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
reason: "Use either --json or --report for one scan command, not both."
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
if (options.json && options.html) {
|
|
160
|
+
return {
|
|
161
|
+
ok: false,
|
|
162
|
+
reason: "Use either --json or --html for one scan command, not both."
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
if (options.report && options.html) {
|
|
166
|
+
return {
|
|
167
|
+
ok: false,
|
|
168
|
+
reason: "Use either --report or --html for one scan command, not both."
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
if (options.html && options.output) {
|
|
172
|
+
return {
|
|
173
|
+
ok: false,
|
|
174
|
+
reason: "--output is only used with --json. For HTML, just pass --html <file>."
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
if ((options.json || options.report || options.html) && options.prompt) {
|
|
178
|
+
return {
|
|
179
|
+
ok: false,
|
|
180
|
+
reason: "Use qodfy prompt <issue-id> for fix prompts, or run qodfy scan --json/--report/--html for reports."
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
return { ok: true };
|
|
184
|
+
}
|
|
185
|
+
function isOutputMode(options) {
|
|
186
|
+
return Boolean(options.json || options.output || options.report || options.html);
|
|
187
|
+
}
|
|
116
188
|
async function resolveScanMode(options) {
|
|
117
189
|
if (options.checks) {
|
|
118
190
|
const parsedChecks = parseChecks(options.checks);
|
|
@@ -141,6 +213,13 @@ async function resolveScanMode(options) {
|
|
|
141
213
|
includeLowConfidence: true
|
|
142
214
|
};
|
|
143
215
|
}
|
|
216
|
+
if (isOutputMode(options)) {
|
|
217
|
+
return {
|
|
218
|
+
ok: true,
|
|
219
|
+
checks: [...recommendedScanChecks],
|
|
220
|
+
label: "Recommended launch scan"
|
|
221
|
+
};
|
|
222
|
+
}
|
|
144
223
|
if (options.interactive === false) {
|
|
145
224
|
return {
|
|
146
225
|
ok: true,
|
|
@@ -380,6 +459,990 @@ async function resolveProjectPath(projectPath) {
|
|
|
380
459
|
};
|
|
381
460
|
}
|
|
382
461
|
}
|
|
462
|
+
function createOutputReport(report, scanMode) {
|
|
463
|
+
return {
|
|
464
|
+
qodfyVersion: CLI_VERSION,
|
|
465
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
466
|
+
projectPath: report.projectPath,
|
|
467
|
+
scanMode: scanMode.label,
|
|
468
|
+
checks: [...scanMode.checks],
|
|
469
|
+
score: report.score,
|
|
470
|
+
stats: { ...report.stats },
|
|
471
|
+
issues: report.issues
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
async function writeReportFile(outputPath, content) {
|
|
475
|
+
const resolvedOutputPath = path.resolve(outputPath);
|
|
476
|
+
await fs.mkdir(path.dirname(resolvedOutputPath), { recursive: true });
|
|
477
|
+
await fs.writeFile(resolvedOutputPath, content, "utf8");
|
|
478
|
+
}
|
|
479
|
+
function renderMarkdownReport(report) {
|
|
480
|
+
const projectName = path.basename(report.projectPath) || report.projectPath;
|
|
481
|
+
const statusLabel = getStatusLabel(report.score);
|
|
482
|
+
const criticalCount = countIssuesBySeverity(report.issues, "critical");
|
|
483
|
+
const warningCount = countIssuesBySeverity(report.issues, "warning");
|
|
484
|
+
const infoCount = countIssuesBySeverity(report.issues, "info");
|
|
485
|
+
const lines = [
|
|
486
|
+
"# Qodfy Launch Readiness Report",
|
|
487
|
+
"",
|
|
488
|
+
`Generated: ${report.generatedAt}`,
|
|
489
|
+
`Project: ${projectName}`,
|
|
490
|
+
`Scan mode: ${report.scanMode}`,
|
|
491
|
+
`Score: ${report.score}/100`,
|
|
492
|
+
`Launch status: ${statusLabel}`,
|
|
493
|
+
"",
|
|
494
|
+
"## Executive Summary",
|
|
495
|
+
"",
|
|
496
|
+
getExecutiveSummary(report),
|
|
497
|
+
"",
|
|
498
|
+
"## Score Breakdown",
|
|
499
|
+
"",
|
|
500
|
+
`- Critical issues: ${criticalCount}`,
|
|
501
|
+
`- Warnings: ${warningCount}`,
|
|
502
|
+
`- Info: ${infoCount}`,
|
|
503
|
+
`- Files scanned: ${report.stats.totalFiles}`,
|
|
504
|
+
`- API routes scanned: ${report.stats.apiRoutes}`,
|
|
505
|
+
`- Scan duration: ${formatDuration(report.stats.durationMs)}`,
|
|
506
|
+
"",
|
|
507
|
+
"## Top Priorities",
|
|
508
|
+
""
|
|
509
|
+
];
|
|
510
|
+
const priorities = getTopPriorities(report.issues);
|
|
511
|
+
if (priorities.length === 0) {
|
|
512
|
+
lines.push("No urgent priorities found. Review warnings below before launch.");
|
|
513
|
+
} else {
|
|
514
|
+
for (const [index, priority] of priorities.entries()) {
|
|
515
|
+
lines.push(`${index + 1}. ${priority}`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
lines.push("", "## What Looks Good", "");
|
|
519
|
+
const observations = getWhatLooksGood(report);
|
|
520
|
+
if (observations.length === 0) {
|
|
521
|
+
lines.push("No positive observations to highlight from this scan.");
|
|
522
|
+
} else {
|
|
523
|
+
for (const observation of observations) {
|
|
524
|
+
lines.push(`- ${observation}`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
lines.push("", "## Issues by Priority", "");
|
|
528
|
+
if (report.issues.length === 0) {
|
|
529
|
+
lines.push("No issues found.");
|
|
530
|
+
appendMarkdownFooter(lines);
|
|
531
|
+
return `${lines.join("\n").trimEnd()}
|
|
532
|
+
`;
|
|
533
|
+
}
|
|
534
|
+
const sortedIssues = getSortedDisplayIssues(report.issues);
|
|
535
|
+
const severityOrder = ["critical", "warning", "info"];
|
|
536
|
+
for (const severity of severityOrder) {
|
|
537
|
+
const severityIssues = sortedIssues.filter((issue) => issue.severity === severity);
|
|
538
|
+
if (severityIssues.length === 0) {
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
lines.push(`**${getSeverityHeading(severity, severityIssues.length)}**`, "");
|
|
542
|
+
for (const category of categoryOrder) {
|
|
543
|
+
const categoryIssues = severityIssues.filter((issue) => issue.category === category);
|
|
544
|
+
if (categoryIssues.length === 0) {
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
lines.push(`_${categoryLabels[category]}_`, "");
|
|
548
|
+
for (const issue of categoryIssues) {
|
|
549
|
+
appendMarkdownIssue(lines, issue);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
appendMarkdownFooter(lines);
|
|
554
|
+
return `${lines.join("\n").trimEnd()}
|
|
555
|
+
`;
|
|
556
|
+
}
|
|
557
|
+
function appendMarkdownFooter(lines) {
|
|
558
|
+
lines.push("", "## Recommended Next Steps", "");
|
|
559
|
+
lines.push("- Fix critical issues first.");
|
|
560
|
+
lines.push("- Review warnings before launch.");
|
|
561
|
+
lines.push("- Re-run Qodfy after changes.");
|
|
562
|
+
lines.push("- Use `qodfy prompt <issue-id>` for focused AI repair prompts.");
|
|
563
|
+
lines.push("", "## Generated by Qodfy", "");
|
|
564
|
+
lines.push("Qodfy scans locally and does not print secret values in reports.");
|
|
565
|
+
}
|
|
566
|
+
function escapeHtml(value) {
|
|
567
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
568
|
+
}
|
|
569
|
+
function renderHtmlReport(report) {
|
|
570
|
+
const projectName = path.basename(report.projectPath) || report.projectPath;
|
|
571
|
+
const statusLabel = getStatusLabel(report.score);
|
|
572
|
+
const statusTone = getStatusTone(report.score);
|
|
573
|
+
const criticalCount = countIssuesBySeverity(report.issues, "critical");
|
|
574
|
+
const warningCount = countIssuesBySeverity(report.issues, "warning");
|
|
575
|
+
const infoCount = countIssuesBySeverity(report.issues, "info");
|
|
576
|
+
const executiveSummary = getExecutiveSummary(report);
|
|
577
|
+
const priorities = getTopPriorities(report.issues);
|
|
578
|
+
const observations = getWhatLooksGood(report);
|
|
579
|
+
const sortedIssues = getSortedDisplayIssues(report.issues);
|
|
580
|
+
const severityOrder = ["critical", "warning", "info"];
|
|
581
|
+
const summaryCards = [
|
|
582
|
+
{ label: "Critical issues", value: criticalCount, tone: criticalCount > 0 ? "critical" : "neutral" },
|
|
583
|
+
{ label: "Warnings", value: warningCount, tone: warningCount > 0 ? "warning" : "neutral" },
|
|
584
|
+
{ label: "Info", value: infoCount, tone: infoCount > 0 ? "info" : "neutral" },
|
|
585
|
+
{ label: "Files scanned", value: report.stats.totalFiles, tone: "neutral" },
|
|
586
|
+
{ label: "API routes", value: report.stats.apiRoutes, tone: "neutral" },
|
|
587
|
+
{ label: "Scan duration", value: formatDuration(report.stats.durationMs), tone: "neutral" }
|
|
588
|
+
];
|
|
589
|
+
const summaryCardsHtml = summaryCards.map(
|
|
590
|
+
(card) => ` <div class="stat-card stat-${card.tone}">
|
|
591
|
+
<div class="stat-label">${escapeHtml(card.label)}</div>
|
|
592
|
+
<div class="stat-value">${escapeHtml(String(card.value))}</div>
|
|
593
|
+
</div>`
|
|
594
|
+
).join("\n");
|
|
595
|
+
const prioritiesHtml = priorities.length === 0 ? `<p class="muted">No urgent priorities found. Review warnings below before launch.</p>` : `<ol class="priority-list">
|
|
596
|
+
${priorities.map((priority) => ` <li>${escapeHtml(priority)}</li>`).join("\n")}
|
|
597
|
+
</ol>`;
|
|
598
|
+
const observationsHtml = observations.length === 0 ? `<p class="muted">No positive observations to highlight from this scan.</p>` : `<ul class="observation-list">
|
|
599
|
+
${observations.map((observation) => ` <li>${escapeHtml(observation)}</li>`).join("\n")}
|
|
600
|
+
</ul>`;
|
|
601
|
+
const issueSectionsHtml = report.issues.length === 0 ? `<p class="muted">No issues found.</p>` : severityOrder.map((severity) => renderHtmlSeveritySection(severity, sortedIssues.filter((issue) => issue.severity === severity))).filter(Boolean).join("\n");
|
|
602
|
+
return `<!doctype html>
|
|
603
|
+
<html lang="en">
|
|
604
|
+
<head>
|
|
605
|
+
<meta charset="utf-8" />
|
|
606
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
607
|
+
<meta name="generator" content="Qodfy" />
|
|
608
|
+
<title>Qodfy Launch Readiness Report</title>
|
|
609
|
+
<style>${getHtmlReportStyles()}</style>
|
|
610
|
+
</head>
|
|
611
|
+
<body>
|
|
612
|
+
<main class="page">
|
|
613
|
+
<header class="hero">
|
|
614
|
+
<p class="eyebrow">Qodfy</p>
|
|
615
|
+
<h1>Launch Readiness Report</h1>
|
|
616
|
+
<dl class="hero-meta">
|
|
617
|
+
<div><dt>Project</dt><dd>${escapeHtml(projectName)}</dd></div>
|
|
618
|
+
<div><dt>Path</dt><dd><code>${escapeHtml(report.projectPath)}</code></dd></div>
|
|
619
|
+
<div><dt>Scan mode</dt><dd>${escapeHtml(report.scanMode)}</dd></div>
|
|
620
|
+
<div><dt>Generated</dt><dd>${escapeHtml(report.generatedAt)}</dd></div>
|
|
621
|
+
</dl>
|
|
622
|
+
<div class="score-card score-${statusTone}">
|
|
623
|
+
<div class="score-value">${escapeHtml(String(report.score))}<span class="score-max">/100</span></div>
|
|
624
|
+
<div class="score-status">${escapeHtml(statusLabel)}</div>
|
|
625
|
+
</div>
|
|
626
|
+
</header>
|
|
627
|
+
|
|
628
|
+
<section class="stats" aria-label="Scan summary">
|
|
629
|
+
${summaryCardsHtml}
|
|
630
|
+
</section>
|
|
631
|
+
|
|
632
|
+
<section class="card" aria-labelledby="executive-summary">
|
|
633
|
+
<h2 id="executive-summary">Executive Summary</h2>
|
|
634
|
+
<p>${escapeHtml(executiveSummary)}</p>
|
|
635
|
+
</section>
|
|
636
|
+
|
|
637
|
+
<section class="card" aria-labelledby="top-priorities">
|
|
638
|
+
<h2 id="top-priorities">Top Priorities</h2>
|
|
639
|
+
${prioritiesHtml}
|
|
640
|
+
</section>
|
|
641
|
+
|
|
642
|
+
<section class="card" aria-labelledby="what-looks-good">
|
|
643
|
+
<h2 id="what-looks-good">What Looks Good</h2>
|
|
644
|
+
${observationsHtml}
|
|
645
|
+
</section>
|
|
646
|
+
|
|
647
|
+
<section aria-labelledby="issues-by-priority">
|
|
648
|
+
<h2 id="issues-by-priority">Issues by Priority</h2>
|
|
649
|
+
${issueSectionsHtml}
|
|
650
|
+
</section>
|
|
651
|
+
|
|
652
|
+
<section class="card" aria-labelledby="next-steps">
|
|
653
|
+
<h2 id="next-steps">Recommended Next Steps</h2>
|
|
654
|
+
<ul>
|
|
655
|
+
<li>Fix critical issues first.</li>
|
|
656
|
+
<li>Review warnings before launch.</li>
|
|
657
|
+
<li>Re-run Qodfy after changes.</li>
|
|
658
|
+
<li>Use <code>qodfy prompt <issue-id></code> for focused AI repair prompts.</li>
|
|
659
|
+
</ul>
|
|
660
|
+
</section>
|
|
661
|
+
|
|
662
|
+
<footer class="footer">
|
|
663
|
+
<p><strong>Generated by Qodfy.</strong></p>
|
|
664
|
+
<p class="muted">Qodfy scans locally and does not print secret values in reports.</p>
|
|
665
|
+
</footer>
|
|
666
|
+
</main>
|
|
667
|
+
</body>
|
|
668
|
+
</html>
|
|
669
|
+
`;
|
|
670
|
+
}
|
|
671
|
+
function renderHtmlSeveritySection(severity, issues) {
|
|
672
|
+
if (issues.length === 0) {
|
|
673
|
+
return "";
|
|
674
|
+
}
|
|
675
|
+
const heading = severity === "critical" ? "Critical" : severity === "warning" ? "Warnings" : "Info";
|
|
676
|
+
const groupsHtml = categoryOrder.map((category) => {
|
|
677
|
+
const categoryIssues = issues.filter((issue) => issue.category === category);
|
|
678
|
+
if (categoryIssues.length === 0) {
|
|
679
|
+
return "";
|
|
680
|
+
}
|
|
681
|
+
const cardsHtml = categoryIssues.map(renderHtmlIssueCard).join("\n");
|
|
682
|
+
return ` <div class="category-group">
|
|
683
|
+
<h4 class="category-heading">${escapeHtml(categoryLabels[category])}</h4>
|
|
684
|
+
${cardsHtml}
|
|
685
|
+
</div>`;
|
|
686
|
+
}).filter(Boolean).join("\n");
|
|
687
|
+
return ` <section class="severity-section severity-${severity}" aria-label="${escapeHtml(heading)} issues">
|
|
688
|
+
<h3 class="severity-heading"><span class="severity-dot"></span>${escapeHtml(heading)} <span class="severity-count">(${issues.length})</span></h3>
|
|
689
|
+
${groupsHtml}
|
|
690
|
+
</section>`;
|
|
691
|
+
}
|
|
692
|
+
function renderHtmlIssueCard(issue) {
|
|
693
|
+
const evidenceHtml = renderHtmlEvidenceList(issue.evidence);
|
|
694
|
+
const contextHtml = renderHtmlEvidenceList(issue.context);
|
|
695
|
+
const tests = getAfterFixTests(issue);
|
|
696
|
+
const testsHtml = tests.length === 0 ? `<p class="muted">No specific tests suggested.</p>` : `<ul>
|
|
697
|
+
${tests.map((test) => ` <li>${escapeHtml(test)}</li>`).join("\n")}
|
|
698
|
+
</ul>`;
|
|
699
|
+
const fileLine = issue.file ? `<div class="meta-row"><span class="meta-label">File</span><code class="meta-value">${escapeHtml(issue.file)}</code></div>` : "";
|
|
700
|
+
const suggestion = issue.suggestion ?? "No specific suggestion provided for this rule yet.";
|
|
701
|
+
const fixPrompt = issue.fixPrompt ?? "No AI fix prompt available for this rule yet.";
|
|
702
|
+
return ` <article class="issue-card issue-${issue.severity}" aria-labelledby="issue-${escapeHtml(issue.id)}-title">
|
|
703
|
+
<header class="issue-header">
|
|
704
|
+
<div class="issue-badges">
|
|
705
|
+
<span class="badge badge-${issue.severity}">${escapeHtml(issue.severity.toUpperCase())}</span>
|
|
706
|
+
<span class="badge badge-confidence badge-confidence-${issue.confidence}">Confidence: ${escapeHtml(issue.confidence)}</span>
|
|
707
|
+
<span class="badge badge-category">${escapeHtml(categoryLabels[issue.category])}</span>
|
|
708
|
+
</div>
|
|
709
|
+
<h4 id="issue-${escapeHtml(issue.id)}-title" class="issue-title">${escapeHtml(issue.title)}</h4>
|
|
710
|
+
<div class="issue-meta">
|
|
711
|
+
<div class="meta-row"><span class="meta-label">ID</span><code class="meta-value">${escapeHtml(issue.id)}</code></div>
|
|
712
|
+
${fileLine}
|
|
713
|
+
</div>
|
|
714
|
+
</header>
|
|
715
|
+
|
|
716
|
+
<section class="issue-section">
|
|
717
|
+
<h5>What Qodfy found</h5>
|
|
718
|
+
<p>${escapeHtml(issue.message)}</p>
|
|
719
|
+
</section>
|
|
720
|
+
|
|
721
|
+
<section class="issue-section">
|
|
722
|
+
<h5>Why it matters</h5>
|
|
723
|
+
<p>${escapeHtml(getWhyItMatters(issue))}</p>
|
|
724
|
+
</section>
|
|
725
|
+
|
|
726
|
+
<section class="issue-section">
|
|
727
|
+
<h5>Evidence</h5>
|
|
728
|
+
${evidenceHtml}
|
|
729
|
+
</section>
|
|
730
|
+
|
|
731
|
+
<section class="issue-section">
|
|
732
|
+
<h5>Context</h5>
|
|
733
|
+
${contextHtml}
|
|
734
|
+
</section>
|
|
735
|
+
|
|
736
|
+
<section class="issue-section">
|
|
737
|
+
<h5>Suggested fix</h5>
|
|
738
|
+
<p>${escapeHtml(suggestion)}</p>
|
|
739
|
+
</section>
|
|
740
|
+
|
|
741
|
+
<section class="issue-section">
|
|
742
|
+
<h5>AI Fix Prompt</h5>
|
|
743
|
+
<pre class="code-block"><code>${escapeHtml(fixPrompt)}</code></pre>
|
|
744
|
+
</section>
|
|
745
|
+
|
|
746
|
+
<section class="issue-section">
|
|
747
|
+
<h5>After fixing, test this</h5>
|
|
748
|
+
${testsHtml}
|
|
749
|
+
</section>
|
|
750
|
+
</article>`;
|
|
751
|
+
}
|
|
752
|
+
function renderHtmlEvidenceList(items) {
|
|
753
|
+
if (!items || items.length === 0) {
|
|
754
|
+
return `<p class="muted">None.</p>`;
|
|
755
|
+
}
|
|
756
|
+
const listItems = items.map((item) => {
|
|
757
|
+
const detail = item.detail ? `: <code>${escapeHtml(item.detail)}</code>` : "";
|
|
758
|
+
return ` <li><strong>${escapeHtml(item.label)}</strong>${detail}</li>`;
|
|
759
|
+
}).join("\n");
|
|
760
|
+
return `<ul class="evidence-list">
|
|
761
|
+
${listItems}
|
|
762
|
+
</ul>`;
|
|
763
|
+
}
|
|
764
|
+
function getStatusTone(score) {
|
|
765
|
+
if (score >= 90) {
|
|
766
|
+
return "ready";
|
|
767
|
+
}
|
|
768
|
+
if (score >= 75) {
|
|
769
|
+
return "almost";
|
|
770
|
+
}
|
|
771
|
+
if (score >= 50) {
|
|
772
|
+
return "needs-fixes";
|
|
773
|
+
}
|
|
774
|
+
return "not-ready";
|
|
775
|
+
}
|
|
776
|
+
function getHtmlReportStyles() {
|
|
777
|
+
return `
|
|
778
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
779
|
+
html { -webkit-text-size-adjust: 100%; }
|
|
780
|
+
body {
|
|
781
|
+
margin: 0;
|
|
782
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
783
|
+
background: #f6f7fb;
|
|
784
|
+
color: #1f2330;
|
|
785
|
+
line-height: 1.55;
|
|
786
|
+
}
|
|
787
|
+
code, pre {
|
|
788
|
+
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
|
789
|
+
}
|
|
790
|
+
.page {
|
|
791
|
+
max-width: 960px;
|
|
792
|
+
margin: 0 auto;
|
|
793
|
+
padding: 32px 20px 64px;
|
|
794
|
+
}
|
|
795
|
+
.eyebrow {
|
|
796
|
+
margin: 0 0 4px;
|
|
797
|
+
font-size: 12px;
|
|
798
|
+
font-weight: 600;
|
|
799
|
+
letter-spacing: 0.12em;
|
|
800
|
+
text-transform: uppercase;
|
|
801
|
+
color: #5b6478;
|
|
802
|
+
}
|
|
803
|
+
h1 {
|
|
804
|
+
margin: 0 0 16px;
|
|
805
|
+
font-size: 28px;
|
|
806
|
+
font-weight: 700;
|
|
807
|
+
letter-spacing: -0.01em;
|
|
808
|
+
}
|
|
809
|
+
h2 {
|
|
810
|
+
margin: 0 0 12px;
|
|
811
|
+
font-size: 20px;
|
|
812
|
+
font-weight: 700;
|
|
813
|
+
letter-spacing: -0.005em;
|
|
814
|
+
}
|
|
815
|
+
h3 {
|
|
816
|
+
margin: 0 0 12px;
|
|
817
|
+
font-size: 16px;
|
|
818
|
+
font-weight: 700;
|
|
819
|
+
}
|
|
820
|
+
h4 {
|
|
821
|
+
margin: 0 0 8px;
|
|
822
|
+
font-size: 16px;
|
|
823
|
+
font-weight: 600;
|
|
824
|
+
}
|
|
825
|
+
h5 {
|
|
826
|
+
margin: 16px 0 6px;
|
|
827
|
+
font-size: 13px;
|
|
828
|
+
font-weight: 700;
|
|
829
|
+
text-transform: uppercase;
|
|
830
|
+
letter-spacing: 0.06em;
|
|
831
|
+
color: #5b6478;
|
|
832
|
+
}
|
|
833
|
+
p { margin: 0 0 12px; }
|
|
834
|
+
p:last-child { margin-bottom: 0; }
|
|
835
|
+
ul, ol { margin: 0 0 12px; padding-left: 22px; }
|
|
836
|
+
ul li, ol li { margin: 4px 0; }
|
|
837
|
+
.muted { color: #6b7384; }
|
|
838
|
+
.hero {
|
|
839
|
+
background: #ffffff;
|
|
840
|
+
border: 1px solid #e6e8ef;
|
|
841
|
+
border-radius: 16px;
|
|
842
|
+
padding: 24px;
|
|
843
|
+
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
|
844
|
+
margin-bottom: 20px;
|
|
845
|
+
display: grid;
|
|
846
|
+
gap: 16px;
|
|
847
|
+
}
|
|
848
|
+
.hero-meta {
|
|
849
|
+
margin: 0;
|
|
850
|
+
display: grid;
|
|
851
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
852
|
+
gap: 12px 24px;
|
|
853
|
+
}
|
|
854
|
+
.hero-meta div { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
|
855
|
+
.hero-meta dt {
|
|
856
|
+
font-size: 11px;
|
|
857
|
+
font-weight: 600;
|
|
858
|
+
letter-spacing: 0.08em;
|
|
859
|
+
text-transform: uppercase;
|
|
860
|
+
color: #5b6478;
|
|
861
|
+
}
|
|
862
|
+
.hero-meta dd {
|
|
863
|
+
margin: 0;
|
|
864
|
+
font-size: 14px;
|
|
865
|
+
color: #1f2330;
|
|
866
|
+
word-break: break-word;
|
|
867
|
+
}
|
|
868
|
+
.hero-meta code {
|
|
869
|
+
font-size: 12px;
|
|
870
|
+
background: #f1f3f8;
|
|
871
|
+
padding: 2px 6px;
|
|
872
|
+
border-radius: 6px;
|
|
873
|
+
}
|
|
874
|
+
.score-card {
|
|
875
|
+
border-radius: 12px;
|
|
876
|
+
padding: 16px 20px;
|
|
877
|
+
display: flex;
|
|
878
|
+
align-items: center;
|
|
879
|
+
justify-content: space-between;
|
|
880
|
+
gap: 16px;
|
|
881
|
+
border: 1px solid transparent;
|
|
882
|
+
}
|
|
883
|
+
.score-card .score-value {
|
|
884
|
+
font-size: 36px;
|
|
885
|
+
font-weight: 700;
|
|
886
|
+
letter-spacing: -0.02em;
|
|
887
|
+
}
|
|
888
|
+
.score-card .score-max {
|
|
889
|
+
font-size: 18px;
|
|
890
|
+
font-weight: 500;
|
|
891
|
+
color: #5b6478;
|
|
892
|
+
margin-left: 2px;
|
|
893
|
+
}
|
|
894
|
+
.score-card .score-status {
|
|
895
|
+
font-size: 14px;
|
|
896
|
+
font-weight: 600;
|
|
897
|
+
padding: 6px 10px;
|
|
898
|
+
border-radius: 999px;
|
|
899
|
+
background: rgba(255,255,255,0.6);
|
|
900
|
+
}
|
|
901
|
+
.score-ready { background: #ecfdf5; border-color: #a7f3d0; color: #065f46; }
|
|
902
|
+
.score-ready .score-status { background: #d1fae5; color: #065f46; }
|
|
903
|
+
.score-almost { background: #f0f9ff; border-color: #bae6fd; color: #075985; }
|
|
904
|
+
.score-almost .score-status { background: #e0f2fe; color: #075985; }
|
|
905
|
+
.score-needs-fixes { background: #fffbeb; border-color: #fcd34d; color: #92400e; }
|
|
906
|
+
.score-needs-fixes .score-status { background: #fef3c7; color: #92400e; }
|
|
907
|
+
.score-not-ready { background: #fef2f2; border-color: #fecaca; color: #991b1b; }
|
|
908
|
+
.score-not-ready .score-status { background: #fee2e2; color: #991b1b; }
|
|
909
|
+
.stats {
|
|
910
|
+
display: grid;
|
|
911
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
912
|
+
gap: 12px;
|
|
913
|
+
margin-bottom: 20px;
|
|
914
|
+
}
|
|
915
|
+
.stat-card {
|
|
916
|
+
background: #ffffff;
|
|
917
|
+
border: 1px solid #e6e8ef;
|
|
918
|
+
border-radius: 12px;
|
|
919
|
+
padding: 14px 16px;
|
|
920
|
+
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
|
921
|
+
}
|
|
922
|
+
.stat-label {
|
|
923
|
+
font-size: 11px;
|
|
924
|
+
font-weight: 600;
|
|
925
|
+
letter-spacing: 0.08em;
|
|
926
|
+
text-transform: uppercase;
|
|
927
|
+
color: #5b6478;
|
|
928
|
+
}
|
|
929
|
+
.stat-value {
|
|
930
|
+
margin-top: 4px;
|
|
931
|
+
font-size: 22px;
|
|
932
|
+
font-weight: 700;
|
|
933
|
+
letter-spacing: -0.01em;
|
|
934
|
+
}
|
|
935
|
+
.stat-critical .stat-value { color: #b91c1c; }
|
|
936
|
+
.stat-warning .stat-value { color: #b45309; }
|
|
937
|
+
.stat-info .stat-value { color: #1d4ed8; }
|
|
938
|
+
.card {
|
|
939
|
+
background: #ffffff;
|
|
940
|
+
border: 1px solid #e6e8ef;
|
|
941
|
+
border-radius: 12px;
|
|
942
|
+
padding: 20px;
|
|
943
|
+
margin-bottom: 16px;
|
|
944
|
+
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
|
945
|
+
}
|
|
946
|
+
.priority-list, .observation-list { margin-bottom: 0; }
|
|
947
|
+
.severity-section {
|
|
948
|
+
background: #ffffff;
|
|
949
|
+
border: 1px solid #e6e8ef;
|
|
950
|
+
border-radius: 14px;
|
|
951
|
+
padding: 20px;
|
|
952
|
+
margin-bottom: 16px;
|
|
953
|
+
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
|
954
|
+
}
|
|
955
|
+
.severity-heading {
|
|
956
|
+
display: flex;
|
|
957
|
+
align-items: center;
|
|
958
|
+
gap: 10px;
|
|
959
|
+
font-size: 18px;
|
|
960
|
+
margin-bottom: 16px;
|
|
961
|
+
}
|
|
962
|
+
.severity-dot {
|
|
963
|
+
width: 10px;
|
|
964
|
+
height: 10px;
|
|
965
|
+
border-radius: 999px;
|
|
966
|
+
display: inline-block;
|
|
967
|
+
background: #94a3b8;
|
|
968
|
+
}
|
|
969
|
+
.severity-critical .severity-dot { background: #dc2626; }
|
|
970
|
+
.severity-warning .severity-dot { background: #d97706; }
|
|
971
|
+
.severity-info .severity-dot { background: #2563eb; }
|
|
972
|
+
.severity-count { color: #6b7384; font-weight: 500; }
|
|
973
|
+
.category-group { margin-top: 16px; }
|
|
974
|
+
.category-group:first-of-type { margin-top: 0; }
|
|
975
|
+
.category-heading {
|
|
976
|
+
font-size: 12px;
|
|
977
|
+
font-weight: 700;
|
|
978
|
+
letter-spacing: 0.08em;
|
|
979
|
+
text-transform: uppercase;
|
|
980
|
+
color: #5b6478;
|
|
981
|
+
margin-bottom: 10px;
|
|
982
|
+
}
|
|
983
|
+
.issue-card {
|
|
984
|
+
border: 1px solid #e6e8ef;
|
|
985
|
+
border-radius: 12px;
|
|
986
|
+
padding: 18px 20px;
|
|
987
|
+
margin-bottom: 12px;
|
|
988
|
+
background: #fbfbfd;
|
|
989
|
+
border-left-width: 4px;
|
|
990
|
+
}
|
|
991
|
+
.issue-critical { border-left-color: #dc2626; }
|
|
992
|
+
.issue-warning { border-left-color: #d97706; }
|
|
993
|
+
.issue-info { border-left-color: #2563eb; }
|
|
994
|
+
.issue-header { margin-bottom: 8px; }
|
|
995
|
+
.issue-badges {
|
|
996
|
+
display: flex;
|
|
997
|
+
flex-wrap: wrap;
|
|
998
|
+
gap: 6px;
|
|
999
|
+
margin-bottom: 8px;
|
|
1000
|
+
}
|
|
1001
|
+
.badge {
|
|
1002
|
+
display: inline-flex;
|
|
1003
|
+
align-items: center;
|
|
1004
|
+
gap: 6px;
|
|
1005
|
+
font-size: 11px;
|
|
1006
|
+
font-weight: 700;
|
|
1007
|
+
letter-spacing: 0.04em;
|
|
1008
|
+
padding: 3px 8px;
|
|
1009
|
+
border-radius: 999px;
|
|
1010
|
+
background: #eef0f5;
|
|
1011
|
+
color: #1f2330;
|
|
1012
|
+
text-transform: uppercase;
|
|
1013
|
+
}
|
|
1014
|
+
.badge-critical { background: #fee2e2; color: #991b1b; }
|
|
1015
|
+
.badge-warning { background: #fef3c7; color: #92400e; }
|
|
1016
|
+
.badge-info { background: #dbeafe; color: #1e40af; }
|
|
1017
|
+
.badge-confidence {
|
|
1018
|
+
background: #eef0f5;
|
|
1019
|
+
color: #334155;
|
|
1020
|
+
text-transform: none;
|
|
1021
|
+
font-weight: 600;
|
|
1022
|
+
letter-spacing: 0;
|
|
1023
|
+
}
|
|
1024
|
+
.badge-confidence-high { background: #dcfce7; color: #166534; }
|
|
1025
|
+
.badge-confidence-medium { background: #fef3c7; color: #92400e; }
|
|
1026
|
+
.badge-confidence-low { background: #e0e7ff; color: #3730a3; }
|
|
1027
|
+
.badge-category {
|
|
1028
|
+
background: #eef0f5;
|
|
1029
|
+
color: #475569;
|
|
1030
|
+
text-transform: none;
|
|
1031
|
+
font-weight: 600;
|
|
1032
|
+
letter-spacing: 0;
|
|
1033
|
+
}
|
|
1034
|
+
.issue-title {
|
|
1035
|
+
margin: 4px 0 8px;
|
|
1036
|
+
font-size: 16px;
|
|
1037
|
+
font-weight: 700;
|
|
1038
|
+
color: #0f172a;
|
|
1039
|
+
}
|
|
1040
|
+
.issue-meta {
|
|
1041
|
+
display: grid;
|
|
1042
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
1043
|
+
gap: 6px 16px;
|
|
1044
|
+
}
|
|
1045
|
+
.meta-row { display: flex; gap: 6px; align-items: baseline; min-width: 0; }
|
|
1046
|
+
.meta-label {
|
|
1047
|
+
font-size: 11px;
|
|
1048
|
+
font-weight: 700;
|
|
1049
|
+
letter-spacing: 0.06em;
|
|
1050
|
+
text-transform: uppercase;
|
|
1051
|
+
color: #5b6478;
|
|
1052
|
+
}
|
|
1053
|
+
.meta-value {
|
|
1054
|
+
font-size: 12px;
|
|
1055
|
+
background: #f1f3f8;
|
|
1056
|
+
padding: 2px 6px;
|
|
1057
|
+
border-radius: 6px;
|
|
1058
|
+
word-break: break-all;
|
|
1059
|
+
}
|
|
1060
|
+
.issue-section { margin-top: 8px; }
|
|
1061
|
+
.issue-section p { margin: 0; }
|
|
1062
|
+
.evidence-list { margin: 0; }
|
|
1063
|
+
.code-block {
|
|
1064
|
+
margin: 0;
|
|
1065
|
+
padding: 12px 14px;
|
|
1066
|
+
background: #0f172a;
|
|
1067
|
+
color: #e2e8f0;
|
|
1068
|
+
border-radius: 10px;
|
|
1069
|
+
overflow: auto;
|
|
1070
|
+
font-size: 12.5px;
|
|
1071
|
+
line-height: 1.55;
|
|
1072
|
+
white-space: pre-wrap;
|
|
1073
|
+
word-break: break-word;
|
|
1074
|
+
}
|
|
1075
|
+
.footer {
|
|
1076
|
+
margin-top: 24px;
|
|
1077
|
+
padding: 20px;
|
|
1078
|
+
border-radius: 12px;
|
|
1079
|
+
background: #ffffff;
|
|
1080
|
+
border: 1px solid #e6e8ef;
|
|
1081
|
+
text-align: center;
|
|
1082
|
+
}
|
|
1083
|
+
@media (max-width: 720px) {
|
|
1084
|
+
.page { padding: 20px 14px 48px; }
|
|
1085
|
+
h1 { font-size: 24px; }
|
|
1086
|
+
.hero-meta, .stats, .issue-meta { grid-template-columns: 1fr; }
|
|
1087
|
+
.stats { gap: 8px; }
|
|
1088
|
+
.score-card { flex-direction: column; align-items: flex-start; }
|
|
1089
|
+
}
|
|
1090
|
+
`;
|
|
1091
|
+
}
|
|
1092
|
+
function appendMarkdownIssue(lines, issue) {
|
|
1093
|
+
lines.push(`### ${issue.title}`);
|
|
1094
|
+
lines.push("");
|
|
1095
|
+
lines.push(`- ID: \`${issue.id}\``);
|
|
1096
|
+
lines.push(`- Severity: ${issue.severity}`);
|
|
1097
|
+
lines.push(`- Confidence: ${issue.confidence}`);
|
|
1098
|
+
lines.push(`- Category: ${categoryLabels[issue.category]}`);
|
|
1099
|
+
if (issue.file) {
|
|
1100
|
+
lines.push(`- File: \`${issue.file}\``);
|
|
1101
|
+
}
|
|
1102
|
+
lines.push("");
|
|
1103
|
+
lines.push("#### What Qodfy found");
|
|
1104
|
+
lines.push("");
|
|
1105
|
+
lines.push(issue.message);
|
|
1106
|
+
lines.push("");
|
|
1107
|
+
lines.push("#### Why it matters");
|
|
1108
|
+
lines.push("");
|
|
1109
|
+
lines.push(getWhyItMatters(issue));
|
|
1110
|
+
lines.push("");
|
|
1111
|
+
lines.push("#### Evidence");
|
|
1112
|
+
lines.push("");
|
|
1113
|
+
appendMarkdownEvidence(lines, issue.evidence);
|
|
1114
|
+
lines.push("");
|
|
1115
|
+
lines.push("#### Context");
|
|
1116
|
+
lines.push("");
|
|
1117
|
+
appendMarkdownEvidence(lines, issue.context);
|
|
1118
|
+
lines.push("");
|
|
1119
|
+
lines.push("#### Suggested fix");
|
|
1120
|
+
lines.push("");
|
|
1121
|
+
lines.push(issue.suggestion ?? "No specific suggestion provided for this rule yet.");
|
|
1122
|
+
lines.push("");
|
|
1123
|
+
lines.push("#### AI Fix Prompt");
|
|
1124
|
+
lines.push("");
|
|
1125
|
+
lines.push("```txt");
|
|
1126
|
+
lines.push(issue.fixPrompt ?? "No AI fix prompt available for this rule yet.");
|
|
1127
|
+
lines.push("```");
|
|
1128
|
+
lines.push("");
|
|
1129
|
+
lines.push("#### After fixing, test this");
|
|
1130
|
+
lines.push("");
|
|
1131
|
+
for (const test of getAfterFixTests(issue)) {
|
|
1132
|
+
lines.push(`- ${test}`);
|
|
1133
|
+
}
|
|
1134
|
+
lines.push("");
|
|
1135
|
+
}
|
|
1136
|
+
function appendMarkdownEvidence(lines, items) {
|
|
1137
|
+
if (!items || items.length === 0) {
|
|
1138
|
+
lines.push("- None");
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
for (const item of items) {
|
|
1142
|
+
const detail = item.detail ? `: ${item.detail}` : "";
|
|
1143
|
+
lines.push(`- ${item.label}${detail}`);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
function getStatusLabel(score) {
|
|
1147
|
+
if (score >= 90) {
|
|
1148
|
+
return "Ready with minor review";
|
|
1149
|
+
}
|
|
1150
|
+
if (score >= 75) {
|
|
1151
|
+
return "Almost ready";
|
|
1152
|
+
}
|
|
1153
|
+
if (score >= 50) {
|
|
1154
|
+
return "Needs fixes before launch";
|
|
1155
|
+
}
|
|
1156
|
+
return "Not ready to launch";
|
|
1157
|
+
}
|
|
1158
|
+
function getSeverityHeading(severity, count) {
|
|
1159
|
+
const noun = severity === "critical" ? "Critical" : severity === "warning" ? "Warnings" : "Info";
|
|
1160
|
+
return `${noun} (${count})`;
|
|
1161
|
+
}
|
|
1162
|
+
function getExecutiveSummary(report) {
|
|
1163
|
+
const issues = report.issues;
|
|
1164
|
+
const criticalCount = countIssuesBySeverity(issues, "critical");
|
|
1165
|
+
const warningCount = countIssuesBySeverity(issues, "warning");
|
|
1166
|
+
const topRiskCategory = getTopRiskCategory(issues);
|
|
1167
|
+
const sentences = [];
|
|
1168
|
+
if (criticalCount > 0) {
|
|
1169
|
+
const issueWord = criticalCount === 1 ? "critical issue" : "critical issues";
|
|
1170
|
+
sentences.push(
|
|
1171
|
+
`Qodfy found ${criticalCount} ${issueWord} that should be fixed before launch.`
|
|
1172
|
+
);
|
|
1173
|
+
} else if (warningCount > 0) {
|
|
1174
|
+
sentences.push(
|
|
1175
|
+
"Qodfy found no critical blockers. Review the warnings below before launch."
|
|
1176
|
+
);
|
|
1177
|
+
} else if (issues.length > 0) {
|
|
1178
|
+
sentences.push(
|
|
1179
|
+
"Qodfy found no critical or warning blockers. Review the info notes below before launch."
|
|
1180
|
+
);
|
|
1181
|
+
} else {
|
|
1182
|
+
sentences.push(
|
|
1183
|
+
"Qodfy did not flag any issues. Do a final manual review and you should be good to launch."
|
|
1184
|
+
);
|
|
1185
|
+
}
|
|
1186
|
+
if (topRiskCategory) {
|
|
1187
|
+
sentences.push(`The main area to review is ${topRiskCategory}.`);
|
|
1188
|
+
}
|
|
1189
|
+
sentences.push(
|
|
1190
|
+
`Score: ${report.score}/100. Launch status: ${getStatusLabel(report.score)}.`
|
|
1191
|
+
);
|
|
1192
|
+
return sentences.join(" ");
|
|
1193
|
+
}
|
|
1194
|
+
function getTopRiskCategory(issues) {
|
|
1195
|
+
if (issues.length === 0) {
|
|
1196
|
+
return null;
|
|
1197
|
+
}
|
|
1198
|
+
const weights = /* @__PURE__ */ new Map();
|
|
1199
|
+
for (const issue of issues) {
|
|
1200
|
+
const weight = issue.severity === "critical" ? 1e3 : issue.severity === "warning" ? 10 : 1;
|
|
1201
|
+
weights.set(issue.category, (weights.get(issue.category) ?? 0) + weight);
|
|
1202
|
+
}
|
|
1203
|
+
let topCategory = null;
|
|
1204
|
+
let topWeight = 0;
|
|
1205
|
+
let topOrder = Number.MAX_SAFE_INTEGER;
|
|
1206
|
+
for (const [category, weight] of weights) {
|
|
1207
|
+
const order = categoryOrder.indexOf(category);
|
|
1208
|
+
if (weight > topWeight || weight === topWeight && order < topOrder) {
|
|
1209
|
+
topWeight = weight;
|
|
1210
|
+
topCategory = category;
|
|
1211
|
+
topOrder = order;
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
if (!topCategory) {
|
|
1215
|
+
return null;
|
|
1216
|
+
}
|
|
1217
|
+
return getCategoryShortName(topCategory);
|
|
1218
|
+
}
|
|
1219
|
+
function getCategoryShortName(category) {
|
|
1220
|
+
if (category === "security") {
|
|
1221
|
+
return "security";
|
|
1222
|
+
}
|
|
1223
|
+
if (category === "api") {
|
|
1224
|
+
return "API routes";
|
|
1225
|
+
}
|
|
1226
|
+
if (category === "webhook") {
|
|
1227
|
+
return "webhooks";
|
|
1228
|
+
}
|
|
1229
|
+
if (category === "ai") {
|
|
1230
|
+
return "AI routes";
|
|
1231
|
+
}
|
|
1232
|
+
if (category === "environment") {
|
|
1233
|
+
return "environment variables";
|
|
1234
|
+
}
|
|
1235
|
+
if (category === "maintainability") {
|
|
1236
|
+
return "maintainability";
|
|
1237
|
+
}
|
|
1238
|
+
return "project setup";
|
|
1239
|
+
}
|
|
1240
|
+
function getWhatLooksGood(report) {
|
|
1241
|
+
const observations = [];
|
|
1242
|
+
const issues = report.issues;
|
|
1243
|
+
const checks = new Set(report.checks);
|
|
1244
|
+
const criticalCount = countIssuesBySeverity(issues, "critical");
|
|
1245
|
+
if (criticalCount === 0) {
|
|
1246
|
+
observations.push("No critical issues found.");
|
|
1247
|
+
}
|
|
1248
|
+
observations.push("Local source scan completed successfully.");
|
|
1249
|
+
if (checks.has("api") && report.stats.apiRoutes > 0) {
|
|
1250
|
+
observations.push("API routes were analyzed method by method.");
|
|
1251
|
+
}
|
|
1252
|
+
const hasPublicReadRouteNote = issues.some(
|
|
1253
|
+
(issue) => issue.ruleId === "api-public-read-route"
|
|
1254
|
+
);
|
|
1255
|
+
if (hasPublicReadRouteNote) {
|
|
1256
|
+
observations.push("Public read routes were separated from protected routes.");
|
|
1257
|
+
}
|
|
1258
|
+
if (checks.has("environment")) {
|
|
1259
|
+
const hasEnvIssue = issues.some((issue) => issue.category === "environment");
|
|
1260
|
+
if (!hasEnvIssue) {
|
|
1261
|
+
observations.push("Environment variable documentation looks complete.");
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
if (checks.has("webhook")) {
|
|
1265
|
+
const hasWebhookSignatureIssue = issues.some(
|
|
1266
|
+
(issue) => issue.ruleId === "webhook-missing-signature-verification"
|
|
1267
|
+
);
|
|
1268
|
+
if (!hasWebhookSignatureIssue) {
|
|
1269
|
+
observations.push("No webhook routes were flagged for missing signature checks.");
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
if (checks.has("ai")) {
|
|
1273
|
+
const hasAiRateIssue = issues.some(
|
|
1274
|
+
(issue) => issue.ruleId === "ai-route-missing-rate-limit"
|
|
1275
|
+
);
|
|
1276
|
+
if (!hasAiRateIssue && report.stats.aiFiles > 0) {
|
|
1277
|
+
observations.push("AI routes were not flagged as missing rate limiting.");
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
if (checks.has("security")) {
|
|
1281
|
+
const hasSecretIssue = issues.some(
|
|
1282
|
+
(issue) => issue.ruleId === "security-hardcoded-secret" || issue.ruleId === "security-client-side-secret"
|
|
1283
|
+
);
|
|
1284
|
+
if (!hasSecretIssue) {
|
|
1285
|
+
observations.push("No hardcoded or client-side secrets were detected.");
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
observations.push("Qodfy did not print any secret values in this report.");
|
|
1289
|
+
return observations;
|
|
1290
|
+
}
|
|
1291
|
+
function getWhyItMatters(issue) {
|
|
1292
|
+
const ruleExplanation = getWhyItMattersByRuleId(issue.ruleId);
|
|
1293
|
+
if (ruleExplanation) {
|
|
1294
|
+
return ruleExplanation;
|
|
1295
|
+
}
|
|
1296
|
+
return getWhyItMattersByCategory(issue.category);
|
|
1297
|
+
}
|
|
1298
|
+
function getWhyItMattersByRuleId(ruleId) {
|
|
1299
|
+
switch (ruleId) {
|
|
1300
|
+
case "admin-route-missing-authorization":
|
|
1301
|
+
return "This route is authenticated, but admin/debug/private routes often need an extra role or permission check. A normal logged-in user should not be able to access admin-only data or tools.";
|
|
1302
|
+
case "public-form-missing-abuse-protection":
|
|
1303
|
+
return "Public forms can be abused by bots or repeated submissions. Validation helps data quality, but rate limiting or spam protection helps prevent abuse.";
|
|
1304
|
+
case "webhook-missing-signature-verification":
|
|
1305
|
+
return "Webhook routes receive external events. Without signature verification, attackers may be able to fake events and trick your app into running real actions.";
|
|
1306
|
+
case "environment-variable-missing-from-example":
|
|
1307
|
+
return "Missing env documentation makes the project harder to deploy or maintain. Future developers (and future you) will not know which variables are required.";
|
|
1308
|
+
case "environment-missing-env-example":
|
|
1309
|
+
return "Without a .env.example, anyone setting up this project has to guess which environment variables are required. That guesswork leads to broken deploys and runtime errors.";
|
|
1310
|
+
case "ai-route-missing-rate-limit":
|
|
1311
|
+
return "AI routes can create real API costs. Rate limiting helps control abuse and unexpected spend, especially if a route is exposed to anonymous users.";
|
|
1312
|
+
case "security-hardcoded-secret":
|
|
1313
|
+
return "Hardcoded secrets can leak through git history, public deploys, or AI tools that read your code. Rotate any real values and load them from environment variables instead.";
|
|
1314
|
+
case "security-client-side-secret":
|
|
1315
|
+
return "Secrets used in client-side code are visible to anyone who opens the browser devtools. Move sensitive values and logic to server-only code.";
|
|
1316
|
+
case "sensitive-api-route-missing-auth":
|
|
1317
|
+
return "API routes that handle sensitive data should verify the caller is authenticated before reading or writing. Without that check, anonymous users may access protected information.";
|
|
1318
|
+
case "api-mutation-route-review-auth":
|
|
1319
|
+
return "Routes that mutate data without an obvious auth check can let anyone create, update, or delete records. Review the auth path before launch.";
|
|
1320
|
+
case "internal-route-missing-protection":
|
|
1321
|
+
return "Internal or operational routes (debug, admin, jobs) are often forgotten before launch. Without protection they can expose admin-only behavior to the public.";
|
|
1322
|
+
case "api-public-read-route":
|
|
1323
|
+
return "This is a public read route. Confirm the data exposed here is safe to share with anonymous visitors and that no sensitive fields are leaking.";
|
|
1324
|
+
case "maintainability-large-file":
|
|
1325
|
+
return "Large files are harder to review, refactor, and test. Splitting them now reduces future cleanup cost and makes AI tools more reliable on this codebase.";
|
|
1326
|
+
case "maintainability-large-file-skipped":
|
|
1327
|
+
return "Some files were too large for Qodfy to read fully. Review them manually before launch in case they hide other issues.";
|
|
1328
|
+
case "maintainability-file-unreadable":
|
|
1329
|
+
return "Files Qodfy could not read may indicate encoding, permission, or generated-output issues that hide real problems from this scan.";
|
|
1330
|
+
case "project-missing-package-json":
|
|
1331
|
+
return "Without a package.json, Qodfy cannot fully reason about your project. Confirm Qodfy is pointed at the correct project root.";
|
|
1332
|
+
case "project-invalid-package-json":
|
|
1333
|
+
return "An invalid package.json blocks tooling, installs, and deploys. Fix the JSON before running other launch checks.";
|
|
1334
|
+
case "project-next-not-detected":
|
|
1335
|
+
return "Qodfy is currently optimized for Next.js apps. If this is a Next.js project, double-check dependencies and folder structure before launch.";
|
|
1336
|
+
case "project-missing-readme":
|
|
1337
|
+
return "A short README helps anyone (including future you) understand what this project does and how to run it.";
|
|
1338
|
+
case "project-source-files-unreadable":
|
|
1339
|
+
return "Some source files could not be read. Review them manually so the scan does not silently skip risky areas of the code.";
|
|
1340
|
+
default:
|
|
1341
|
+
return null;
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
function getWhyItMattersByCategory(category) {
|
|
1345
|
+
switch (category) {
|
|
1346
|
+
case "security":
|
|
1347
|
+
return "Security risks can leak data, expose secrets, or grant unwanted access to your app. These should be the first issues you review before launch.";
|
|
1348
|
+
case "api":
|
|
1349
|
+
return "API routes shape what your app exposes to the internet. Confirm that authentication, input validation, and abuse protection match what each route does.";
|
|
1350
|
+
case "webhook":
|
|
1351
|
+
return "Webhooks receive events from external systems. Verifying signatures prevents fake or replayed events from being trusted by your app.";
|
|
1352
|
+
case "ai":
|
|
1353
|
+
return "AI routes have unique cost and abuse risks. Rate limits and usage caps protect your spend and keep the service usable for real users.";
|
|
1354
|
+
case "environment":
|
|
1355
|
+
return "Environment variables control how your app connects to external services. Missing or undocumented variables cause deploy failures and runtime errors.";
|
|
1356
|
+
case "maintainability":
|
|
1357
|
+
return "Maintainability signals do not block launch but slow future work. Cleaning them up early reduces friction later.";
|
|
1358
|
+
case "project":
|
|
1359
|
+
return "Project setup signals affect tooling, deploys, and onboarding. Fixing them early prevents surprises later in the launch.";
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
function getAfterFixTests(issue) {
|
|
1363
|
+
const ruleTests = getAfterFixTestsByRuleId(issue.ruleId);
|
|
1364
|
+
if (ruleTests) {
|
|
1365
|
+
return ruleTests;
|
|
1366
|
+
}
|
|
1367
|
+
return getDefaultAfterFixTests();
|
|
1368
|
+
}
|
|
1369
|
+
function getAfterFixTestsByRuleId(ruleId) {
|
|
1370
|
+
switch (ruleId) {
|
|
1371
|
+
case "admin-route-missing-authorization":
|
|
1372
|
+
return [
|
|
1373
|
+
"Test as an unauthenticated user.",
|
|
1374
|
+
"Test as a normal logged-in user.",
|
|
1375
|
+
"Test as an admin/staff user.",
|
|
1376
|
+
"Confirm non-admin users receive 403."
|
|
1377
|
+
];
|
|
1378
|
+
case "public-form-missing-abuse-protection":
|
|
1379
|
+
return [
|
|
1380
|
+
"Submit a valid form and confirm it still works.",
|
|
1381
|
+
"Submit invalid input and confirm validation returns 400.",
|
|
1382
|
+
"Send repeated requests and confirm rate limiting or spam protection behavior.",
|
|
1383
|
+
"Confirm no sensitive provider/API error details are exposed."
|
|
1384
|
+
];
|
|
1385
|
+
case "webhook-missing-signature-verification":
|
|
1386
|
+
return [
|
|
1387
|
+
"Test a valid signed webhook.",
|
|
1388
|
+
"Test an invalid signature.",
|
|
1389
|
+
"Confirm invalid requests are rejected before processing.",
|
|
1390
|
+
"Confirm the event is not processed twice."
|
|
1391
|
+
];
|
|
1392
|
+
case "environment-missing-env-example":
|
|
1393
|
+
case "environment-variable-missing-from-example":
|
|
1394
|
+
return [
|
|
1395
|
+
"Add the variable name to .env.example without a real value.",
|
|
1396
|
+
"Run the app locally with required env values.",
|
|
1397
|
+
"Confirm deployment docs or hosting env vars are updated."
|
|
1398
|
+
];
|
|
1399
|
+
case "ai-route-missing-rate-limit":
|
|
1400
|
+
return [
|
|
1401
|
+
"Send a normal AI request.",
|
|
1402
|
+
"Send repeated requests and confirm rate limiting.",
|
|
1403
|
+
"Confirm rejected requests do not call the AI provider.",
|
|
1404
|
+
"Check logs/cost controls after testing."
|
|
1405
|
+
];
|
|
1406
|
+
case "sensitive-api-route-missing-auth":
|
|
1407
|
+
case "api-mutation-route-review-auth":
|
|
1408
|
+
return [
|
|
1409
|
+
"Test unauthenticated access.",
|
|
1410
|
+
"Test authenticated access.",
|
|
1411
|
+
"Confirm unauthorized users receive 401 or 403.",
|
|
1412
|
+
"Confirm existing response formats still work."
|
|
1413
|
+
];
|
|
1414
|
+
case "internal-route-missing-protection":
|
|
1415
|
+
return [
|
|
1416
|
+
"Test without the secret/auth guard.",
|
|
1417
|
+
"Test with a valid secret/auth guard.",
|
|
1418
|
+
"Confirm invalid requests are rejected before internal work runs."
|
|
1419
|
+
];
|
|
1420
|
+
case "security-hardcoded-secret":
|
|
1421
|
+
case "security-client-side-secret":
|
|
1422
|
+
return [
|
|
1423
|
+
"Rotate any real secret values that were exposed.",
|
|
1424
|
+
"Confirm the secret is loaded from environment variables, not source code.",
|
|
1425
|
+
"Re-run Qodfy and confirm the issue no longer appears.",
|
|
1426
|
+
"Check git history and remove any committed real values if needed."
|
|
1427
|
+
];
|
|
1428
|
+
case "api-public-read-route":
|
|
1429
|
+
return [
|
|
1430
|
+
"Confirm the data exposed here is safe to share with anonymous users.",
|
|
1431
|
+
"Test the route as an anonymous visitor.",
|
|
1432
|
+
"Confirm no sensitive fields leak in the response."
|
|
1433
|
+
];
|
|
1434
|
+
default:
|
|
1435
|
+
return null;
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
function getDefaultAfterFixTests() {
|
|
1439
|
+
return [
|
|
1440
|
+
"Re-run Qodfy after the fix.",
|
|
1441
|
+
"Test the affected route or file manually.",
|
|
1442
|
+
"Confirm existing behavior still works.",
|
|
1443
|
+
"Check logs for unexpected errors."
|
|
1444
|
+
];
|
|
1445
|
+
}
|
|
383
1446
|
function printReport(report, maxIssues, showPrompts, scanModeLabel, showDetails, showAllIssues) {
|
|
384
1447
|
console.log(pc.bold("Qodfy Report"));
|
|
385
1448
|
console.log("");
|
|
@@ -539,14 +1602,23 @@ Run qodfy scan --all to see the current issue IDs.`
|
|
|
539
1602
|
}
|
|
540
1603
|
printFixPrompt(issue);
|
|
541
1604
|
}
|
|
1605
|
+
function getSeverityText(severity) {
|
|
1606
|
+
if (severity === "critical") {
|
|
1607
|
+
return "CRITICAL";
|
|
1608
|
+
}
|
|
1609
|
+
if (severity === "warning") {
|
|
1610
|
+
return "WARNING";
|
|
1611
|
+
}
|
|
1612
|
+
return "INFO";
|
|
1613
|
+
}
|
|
542
1614
|
function getSeverityLabel(severity) {
|
|
543
1615
|
if (severity === "critical") {
|
|
544
|
-
return pc.red(
|
|
1616
|
+
return pc.red(getSeverityText(severity));
|
|
545
1617
|
}
|
|
546
1618
|
if (severity === "warning") {
|
|
547
|
-
return pc.yellow(
|
|
1619
|
+
return pc.yellow(getSeverityText(severity));
|
|
548
1620
|
}
|
|
549
|
-
return pc.blue(
|
|
1621
|
+
return pc.blue(getSeverityText(severity));
|
|
550
1622
|
}
|
|
551
1623
|
function countIssuesBySeverity(issues, severity) {
|
|
552
1624
|
return issues.filter((issue) => issue.severity === severity).length;
|
|
@@ -663,13 +1735,13 @@ function shellQuote(value) {
|
|
|
663
1735
|
}
|
|
664
1736
|
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
665
1737
|
}
|
|
666
|
-
function printScanError(reason) {
|
|
667
|
-
console.error(pc.red("Qodfy could not scan this project."));
|
|
1738
|
+
function printScanError(reason, useColor = true) {
|
|
1739
|
+
console.error(useColor ? pc.red("Qodfy could not scan this project.") : "Qodfy could not scan this project.");
|
|
668
1740
|
console.error("");
|
|
669
|
-
console.error(pc.bold("Reason:"));
|
|
1741
|
+
console.error(useColor ? pc.bold("Reason:") : "Reason:");
|
|
670
1742
|
console.error(reason);
|
|
671
1743
|
console.error("");
|
|
672
|
-
console.error(pc.bold("Try:"));
|
|
1744
|
+
console.error(useColor ? pc.bold("Try:") : "Try:");
|
|
673
1745
|
console.error("qodfy scan --path ./my-next-app");
|
|
674
1746
|
}
|
|
675
1747
|
function printPromptError(reason) {
|