react-doctor-cli-dev 1.0.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/backend/.env +3 -0
- package/backend/dist/index.js +43 -0
- package/backend/dist/middleware/auth.js +16 -0
- package/backend/dist/routes/reports.js +93 -0
- package/backend/package-lock.json +2000 -0
- package/backend/package.json +30 -0
- package/backend/src/db.ts +24 -0
- package/backend/src/index.ts +49 -0
- package/backend/src/middleware/auth.ts +21 -0
- package/backend/src/routes/reports.ts +110 -0
- package/backend/tsconfig.json +12 -0
- package/cli/bin/react-doctor.js +29 -0
- package/cli/dist/commands/analyze.js +125 -0
- package/cli/dist/commands/full.js +366 -0
- package/cli/dist/commands/install.js +138 -0
- package/cli/dist/commands/profile.js +166 -0
- package/cli/dist/index.js +78 -0
- package/cli/dist/ui.js +113 -0
- package/cli/package-lock.json +936 -0
- package/cli/package.json +34 -0
- package/cli/src/commands/analyze.ts +162 -0
- package/cli/src/commands/full.ts +574 -0
- package/cli/src/commands/install.ts +163 -0
- package/cli/src/commands/profile.ts +246 -0
- package/cli/src/index.ts +84 -0
- package/cli/src/ui.ts +120 -0
- package/cli/tsconfig.json +16 -0
- package/core/report-compiler/index.ts +359 -0
- package/core/report-compiler/test-report-compiler.ts +126 -0
- package/core/rule-engine/context-builder.ts +146 -0
- package/core/rule-engine/evaluator.ts +131 -0
- package/core/rule-engine/index.ts +222 -0
- package/core/rule-engine/rules.json +304 -0
- package/core/rule-engine/suggestion-builder.ts +209 -0
- package/core/rule-engine/test-rule-engine.ts +144 -0
- package/core/rule-engine/types.ts +202 -0
- package/core/runtime/profiler/browser.ts +121 -0
- package/core/runtime/profiler/collectors.ts +216 -0
- package/core/runtime/profiler/index.ts +311 -0
- package/core/runtime/profiler/porfiler.ts +967 -0
- package/core/runtime/profiler/route-scanner.ts +76 -0
- package/core/runtime/profiler/score.ts +59 -0
- package/core/runtime/profiler/server.ts +115 -0
- package/core/runtime/profiler/types.ts +65 -0
- package/core/runtime/test-runtime-profiler.ts +226 -0
- package/core/static-ana/static/analyzer.ts +145 -0
- package/core/static-ana/static/ast-parser.ts +31 -0
- package/core/static-ana/static/detectors/console-log.ts +49 -0
- package/core/static-ana/static/detectors/dead-code.ts +51 -0
- package/core/static-ana/static/detectors/effect-loop.ts +45 -0
- package/core/static-ana/static/detectors/index.ts +16 -0
- package/core/static-ana/static/detectors/inline-function.ts +59 -0
- package/core/static-ana/static/detectors/inline-style.ts +52 -0
- package/core/static-ana/static/detectors/large-component.ts +79 -0
- package/core/static-ana/static/detectors/missing-key.ts +56 -0
- package/core/static-ana/static/detectors/missing-memo.ts +59 -0
- package/core/static-ana/static/detectors/prop-drilling.ts +66 -0
- package/core/static-ana/static/helpers.ts +81 -0
- package/core/static-ana/static/scanner.ts +93 -0
- package/core/static-ana/test-analyzer.ts +115 -0
- package/core/static-ana/types.ts +25 -0
- package/core/tests/mock-react-project/src/app.tsx +22 -0
- package/core/tests/mock-react-project/src/components/Button.tsx +9 -0
- package/core/tests/mock-react-project/src/components/Header.tsx +3 -0
- package/core/tests/mock-react-project/src/components/ListTesting.tsx +51 -0
- package/core/tests/mock-react-project/src/components/UserDashboard.tsx +66 -0
- package/core/tests/mock-react-project/src/utils.ts +4 -0
- package/package.json +55 -0
- package/react-doctor-cli-dev-1.0.0.tgz +0 -0
- package/shared/dist/index.d.ts +2 -0
- package/shared/dist/index.js +19 -0
- package/shared/dist/schemas.d.ts +91 -0
- package/shared/dist/schemas.js +82 -0
- package/shared/dist/types.d.ts +44 -0
- package/shared/dist/types.js +2 -0
- package/shared/package-lock.json +47 -0
- package/shared/package.json +21 -0
- package/shared/src/index.ts +4 -0
- package/shared/src/schemas.ts +136 -0
- package/shared/src/types.ts +137 -0
- package/shared/tsconfig.json +15 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// cli/src/commands/full.ts
|
|
3
|
+
//
|
|
4
|
+
// react-doctor full <projectPath>
|
|
5
|
+
//
|
|
6
|
+
// The main command. Runs the entire React Doctor pipeline:
|
|
7
|
+
//
|
|
8
|
+
// 1. FileScanner → finds all JSX/TSX files
|
|
9
|
+
// 2. StaticAnalyzer → detects bad code patterns
|
|
10
|
+
// 3. RuntimeProfiler → measures live browser performance
|
|
11
|
+
// 4. RuleEngine → combines both reports into suggestions
|
|
12
|
+
// 5. ReportCompiler → merges everything into finalreport.json
|
|
13
|
+
// 6. Upload → sends the report to the backend API
|
|
14
|
+
//
|
|
15
|
+
// HOW IMPORTS WORK:
|
|
16
|
+
// The CLI imports core modules directly as TypeScript classes.
|
|
17
|
+
// No shell commands, no spawning child processes, no ts-node
|
|
18
|
+
// inside ts-node. Everything runs in the same Node.js process,
|
|
19
|
+
// which means objects are passed between steps in memory —
|
|
20
|
+
// fast, clean, and no disk reads between steps.
|
|
21
|
+
//
|
|
22
|
+
// The core folder is 2 levels up from cli/src/commands/:
|
|
23
|
+
// cli/src/commands/full.ts
|
|
24
|
+
// → ../../.. = react-tool root
|
|
25
|
+
// → ../../../core = core folder
|
|
26
|
+
// ─────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
import { Command } from "commander";
|
|
29
|
+
import path from "path";
|
|
30
|
+
import fs from "fs";
|
|
31
|
+
import chalk from "chalk";
|
|
32
|
+
import axios from "axios";
|
|
33
|
+
import { spawn } from "child_process";
|
|
34
|
+
import {
|
|
35
|
+
printBanner,
|
|
36
|
+
printSection,
|
|
37
|
+
printResult,
|
|
38
|
+
printDone,
|
|
39
|
+
printFail,
|
|
40
|
+
printInfo,
|
|
41
|
+
scoreBadge,
|
|
42
|
+
severityIcon,
|
|
43
|
+
vitalStatus,
|
|
44
|
+
spinner,
|
|
45
|
+
} from "../ui";
|
|
46
|
+
|
|
47
|
+
// ── Core imports ──────────────────────────────────────────────
|
|
48
|
+
// These are the actual classes from the core folder.
|
|
49
|
+
// We use require() with a resolved path so they work whether
|
|
50
|
+
// the CLI is run from its own folder or from the project root.
|
|
51
|
+
|
|
52
|
+
function getCoreModule(relativePath: string) {
|
|
53
|
+
// __dirname = cli/src/commands/
|
|
54
|
+
// 3 levels up = react-tool root
|
|
55
|
+
// then into core/
|
|
56
|
+
return require(
|
|
57
|
+
path.resolve(__dirname, "..", "..", "..", "core", relativePath),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─────────────────────────────────────────────────────────────
|
|
62
|
+
// REGISTER COMMAND
|
|
63
|
+
// ─────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
export function registerFullCommand(program: Command): void {
|
|
66
|
+
program
|
|
67
|
+
.command("full")
|
|
68
|
+
.description(
|
|
69
|
+
"Run the complete React Doctor diagnostic (static + runtime + rules)",
|
|
70
|
+
)
|
|
71
|
+
.argument(
|
|
72
|
+
"[projectPath]",
|
|
73
|
+
"Path to the React project (defaults to current directory)",
|
|
74
|
+
process.cwd(),
|
|
75
|
+
)
|
|
76
|
+
.option(
|
|
77
|
+
"--desktop",
|
|
78
|
+
"Profile on desktop viewport 1280x720 (default if neither flag is passed)",
|
|
79
|
+
false,
|
|
80
|
+
)
|
|
81
|
+
.option(
|
|
82
|
+
"--mobile",
|
|
83
|
+
"Profile on mobile viewport — iPhone 12 Pro 390x844",
|
|
84
|
+
false,
|
|
85
|
+
)
|
|
86
|
+
.option(
|
|
87
|
+
"--cpu <rate>",
|
|
88
|
+
"CPU throttle rate for profiler: 1 (real speed) | 4 (Lighthouse mobile) | 6 (low-end)",
|
|
89
|
+
(v: string) => parseInt(v) as 1 | 4 | 6,
|
|
90
|
+
1,
|
|
91
|
+
)
|
|
92
|
+
.option(
|
|
93
|
+
"--throttle <preset>",
|
|
94
|
+
"Network throttle: none | slow4g | 3g (only meaningful against deployed URLs)",
|
|
95
|
+
"none",
|
|
96
|
+
)
|
|
97
|
+
.option(
|
|
98
|
+
"--upload",
|
|
99
|
+
"Upload the final report to the React Doctor backend API",
|
|
100
|
+
false,
|
|
101
|
+
)
|
|
102
|
+
.option(
|
|
103
|
+
"--api-url <url>",
|
|
104
|
+
"Backend API URL to upload to",
|
|
105
|
+
"http://localhost:3000",
|
|
106
|
+
)
|
|
107
|
+
// ✅ Single --api-key option (defined BEFORE .action)
|
|
108
|
+
.option(
|
|
109
|
+
"--api-key <key>",
|
|
110
|
+
"API key for backend authentication (overrides REACT_DOCTOR_API_KEY env var)",
|
|
111
|
+
process.env.REACT_DOCTOR_API_KEY || "react-doctor-secret-key-change-this",
|
|
112
|
+
)
|
|
113
|
+
.option("--no-banner", "Skip the banner")
|
|
114
|
+
// ✅ .action() comes LAST, with no trailing semicolon/comment
|
|
115
|
+
.action(async (projectPath: string, options) => {
|
|
116
|
+
await runFullCommand(projectPath, options);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
// ─────────────────────────────────────────────────────────────
|
|
120
|
+
// MAIN RUNNER
|
|
121
|
+
// Exported so other commands (analyze --full) can call it too.
|
|
122
|
+
// ─────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
export async function runFullCommand(
|
|
125
|
+
projectPath: string,
|
|
126
|
+
options: {
|
|
127
|
+
desktop?: boolean;
|
|
128
|
+
mobile?: boolean;
|
|
129
|
+
cpu?: 1 | 4 | 6;
|
|
130
|
+
throttle?: string;
|
|
131
|
+
upload?: boolean;
|
|
132
|
+
apiUrl?: string;
|
|
133
|
+
noBanner?: boolean;
|
|
134
|
+
apiKey?: string;
|
|
135
|
+
} = {},
|
|
136
|
+
): Promise<void> {
|
|
137
|
+
const resolvedPath = path.resolve(projectPath);
|
|
138
|
+
|
|
139
|
+
if (!options.noBanner) printBanner();
|
|
140
|
+
|
|
141
|
+
// ── Validate that target is a React project ────────────────
|
|
142
|
+
if (!fs.existsSync(path.join(resolvedPath, "package.json"))) {
|
|
143
|
+
printFail(
|
|
144
|
+
`No package.json found at: ${resolvedPath}\n\n` +
|
|
145
|
+
` Make sure you point to the root of a React project.\n` +
|
|
146
|
+
` Example: react-doctor full ./my-react-app`,
|
|
147
|
+
);
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── Determine device configuration ──────────────────────────
|
|
152
|
+
// --desktop and --mobile are independent flags.
|
|
153
|
+
// If neither is passed, desktop is the default.
|
|
154
|
+
// If only --mobile is passed, only mobile runs.
|
|
155
|
+
// If both are passed, both run in one pass.
|
|
156
|
+
const wantDesktop = options.desktop || (!options.desktop && !options.mobile);
|
|
157
|
+
const wantMobile = options.mobile ?? false;
|
|
158
|
+
|
|
159
|
+
const devices: ("desktop" | "mobile")[] | "desktop" | "mobile" =
|
|
160
|
+
wantDesktop && wantMobile
|
|
161
|
+
? ["desktop", "mobile"]
|
|
162
|
+
: wantMobile
|
|
163
|
+
? "mobile"
|
|
164
|
+
: "desktop";
|
|
165
|
+
|
|
166
|
+
const deviceLabel =
|
|
167
|
+
wantDesktop && wantMobile
|
|
168
|
+
? "desktop + mobile"
|
|
169
|
+
: wantMobile
|
|
170
|
+
? "mobile"
|
|
171
|
+
: "desktop";
|
|
172
|
+
|
|
173
|
+
const throttleLabel = options.throttle ?? "none";
|
|
174
|
+
const cpuLabel = options.cpu ?? 1;
|
|
175
|
+
|
|
176
|
+
printSection("Full Diagnostic");
|
|
177
|
+
printInfo("Project", resolvedPath);
|
|
178
|
+
printInfo("Device", deviceLabel);
|
|
179
|
+
printInfo("CPU", `${cpuLabel}x`);
|
|
180
|
+
printInfo("Network", throttleLabel);
|
|
181
|
+
|
|
182
|
+
// ── Output directory ───────────────────────────────────────
|
|
183
|
+
// Reports are saved inside the user's project in a hidden
|
|
184
|
+
// .react-doctor/ folder — easy to find, easy to gitignore.
|
|
185
|
+
const outputDir = path.join(resolvedPath, ".react-doctor");
|
|
186
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
187
|
+
|
|
188
|
+
// ════════════════════════════════════════════════════════════
|
|
189
|
+
// STEP 1 — STATIC ANALYSIS
|
|
190
|
+
// ════════════════════════════════════════════════════════════
|
|
191
|
+
|
|
192
|
+
printSection("Step 1 / 4 — Static Analysis");
|
|
193
|
+
|
|
194
|
+
let staticReport: any;
|
|
195
|
+
|
|
196
|
+
const staticSpin = spinner("Scanning JSX/TSX source files...");
|
|
197
|
+
try {
|
|
198
|
+
const { FileScanner } = getCoreModule("static-ana/static/scanner");
|
|
199
|
+
const { StaticAnalyzer } = getCoreModule("static-ana/static/analyzer");
|
|
200
|
+
|
|
201
|
+
const scanner = new FileScanner();
|
|
202
|
+
const analyzer = new StaticAnalyzer();
|
|
203
|
+
|
|
204
|
+
const files = await scanner.findFiles(resolvedPath);
|
|
205
|
+
staticSpin.text = ` Analyzing ${files.length} file(s)...`;
|
|
206
|
+
|
|
207
|
+
staticReport = await analyzer.analyze(files);
|
|
208
|
+
|
|
209
|
+
fs.writeFileSync(
|
|
210
|
+
path.join(outputDir, "staticreport.json"),
|
|
211
|
+
JSON.stringify(staticReport, null, 2),
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
const critical =
|
|
215
|
+
staticReport.issues?.filter((i: any) => i.severity === "critical")
|
|
216
|
+
.length ?? 0;
|
|
217
|
+
const warnings =
|
|
218
|
+
staticReport.issues?.filter((i: any) => i.severity === "warning")
|
|
219
|
+
.length ?? 0;
|
|
220
|
+
const infos =
|
|
221
|
+
staticReport.issues?.filter((i: any) => i.severity === "info").length ??
|
|
222
|
+
0;
|
|
223
|
+
const total = staticReport.issues?.length ?? 0;
|
|
224
|
+
|
|
225
|
+
staticSpin.succeed(
|
|
226
|
+
chalk.green(`Static analysis complete — ${files.length} files scanned`),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
printResult(
|
|
230
|
+
"Files analyzed",
|
|
231
|
+
String(staticReport.filesAnalyzed ?? 0),
|
|
232
|
+
"info",
|
|
233
|
+
);
|
|
234
|
+
printResult("Total issues", String(total), total > 0 ? "warn" : "good");
|
|
235
|
+
printResult("Critical", String(critical), critical > 0 ? "poor" : "good");
|
|
236
|
+
printResult("Warnings", String(warnings), warnings > 0 ? "warn" : "good");
|
|
237
|
+
printResult("Info", String(infos), "info");
|
|
238
|
+
printResult("Health grade", staticReport.grade ?? "N/A", "info");
|
|
239
|
+
} catch (err: any) {
|
|
240
|
+
staticSpin.fail(chalk.red("Static analysis failed"));
|
|
241
|
+
console.log(chalk.red(`\n ${err.message}\n`));
|
|
242
|
+
staticReport = null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ════════════════════════════════════════════════════════════
|
|
246
|
+
// STEP 2 — RUNTIME PROFILING
|
|
247
|
+
// ════════════════════════════════════════════════════════════
|
|
248
|
+
|
|
249
|
+
printSection("Step 2 / 4 — Runtime Profiler");
|
|
250
|
+
|
|
251
|
+
let runtimeReports: Record<string, any> = {};
|
|
252
|
+
|
|
253
|
+
const profilingSpin = spinner("Starting dev server and launching Chrome...");
|
|
254
|
+
try {
|
|
255
|
+
const { RuntimeProfiler } = getCoreModule("runtime/profiler/index");
|
|
256
|
+
|
|
257
|
+
const profiler = new RuntimeProfiler(resolvedPath, outputDir);
|
|
258
|
+
profilingSpin.text = " Profiling... (this takes ~30 seconds per route)";
|
|
259
|
+
|
|
260
|
+
runtimeReports = await profiler.profile([], {
|
|
261
|
+
device: devices,
|
|
262
|
+
throttle: throttleLabel,
|
|
263
|
+
cpuThrottle: cpuLabel,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
fs.writeFileSync(
|
|
267
|
+
path.join(outputDir, "runtimereport.json"),
|
|
268
|
+
JSON.stringify(runtimeReports, null, 2),
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
const routeKeys = Object.keys(runtimeReports);
|
|
272
|
+
profilingSpin.succeed(
|
|
273
|
+
chalk.green(
|
|
274
|
+
`Profiling complete — ${routeKeys.length} route/device combination(s)`,
|
|
275
|
+
),
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
// Print results for each route
|
|
279
|
+
for (const [key, report] of Object.entries(runtimeReports)) {
|
|
280
|
+
const [route, device] = key.includes("::")
|
|
281
|
+
? key.split("::")
|
|
282
|
+
: [key, "desktop"];
|
|
283
|
+
|
|
284
|
+
console.log();
|
|
285
|
+
console.log(
|
|
286
|
+
` ${chalk.bold(route)} ${chalk.gray(`[${device}]`)} Score: ${scoreBadge(report.performanceScore)}`,
|
|
287
|
+
);
|
|
288
|
+
// ── Device / CPU / Network line ──────────────────────────
|
|
289
|
+
console.log(
|
|
290
|
+
` ${chalk.gray("Device:")} ${device} ` +
|
|
291
|
+
`${chalk.gray("CPU:")} ${report.cpuThrottling ?? cpuLabel}x ` +
|
|
292
|
+
`${chalk.gray("Network:")} ${throttleLabel}`,
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
printResult(
|
|
296
|
+
"LCP",
|
|
297
|
+
`${report.metrics.lcp.toFixed(0)}ms`,
|
|
298
|
+
vitalStatus("lcp", report.metrics.lcp),
|
|
299
|
+
);
|
|
300
|
+
printResult(
|
|
301
|
+
"FCP",
|
|
302
|
+
`${report.metrics.fcp.toFixed(0)}ms`,
|
|
303
|
+
vitalStatus("fcp", report.metrics.fcp),
|
|
304
|
+
);
|
|
305
|
+
printResult(
|
|
306
|
+
"TTFB",
|
|
307
|
+
`${report.metrics.ttfb.toFixed(0)}ms`,
|
|
308
|
+
vitalStatus("ttfb", report.metrics.ttfb),
|
|
309
|
+
);
|
|
310
|
+
printResult(
|
|
311
|
+
"CLS",
|
|
312
|
+
report.metrics.cls.toFixed(3),
|
|
313
|
+
vitalStatus("cls", report.metrics.cls),
|
|
314
|
+
);
|
|
315
|
+
printResult(
|
|
316
|
+
"INP",
|
|
317
|
+
`${report.metrics.inp.toFixed(0)}ms`,
|
|
318
|
+
vitalStatus("inp", report.metrics.inp),
|
|
319
|
+
);
|
|
320
|
+
printResult(
|
|
321
|
+
"Render time",
|
|
322
|
+
`${report.renderTime}ms`,
|
|
323
|
+
report.renderTime <= 2000
|
|
324
|
+
? "good"
|
|
325
|
+
: report.renderTime <= 4000
|
|
326
|
+
? "warn"
|
|
327
|
+
: "poor",
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
if ((report.errors ?? []).length > 0) {
|
|
331
|
+
const errs = report.errors.filter(
|
|
332
|
+
(e: any) => e.type === "error",
|
|
333
|
+
).length;
|
|
334
|
+
const warn = report.errors.filter(
|
|
335
|
+
(e: any) => e.type === "warning",
|
|
336
|
+
).length;
|
|
337
|
+
printResult(
|
|
338
|
+
"Issues",
|
|
339
|
+
`${errs} error(s) ${warn} warning(s)`,
|
|
340
|
+
errs > 0 ? "poor" : "warn",
|
|
341
|
+
);
|
|
342
|
+
} else {
|
|
343
|
+
printResult("Issues", "None detected", "good");
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
} catch (err: any) {
|
|
347
|
+
profilingSpin.fail(chalk.red("Runtime profiling failed"));
|
|
348
|
+
console.log(chalk.red(`\n ${err.message}\n`));
|
|
349
|
+
// Profiling failure is not fatal — rule engine can still run on static data
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ════════════════════════════════════════════════════════════
|
|
353
|
+
// STEP 3 — RULE ENGINE
|
|
354
|
+
// ════════════════════════════════════════════════════════════
|
|
355
|
+
|
|
356
|
+
printSection("Step 3 / 4 — Rule Engine");
|
|
357
|
+
|
|
358
|
+
let ruleResults: any[] = [];
|
|
359
|
+
|
|
360
|
+
const ruleSpin = spinner("Evaluating rules against both reports...");
|
|
361
|
+
try {
|
|
362
|
+
const { RuleEngine } = getCoreModule("rule-engine/index");
|
|
363
|
+
|
|
364
|
+
const engine = new RuleEngine(outputDir);
|
|
365
|
+
ruleResults = await engine.run(staticReport, runtimeReports);
|
|
366
|
+
|
|
367
|
+
const allSuggestions = ruleResults.flatMap((r: any) => r.suggestions);
|
|
368
|
+
fs.writeFileSync(
|
|
369
|
+
path.join(outputDir, "suggestions.json"),
|
|
370
|
+
JSON.stringify(ruleResults, null, 2),
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
const total = allSuggestions.length;
|
|
374
|
+
const critical = allSuggestions.filter(
|
|
375
|
+
(s: any) => s.severity === "critical",
|
|
376
|
+
).length;
|
|
377
|
+
const warnings = allSuggestions.filter(
|
|
378
|
+
(s: any) => s.severity === "warning",
|
|
379
|
+
).length;
|
|
380
|
+
const infos = allSuggestions.filter(
|
|
381
|
+
(s: any) => s.severity === "info",
|
|
382
|
+
).length;
|
|
383
|
+
|
|
384
|
+
ruleSpin.succeed(
|
|
385
|
+
chalk.green(`Rule Engine complete — ${total} suggestion(s) generated`),
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
printResult("Critical", String(critical), critical > 0 ? "poor" : "good");
|
|
389
|
+
printResult("Warnings", String(warnings), warnings > 0 ? "warn" : "good");
|
|
390
|
+
printResult("Info", String(infos), "info");
|
|
391
|
+
|
|
392
|
+
if (total > 0) {
|
|
393
|
+
console.log();
|
|
394
|
+
console.log(chalk.gray(" Top suggestions:"));
|
|
395
|
+
allSuggestions.slice(0, 5).forEach((s: any) => {
|
|
396
|
+
const icon = severityIcon(s.severity);
|
|
397
|
+
const comp = s.affectedComponent
|
|
398
|
+
? chalk.cyan(` [${s.affectedComponent}]`)
|
|
399
|
+
: "";
|
|
400
|
+
console.log(` ${icon} ${s.title}${comp}`);
|
|
401
|
+
});
|
|
402
|
+
if (total > 5) {
|
|
403
|
+
console.log(
|
|
404
|
+
chalk.gray(`\n ... and ${total - 5} more in the full report.`),
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
} catch (err: any) {
|
|
409
|
+
ruleSpin.fail(chalk.red("Rule Engine failed"));
|
|
410
|
+
console.log(chalk.red(`\n ${err.message}\n`));
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ════════════════════════════════════════════════════════════
|
|
414
|
+
// STEP 4 — REPORT COMPILER
|
|
415
|
+
// ════════════════════════════════════════════════════════════
|
|
416
|
+
|
|
417
|
+
printSection("Step 4 / 4 — Report Compiler");
|
|
418
|
+
|
|
419
|
+
let finalReport: any = null;
|
|
420
|
+
|
|
421
|
+
const compilerSpin = spinner("Compiling final report...");
|
|
422
|
+
try {
|
|
423
|
+
const { ReportCompiler } = getCoreModule("report-compiler/index");
|
|
424
|
+
|
|
425
|
+
const compiler = new ReportCompiler(outputDir);
|
|
426
|
+
|
|
427
|
+
finalReport = await compiler.compile(
|
|
428
|
+
staticReport,
|
|
429
|
+
runtimeReports,
|
|
430
|
+
ruleResults,
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
fs.writeFileSync(
|
|
434
|
+
path.join(outputDir, "finalreport.json"),
|
|
435
|
+
JSON.stringify(finalReport, null, 2),
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
compilerSpin.succeed(chalk.green("Final report compiled"));
|
|
439
|
+
printResult(
|
|
440
|
+
"Overall score",
|
|
441
|
+
scoreBadge(finalReport.performanceScore),
|
|
442
|
+
"none",
|
|
443
|
+
);
|
|
444
|
+
printResult(
|
|
445
|
+
"Report saved",
|
|
446
|
+
path.join(outputDir, "finalreport.json"),
|
|
447
|
+
"info",
|
|
448
|
+
);
|
|
449
|
+
} catch (err: any) {
|
|
450
|
+
compilerSpin.fail(chalk.red("Report Compiler failed"));
|
|
451
|
+
console.log(chalk.red(`\n ${err.message}\n`));
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ════════════════════════════════════════════════════════════
|
|
455
|
+
// OPTIONAL — UPLOAD TO BACKEND API
|
|
456
|
+
// ════════════════════════════════════════════════════════════
|
|
457
|
+
|
|
458
|
+
if (options.upload && finalReport) {
|
|
459
|
+
printSection("Uploading to Backend");
|
|
460
|
+
|
|
461
|
+
const uploadSpin = spinner(`Connecting to ${options.apiUrl}...`);
|
|
462
|
+
|
|
463
|
+
try {
|
|
464
|
+
// Ensure apiUrl has a default
|
|
465
|
+
const apiUrl = options.apiUrl ?? "http://localhost:3000";
|
|
466
|
+
|
|
467
|
+
// 1. Check if backend is already running
|
|
468
|
+
try {
|
|
469
|
+
await axios.get(`${apiUrl}/health`, { timeout: 2000 });
|
|
470
|
+
uploadSpin.text = "Backend detected. Preparing upload...";
|
|
471
|
+
} catch (err) {
|
|
472
|
+
// 2. If not running, start it automatically
|
|
473
|
+
|
|
474
|
+
// Determine the project root directory (where cli/ and backend/ are siblings)
|
|
475
|
+
const projectRoot = path.resolve(__dirname, "..", "..", "..");
|
|
476
|
+
|
|
477
|
+
// Backend is a sibling folder to cli at projectRoot
|
|
478
|
+
const backendRoot = path.resolve(projectRoot, "backend");
|
|
479
|
+
|
|
480
|
+
// Check for compiled JS first (for installed packages)
|
|
481
|
+
const backendDist = path.join(backendRoot, "dist", "index.js");
|
|
482
|
+
// Check for TS source (for local dev)
|
|
483
|
+
const backendSrc = path.join(backendRoot, "src", "index.ts");
|
|
484
|
+
|
|
485
|
+
let command: string;
|
|
486
|
+
let args: string[];
|
|
487
|
+
|
|
488
|
+
if (fs.existsSync(backendDist)) {
|
|
489
|
+
command = "node";
|
|
490
|
+
args = [backendDist];
|
|
491
|
+
} else if (fs.existsSync(backendSrc)) {
|
|
492
|
+
command = "npx";
|
|
493
|
+
args = ["ts-node", backendSrc];
|
|
494
|
+
} else {
|
|
495
|
+
throw new Error(`Cannot find backend at: ${backendRoot}. Ensure 'backend' folder exists next to 'cli'.`);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
uploadSpin.text = "Backend not found. Starting local server automatically...";
|
|
499
|
+
|
|
500
|
+
// Extract port safely
|
|
501
|
+
const port = new URL(apiUrl).port || "3000";
|
|
502
|
+
|
|
503
|
+
// Spawn the backend process
|
|
504
|
+
// Create backend data directory in the target project
|
|
505
|
+
const backendDataDir = path.join(outputDir, "backend-data");
|
|
506
|
+
fs.mkdirSync(backendDataDir, { recursive: true });
|
|
507
|
+
|
|
508
|
+
const backendProcess = spawn(command, args, {
|
|
509
|
+
stdio: "inherit",
|
|
510
|
+
env: {
|
|
511
|
+
...process.env,
|
|
512
|
+
API_KEY: options.apiKey || "react-doctor-secret-key-change-this",
|
|
513
|
+
PORT: port,
|
|
514
|
+
DB_PATH: path.join(backendDataDir, "reports.db"),
|
|
515
|
+
},
|
|
516
|
+
cwd: backendRoot
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
// 3. Wait for backend to be ready
|
|
520
|
+
let isReady = false;
|
|
521
|
+
let retries = 0;
|
|
522
|
+
while (!isReady && retries < 15) {
|
|
523
|
+
try {
|
|
524
|
+
await axios.get(`${apiUrl}/health`, { timeout: 1000 });
|
|
525
|
+
isReady = true;
|
|
526
|
+
} catch {
|
|
527
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
528
|
+
retries++;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (!isReady) throw new Error("Backend failed to start after 15 seconds.");
|
|
533
|
+
uploadSpin.text = "Backend started successfully!";
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// 4. Perform the actual upload
|
|
537
|
+
uploadSpin.text = "Uploading report...";
|
|
538
|
+
await axios.post(`${apiUrl}/api/reports/upload`, finalReport, {
|
|
539
|
+
headers: {
|
|
540
|
+
"Content-Type": "application/json",
|
|
541
|
+
"x-api-key": options.apiKey || "react-doctor-secret-key-change-this",
|
|
542
|
+
} as Record<string, string>,
|
|
543
|
+
timeout: 10000,
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
uploadSpin.succeed(chalk.green("Report uploaded successfully"));
|
|
547
|
+
} catch (err: any) {
|
|
548
|
+
uploadSpin.fail(chalk.yellow("Upload failed — report saved locally"));
|
|
549
|
+
console.log(chalk.gray(` ${err.message}`));
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
// ════════════════════════════════════════════════════════════
|
|
553
|
+
// FINAL SUMMARY
|
|
554
|
+
// ════════════════════════════════════════════════════════════
|
|
555
|
+
|
|
556
|
+
printSection("Summary");
|
|
557
|
+
|
|
558
|
+
console.log(chalk.gray(" Reports saved to:"));
|
|
559
|
+
console.log(chalk.cyan(` ${path.join(outputDir, "staticreport.json")}`));
|
|
560
|
+
console.log(chalk.cyan(` ${path.join(outputDir, "runtimereport.json")}`));
|
|
561
|
+
console.log(chalk.cyan(` ${path.join(outputDir, "suggestions.json")}`));
|
|
562
|
+
console.log(chalk.cyan(` ${path.join(outputDir, "finalreport.json")}`));
|
|
563
|
+
console.log();
|
|
564
|
+
|
|
565
|
+
if (!options.upload) {
|
|
566
|
+
console.log(
|
|
567
|
+
chalk.gray(" Tip: add ") +
|
|
568
|
+
chalk.cyan("--upload") +
|
|
569
|
+
chalk.gray(" to send results to the dashboard."),
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
printDone("Full diagnostic finished.");
|
|
574
|
+
}
|