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,163 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// cli/src/commands/install.ts
|
|
3
|
+
//
|
|
4
|
+
// react-doctor install
|
|
5
|
+
//
|
|
6
|
+
// Installs React Doctor from GitHub into a React project.
|
|
7
|
+
// Runs the equivalent of:
|
|
8
|
+
//
|
|
9
|
+
// npm install --save-dev softar-dev/React_Doctor
|
|
10
|
+
//
|
|
11
|
+
// WHY THIS COMMAND EXISTS:
|
|
12
|
+
// When React Doctor is published and another developer wants
|
|
13
|
+
// to use it, they can run "npx react-doctor install" instead
|
|
14
|
+
// of having to remember the GitHub package name. It also
|
|
15
|
+
// detects their package manager (npm/yarn/pnpm) automatically.
|
|
16
|
+
//
|
|
17
|
+
// NOTE: This command is only useful once the GitHub repo is
|
|
18
|
+
// public and the project is ready for other developers to use.
|
|
19
|
+
// During development you run the tool directly from the
|
|
20
|
+
// react-tool/ folder with ts-node, so this command is not
|
|
21
|
+
// needed for your own workflow.
|
|
22
|
+
// ─────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
import { Command } from "commander";
|
|
25
|
+
import { spawn } from "child_process";
|
|
26
|
+
import path from "path";
|
|
27
|
+
import fs from "fs";
|
|
28
|
+
import chalk from "chalk";
|
|
29
|
+
import {
|
|
30
|
+
printBanner, printSection,
|
|
31
|
+
printDone, printFail, printInfo, spinner,
|
|
32
|
+
} from "../ui";
|
|
33
|
+
|
|
34
|
+
// The GitHub package identifier — owner/repo format.
|
|
35
|
+
// npm, yarn, and pnpm all understand this without any registry.
|
|
36
|
+
const GITHUB_PACKAGE = "softar-dev/React_Doctor";
|
|
37
|
+
|
|
38
|
+
// ─────────────────────────────────────────────────────────────
|
|
39
|
+
// REGISTER COMMAND
|
|
40
|
+
// ─────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
export function registerInstallCommand(program: Command): void {
|
|
43
|
+
program
|
|
44
|
+
.command("install")
|
|
45
|
+
.description(`Install React Doctor from GitHub (${GITHUB_PACKAGE})`)
|
|
46
|
+
.option(
|
|
47
|
+
"-p, --path <projectPath>",
|
|
48
|
+
"Path to the React project to install into (defaults to current directory)",
|
|
49
|
+
process.cwd(),
|
|
50
|
+
)
|
|
51
|
+
.option("--no-banner", "Skip the banner")
|
|
52
|
+
.action(async (options) => {
|
|
53
|
+
|
|
54
|
+
const projectPath = path.resolve(options.path);
|
|
55
|
+
|
|
56
|
+
if (!options.noBanner) printBanner();
|
|
57
|
+
|
|
58
|
+
// ── Validate this is a React project ────────────────────
|
|
59
|
+
const pkgJsonPath = path.join(projectPath, "package.json");
|
|
60
|
+
if (!fs.existsSync(pkgJsonPath)) {
|
|
61
|
+
printFail(
|
|
62
|
+
`No package.json found at: ${projectPath}\n\n` +
|
|
63
|
+
` Make sure you are inside a React project, or pass the path:\n` +
|
|
64
|
+
` react-doctor install --path ./my-react-app`,
|
|
65
|
+
);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Warn if React is not in the project's dependencies
|
|
70
|
+
try {
|
|
71
|
+
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"));
|
|
72
|
+
const deps = { ...pkgJson.dependencies, ...pkgJson.devDependencies };
|
|
73
|
+
if (!deps["react"]) {
|
|
74
|
+
console.log(chalk.yellow(
|
|
75
|
+
" ⚠️ React not found in this project's dependencies.\n" +
|
|
76
|
+
" Installing anyway — make sure this is a React project.\n",
|
|
77
|
+
));
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// If we can't read package.json, just continue
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Detect package manager ───────────────────────────────
|
|
84
|
+
// Check for lock files to know which package manager the
|
|
85
|
+
// project uses. Order matters — check yarn and pnpm first
|
|
86
|
+
// since npm is the fallback.
|
|
87
|
+
const pkgManager =
|
|
88
|
+
fs.existsSync(path.join(projectPath, "yarn.lock")) ? "yarn" :
|
|
89
|
+
fs.existsSync(path.join(projectPath, "pnpm-lock.yaml")) ? "pnpm" :
|
|
90
|
+
"npm";
|
|
91
|
+
|
|
92
|
+
printSection("Installing React Doctor");
|
|
93
|
+
printInfo("Project", projectPath);
|
|
94
|
+
printInfo("Manager", pkgManager);
|
|
95
|
+
printInfo("Source", `github.com/${GITHUB_PACKAGE}`);
|
|
96
|
+
console.log();
|
|
97
|
+
|
|
98
|
+
// ── Build the install command ────────────────────────────
|
|
99
|
+
// The "owner/repo" format is understood natively by all
|
|
100
|
+
// three package managers — no npm registry needed.
|
|
101
|
+
let args: string[];
|
|
102
|
+
if (pkgManager === "yarn") {
|
|
103
|
+
args = ["add", "--dev", GITHUB_PACKAGE];
|
|
104
|
+
} else if (pkgManager === "pnpm") {
|
|
105
|
+
args = ["add", "--save-dev", GITHUB_PACKAGE];
|
|
106
|
+
} else {
|
|
107
|
+
args = ["install", "--save-dev", GITHUB_PACKAGE];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
console.log(chalk.gray(` Running: `) + chalk.cyan(`${pkgManager} ${args.join(" ")}\n`));
|
|
111
|
+
|
|
112
|
+
const spin = spinner("Downloading from GitHub...");
|
|
113
|
+
|
|
114
|
+
await new Promise<void>((resolve, reject) => {
|
|
115
|
+
const isWin = process.platform === "win32";
|
|
116
|
+
|
|
117
|
+
const proc = spawn(pkgManager, args, {
|
|
118
|
+
cwd: projectPath,
|
|
119
|
+
shell: isWin ? true : "/bin/bash",
|
|
120
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
let output = "";
|
|
124
|
+
proc.stdout?.on("data", (d: Buffer) => { output += d.toString(); });
|
|
125
|
+
proc.stderr?.on("data", (d: Buffer) => { output += d.toString(); });
|
|
126
|
+
|
|
127
|
+
proc.on("close", (code) => {
|
|
128
|
+
if (code === 0) {
|
|
129
|
+
spin.succeed(chalk.green("Installed successfully!"));
|
|
130
|
+
resolve();
|
|
131
|
+
} else {
|
|
132
|
+
spin.fail(chalk.red("Installation failed."));
|
|
133
|
+
if (output) console.log(chalk.gray(`\n${output}`));
|
|
134
|
+
reject(new Error(`${pkgManager} exited with code ${code}`));
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
proc.on("error", reject);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ── Show usage after install ─────────────────────────────
|
|
142
|
+
printSection("You are ready");
|
|
143
|
+
console.log(" Run these commands from inside your React project:\n");
|
|
144
|
+
|
|
145
|
+
const commands: [string, string][] = [
|
|
146
|
+
["npx react-doctor full ./", "Run the full diagnostic (recommended)"],
|
|
147
|
+
["npx react-doctor full ./ --mobile", "Include mobile viewport profiling"],
|
|
148
|
+
["npx react-doctor analyze ./", "Static code analysis only (no Chrome needed)"],
|
|
149
|
+
["npx react-doctor profile ./", "Runtime profiling only"],
|
|
150
|
+
["npx react-doctor --help", "See all available options"],
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
commands.forEach(([cmd, desc]) => {
|
|
154
|
+
console.log(
|
|
155
|
+
` ${chalk.cyan(cmd.padEnd(44))}` +
|
|
156
|
+
chalk.gray(desc),
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
console.log();
|
|
161
|
+
printDone("React Doctor is installed and ready.");
|
|
162
|
+
});
|
|
163
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// cli/src/commands/profile.ts
|
|
3
|
+
//
|
|
4
|
+
// react-doctor profile <projectPath>
|
|
5
|
+
//
|
|
6
|
+
// Runs the Runtime Profiler only — needs Chrome.
|
|
7
|
+
// Boots the React dev server, opens headless Chrome,
|
|
8
|
+
// measures Web Vitals + React profiler data, saves
|
|
9
|
+
// runtimereport.json to .react-doctor/
|
|
10
|
+
//
|
|
11
|
+
// Use this when:
|
|
12
|
+
// - You already ran static analysis and want performance data
|
|
13
|
+
// - You want a quick runtime check without code analysis
|
|
14
|
+
// - You want to compare desktop vs mobile performance
|
|
15
|
+
//
|
|
16
|
+
// Options:
|
|
17
|
+
// --mobile also profile on mobile viewport
|
|
18
|
+
// --cpu 4 simulate 4x CPU slowdown (Lighthouse preset)
|
|
19
|
+
// --throttle slow4g simulate slow network (deployed URLs only)
|
|
20
|
+
//
|
|
21
|
+
// Use "react-doctor full" to run everything together.
|
|
22
|
+
// ─────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
import { Command } from "commander";
|
|
25
|
+
import path from "path";
|
|
26
|
+
import fs from "fs";
|
|
27
|
+
import chalk from "chalk";
|
|
28
|
+
import {
|
|
29
|
+
printBanner, printSection, printResult,
|
|
30
|
+
printDone, printFail, printInfo,
|
|
31
|
+
scoreBadge, vitalStatus, spinner,
|
|
32
|
+
} from "../ui";
|
|
33
|
+
|
|
34
|
+
function getCoreModule(relativePath: string) {
|
|
35
|
+
return require(path.resolve(__dirname, "..", "..", "..", "core", relativePath));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ─────────────────────────────────────────────────────────────
|
|
39
|
+
// REGISTER COMMAND
|
|
40
|
+
// ─────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
export function registerProfileCommand(program: Command): void {
|
|
43
|
+
program
|
|
44
|
+
.command("profile")
|
|
45
|
+
.description("Run the runtime profiler only (requires Chrome)")
|
|
46
|
+
.argument(
|
|
47
|
+
"[projectPath]",
|
|
48
|
+
"Path to the React project (defaults to current directory)",
|
|
49
|
+
process.cwd(),
|
|
50
|
+
)
|
|
51
|
+
.option(
|
|
52
|
+
"--desktop",
|
|
53
|
+
"Profile on desktop viewport 1280x720 (default if neither flag is passed)",
|
|
54
|
+
false,
|
|
55
|
+
)
|
|
56
|
+
.option(
|
|
57
|
+
"--mobile",
|
|
58
|
+
"Profile on mobile viewport — iPhone 12 Pro 390x844",
|
|
59
|
+
false,
|
|
60
|
+
)
|
|
61
|
+
.option(
|
|
62
|
+
"--cpu <rate>",
|
|
63
|
+
"CPU throttle: 1 (real speed) | 4 (Lighthouse mobile) | 6 (low-end)",
|
|
64
|
+
(v: string) => parseInt(v) as 1 | 4 | 6,
|
|
65
|
+
1,
|
|
66
|
+
)
|
|
67
|
+
.option(
|
|
68
|
+
"--throttle <preset>",
|
|
69
|
+
"Network throttle: none | slow4g | 3g (only meaningful against deployed URLs)",
|
|
70
|
+
"none",
|
|
71
|
+
)
|
|
72
|
+
.option("--no-banner", "Skip the banner")
|
|
73
|
+
.action(async (projectPath: string, options) => {
|
|
74
|
+
|
|
75
|
+
const resolvedPath = path.resolve(projectPath);
|
|
76
|
+
|
|
77
|
+
if (!options.noBanner) printBanner();
|
|
78
|
+
|
|
79
|
+
// ── Validate project ────────────────────────────────────
|
|
80
|
+
if (!fs.existsSync(path.join(resolvedPath, "package.json"))) {
|
|
81
|
+
printFail(
|
|
82
|
+
`No package.json found at: ${resolvedPath}\n\n` +
|
|
83
|
+
` Pass the path to your React project:\n` +
|
|
84
|
+
` react-doctor profile ./my-react-app`,
|
|
85
|
+
);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Determine devices ────────────────────────────────────
|
|
90
|
+
// --desktop and --mobile are independent flags.
|
|
91
|
+
// If neither is passed, desktop is the default.
|
|
92
|
+
// If only --mobile is passed, only mobile runs.
|
|
93
|
+
// If both are passed, both run in one pass.
|
|
94
|
+
const wantDesktop = options.desktop || (!options.desktop && !options.mobile);
|
|
95
|
+
const wantMobile = options.mobile ?? false;
|
|
96
|
+
|
|
97
|
+
const devices: ("desktop" | "mobile")[] | "desktop" | "mobile" =
|
|
98
|
+
wantDesktop && wantMobile ? ["desktop", "mobile"] :
|
|
99
|
+
wantMobile ? "mobile" :
|
|
100
|
+
"desktop";
|
|
101
|
+
|
|
102
|
+
const deviceLabel =
|
|
103
|
+
wantDesktop && wantMobile ? "desktop + mobile" :
|
|
104
|
+
wantMobile ? "mobile" :
|
|
105
|
+
"desktop";
|
|
106
|
+
|
|
107
|
+
printSection("Runtime Profiler");
|
|
108
|
+
printInfo("Project", resolvedPath);
|
|
109
|
+
printInfo("Device", deviceLabel);
|
|
110
|
+
printInfo("CPU", `${options.cpu}x`);
|
|
111
|
+
printInfo("Network", options.throttle);
|
|
112
|
+
console.log();
|
|
113
|
+
|
|
114
|
+
// ── Run the profiler ────────────────────────────────────
|
|
115
|
+
const spin = spinner("Starting dev server...");
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const { RuntimeProfiler } = getCoreModule("runtime/profiler/index");
|
|
119
|
+
|
|
120
|
+
const profiler = new RuntimeProfiler(resolvedPath);
|
|
121
|
+
spin.text = " Launching headless Chrome...";
|
|
122
|
+
|
|
123
|
+
const runtimeReports = await profiler.profile([], {
|
|
124
|
+
device: devices,
|
|
125
|
+
throttle: options.throttle as "none" | "slow4g" | "3g",
|
|
126
|
+
cpuThrottle: options.cpu as 1 | 4 | 6,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Save to .react-doctor/
|
|
130
|
+
const outputDir = path.join(resolvedPath, ".react-doctor");
|
|
131
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
132
|
+
fs.writeFileSync(
|
|
133
|
+
path.join(outputDir, "runtimereport.json"),
|
|
134
|
+
JSON.stringify(runtimeReports, null, 2),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const routeKeys = Object.keys(runtimeReports);
|
|
138
|
+
spin.succeed(chalk.green(`Profiling complete — ${routeKeys.length} route/device combination(s)`));
|
|
139
|
+
|
|
140
|
+
// ── Results ─────────────────────────────────────────────
|
|
141
|
+
printSection("Results");
|
|
142
|
+
|
|
143
|
+
for (const [key, report] of Object.entries(runtimeReports) as [string, any][]) {
|
|
144
|
+
const [route, device] = key.includes("::") ? key.split("::") : [key, "desktop"];
|
|
145
|
+
|
|
146
|
+
console.log();
|
|
147
|
+
console.log(
|
|
148
|
+
` ${chalk.bold("Route:")} ${route} ` +
|
|
149
|
+
`${chalk.gray(`[${device}]`)} ` +
|
|
150
|
+
`Score: ${scoreBadge(report.performanceScore)}`,
|
|
151
|
+
);
|
|
152
|
+
console.log();
|
|
153
|
+
|
|
154
|
+
// Web vitals
|
|
155
|
+
printResult("LCP", `${report.metrics.lcp.toFixed(0)}ms`, vitalStatus("lcp", report.metrics.lcp));
|
|
156
|
+
printResult("FCP", `${report.metrics.fcp.toFixed(0)}ms`, vitalStatus("fcp", report.metrics.fcp));
|
|
157
|
+
printResult("TTFB", `${report.metrics.ttfb.toFixed(0)}ms`, vitalStatus("ttfb", report.metrics.ttfb));
|
|
158
|
+
printResult("CLS", report.metrics.cls.toFixed(3), vitalStatus("cls", report.metrics.cls));
|
|
159
|
+
printResult("INP", `${report.metrics.inp.toFixed(0)}ms`, vitalStatus("inp", report.metrics.inp));
|
|
160
|
+
printResult("Render time", `${report.renderTime}ms`,
|
|
161
|
+
report.renderTime <= 2000 ? "good" : report.renderTime <= 4000 ? "warn" : "poor");
|
|
162
|
+
|
|
163
|
+
// React profiler
|
|
164
|
+
if (report.commitDurations?.length > 0) {
|
|
165
|
+
const avg = (report.commitDurations.reduce((a: number, b: number) => a + b, 0) /
|
|
166
|
+
report.commitDurations.length).toFixed(1);
|
|
167
|
+
const slow = report.commitDurations.filter((d: number) => d > 16).length;
|
|
168
|
+
printResult(
|
|
169
|
+
"React commits",
|
|
170
|
+
`${report.commitDurations.length} total, avg ${avg}ms`,
|
|
171
|
+
slow > 0 ? "warn" : "good",
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Top re-render component
|
|
176
|
+
const rerenderEntries = Object.entries(report.rerenders ?? {})
|
|
177
|
+
.sort(([, a], [, b]) => (b as number) - (a as number));
|
|
178
|
+
if (rerenderEntries.length > 0) {
|
|
179
|
+
const [topName, topCount] = rerenderEntries[0];
|
|
180
|
+
printResult(
|
|
181
|
+
"Most re-renders",
|
|
182
|
+
`${topName} (${topCount}x)`,
|
|
183
|
+
(topCount as number) >= 10 ? "poor" : (topCount as number) >= 5 ? "warn" : "good",
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// System stats
|
|
188
|
+
printResult("Page weight", `${report.stats.payloadMB} MB`, "info");
|
|
189
|
+
printResult("JS heap", `${report.stats.jsHeapMB} MB`, "info");
|
|
190
|
+
printResult("DOM nodes", String(report.stats.domNodes), "info");
|
|
191
|
+
|
|
192
|
+
if (report.stats.topOffender) {
|
|
193
|
+
printResult(
|
|
194
|
+
"Heaviest file",
|
|
195
|
+
`${report.stats.topOffender.name} (${report.stats.topOffender.size.toFixed(2)} MB)`,
|
|
196
|
+
"warn",
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Errors
|
|
201
|
+
const errorCount = (report.errors ?? []).filter((e: any) => e.type === "error").length;
|
|
202
|
+
const warningCount = (report.errors ?? []).filter((e: any) => e.type === "warning").length;
|
|
203
|
+
if (errorCount > 0 || warningCount > 0) {
|
|
204
|
+
printResult(
|
|
205
|
+
"Issues",
|
|
206
|
+
`${errorCount} error(s) ${warningCount} warning(s)`,
|
|
207
|
+
errorCount > 0 ? "poor" : "warn",
|
|
208
|
+
);
|
|
209
|
+
(report.errors ?? []).slice(0, 3).forEach((e: any) => {
|
|
210
|
+
const icon = e.type === "error" ? chalk.red(" ✗") : chalk.yellow(" !");
|
|
211
|
+
console.log(`${icon} ${chalk.gray(e.message.slice(0, 90))}`);
|
|
212
|
+
});
|
|
213
|
+
} else {
|
|
214
|
+
printResult("Issues", "None detected", "good");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Screenshots
|
|
218
|
+
if (report.screenshots?.length > 0) {
|
|
219
|
+
const labels = report.screenshots.map((s: any) => `${s.label}@${s.takenAt}ms`).join(" ");
|
|
220
|
+
printResult("Screenshots", labels, "info");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
console.log();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
printResult(
|
|
227
|
+
"Report saved",
|
|
228
|
+
path.join(resolvedPath, ".react-doctor", "runtimereport.json"),
|
|
229
|
+
"info",
|
|
230
|
+
);
|
|
231
|
+
console.log();
|
|
232
|
+
|
|
233
|
+
printDone("Runtime profiling finished.");
|
|
234
|
+
console.log(
|
|
235
|
+
chalk.gray(" Tip: run ") +
|
|
236
|
+
chalk.cyan("react-doctor full ./") +
|
|
237
|
+
chalk.gray(" to also get static analysis and improvement suggestions.\n"),
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
} catch (err: any) {
|
|
241
|
+
spin.fail(chalk.red("Runtime profiling failed"));
|
|
242
|
+
console.log(chalk.red(`\n ${err.message}\n`));
|
|
243
|
+
process.exit(1);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
}
|
package/cli/src/index.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ─────────────────────────────────────────────────────────────
|
|
3
|
+
// cli/src/index.ts
|
|
4
|
+
//
|
|
5
|
+
// The CLI entry point. This is the file that runs when the
|
|
6
|
+
// user types "react-doctor" in their terminal.
|
|
7
|
+
//
|
|
8
|
+
// HOW IT WORKS:
|
|
9
|
+
// 1. Commander.js parses the command and flags from argv
|
|
10
|
+
// 2. The matching command handler is called
|
|
11
|
+
// 3. The handler imports core modules and runs the pipeline
|
|
12
|
+
//
|
|
13
|
+
// HOW THE BINARY REGISTRATION WORKS:
|
|
14
|
+
// package.json has a "bin" field:
|
|
15
|
+
// "bin": { "react-doctor": "./dist/index.js" }
|
|
16
|
+
//
|
|
17
|
+
// After "npm link" (dev) or "npm install" (production),
|
|
18
|
+
// npm creates a symlink from the system's bin directory
|
|
19
|
+
// to this file. That's what makes "react-doctor" a real
|
|
20
|
+
// terminal command available anywhere.
|
|
21
|
+
//
|
|
22
|
+
// THE SHEBANG (#!/usr/bin/env node) on line 1:
|
|
23
|
+
// This tells the OS to run this file with Node.js when
|
|
24
|
+
// called directly as a script. Without it, the OS doesn't
|
|
25
|
+
// know which interpreter to use.
|
|
26
|
+
// ─────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
import { Command } from "commander";
|
|
29
|
+
import { registerAnalyzeCommand } from "./commands/analyze";
|
|
30
|
+
import { registerProfileCommand } from "./commands/profile";
|
|
31
|
+
import { registerFullCommand } from "./commands/full";
|
|
32
|
+
import { registerInstallCommand } from "./commands/install";
|
|
33
|
+
|
|
34
|
+
const program = new Command();
|
|
35
|
+
|
|
36
|
+
// ── Program metadata ──────────────────────────────────────────
|
|
37
|
+
program
|
|
38
|
+
.name("react-doctor")
|
|
39
|
+
.description("React performance analyzer — static analysis + runtime profiling + smart suggestions")
|
|
40
|
+
.version("1.0.0");
|
|
41
|
+
|
|
42
|
+
// ── Register all commands ─────────────────────────────────────
|
|
43
|
+
// Each function adds one command to the program.
|
|
44
|
+
// The order here is the order they appear in --help output.
|
|
45
|
+
|
|
46
|
+
registerFullCommand(program); // react-doctor full
|
|
47
|
+
registerAnalyzeCommand(program); // react-doctor analyze
|
|
48
|
+
registerProfileCommand(program); // react-doctor profile
|
|
49
|
+
registerInstallCommand(program); // react-doctor install
|
|
50
|
+
|
|
51
|
+
// ── Usage examples shown at bottom of --help ─────────────────
|
|
52
|
+
program.addHelpText("after", `
|
|
53
|
+
Examples:
|
|
54
|
+
$ react-doctor full ./my-app Desktop only (default)
|
|
55
|
+
$ react-doctor full ./my-app --mobile Mobile only
|
|
56
|
+
$ react-doctor full ./my-app --desktop --mobile Both desktop and mobile
|
|
57
|
+
$ react-doctor full ./my-app --cpu 4 Simulate slow Android device
|
|
58
|
+
$ react-doctor full ./my-app --throttle slow4g Simulate slow 4G network
|
|
59
|
+
$ react-doctor full ./my-app --throttle 3g Simulate 3G network
|
|
60
|
+
$ react-doctor full ./my-app --cpu 4 --throttle 3g Slow device + slow network
|
|
61
|
+
$ react-doctor full ./my-app --upload Upload results to dashboard
|
|
62
|
+
|
|
63
|
+
$ react-doctor analyze ./my-app Static code analysis only
|
|
64
|
+
$ react-doctor analyze ./my-app --full Static + runtime + rules
|
|
65
|
+
|
|
66
|
+
$ react-doctor profile ./my-app Desktop only (default)
|
|
67
|
+
$ react-doctor profile ./my-app --mobile Mobile only
|
|
68
|
+
$ react-doctor profile ./my-app --desktop --mobile Both devices
|
|
69
|
+
$ react-doctor profile ./my-app --cpu 4 4x CPU slowdown simulation
|
|
70
|
+
$ react-doctor profile ./my-app --throttle slow4g Simulate slow 4G network
|
|
71
|
+
$ react-doctor profile ./my-app --throttle 3g Simulate 3G network
|
|
72
|
+
|
|
73
|
+
$ react-doctor install Install from GitHub into a project
|
|
74
|
+
$ react-doctor install --path ./my-app Install into a specific folder
|
|
75
|
+
`);
|
|
76
|
+
|
|
77
|
+
// ── Show help if called with no arguments ─────────────────────
|
|
78
|
+
// Without this, calling "react-doctor" with no command just
|
|
79
|
+
// exits silently, which is confusing. This prints help instead.
|
|
80
|
+
if (process.argv.length < 3) {
|
|
81
|
+
program.help();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
program.parse(process.argv);
|
package/cli/src/ui.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// cli/src/ui.ts
|
|
3
|
+
//
|
|
4
|
+
// Shared terminal output helpers used by every command.
|
|
5
|
+
// All chalk colors, ora spinners, and print functions live
|
|
6
|
+
// here so the visual style is consistent across the CLI.
|
|
7
|
+
//
|
|
8
|
+
// WHY ONE FILE FOR ALL UI:
|
|
9
|
+
// If each command file had its own chalk/ora setup, changing
|
|
10
|
+
// a color or spacing would require editing every file. This
|
|
11
|
+
// way you change it once here and it applies everywhere.
|
|
12
|
+
// ─────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
import chalk from "chalk";
|
|
15
|
+
import ora, { Ora } from "ora";
|
|
16
|
+
|
|
17
|
+
// ── Banner ────────────────────────────────────────────────────
|
|
18
|
+
// Printed once at the start of every command run.
|
|
19
|
+
|
|
20
|
+
export function printBanner(): void {
|
|
21
|
+
console.log();
|
|
22
|
+
console.log(chalk.cyan.bold(" ┌─────────────────────────────────┐"));
|
|
23
|
+
console.log(chalk.cyan.bold(" │ 🩺 React Doctor │"));
|
|
24
|
+
console.log(chalk.cyan.bold(" │ React Performance Analyzer │"));
|
|
25
|
+
console.log(chalk.cyan.bold(" └─────────────────────────────────┘"));
|
|
26
|
+
console.log();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── Section headers ───────────────────────────────────────────
|
|
30
|
+
// Visually separates sections within a command's output.
|
|
31
|
+
|
|
32
|
+
export function printSection(title: string): void {
|
|
33
|
+
console.log();
|
|
34
|
+
console.log(chalk.cyan.bold(` ── ${title} `).padEnd(58, "─"));
|
|
35
|
+
console.log();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Spinner ───────────────────────────────────────────────────
|
|
39
|
+
// Returns a running ora spinner.
|
|
40
|
+
// Caller calls .succeed() or .fail() when done.
|
|
41
|
+
|
|
42
|
+
export function spinner(text: string): Ora {
|
|
43
|
+
return ora({ text: ` ${text}`, color: "cyan", spinner: "dots" }).start();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Single result line ────────────────────────────────────────
|
|
47
|
+
// Prints one labeled metric with a colored status dot.
|
|
48
|
+
|
|
49
|
+
export function printResult(
|
|
50
|
+
label: string,
|
|
51
|
+
value: string,
|
|
52
|
+
status: "good" | "warn" | "poor" | "info" | "none" = "none",
|
|
53
|
+
): void {
|
|
54
|
+
const dot =
|
|
55
|
+
status === "good" ? chalk.green("●") :
|
|
56
|
+
status === "warn" ? chalk.yellow("●") :
|
|
57
|
+
status === "poor" ? chalk.red("●") :
|
|
58
|
+
status === "info" ? chalk.blue("●") :
|
|
59
|
+
chalk.gray("·");
|
|
60
|
+
|
|
61
|
+
console.log(` ${dot} ${chalk.gray(label.padEnd(24))} ${value}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Score badge ───────────────────────────────────────────────
|
|
65
|
+
// Formats a 0-100 score with a color band.
|
|
66
|
+
|
|
67
|
+
export function scoreBadge(score: number): string {
|
|
68
|
+
if (score >= 90) return chalk.green.bold(`${score}/100`) + chalk.green(" Excellent");
|
|
69
|
+
if (score >= 70) return chalk.yellow.bold(`${score}/100`) + chalk.yellow(" Good");
|
|
70
|
+
if (score >= 50) return chalk.hex("#FFA500").bold(`${score}/100`) + chalk.hex("#FFA500")(" Needs Work");
|
|
71
|
+
return chalk.red.bold(`${score}/100`) + chalk.red(" Poor");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Severity icon ─────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
export function severityIcon(s: string): string {
|
|
77
|
+
if (s === "critical") return chalk.red("❌");
|
|
78
|
+
if (s === "warning") return chalk.yellow("⚠️ ");
|
|
79
|
+
return chalk.blue("ℹ️ ");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Done / fail ───────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
export function printDone(message: string): void {
|
|
85
|
+
console.log();
|
|
86
|
+
console.log(chalk.green.bold(` ✅ ${message}`));
|
|
87
|
+
console.log();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function printFail(message: string): void {
|
|
91
|
+
console.log();
|
|
92
|
+
console.log(chalk.red.bold(` ❌ ${message}`));
|
|
93
|
+
console.log();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Muted info line ───────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
export function printInfo(label: string, value: string): void {
|
|
99
|
+
console.log(` ${chalk.gray(label.padEnd(16))} ${chalk.white(value)}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Web vital threshold helper ────────────────────────────────
|
|
103
|
+
// Maps a metric value to good/warn/poor for colored output.
|
|
104
|
+
|
|
105
|
+
export function vitalStatus(
|
|
106
|
+
metric: "lcp" | "fcp" | "ttfb" | "inp" | "cls",
|
|
107
|
+
value: number,
|
|
108
|
+
): "good" | "warn" | "poor" {
|
|
109
|
+
const thresholds: Record<string, { good: number; poor: number }> = {
|
|
110
|
+
lcp: { good: 2500, poor: 4000 },
|
|
111
|
+
fcp: { good: 1800, poor: 3000 },
|
|
112
|
+
ttfb: { good: 800, poor: 1800 },
|
|
113
|
+
inp: { good: 200, poor: 500 },
|
|
114
|
+
cls: { good: 0.1, poor: 0.25 },
|
|
115
|
+
};
|
|
116
|
+
const t = thresholds[metric];
|
|
117
|
+
if (value <= t.good) return "good";
|
|
118
|
+
if (value <= t.poor) return "warn";
|
|
119
|
+
return "poor";
|
|
120
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "commonjs",
|
|
5
|
+
"lib": ["ES2020"],
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"rootDir": "./src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"resolveJsonModule": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*"],
|
|
15
|
+
"exclude": ["node_modules", "dist"]
|
|
16
|
+
}
|