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,76 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import fs from "fs-extra";
|
|
3
|
+
import * as parser from "@babel/parser";
|
|
4
|
+
import * as traverseLib from "@babel/traverse";
|
|
5
|
+
|
|
6
|
+
const traverse: Function = (traverseLib as any).default ?? (traverseLib as any);
|
|
7
|
+
|
|
8
|
+
export class RouteScanner {
|
|
9
|
+
static async scanForRoutes(projectPath: string): Promise<string[]> {
|
|
10
|
+
const routes: string[] = ["/"];
|
|
11
|
+
|
|
12
|
+
const potentialFiles = [
|
|
13
|
+
path.join(projectPath, "src", "App.tsx"),
|
|
14
|
+
path.join(projectPath, "src", "App.jsx"),
|
|
15
|
+
path.join(projectPath, "src", "main.tsx"),
|
|
16
|
+
path.join(projectPath, "src", "main.jsx"),
|
|
17
|
+
path.join(projectPath, "src", "routes.tsx"),
|
|
18
|
+
path.join(projectPath, "src", "routes.jsx"),
|
|
19
|
+
path.join(projectPath, "src", "router.tsx"),
|
|
20
|
+
path.join(projectPath, "src", "router.jsx"),
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
for (const filePath of potentialFiles) {
|
|
24
|
+
if (!fs.existsSync(filePath)) continue;
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const code = await fs.readFile(filePath, "utf-8");
|
|
28
|
+
const ast = parser.parse(code, {
|
|
29
|
+
sourceType: "module",
|
|
30
|
+
plugins: ["jsx", "typescript"],
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
traverse(ast, {
|
|
34
|
+
// āā React Router v5: <Route path="/foo" /> āāāāāāāāāā
|
|
35
|
+
JSXOpeningElement(p: any) {
|
|
36
|
+
const isRoute = (p.node.name as any).name === "Route";
|
|
37
|
+
if (isRoute) {
|
|
38
|
+
const pathAttr = p.node.attributes.find(
|
|
39
|
+
(attr: any) => attr.name?.name === "path",
|
|
40
|
+
);
|
|
41
|
+
if (
|
|
42
|
+
pathAttr &&
|
|
43
|
+
"value" in pathAttr &&
|
|
44
|
+
pathAttr.value?.type === "StringLiteral"
|
|
45
|
+
) {
|
|
46
|
+
routes.push(pathAttr.value.value);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
// āā React Router v6: createBrowserRouter([{ path: "/foo" }]) āā
|
|
52
|
+
ObjectExpression(p: any) {
|
|
53
|
+
const pathProp = p.node.properties.find(
|
|
54
|
+
(prop: any) =>
|
|
55
|
+
prop.type === "ObjectProperty" &&
|
|
56
|
+
prop.key?.name === "path" &&
|
|
57
|
+
prop.value?.type === "StringLiteral",
|
|
58
|
+
);
|
|
59
|
+
if (pathProp) {
|
|
60
|
+
const routePath = pathProp.value.value;
|
|
61
|
+
// Skip dynamic segments like /docs/:id ā only add static base routes
|
|
62
|
+
if (!routePath.includes(":")) {
|
|
63
|
+
routes.push(routePath);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
} catch {
|
|
69
|
+
// Skip files that fail to parse silently
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const result = [...new Set(routes)];
|
|
74
|
+
return result.length > 0 ? result : ["/"];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { WebVitals, PageError } from "../../../shared/src/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Calculates a 0ā100 performance score from all collected metrics.
|
|
5
|
+
*
|
|
6
|
+
* Each metric is normalized to 0ā100 based on its good/poor threshold,
|
|
7
|
+
* then a weighted average is computed. Weights must sum to 1.0:
|
|
8
|
+
*
|
|
9
|
+
* LCP 0.30 ā biggest impact on perceived load speed
|
|
10
|
+
* Render time 0.20 ā total time to interactive
|
|
11
|
+
* FCP 0.15 ā first sign of life
|
|
12
|
+
* Commit avg 0.15 ā React rendering efficiency
|
|
13
|
+
* TTFB 0.10 ā server response speed
|
|
14
|
+
* CLS 0.05 ā visual stability
|
|
15
|
+
* INP 0.05 ā interaction responsiveness
|
|
16
|
+
*
|
|
17
|
+
* Score bands: 90ā100 Excellent, 70ā89 Good, 50ā69 Needs Work, <50 Poor.
|
|
18
|
+
*
|
|
19
|
+
* Penalties:
|
|
20
|
+
* Each JS error ā -5 points (capped at -20)
|
|
21
|
+
* Each warning ā -2 points (capped at -10)
|
|
22
|
+
*/
|
|
23
|
+
export function calculatePerformanceScore(
|
|
24
|
+
vitals: WebVitals,
|
|
25
|
+
renderTime: number,
|
|
26
|
+
commitDurations: number[],
|
|
27
|
+
errors: PageError[],
|
|
28
|
+
): number {
|
|
29
|
+
// Normalize a value to 0ā100 where lower is better
|
|
30
|
+
function normalize(value: number, good: number, poor: number): number {
|
|
31
|
+
if (value <= good) return 100;
|
|
32
|
+
if (value >= poor) return 0;
|
|
33
|
+
return Math.round(100 * (1 - (value - good) / (poor - good)));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const avgCommit = commitDurations.length > 0
|
|
37
|
+
? commitDurations.reduce((a, b) => a + b, 0) / commitDurations.length
|
|
38
|
+
: 0;
|
|
39
|
+
|
|
40
|
+
const scores = {
|
|
41
|
+
lcp: normalize(vitals.lcp, 2500, 4000) * 0.30,
|
|
42
|
+
renderTime: normalize(renderTime, 2000, 5000) * 0.20,
|
|
43
|
+
fcp: normalize(vitals.fcp, 1800, 3000) * 0.15,
|
|
44
|
+
commitAvg: normalize(avgCommit, 16, 100) * 0.15,
|
|
45
|
+
ttfb: normalize(vitals.ttfb, 800, 1800) * 0.10,
|
|
46
|
+
cls: normalize(vitals.cls, 0.1, 0.25) * 0.05,
|
|
47
|
+
inp: normalize(vitals.inp, 200, 500) * 0.05,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
let score = Object.values(scores).reduce((a, b) => a + b, 0);
|
|
51
|
+
|
|
52
|
+
const errorCount = errors.filter(e => e.type === "error").length;
|
|
53
|
+
const warningCount = errors.filter(e => e.type === "warning").length;
|
|
54
|
+
score -= Math.min(errorCount * 5, 20);
|
|
55
|
+
score -= Math.min(warningCount * 2, 10);
|
|
56
|
+
|
|
57
|
+
return Math.max(0, Math.min(100, Math.round(score)));
|
|
58
|
+
}
|
|
59
|
+
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { spawn, ChildProcess, execSync } from "child_process";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import fs from "fs-extra";
|
|
4
|
+
import os from "os";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Detects which package manager the project uses by checking
|
|
8
|
+
* for lock files, then spawns the dev server as a background
|
|
9
|
+
* child process.
|
|
10
|
+
*
|
|
11
|
+
* Windows: shell:true so npm.cmd resolves correctly.
|
|
12
|
+
* Linux: tries /bin/bash first, falls back to /bin/sh if bash
|
|
13
|
+
* is not present (Alpine, minimal containers, etc).
|
|
14
|
+
*/
|
|
15
|
+
export function spawnDevServer(projectPath: string): ChildProcess {
|
|
16
|
+
const isWin = os.platform() === "win32";
|
|
17
|
+
|
|
18
|
+
const pkgManager = fs.existsSync(path.join(projectPath, "yarn.lock"))
|
|
19
|
+
? "yarn"
|
|
20
|
+
: fs.existsSync(path.join(projectPath, "pnpm-lock.yaml"))
|
|
21
|
+
? "pnpm"
|
|
22
|
+
: "npm";
|
|
23
|
+
|
|
24
|
+
console.log(`š¦ Starting ${pkgManager} dev server...`);
|
|
25
|
+
|
|
26
|
+
// On Linux, prefer bash but fall back to sh if bash is missing
|
|
27
|
+
const shell = isWin
|
|
28
|
+
? true
|
|
29
|
+
: fs.existsSync("/bin/bash")
|
|
30
|
+
? "/bin/bash"
|
|
31
|
+
: "/bin/sh";
|
|
32
|
+
|
|
33
|
+
return spawn(pkgManager, ["run", "dev"], {
|
|
34
|
+
cwd: projectPath,
|
|
35
|
+
shell,
|
|
36
|
+
env: {
|
|
37
|
+
...process.env,
|
|
38
|
+
...(isWin ? {} : { PATH: process.env.PATH + ":/usr/local/bin:/usr/bin:/bin" }),
|
|
39
|
+
},
|
|
40
|
+
detached: !isWin,
|
|
41
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
42
|
+
windowsHide: true,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Watches the dev server's stdout and stderr for a port number.
|
|
48
|
+
*
|
|
49
|
+
* Handles both "localhost:PORT" and "127.0.0.1:PORT" since
|
|
50
|
+
* some Linux setups print the IP instead of the hostname.
|
|
51
|
+
* Also handles "port PORT" format used by some dev servers.
|
|
52
|
+
*/
|
|
53
|
+
export function waitForServer(devServer: ChildProcess): Promise<number> {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
let resolved = false;
|
|
56
|
+
|
|
57
|
+
const timeout = setTimeout(
|
|
58
|
+
() => reject(new Error("ā±ļø Dev server timed out after 60 seconds!")),
|
|
59
|
+
60000,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const onData = (data: Buffer) => {
|
|
63
|
+
// Strip ANSI color codes before running the regex
|
|
64
|
+
const output = data.toString().replace(/\x1B\[[0-9;]*[mGKHF]/g, "");
|
|
65
|
+
console.log(` ${output.trim()}`);
|
|
66
|
+
|
|
67
|
+
// Match "localhost:PORT", "127.0.0.1:PORT", or "port PORT"
|
|
68
|
+
const match =
|
|
69
|
+
output.match(/(?:localhost|127\.0\.0\.1):(\d+)/) ??
|
|
70
|
+
output.match(/port\s+(\d+)/i);
|
|
71
|
+
|
|
72
|
+
if (match && !resolved) {
|
|
73
|
+
resolved = true;
|
|
74
|
+
clearTimeout(timeout);
|
|
75
|
+
devServer.stdout?.off("data", onData);
|
|
76
|
+
devServer.stderr?.off("data", onData);
|
|
77
|
+
const port = parseInt(match[1], 10);
|
|
78
|
+
// 2-second buffer: Vite announces port before it's fully bound
|
|
79
|
+
setTimeout(() => resolve(port), 2000);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
devServer.stdout?.on("data", onData);
|
|
84
|
+
devServer.stderr?.on("data", onData);
|
|
85
|
+
|
|
86
|
+
devServer.on("error", (err) => {
|
|
87
|
+
clearTimeout(timeout);
|
|
88
|
+
reject(new Error(`ā Dev server failed to start: ${err.message}`));
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Kills the dev server process tree cleanly on both platforms.
|
|
95
|
+
*
|
|
96
|
+
* Linux: process.kill(-pid) sends SIGTERM to the entire process group.
|
|
97
|
+
* Windows: taskkill /F /T kills the process tree.
|
|
98
|
+
* ESRCH: process already stopped ā ignored safely.
|
|
99
|
+
*/
|
|
100
|
+
export function killDevServer(devServer: ChildProcess): void {
|
|
101
|
+
if (!devServer.pid) return;
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
if (os.platform() === "win32") {
|
|
105
|
+
spawn("taskkill", ["/pid", devServer.pid.toString(), "/f", "/t"]);
|
|
106
|
+
} else {
|
|
107
|
+
process.kill(-devServer.pid);
|
|
108
|
+
}
|
|
109
|
+
} catch (error) {
|
|
110
|
+
const err = error as any;
|
|
111
|
+
if (err.code !== "ESRCH") {
|
|
112
|
+
console.warn(` ā ļø Cleanup warning: ${err.message}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export interface ReactProfilerData {
|
|
2
|
+
rerenders: Record<string, number>;
|
|
3
|
+
commitDurations: number[];
|
|
4
|
+
renderTime: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export type DeviceType = "desktop" | "mobile";
|
|
8
|
+
export type ThrottlePreset = "none" | "slow4g" | "3g";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* CPU throttle rate for Chrome's Emulation.setCPUThrottlingRate.
|
|
12
|
+
* 1 = real hardware speed
|
|
13
|
+
* 4 = 4x slowdown ā matches Lighthouse mobile preset (mid-range Android)
|
|
14
|
+
* 6 = 6x slowdown ā low-end device simulation
|
|
15
|
+
*
|
|
16
|
+
* Works on localhost because it slows JS execution itself, not network.
|
|
17
|
+
*/
|
|
18
|
+
export type CpuThrottle = 1 | 4 | 6;
|
|
19
|
+
|
|
20
|
+
export interface ProfileOptions {
|
|
21
|
+
// Single device or array to test both in one run
|
|
22
|
+
device?: DeviceType | DeviceType[];
|
|
23
|
+
throttle?: ThrottlePreset;
|
|
24
|
+
// 1 = no throttle (default), 4 = Lighthouse mobile, 6 = low-end
|
|
25
|
+
cpuThrottle?: CpuThrottle;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
29
|
+
// NETWORK PRESETS (Chrome DevTools Protocol values)
|
|
30
|
+
// downloadThroughput and uploadThroughput are in bytes/sec
|
|
31
|
+
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
32
|
+
export const NETWORK_PRESETS = {
|
|
33
|
+
none: null,
|
|
34
|
+
slow4g: {
|
|
35
|
+
downloadThroughput: (9 * 1024 * 1024) / 8,
|
|
36
|
+
uploadThroughput: (750 * 1024) / 8,
|
|
37
|
+
latency: 170,
|
|
38
|
+
},
|
|
39
|
+
"3g": {
|
|
40
|
+
downloadThroughput: (1.5 * 1024 * 1024) / 8,
|
|
41
|
+
uploadThroughput: (750 * 1024) / 8,
|
|
42
|
+
latency: 300,
|
|
43
|
+
},
|
|
44
|
+
} as const;
|
|
45
|
+
|
|
46
|
+
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
47
|
+
// DEVICE PRESETS
|
|
48
|
+
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
49
|
+
export const DEVICE_PRESETS = {
|
|
50
|
+
desktop: {
|
|
51
|
+
viewport: { width: 1280, height: 720 },
|
|
52
|
+
userAgent: null,
|
|
53
|
+
hasTouch: false,
|
|
54
|
+
isMobile: false,
|
|
55
|
+
},
|
|
56
|
+
mobile: {
|
|
57
|
+
viewport: { width: 390, height: 844 }, // iPhone 12 Pro
|
|
58
|
+
userAgent:
|
|
59
|
+
"Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) " +
|
|
60
|
+
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 " +
|
|
61
|
+
"Mobile/15E148 Safari/604.1",
|
|
62
|
+
hasTouch: true,
|
|
63
|
+
isMobile: true,
|
|
64
|
+
},
|
|
65
|
+
} as const;
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { RuntimeProfiler } from "./profiler";
|
|
2
|
+
|
|
3
|
+
import path from "path";
|
|
4
|
+
|
|
5
|
+
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
6
|
+
// STATUS HELPERS
|
|
7
|
+
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
8
|
+
|
|
9
|
+
type VitalKey = "lcp" | "fcp" | "ttfb" | "inp" | "cls";
|
|
10
|
+
|
|
11
|
+
const vitalThresholds: Record<VitalKey, { good: number; poor: number }> = {
|
|
12
|
+
lcp: { good: 2500, poor: 4000 },
|
|
13
|
+
fcp: { good: 1800, poor: 3000 },
|
|
14
|
+
ttfb: { good: 800, poor: 1800 },
|
|
15
|
+
inp: { good: 200, poor: 500 },
|
|
16
|
+
cls: { good: 0.1, poor: 0.25 },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function getStatus(metric: string, value: number): string {
|
|
20
|
+
const limit = vitalThresholds[metric.toLowerCase() as VitalKey];
|
|
21
|
+
if (!limit) return "āŖ Unknown";
|
|
22
|
+
return value <= limit.good ? "š¢ Good" : value <= limit.poor ? "š” Needs Improvement" : "š“ Poor";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type HealthKey = "heap" | "weight" | "nodes";
|
|
26
|
+
|
|
27
|
+
const healthThresholds: Record<HealthKey, { good: number; poor: number }> = {
|
|
28
|
+
heap: { good: 150, poor: 300 },
|
|
29
|
+
weight: { good: 3, poor: 8 },
|
|
30
|
+
nodes: { good: 1500, poor: 3000 },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function getSystemStatus(metric: string, value: number): string {
|
|
34
|
+
const limit = healthThresholds[metric.toLowerCase() as HealthKey];
|
|
35
|
+
if (!limit) return "āŖ Unknown";
|
|
36
|
+
return value <= limit.good ? "š¢ Good" : value <= limit.poor ? "š” Needs Improvement" : "š“ Poor";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getRenderTimeStatus(ms: number): string {
|
|
40
|
+
return ms <= 2000 ? "š¢ Good" : ms <= 4000 ? "š” Needs Improvement" : "š“ Poor";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Score badge ā clear visual band so you know at a glance how the app did
|
|
44
|
+
function getScoreBadge(score: number): string {
|
|
45
|
+
if (score >= 90) return `${score}/100 š¢ Excellent`;
|
|
46
|
+
if (score >= 70) return `${score}/100 š” Good`;
|
|
47
|
+
if (score >= 50) return `${score}/100 š Needs Work`;
|
|
48
|
+
return `${score}/100 š“ Poor`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function describeDevice(device: string): string {
|
|
52
|
+
return device === "mobile"
|
|
53
|
+
? "š± Mobile (iPhone 12 Pro, 390Ć844)"
|
|
54
|
+
: "š„ļø Desktop (1280Ć720)";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function describeThrottle(throttle: string): string {
|
|
58
|
+
if (throttle === "3g") return "š¢ 3G (1.5 Mbps / 300ms RTT)";
|
|
59
|
+
if (throttle === "slow4g") return "š Slow 4G (9 Mbps / 170ms RTT)";
|
|
60
|
+
return "ā” No throttle (localhost speed)";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function describeCpu(rate: number): string {
|
|
64
|
+
if (rate === 4) return "āļø CPU 4x slowdown (Lighthouse mobile preset)";
|
|
65
|
+
if (rate === 6) return "āļø CPU 6x slowdown (low-end device)";
|
|
66
|
+
return "āļø CPU no throttle (real hardware speed)";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
70
|
+
// AUDIT OPTIONS
|
|
71
|
+
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
72
|
+
|
|
73
|
+
// device: "desktop" | "mobile" | ["desktop", "mobile"]
|
|
74
|
+
// throttle: "none" | "slow4g" | "3g"
|
|
75
|
+
// ā ļø Only meaningful against deployed URLs, not localhost.
|
|
76
|
+
//
|
|
77
|
+
// cpuThrottle: 1 (default) | 4 (Lighthouse mobile) | 6 (low-end)
|
|
78
|
+
// ā
Works on localhost ā slows JS execution, not data transfer.
|
|
79
|
+
// Use 4 to simulate what a mid-range Android phone feels like.
|
|
80
|
+
const AUDIT_OPTIONS = {
|
|
81
|
+
device: ["desktop", "mobile"] as ("desktop" | "mobile")[],
|
|
82
|
+
throttle: "none" as const,
|
|
83
|
+
cpuThrottle: 1 as 1 | 4 | 6,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
87
|
+
// MAIN TEST
|
|
88
|
+
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
89
|
+
|
|
90
|
+
export async function startTest() {
|
|
91
|
+
console.log("=========================================================");
|
|
92
|
+
console.log("𩺠REACT DOCTOR: FULL SMART DIAGNOSTIC š©ŗ");
|
|
93
|
+
console.log("=========================================================");
|
|
94
|
+
|
|
95
|
+
const targetProject = path.resolve(
|
|
96
|
+
process.argv[2] || process.env.TARGET_PROJECT || process.cwd(),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
console.log(`š Target: ${targetProject}`);
|
|
100
|
+
|
|
101
|
+
const deviceList = Array.isArray(AUDIT_OPTIONS.device)
|
|
102
|
+
? AUDIT_OPTIONS.device
|
|
103
|
+
: [AUDIT_OPTIONS.device];
|
|
104
|
+
deviceList.forEach(d => console.log(describeDevice(d)));
|
|
105
|
+
console.log(describeThrottle(AUDIT_OPTIONS.throttle));
|
|
106
|
+
console.log(describeCpu(AUDIT_OPTIONS.cpuThrottle));
|
|
107
|
+
|
|
108
|
+
const profiler = new RuntimeProfiler(targetProject);
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
const masterReport = await profiler.profile([], AUDIT_OPTIONS);
|
|
112
|
+
const routesFound = Object.keys(masterReport);
|
|
113
|
+
|
|
114
|
+
if (routesFound.length === 0) {
|
|
115
|
+
console.log("\nā ļø No routes were audited.");
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
routesFound.forEach((key) => {
|
|
120
|
+
const report = masterReport[key];
|
|
121
|
+
const [route] = key.includes("::") ? key.split("::") : [key];
|
|
122
|
+
|
|
123
|
+
console.log(`\nš ROUTE: ${route}`);
|
|
124
|
+
console.log(` Device: ${report.deviceType} | CPU: ${report.cpuThrottling}x | Network: ${AUDIT_OPTIONS.throttle}`);
|
|
125
|
+
|
|
126
|
+
// āā PERFORMANCE SCORE āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
127
|
+
console.log(`\nš PERFORMANCE SCORE: ${getScoreBadge(report.performanceScore)}`);
|
|
128
|
+
console.log("---------------------------------------------------------");
|
|
129
|
+
|
|
130
|
+
// āā TABLE 1: WEB VITALS āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
131
|
+
console.log("ā” SPEED METRICS");
|
|
132
|
+
console.table({
|
|
133
|
+
"LCP (Paint)": { Value: `${report.metrics.lcp.toFixed(0)}ms`, Status: getStatus("lcp", report.metrics.lcp) },
|
|
134
|
+
"FCP (Content)": { Value: `${report.metrics.fcp.toFixed(0)}ms`, Status: getStatus("fcp", report.metrics.fcp) },
|
|
135
|
+
"TTFB (Server)": { Value: `${report.metrics.ttfb.toFixed(0)}ms`, Status: getStatus("ttfb", report.metrics.ttfb) },
|
|
136
|
+
"CLS (Stability)":{ Value: report.metrics.cls.toFixed(3), Status: getStatus("cls", report.metrics.cls) },
|
|
137
|
+
"INP (Response)": { Value: `${report.metrics.inp.toFixed(0)}ms`, Status: getStatus("inp", report.metrics.inp) },
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// āā TABLE 2: SYSTEM HEALTH āāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
141
|
+
console.log("𩺠SYSTEM HEALTH");
|
|
142
|
+
console.table({
|
|
143
|
+
"Memory (RAM)": { Value: `${report.stats.jsHeapMB} MB`, Status: getSystemStatus("heap", parseFloat(report.stats.jsHeapMB)) },
|
|
144
|
+
"Page Weight": { Value: `${report.stats.payloadMB} MB`, Status: getSystemStatus("weight", parseFloat(report.stats.payloadMB)) },
|
|
145
|
+
"DOM Nodes": { Value: report.stats.domNodes, Status: getSystemStatus("nodes", report.stats.domNodes) },
|
|
146
|
+
"Render Time": { Value: `${report.renderTime}ms`, Status: getRenderTimeStatus(report.renderTime) },
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
if (report.stats.topOffender) {
|
|
150
|
+
console.log(`š TOP OFFENDER: ${report.stats.topOffender.name} (${report.stats.topOffender.size.toFixed(2)} MB)`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// āā TABLE 3: REACT PROFILER āāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
154
|
+
console.log("\nāļø REACT PROFILER");
|
|
155
|
+
|
|
156
|
+
if (report.commitDurations.length > 0) {
|
|
157
|
+
const total = report.commitDurations.reduce((a, b) => a + b, 0);
|
|
158
|
+
const avg = (total / report.commitDurations.length).toFixed(2);
|
|
159
|
+
const max = Math.max(...report.commitDurations).toFixed(2);
|
|
160
|
+
const slow = report.commitDurations.filter(d => d > 16).length;
|
|
161
|
+
|
|
162
|
+
console.log(` Commits: ${report.commitDurations.length} total`);
|
|
163
|
+
console.log(` Avg commit: ${avg}ms`);
|
|
164
|
+
console.log(` Slowest: ${max}ms`);
|
|
165
|
+
if (slow > 0) {
|
|
166
|
+
console.log(` ā ļø ${slow} commit(s) exceeded 16ms (60fps budget)`);
|
|
167
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
console.log(" Commits: none recorded (app may not be in dev mode)");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const rerenderEntries = Object.entries(report.rerenders);
|
|
173
|
+
if (rerenderEntries.length > 0) {
|
|
174
|
+
console.log("\n Re-renders per component:");
|
|
175
|
+
const rerenderTable: Record<string, { Renders: number; Status: string }> = {};
|
|
176
|
+
for (const [name, count] of rerenderEntries.sort(([, a], [, b]) => b - a)) {
|
|
177
|
+
rerenderTable[name] = {
|
|
178
|
+
Renders: count,
|
|
179
|
+
Status: count >= 10 ? "š“ Excessive" : count >= 5 ? "š” High" : "š¢ Normal",
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
console.table(rerenderTable);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// āā ERRORS & WARNINGS āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
186
|
+
console.log("\nš ERRORS & WARNINGS");
|
|
187
|
+
if (report.errors.length === 0) {
|
|
188
|
+
console.log(" ā
No errors or warnings detected");
|
|
189
|
+
} else {
|
|
190
|
+
const jsErrors = report.errors.filter(e => e.type === "error");
|
|
191
|
+
const warnings = report.errors.filter(e => e.type === "warning");
|
|
192
|
+
|
|
193
|
+
if (jsErrors.length > 0) {
|
|
194
|
+
console.log(` ā ${jsErrors.length} error(s):`);
|
|
195
|
+
jsErrors.forEach(e => console.log(` [${e.source}] ${e.message.slice(0, 120)}`));
|
|
196
|
+
}
|
|
197
|
+
if (warnings.length > 0) {
|
|
198
|
+
console.log(` ā ļø ${warnings.length} warning(s):`);
|
|
199
|
+
warnings.forEach(e => console.log(` [${e.source}] ${e.message.slice(0, 120)}`));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// āā SCREENSHOTS āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
204
|
+
console.log("\nšø SCREENSHOTS");
|
|
205
|
+
if (report.screenshots.length === 0) {
|
|
206
|
+
console.log(" No screenshots captured");
|
|
207
|
+
} else {
|
|
208
|
+
report.screenshots.forEach(s => {
|
|
209
|
+
console.log(` ${s.label.padEnd(10)} ā captured at ${s.takenAt}ms`);
|
|
210
|
+
});
|
|
211
|
+
console.log(` š PNG files saved to: core/reports/screenshots/`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
console.log("---------------------------------------------------------");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
console.log("\nā
Full Diagnostic Finished Successfully.");
|
|
218
|
+
} catch (error) {
|
|
219
|
+
console.error("\nā PROFILER ERROR!");
|
|
220
|
+
console.error(error);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (require.main === module) {
|
|
225
|
+
startTest();
|
|
226
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { ASTParser } from './ast-parser';
|
|
4
|
+
import { StaticReport, ComponentIssue } from '../../../shared/src/types';
|
|
5
|
+
import { ScannedFile } from './scanner';
|
|
6
|
+
|
|
7
|
+
// Import all detectors
|
|
8
|
+
import {
|
|
9
|
+
detectConsoleLogs,
|
|
10
|
+
detectLargeComponents,
|
|
11
|
+
detectInlineFunctions,
|
|
12
|
+
detectDeadCode,
|
|
13
|
+
detectInfiniteLoops,
|
|
14
|
+
detectInlineStyles,
|
|
15
|
+
detectMissingKeys,
|
|
16
|
+
detectPropDrilling,
|
|
17
|
+
detectMissingMemo
|
|
18
|
+
} from './detectors';
|
|
19
|
+
|
|
20
|
+
export class StaticAnalyzer {
|
|
21
|
+
private parser: ASTParser;
|
|
22
|
+
|
|
23
|
+
constructor() {
|
|
24
|
+
this.parser = new ASTParser();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Orchestrates the analysis of multiple files
|
|
29
|
+
*/
|
|
30
|
+
async analyze(files: ScannedFile[]): Promise<StaticReport> {
|
|
31
|
+
console.log(`\n${"=".repeat(70)}`);
|
|
32
|
+
console.log(`š Static Analysis - Analyzing ${files.length} file(s)`);
|
|
33
|
+
console.log("=".repeat(70));
|
|
34
|
+
|
|
35
|
+
const allIssues: ComponentIssue[] = [];
|
|
36
|
+
let filesAnalyzed = 0;
|
|
37
|
+
let filesFailed = 0;
|
|
38
|
+
|
|
39
|
+
for (const file of files) {
|
|
40
|
+
try {
|
|
41
|
+
const code = fs.readFileSync(file.path, 'utf-8');
|
|
42
|
+
const ast = this.parser.parse(code, file.path);
|
|
43
|
+
|
|
44
|
+
const fileIssues = [
|
|
45
|
+
...detectConsoleLogs(ast, file.relativePath),
|
|
46
|
+
...detectLargeComponents(ast, file.relativePath),
|
|
47
|
+
...detectInlineFunctions(ast, file.relativePath),
|
|
48
|
+
...detectDeadCode(ast, file.relativePath),
|
|
49
|
+
...detectInfiniteLoops(ast, file.relativePath),
|
|
50
|
+
...detectInlineStyles(ast, file.relativePath),
|
|
51
|
+
...detectMissingKeys(ast, file.relativePath),
|
|
52
|
+
...detectPropDrilling(ast, file.relativePath),
|
|
53
|
+
...detectMissingMemo(ast, file.relativePath),
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
allIssues.push(...fileIssues);
|
|
57
|
+
filesAnalyzed++;
|
|
58
|
+
|
|
59
|
+
if (fileIssues.length > 0) {
|
|
60
|
+
console.log(` ā ļø ${file.relativePath.padEnd(50)} ${fileIssues.length} issue(s)`);
|
|
61
|
+
} else {
|
|
62
|
+
console.log(` ā
${file.relativePath}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
} catch (error) {
|
|
66
|
+
filesFailed++;
|
|
67
|
+
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
|
68
|
+
console.error(` ā ${file.relativePath.padEnd(50)} ${errorMsg}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const grade = this.calculateGrade(allIssues, filesAnalyzed);
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
timestamp: new Date().toISOString(),
|
|
76
|
+
componentCount: filesAnalyzed,
|
|
77
|
+
issues: allIssues,
|
|
78
|
+
filesAnalyzed,
|
|
79
|
+
filesFailed,
|
|
80
|
+
grade
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Persistence: Saves the results to static-report.json
|
|
86
|
+
*/
|
|
87
|
+
saveReport(report: StaticReport): string {
|
|
88
|
+
// š __dirname gives the directory of this current file.
|
|
89
|
+
// We go up two levels to reach the 'react_doctor' root, then into 'reports'.
|
|
90
|
+
const reportDir = path.resolve(__dirname, '../../reports');
|
|
91
|
+
|
|
92
|
+
// š Ensure the directory exists (create it if missing)
|
|
93
|
+
if (!fs.existsSync(reportDir)) {
|
|
94
|
+
fs.mkdirSync(reportDir, { recursive: true });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const filePath = path.join(reportDir, 'staticreport.json');
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
fs.writeFileSync(filePath, JSON.stringify(report, null, 2), 'utf-8');
|
|
101
|
+
console.log(`\nš Static Report saved to: ${filePath}`);
|
|
102
|
+
return filePath;
|
|
103
|
+
} catch (error) {
|
|
104
|
+
throw new Error(`Failed to save Static JSON report: ${error}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private calculateGrade(issues: ComponentIssue[], fileCount: number): string {
|
|
109
|
+
if (fileCount === 0) return 'A+';
|
|
110
|
+
const hasCritical = issues.some(i => i.severity === 'critical');
|
|
111
|
+
|
|
112
|
+
const totalPenalty = issues.reduce((acc, issue) => {
|
|
113
|
+
switch (issue.severity) {
|
|
114
|
+
case 'critical': return acc + 30;
|
|
115
|
+
case 'warning': return acc + 10;
|
|
116
|
+
case 'info': return acc + 2;
|
|
117
|
+
default: return acc;
|
|
118
|
+
}
|
|
119
|
+
}, 0);
|
|
120
|
+
|
|
121
|
+
const scorePerFile = totalPenalty / fileCount;
|
|
122
|
+
|
|
123
|
+
if (hasCritical) return 'F';
|
|
124
|
+
if (scorePerFile === 0) return 'A+';
|
|
125
|
+
if (scorePerFile < 5) return 'A';
|
|
126
|
+
if (scorePerFile < 15) return 'B';
|
|
127
|
+
if (scorePerFile < 30) return 'C';
|
|
128
|
+
if (scorePerFile < 50) return 'D';
|
|
129
|
+
return 'F';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
getSummary(report: StaticReport) {
|
|
133
|
+
const critical = report.issues.filter(i => i.severity === 'critical').length;
|
|
134
|
+
const warnings = report.issues.filter(i => i.severity === 'warning').length;
|
|
135
|
+
const info = report.issues.filter(i => i.severity === 'info').length;
|
|
136
|
+
|
|
137
|
+
const byDetector: Record<string, number> = {};
|
|
138
|
+
report.issues.forEach(issue => {
|
|
139
|
+
const detectorType = issue.id.split('-')[0];
|
|
140
|
+
byDetector[detectorType] = (byDetector[detectorType] || 0) + 1;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return { critical, warnings, info, byDetector };
|
|
144
|
+
}
|
|
145
|
+
}
|