qodfy 0.2.8 → 0.2.10
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 +20 -3
- package/dist/index.js +541 -14
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -18,9 +18,24 @@ 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 or Markdown reports:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npx qodfy scan --json --output qodfy-report.json
|
|
31
|
+
npx qodfy scan --report qodfy-report.md
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
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).
|
|
35
|
+
|
|
21
36
|
## What Qodfy Checks Today
|
|
22
37
|
|
|
23
|
-
Qodfy
|
|
38
|
+
Qodfy scans locally and looks for common launch-readiness risks:
|
|
24
39
|
|
|
25
40
|
- Next.js project detection
|
|
26
41
|
- missing `.env.example`
|
|
@@ -67,6 +82,9 @@ Fix critical issues first, then warnings, then cleanup items.
|
|
|
67
82
|
```bash
|
|
68
83
|
qodfy scan
|
|
69
84
|
qodfy scan --path <project-path>
|
|
85
|
+
qodfy scan --json
|
|
86
|
+
qodfy scan --json --output qodfy-report.json
|
|
87
|
+
qodfy scan --report qodfy-report.md
|
|
70
88
|
qodfy --help
|
|
71
89
|
qodfy --version
|
|
72
90
|
```
|
|
@@ -79,13 +97,12 @@ Qodfy starts at `100`.
|
|
|
79
97
|
- Warning: `-8`
|
|
80
98
|
- Info: no major score penalty
|
|
81
99
|
|
|
82
|
-
The score is intentionally simple
|
|
100
|
+
The score is intentionally simple and will become more precise as the rule set improves.
|
|
83
101
|
|
|
84
102
|
## Roadmap
|
|
85
103
|
|
|
86
104
|
Near-term priorities:
|
|
87
105
|
|
|
88
|
-
- JSON and Markdown output
|
|
89
106
|
- `.env.example` coverage for `process.env.*`
|
|
90
107
|
- exposed secret detection
|
|
91
108
|
- Stripe webhook signature checks
|
package/dist/index.js
CHANGED
|
@@ -11,34 +11,59 @@ import {
|
|
|
11
11
|
scanProject,
|
|
12
12
|
validScanChecks
|
|
13
13
|
} from "@qodfy/core";
|
|
14
|
-
var CLI_VERSION = "0.2.
|
|
14
|
+
var CLI_VERSION = "0.2.10";
|
|
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").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
|
+
}
|
|
42
67
|
if (options.prompt) {
|
|
43
68
|
printPromptFromReport(report, options.prompt);
|
|
44
69
|
return;
|
|
@@ -55,7 +80,7 @@ program.command("scan").description("Scan a project for launch readiness issues.
|
|
|
55
80
|
if (isPromptCancelError(error)) {
|
|
56
81
|
console.log("Scan cancelled.");
|
|
57
82
|
} else {
|
|
58
|
-
printScanError(getErrorMessage(error));
|
|
83
|
+
printScanError(getErrorMessage(error), !isOutputMode(options));
|
|
59
84
|
}
|
|
60
85
|
process.exitCode = 1;
|
|
61
86
|
}
|
|
@@ -113,6 +138,30 @@ var categoryLabels = {
|
|
|
113
138
|
project: "Project"
|
|
114
139
|
};
|
|
115
140
|
await program.parseAsync();
|
|
141
|
+
function validateScanOutputOptions(options) {
|
|
142
|
+
if (options.output && !options.json) {
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
reason: "Use --output together with --json, for example: qodfy scan --json --output qodfy-report.json."
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (options.json && options.report) {
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
reason: "Use either --json or --report for one scan command, not both."
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if ((options.json || options.report) && options.prompt) {
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
reason: "Use qodfy prompt <issue-id> for fix prompts, or run qodfy scan --json/--report for reports."
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
return { ok: true };
|
|
161
|
+
}
|
|
162
|
+
function isOutputMode(options) {
|
|
163
|
+
return Boolean(options.json || options.output || options.report);
|
|
164
|
+
}
|
|
116
165
|
async function resolveScanMode(options) {
|
|
117
166
|
if (options.checks) {
|
|
118
167
|
const parsedChecks = parseChecks(options.checks);
|
|
@@ -141,6 +190,13 @@ async function resolveScanMode(options) {
|
|
|
141
190
|
includeLowConfidence: true
|
|
142
191
|
};
|
|
143
192
|
}
|
|
193
|
+
if (isOutputMode(options)) {
|
|
194
|
+
return {
|
|
195
|
+
ok: true,
|
|
196
|
+
checks: [...recommendedScanChecks],
|
|
197
|
+
label: "Recommended launch scan"
|
|
198
|
+
};
|
|
199
|
+
}
|
|
144
200
|
if (options.interactive === false) {
|
|
145
201
|
return {
|
|
146
202
|
ok: true,
|
|
@@ -380,6 +436,464 @@ async function resolveProjectPath(projectPath) {
|
|
|
380
436
|
};
|
|
381
437
|
}
|
|
382
438
|
}
|
|
439
|
+
function createOutputReport(report, scanMode) {
|
|
440
|
+
return {
|
|
441
|
+
qodfyVersion: CLI_VERSION,
|
|
442
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
443
|
+
projectPath: report.projectPath,
|
|
444
|
+
scanMode: scanMode.label,
|
|
445
|
+
checks: [...scanMode.checks],
|
|
446
|
+
score: report.score,
|
|
447
|
+
stats: { ...report.stats },
|
|
448
|
+
issues: report.issues
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
async function writeReportFile(outputPath, content) {
|
|
452
|
+
const resolvedOutputPath = path.resolve(outputPath);
|
|
453
|
+
await fs.mkdir(path.dirname(resolvedOutputPath), { recursive: true });
|
|
454
|
+
await fs.writeFile(resolvedOutputPath, content, "utf8");
|
|
455
|
+
}
|
|
456
|
+
function renderMarkdownReport(report) {
|
|
457
|
+
const projectName = path.basename(report.projectPath) || report.projectPath;
|
|
458
|
+
const statusLabel = getStatusLabel(report.score);
|
|
459
|
+
const criticalCount = countIssuesBySeverity(report.issues, "critical");
|
|
460
|
+
const warningCount = countIssuesBySeverity(report.issues, "warning");
|
|
461
|
+
const infoCount = countIssuesBySeverity(report.issues, "info");
|
|
462
|
+
const lines = [
|
|
463
|
+
"# Qodfy Launch Readiness Report",
|
|
464
|
+
"",
|
|
465
|
+
`Generated: ${report.generatedAt}`,
|
|
466
|
+
`Project: ${projectName}`,
|
|
467
|
+
`Scan mode: ${report.scanMode}`,
|
|
468
|
+
`Score: ${report.score}/100`,
|
|
469
|
+
`Launch status: ${statusLabel}`,
|
|
470
|
+
"",
|
|
471
|
+
"## Executive Summary",
|
|
472
|
+
"",
|
|
473
|
+
getExecutiveSummary(report),
|
|
474
|
+
"",
|
|
475
|
+
"## Score Breakdown",
|
|
476
|
+
"",
|
|
477
|
+
`- Critical issues: ${criticalCount}`,
|
|
478
|
+
`- Warnings: ${warningCount}`,
|
|
479
|
+
`- Info: ${infoCount}`,
|
|
480
|
+
`- Files scanned: ${report.stats.totalFiles}`,
|
|
481
|
+
`- API routes scanned: ${report.stats.apiRoutes}`,
|
|
482
|
+
`- Scan duration: ${formatDuration(report.stats.durationMs)}`,
|
|
483
|
+
"",
|
|
484
|
+
"## Top Priorities",
|
|
485
|
+
""
|
|
486
|
+
];
|
|
487
|
+
const priorities = getTopPriorities(report.issues);
|
|
488
|
+
if (priorities.length === 0) {
|
|
489
|
+
lines.push("No urgent priorities found. Review warnings below before launch.");
|
|
490
|
+
} else {
|
|
491
|
+
for (const [index, priority] of priorities.entries()) {
|
|
492
|
+
lines.push(`${index + 1}. ${priority}`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
lines.push("", "## What Looks Good", "");
|
|
496
|
+
const observations = getWhatLooksGood(report);
|
|
497
|
+
if (observations.length === 0) {
|
|
498
|
+
lines.push("No positive observations to highlight from this scan.");
|
|
499
|
+
} else {
|
|
500
|
+
for (const observation of observations) {
|
|
501
|
+
lines.push(`- ${observation}`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
lines.push("", "## Issues by Priority", "");
|
|
505
|
+
if (report.issues.length === 0) {
|
|
506
|
+
lines.push("No issues found.");
|
|
507
|
+
appendMarkdownFooter(lines);
|
|
508
|
+
return `${lines.join("\n").trimEnd()}
|
|
509
|
+
`;
|
|
510
|
+
}
|
|
511
|
+
const sortedIssues = getSortedDisplayIssues(report.issues);
|
|
512
|
+
const severityOrder = ["critical", "warning", "info"];
|
|
513
|
+
for (const severity of severityOrder) {
|
|
514
|
+
const severityIssues = sortedIssues.filter((issue) => issue.severity === severity);
|
|
515
|
+
if (severityIssues.length === 0) {
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
lines.push(`**${getSeverityHeading(severity, severityIssues.length)}**`, "");
|
|
519
|
+
for (const category of categoryOrder) {
|
|
520
|
+
const categoryIssues = severityIssues.filter((issue) => issue.category === category);
|
|
521
|
+
if (categoryIssues.length === 0) {
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
lines.push(`_${categoryLabels[category]}_`, "");
|
|
525
|
+
for (const issue of categoryIssues) {
|
|
526
|
+
appendMarkdownIssue(lines, issue);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
appendMarkdownFooter(lines);
|
|
531
|
+
return `${lines.join("\n").trimEnd()}
|
|
532
|
+
`;
|
|
533
|
+
}
|
|
534
|
+
function appendMarkdownFooter(lines) {
|
|
535
|
+
lines.push("", "## Recommended Next Steps", "");
|
|
536
|
+
lines.push("- Fix critical issues first.");
|
|
537
|
+
lines.push("- Review warnings before launch.");
|
|
538
|
+
lines.push("- Re-run Qodfy after changes.");
|
|
539
|
+
lines.push("- Use `qodfy prompt <issue-id>` for focused AI repair prompts.");
|
|
540
|
+
lines.push("", "## Generated by Qodfy", "");
|
|
541
|
+
lines.push("Qodfy scans locally and does not print secret values in reports.");
|
|
542
|
+
}
|
|
543
|
+
function appendMarkdownIssue(lines, issue) {
|
|
544
|
+
lines.push(`### ${issue.title}`);
|
|
545
|
+
lines.push("");
|
|
546
|
+
lines.push(`- ID: \`${issue.id}\``);
|
|
547
|
+
lines.push(`- Severity: ${issue.severity}`);
|
|
548
|
+
lines.push(`- Confidence: ${issue.confidence}`);
|
|
549
|
+
lines.push(`- Category: ${categoryLabels[issue.category]}`);
|
|
550
|
+
if (issue.file) {
|
|
551
|
+
lines.push(`- File: \`${issue.file}\``);
|
|
552
|
+
}
|
|
553
|
+
lines.push("");
|
|
554
|
+
lines.push("#### What Qodfy found");
|
|
555
|
+
lines.push("");
|
|
556
|
+
lines.push(issue.message);
|
|
557
|
+
lines.push("");
|
|
558
|
+
lines.push("#### Why it matters");
|
|
559
|
+
lines.push("");
|
|
560
|
+
lines.push(getWhyItMatters(issue));
|
|
561
|
+
lines.push("");
|
|
562
|
+
lines.push("#### Evidence");
|
|
563
|
+
lines.push("");
|
|
564
|
+
appendMarkdownEvidence(lines, issue.evidence);
|
|
565
|
+
lines.push("");
|
|
566
|
+
lines.push("#### Context");
|
|
567
|
+
lines.push("");
|
|
568
|
+
appendMarkdownEvidence(lines, issue.context);
|
|
569
|
+
lines.push("");
|
|
570
|
+
lines.push("#### Suggested fix");
|
|
571
|
+
lines.push("");
|
|
572
|
+
lines.push(issue.suggestion ?? "No specific suggestion provided for this rule yet.");
|
|
573
|
+
lines.push("");
|
|
574
|
+
lines.push("#### AI Fix Prompt");
|
|
575
|
+
lines.push("");
|
|
576
|
+
lines.push("```txt");
|
|
577
|
+
lines.push(issue.fixPrompt ?? "No AI fix prompt available for this rule yet.");
|
|
578
|
+
lines.push("```");
|
|
579
|
+
lines.push("");
|
|
580
|
+
lines.push("#### After fixing, test this");
|
|
581
|
+
lines.push("");
|
|
582
|
+
for (const test of getAfterFixTests(issue)) {
|
|
583
|
+
lines.push(`- ${test}`);
|
|
584
|
+
}
|
|
585
|
+
lines.push("");
|
|
586
|
+
}
|
|
587
|
+
function appendMarkdownEvidence(lines, items) {
|
|
588
|
+
if (!items || items.length === 0) {
|
|
589
|
+
lines.push("- None");
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
for (const item of items) {
|
|
593
|
+
const detail = item.detail ? `: ${item.detail}` : "";
|
|
594
|
+
lines.push(`- ${item.label}${detail}`);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
function getStatusLabel(score) {
|
|
598
|
+
if (score >= 90) {
|
|
599
|
+
return "Ready with minor review";
|
|
600
|
+
}
|
|
601
|
+
if (score >= 75) {
|
|
602
|
+
return "Almost ready";
|
|
603
|
+
}
|
|
604
|
+
if (score >= 50) {
|
|
605
|
+
return "Needs fixes before launch";
|
|
606
|
+
}
|
|
607
|
+
return "Not ready to launch";
|
|
608
|
+
}
|
|
609
|
+
function getSeverityHeading(severity, count) {
|
|
610
|
+
const noun = severity === "critical" ? "Critical" : severity === "warning" ? "Warnings" : "Info";
|
|
611
|
+
return `${noun} (${count})`;
|
|
612
|
+
}
|
|
613
|
+
function getExecutiveSummary(report) {
|
|
614
|
+
const issues = report.issues;
|
|
615
|
+
const criticalCount = countIssuesBySeverity(issues, "critical");
|
|
616
|
+
const warningCount = countIssuesBySeverity(issues, "warning");
|
|
617
|
+
const topRiskCategory = getTopRiskCategory(issues);
|
|
618
|
+
const sentences = [];
|
|
619
|
+
if (criticalCount > 0) {
|
|
620
|
+
const issueWord = criticalCount === 1 ? "critical issue" : "critical issues";
|
|
621
|
+
sentences.push(
|
|
622
|
+
`Qodfy found ${criticalCount} ${issueWord} that should be fixed before launch.`
|
|
623
|
+
);
|
|
624
|
+
} else if (warningCount > 0) {
|
|
625
|
+
sentences.push(
|
|
626
|
+
"Qodfy found no critical blockers. Review the warnings below before launch."
|
|
627
|
+
);
|
|
628
|
+
} else if (issues.length > 0) {
|
|
629
|
+
sentences.push(
|
|
630
|
+
"Qodfy found no critical or warning blockers. Review the info notes below before launch."
|
|
631
|
+
);
|
|
632
|
+
} else {
|
|
633
|
+
sentences.push(
|
|
634
|
+
"Qodfy did not flag any issues. Do a final manual review and you should be good to launch."
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
if (topRiskCategory) {
|
|
638
|
+
sentences.push(`The main area to review is ${topRiskCategory}.`);
|
|
639
|
+
}
|
|
640
|
+
sentences.push(
|
|
641
|
+
`Score: ${report.score}/100. Launch status: ${getStatusLabel(report.score)}.`
|
|
642
|
+
);
|
|
643
|
+
return sentences.join(" ");
|
|
644
|
+
}
|
|
645
|
+
function getTopRiskCategory(issues) {
|
|
646
|
+
if (issues.length === 0) {
|
|
647
|
+
return null;
|
|
648
|
+
}
|
|
649
|
+
const weights = /* @__PURE__ */ new Map();
|
|
650
|
+
for (const issue of issues) {
|
|
651
|
+
const weight = issue.severity === "critical" ? 1e3 : issue.severity === "warning" ? 10 : 1;
|
|
652
|
+
weights.set(issue.category, (weights.get(issue.category) ?? 0) + weight);
|
|
653
|
+
}
|
|
654
|
+
let topCategory = null;
|
|
655
|
+
let topWeight = 0;
|
|
656
|
+
let topOrder = Number.MAX_SAFE_INTEGER;
|
|
657
|
+
for (const [category, weight] of weights) {
|
|
658
|
+
const order = categoryOrder.indexOf(category);
|
|
659
|
+
if (weight > topWeight || weight === topWeight && order < topOrder) {
|
|
660
|
+
topWeight = weight;
|
|
661
|
+
topCategory = category;
|
|
662
|
+
topOrder = order;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
if (!topCategory) {
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
return getCategoryShortName(topCategory);
|
|
669
|
+
}
|
|
670
|
+
function getCategoryShortName(category) {
|
|
671
|
+
if (category === "security") {
|
|
672
|
+
return "security";
|
|
673
|
+
}
|
|
674
|
+
if (category === "api") {
|
|
675
|
+
return "API routes";
|
|
676
|
+
}
|
|
677
|
+
if (category === "webhook") {
|
|
678
|
+
return "webhooks";
|
|
679
|
+
}
|
|
680
|
+
if (category === "ai") {
|
|
681
|
+
return "AI routes";
|
|
682
|
+
}
|
|
683
|
+
if (category === "environment") {
|
|
684
|
+
return "environment variables";
|
|
685
|
+
}
|
|
686
|
+
if (category === "maintainability") {
|
|
687
|
+
return "maintainability";
|
|
688
|
+
}
|
|
689
|
+
return "project setup";
|
|
690
|
+
}
|
|
691
|
+
function getWhatLooksGood(report) {
|
|
692
|
+
const observations = [];
|
|
693
|
+
const issues = report.issues;
|
|
694
|
+
const checks = new Set(report.checks);
|
|
695
|
+
const criticalCount = countIssuesBySeverity(issues, "critical");
|
|
696
|
+
if (criticalCount === 0) {
|
|
697
|
+
observations.push("No critical issues found.");
|
|
698
|
+
}
|
|
699
|
+
observations.push("Local source scan completed successfully.");
|
|
700
|
+
if (checks.has("api") && report.stats.apiRoutes > 0) {
|
|
701
|
+
observations.push("API routes were analyzed method by method.");
|
|
702
|
+
}
|
|
703
|
+
const hasPublicReadRouteNote = issues.some(
|
|
704
|
+
(issue) => issue.ruleId === "api-public-read-route"
|
|
705
|
+
);
|
|
706
|
+
if (hasPublicReadRouteNote) {
|
|
707
|
+
observations.push("Public read routes were separated from protected routes.");
|
|
708
|
+
}
|
|
709
|
+
if (checks.has("environment")) {
|
|
710
|
+
const hasEnvIssue = issues.some((issue) => issue.category === "environment");
|
|
711
|
+
if (!hasEnvIssue) {
|
|
712
|
+
observations.push("Environment variable documentation looks complete.");
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
if (checks.has("webhook")) {
|
|
716
|
+
const hasWebhookSignatureIssue = issues.some(
|
|
717
|
+
(issue) => issue.ruleId === "webhook-missing-signature-verification"
|
|
718
|
+
);
|
|
719
|
+
if (!hasWebhookSignatureIssue) {
|
|
720
|
+
observations.push("No webhook routes were flagged for missing signature checks.");
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
if (checks.has("ai")) {
|
|
724
|
+
const hasAiRateIssue = issues.some(
|
|
725
|
+
(issue) => issue.ruleId === "ai-route-missing-rate-limit"
|
|
726
|
+
);
|
|
727
|
+
if (!hasAiRateIssue && report.stats.aiFiles > 0) {
|
|
728
|
+
observations.push("AI routes were not flagged as missing rate limiting.");
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
if (checks.has("security")) {
|
|
732
|
+
const hasSecretIssue = issues.some(
|
|
733
|
+
(issue) => issue.ruleId === "security-hardcoded-secret" || issue.ruleId === "security-client-side-secret"
|
|
734
|
+
);
|
|
735
|
+
if (!hasSecretIssue) {
|
|
736
|
+
observations.push("No hardcoded or client-side secrets were detected.");
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
observations.push("Qodfy did not print any secret values in this report.");
|
|
740
|
+
return observations;
|
|
741
|
+
}
|
|
742
|
+
function getWhyItMatters(issue) {
|
|
743
|
+
const ruleExplanation = getWhyItMattersByRuleId(issue.ruleId);
|
|
744
|
+
if (ruleExplanation) {
|
|
745
|
+
return ruleExplanation;
|
|
746
|
+
}
|
|
747
|
+
return getWhyItMattersByCategory(issue.category);
|
|
748
|
+
}
|
|
749
|
+
function getWhyItMattersByRuleId(ruleId) {
|
|
750
|
+
switch (ruleId) {
|
|
751
|
+
case "admin-route-missing-authorization":
|
|
752
|
+
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.";
|
|
753
|
+
case "public-form-missing-abuse-protection":
|
|
754
|
+
return "Public forms can be abused by bots or repeated submissions. Validation helps data quality, but rate limiting or spam protection helps prevent abuse.";
|
|
755
|
+
case "webhook-missing-signature-verification":
|
|
756
|
+
return "Webhook routes receive external events. Without signature verification, attackers may be able to fake events and trick your app into running real actions.";
|
|
757
|
+
case "environment-variable-missing-from-example":
|
|
758
|
+
return "Missing env documentation makes the project harder to deploy or maintain. Future developers (and future you) will not know which variables are required.";
|
|
759
|
+
case "environment-missing-env-example":
|
|
760
|
+
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.";
|
|
761
|
+
case "ai-route-missing-rate-limit":
|
|
762
|
+
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.";
|
|
763
|
+
case "security-hardcoded-secret":
|
|
764
|
+
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.";
|
|
765
|
+
case "security-client-side-secret":
|
|
766
|
+
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.";
|
|
767
|
+
case "sensitive-api-route-missing-auth":
|
|
768
|
+
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.";
|
|
769
|
+
case "api-mutation-route-review-auth":
|
|
770
|
+
return "Routes that mutate data without an obvious auth check can let anyone create, update, or delete records. Review the auth path before launch.";
|
|
771
|
+
case "internal-route-missing-protection":
|
|
772
|
+
return "Internal or operational routes (debug, admin, jobs) are often forgotten before launch. Without protection they can expose admin-only behavior to the public.";
|
|
773
|
+
case "api-public-read-route":
|
|
774
|
+
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.";
|
|
775
|
+
case "maintainability-large-file":
|
|
776
|
+
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.";
|
|
777
|
+
case "maintainability-large-file-skipped":
|
|
778
|
+
return "Some files were too large for Qodfy to read fully. Review them manually before launch in case they hide other issues.";
|
|
779
|
+
case "maintainability-file-unreadable":
|
|
780
|
+
return "Files Qodfy could not read may indicate encoding, permission, or generated-output issues that hide real problems from this scan.";
|
|
781
|
+
case "project-missing-package-json":
|
|
782
|
+
return "Without a package.json, Qodfy cannot fully reason about your project. Confirm Qodfy is pointed at the correct project root.";
|
|
783
|
+
case "project-invalid-package-json":
|
|
784
|
+
return "An invalid package.json blocks tooling, installs, and deploys. Fix the JSON before running other launch checks.";
|
|
785
|
+
case "project-next-not-detected":
|
|
786
|
+
return "Qodfy is currently optimized for Next.js apps. If this is a Next.js project, double-check dependencies and folder structure before launch.";
|
|
787
|
+
case "project-missing-readme":
|
|
788
|
+
return "A short README helps anyone (including future you) understand what this project does and how to run it.";
|
|
789
|
+
case "project-source-files-unreadable":
|
|
790
|
+
return "Some source files could not be read. Review them manually so the scan does not silently skip risky areas of the code.";
|
|
791
|
+
default:
|
|
792
|
+
return null;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
function getWhyItMattersByCategory(category) {
|
|
796
|
+
switch (category) {
|
|
797
|
+
case "security":
|
|
798
|
+
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.";
|
|
799
|
+
case "api":
|
|
800
|
+
return "API routes shape what your app exposes to the internet. Confirm that authentication, input validation, and abuse protection match what each route does.";
|
|
801
|
+
case "webhook":
|
|
802
|
+
return "Webhooks receive events from external systems. Verifying signatures prevents fake or replayed events from being trusted by your app.";
|
|
803
|
+
case "ai":
|
|
804
|
+
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.";
|
|
805
|
+
case "environment":
|
|
806
|
+
return "Environment variables control how your app connects to external services. Missing or undocumented variables cause deploy failures and runtime errors.";
|
|
807
|
+
case "maintainability":
|
|
808
|
+
return "Maintainability signals do not block launch but slow future work. Cleaning them up early reduces friction later.";
|
|
809
|
+
case "project":
|
|
810
|
+
return "Project setup signals affect tooling, deploys, and onboarding. Fixing them early prevents surprises later in the launch.";
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
function getAfterFixTests(issue) {
|
|
814
|
+
const ruleTests = getAfterFixTestsByRuleId(issue.ruleId);
|
|
815
|
+
if (ruleTests) {
|
|
816
|
+
return ruleTests;
|
|
817
|
+
}
|
|
818
|
+
return getDefaultAfterFixTests();
|
|
819
|
+
}
|
|
820
|
+
function getAfterFixTestsByRuleId(ruleId) {
|
|
821
|
+
switch (ruleId) {
|
|
822
|
+
case "admin-route-missing-authorization":
|
|
823
|
+
return [
|
|
824
|
+
"Test as an unauthenticated user.",
|
|
825
|
+
"Test as a normal logged-in user.",
|
|
826
|
+
"Test as an admin/staff user.",
|
|
827
|
+
"Confirm non-admin users receive 403."
|
|
828
|
+
];
|
|
829
|
+
case "public-form-missing-abuse-protection":
|
|
830
|
+
return [
|
|
831
|
+
"Submit a valid form and confirm it still works.",
|
|
832
|
+
"Submit invalid input and confirm validation returns 400.",
|
|
833
|
+
"Send repeated requests and confirm rate limiting or spam protection behavior.",
|
|
834
|
+
"Confirm no sensitive provider/API error details are exposed."
|
|
835
|
+
];
|
|
836
|
+
case "webhook-missing-signature-verification":
|
|
837
|
+
return [
|
|
838
|
+
"Test a valid signed webhook.",
|
|
839
|
+
"Test an invalid signature.",
|
|
840
|
+
"Confirm invalid requests are rejected before processing.",
|
|
841
|
+
"Confirm the event is not processed twice."
|
|
842
|
+
];
|
|
843
|
+
case "environment-missing-env-example":
|
|
844
|
+
case "environment-variable-missing-from-example":
|
|
845
|
+
return [
|
|
846
|
+
"Add the variable name to .env.example without a real value.",
|
|
847
|
+
"Run the app locally with required env values.",
|
|
848
|
+
"Confirm deployment docs or hosting env vars are updated."
|
|
849
|
+
];
|
|
850
|
+
case "ai-route-missing-rate-limit":
|
|
851
|
+
return [
|
|
852
|
+
"Send a normal AI request.",
|
|
853
|
+
"Send repeated requests and confirm rate limiting.",
|
|
854
|
+
"Confirm rejected requests do not call the AI provider.",
|
|
855
|
+
"Check logs/cost controls after testing."
|
|
856
|
+
];
|
|
857
|
+
case "sensitive-api-route-missing-auth":
|
|
858
|
+
case "api-mutation-route-review-auth":
|
|
859
|
+
return [
|
|
860
|
+
"Test unauthenticated access.",
|
|
861
|
+
"Test authenticated access.",
|
|
862
|
+
"Confirm unauthorized users receive 401 or 403.",
|
|
863
|
+
"Confirm existing response formats still work."
|
|
864
|
+
];
|
|
865
|
+
case "internal-route-missing-protection":
|
|
866
|
+
return [
|
|
867
|
+
"Test without the secret/auth guard.",
|
|
868
|
+
"Test with a valid secret/auth guard.",
|
|
869
|
+
"Confirm invalid requests are rejected before internal work runs."
|
|
870
|
+
];
|
|
871
|
+
case "security-hardcoded-secret":
|
|
872
|
+
case "security-client-side-secret":
|
|
873
|
+
return [
|
|
874
|
+
"Rotate any real secret values that were exposed.",
|
|
875
|
+
"Confirm the secret is loaded from environment variables, not source code.",
|
|
876
|
+
"Re-run Qodfy and confirm the issue no longer appears.",
|
|
877
|
+
"Check git history and remove any committed real values if needed."
|
|
878
|
+
];
|
|
879
|
+
case "api-public-read-route":
|
|
880
|
+
return [
|
|
881
|
+
"Confirm the data exposed here is safe to share with anonymous users.",
|
|
882
|
+
"Test the route as an anonymous visitor.",
|
|
883
|
+
"Confirm no sensitive fields leak in the response."
|
|
884
|
+
];
|
|
885
|
+
default:
|
|
886
|
+
return null;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
function getDefaultAfterFixTests() {
|
|
890
|
+
return [
|
|
891
|
+
"Re-run Qodfy after the fix.",
|
|
892
|
+
"Test the affected route or file manually.",
|
|
893
|
+
"Confirm existing behavior still works.",
|
|
894
|
+
"Check logs for unexpected errors."
|
|
895
|
+
];
|
|
896
|
+
}
|
|
383
897
|
function printReport(report, maxIssues, showPrompts, scanModeLabel, showDetails, showAllIssues) {
|
|
384
898
|
console.log(pc.bold("Qodfy Report"));
|
|
385
899
|
console.log("");
|
|
@@ -539,14 +1053,23 @@ Run qodfy scan --all to see the current issue IDs.`
|
|
|
539
1053
|
}
|
|
540
1054
|
printFixPrompt(issue);
|
|
541
1055
|
}
|
|
1056
|
+
function getSeverityText(severity) {
|
|
1057
|
+
if (severity === "critical") {
|
|
1058
|
+
return "CRITICAL";
|
|
1059
|
+
}
|
|
1060
|
+
if (severity === "warning") {
|
|
1061
|
+
return "WARNING";
|
|
1062
|
+
}
|
|
1063
|
+
return "INFO";
|
|
1064
|
+
}
|
|
542
1065
|
function getSeverityLabel(severity) {
|
|
543
1066
|
if (severity === "critical") {
|
|
544
|
-
return pc.red(
|
|
1067
|
+
return pc.red(getSeverityText(severity));
|
|
545
1068
|
}
|
|
546
1069
|
if (severity === "warning") {
|
|
547
|
-
return pc.yellow(
|
|
1070
|
+
return pc.yellow(getSeverityText(severity));
|
|
548
1071
|
}
|
|
549
|
-
return pc.blue(
|
|
1072
|
+
return pc.blue(getSeverityText(severity));
|
|
550
1073
|
}
|
|
551
1074
|
function countIssuesBySeverity(issues, severity) {
|
|
552
1075
|
return issues.filter((issue) => issue.severity === severity).length;
|
|
@@ -607,6 +1130,10 @@ function getTopPriorities(issues) {
|
|
|
607
1130
|
ruleIds: ["internal-route-missing-protection"],
|
|
608
1131
|
message: "Protect internal or operational API routes before launch."
|
|
609
1132
|
},
|
|
1133
|
+
{
|
|
1134
|
+
ruleIds: ["admin-route-missing-authorization"],
|
|
1135
|
+
message: "Confirm admin/private routes have role or permission checks."
|
|
1136
|
+
},
|
|
610
1137
|
{
|
|
611
1138
|
ruleIds: ["public-form-missing-abuse-protection"],
|
|
612
1139
|
message: "Add abuse protection to public form routes."
|
|
@@ -659,13 +1186,13 @@ function shellQuote(value) {
|
|
|
659
1186
|
}
|
|
660
1187
|
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
661
1188
|
}
|
|
662
|
-
function printScanError(reason) {
|
|
663
|
-
console.error(pc.red("Qodfy could not scan this project."));
|
|
1189
|
+
function printScanError(reason, useColor = true) {
|
|
1190
|
+
console.error(useColor ? pc.red("Qodfy could not scan this project.") : "Qodfy could not scan this project.");
|
|
664
1191
|
console.error("");
|
|
665
|
-
console.error(pc.bold("Reason:"));
|
|
1192
|
+
console.error(useColor ? pc.bold("Reason:") : "Reason:");
|
|
666
1193
|
console.error(reason);
|
|
667
1194
|
console.error("");
|
|
668
|
-
console.error(pc.bold("Try:"));
|
|
1195
|
+
console.error(useColor ? pc.bold("Try:") : "Try:");
|
|
669
1196
|
console.error("qodfy scan --path ./my-next-app");
|
|
670
1197
|
}
|
|
671
1198
|
function printPromptError(reason) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qodfy",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.10",
|
|
4
4
|
"description": "Open-source launch readiness scanner for AI-built apps.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"qodfy",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"@inquirer/prompts": "^8.4.3",
|
|
53
53
|
"commander": "^14.0.3",
|
|
54
54
|
"picocolors": "^1.1.1",
|
|
55
|
-
"@qodfy/core": "^0.2.
|
|
55
|
+
"@qodfy/core": "^0.2.9"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@types/node": "^25.7.0",
|