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,209 @@
|
|
|
1
|
+
// Takes a fired rule and the evaluation context, and produces
|
|
2
|
+
// a clean Suggestion object ready for the report.
|
|
3
|
+
//
|
|
4
|
+
// The key job of this file is PLACEHOLDER RESOLUTION.
|
|
5
|
+
// Rule messages in rules.json contain tokens like {metrics.lcp}
|
|
6
|
+
// that get replaced with real values from the context.
|
|
7
|
+
//
|
|
8
|
+
// Example:
|
|
9
|
+
// Template: "Your LCP is {metrics.lcp}ms."
|
|
10
|
+
// Context: metrics.lcp = 2396
|
|
11
|
+
// Output: "Your LCP is 2396ms."
|
|
12
|
+
//
|
|
13
|
+
// This makes every suggestion specific to the actual project
|
|
14
|
+
// being analyzed — not just generic advice.
|
|
15
|
+
// ─────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
import { Suggestion } from "../../shared/src/types";
|
|
18
|
+
import { RuleDefinition, EvaluationContext } from "./types";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Builds a Suggestion object from a fired rule and the context.
|
|
22
|
+
*
|
|
23
|
+
* The returned Suggestion has:
|
|
24
|
+
* id — same as the rule id
|
|
25
|
+
* title — the rule title (no placeholders)
|
|
26
|
+
* description — the rule message with placeholders resolved
|
|
27
|
+
* severity — copied from the rule
|
|
28
|
+
* affectedComponent — the most relevant component name (if any)
|
|
29
|
+
* fix — the concrete fix instructions
|
|
30
|
+
*
|
|
31
|
+
* @param rule — the rule that fired
|
|
32
|
+
* @param context — the context used to resolve placeholders
|
|
33
|
+
*/
|
|
34
|
+
export function buildSuggestion(
|
|
35
|
+
rule: RuleDefinition,
|
|
36
|
+
context: EvaluationContext,
|
|
37
|
+
): Suggestion {
|
|
38
|
+
// Resolve all {placeholder} tokens in the message string.
|
|
39
|
+
// The fix string can also contain placeholders like {component}.
|
|
40
|
+
const description = resolvePlaceholders(rule.message, context);
|
|
41
|
+
const fix = resolvePlaceholders(rule.fix, context);
|
|
42
|
+
|
|
43
|
+
// Find the most relevant component name for this suggestion.
|
|
44
|
+
// We use this to populate the affectedComponent field, which
|
|
45
|
+
// the dashboard uses to highlight the specific component.
|
|
46
|
+
const affectedComponent = resolveAffectedComponent(rule, context);
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
id: rule.id,
|
|
50
|
+
title: rule.title,
|
|
51
|
+
description,
|
|
52
|
+
severity: rule.severity,
|
|
53
|
+
fix,
|
|
54
|
+
...(affectedComponent ? { affectedComponent } : {}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolves {placeholder} tokens in a template string.
|
|
60
|
+
*
|
|
61
|
+
* Supported placeholders and what they resolve to:
|
|
62
|
+
*
|
|
63
|
+
* {metrics.lcp} → actual LCP value rounded to nearest ms
|
|
64
|
+
* {metrics.fcp} → actual FCP value rounded to nearest ms
|
|
65
|
+
* {metrics.cls} → CLS score rounded to 3 decimal places
|
|
66
|
+
* {metrics.inp} → INP value rounded to nearest ms
|
|
67
|
+
* {metrics.ttfb} → TTFB value rounded to nearest ms
|
|
68
|
+
* {renderTime} → total render time in ms
|
|
69
|
+
* {commitDuration} → slowest React commit duration in ms
|
|
70
|
+
* {avgCommit} → average commit duration in ms (1 decimal)
|
|
71
|
+
* {rerenders} → re-render count of the top component
|
|
72
|
+
* {component} → name of the most re-rendered component
|
|
73
|
+
* {domNodes} → number of DOM nodes
|
|
74
|
+
* {payloadMB} → page weight in MB (2 decimal places)
|
|
75
|
+
* {jsHeapMB} → JS heap usage in MB (2 decimal places)
|
|
76
|
+
* {errorCount} → number of JS errors
|
|
77
|
+
* {performanceScore}→ 0-100 performance score
|
|
78
|
+
*
|
|
79
|
+
* Unknown placeholders are left as-is so they don't silently
|
|
80
|
+
* disappear if there's a typo in rules.json.
|
|
81
|
+
*/
|
|
82
|
+
function resolvePlaceholders(
|
|
83
|
+
template: string,
|
|
84
|
+
context: EvaluationContext,
|
|
85
|
+
): string {
|
|
86
|
+
// Build a map of all known placeholder names → resolved values.
|
|
87
|
+
// We format numbers here so the messages read naturally.
|
|
88
|
+
const values: Record<string, string> = {
|
|
89
|
+
"metrics.lcp": `${Math.round(context.metrics.lcp)}`,
|
|
90
|
+
"metrics.fcp": `${Math.round(context.metrics.fcp)}`,
|
|
91
|
+
"metrics.cls": `${context.metrics.cls.toFixed(3)}`,
|
|
92
|
+
"metrics.inp": `${Math.round(context.metrics.inp)}`,
|
|
93
|
+
"metrics.ttfb": `${Math.round(context.metrics.ttfb)}`,
|
|
94
|
+
"renderTime": `${context.renderTime}`,
|
|
95
|
+
"commitDuration": `${context.slowestCommit.toFixed(1)}`,
|
|
96
|
+
"avgCommit": `${context.avgCommit.toFixed(1)}`,
|
|
97
|
+
"rerenders": `${context.topRerenderCount}`,
|
|
98
|
+
"component": context.topRerenderComponent || "unknown",
|
|
99
|
+
"domNodes": `${context.domNodes}`,
|
|
100
|
+
"payloadMB": `${context.payloadMB.toFixed(2)}`,
|
|
101
|
+
"jsHeapMB": `${context.jsHeapMB.toFixed(2)}`,
|
|
102
|
+
"errorCount": `${context.errorCount}`,
|
|
103
|
+
"slowCommitCount": `${context.slowCommitCount}`,
|
|
104
|
+
"performanceScore": `${context.performanceScore}`,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Replace every {placeholder} in the template string.
|
|
108
|
+
// The regex matches anything between { and } and looks it up
|
|
109
|
+
// in the values map above.
|
|
110
|
+
return template.replace(/\{([^}]+)\}/g, (match, key) => {
|
|
111
|
+
// If the key is known, return its value. Otherwise keep the
|
|
112
|
+
// original {key} so the developer knows something is missing.
|
|
113
|
+
return values[key] ?? match;
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Determines which component is most relevant to this suggestion.
|
|
119
|
+
*
|
|
120
|
+
* Priority order:
|
|
121
|
+
* 1. If the rule is about re-renders, use the top re-render component
|
|
122
|
+
* 2. If there's a static issue matching the rule category, use that component
|
|
123
|
+
* 3. Otherwise return undefined (no specific component to blame)
|
|
124
|
+
*
|
|
125
|
+
* The affectedComponent name is shown in the dashboard to help
|
|
126
|
+
* the developer navigate directly to the problematic file.
|
|
127
|
+
*/
|
|
128
|
+
function resolveAffectedComponent(
|
|
129
|
+
rule: RuleDefinition,
|
|
130
|
+
context: EvaluationContext,
|
|
131
|
+
): string | undefined {
|
|
132
|
+
// For re-render related rules, the top re-render component is the target.
|
|
133
|
+
// These rules fire because a specific component is re-rendering too much,
|
|
134
|
+
// so we point the developer directly at that component.
|
|
135
|
+
//
|
|
136
|
+
// NOTE: "inline-function" is intentionally excluded here now — the new
|
|
137
|
+
// static-only "inline-functions-detected" rule should resolve its component
|
|
138
|
+
// from the static issues map below, not from the re-render data. The cross
|
|
139
|
+
// rule "inline-functions-with-high-rerenders" still uses the re-render data
|
|
140
|
+
// because it fires when both are true.
|
|
141
|
+
if (
|
|
142
|
+
rule.id.includes("excessive-rerender") ||
|
|
143
|
+
rule.id.includes("missing-memo") ||
|
|
144
|
+
rule.id.includes("slow-commit") ||
|
|
145
|
+
rule.id === "inline-functions-with-high-rerenders"
|
|
146
|
+
) {
|
|
147
|
+
if (context.topRerenderComponent) {
|
|
148
|
+
return context.topRerenderComponent;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// For static rules (and cross rules with a static component), find the
|
|
153
|
+
// first matching issue's component name from the static report.
|
|
154
|
+
//
|
|
155
|
+
// The map below links each rule ID to the issue ID prefix it targets.
|
|
156
|
+
// Example: "missing-list-keys" targets issues starting with "missing-key"
|
|
157
|
+
// because generateIssueId('missing-key', file, line) is how the detector
|
|
158
|
+
// names them. startsWith() catches all of them regardless of file/line.
|
|
159
|
+
//
|
|
160
|
+
// FIX: Added "inline-functions-detected" for the new static-only rule.
|
|
161
|
+
// FIX: console-log and dead-code issues DO carry a component name from
|
|
162
|
+
// the detector — they were already in the map and working correctly.
|
|
163
|
+
const staticPrefixMap: Record<string, string> = {
|
|
164
|
+
"console-logs-in-production": "console-log",
|
|
165
|
+
"missing-list-keys": "missing-key",
|
|
166
|
+
"inline-styles-detected": "inline-style",
|
|
167
|
+
"prop-drilling-detected": "prop-drilling",
|
|
168
|
+
"dead-code-detected": "dead-code",
|
|
169
|
+
"effect-loop-risk": "effect-loop",
|
|
170
|
+
"missing-memo-with-rerenders": "missing-memo",
|
|
171
|
+
"large-component-with-slow-commit": "large-component",
|
|
172
|
+
"inline-functions-with-high-rerenders": "inline-function",
|
|
173
|
+
"inline-functions-detected": "inline-function", // FIX: new rule
|
|
174
|
+
"missing-memo-with-slow-commit": "missing-memo",
|
|
175
|
+
"prop-drilling-with-rerenders": "prop-drilling",
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const prefix = staticPrefixMap[rule.id];
|
|
179
|
+
if (prefix) {
|
|
180
|
+
const matchingIssue = context.issues.find(i => i.id.startsWith(prefix));
|
|
181
|
+
if (matchingIssue?.component) {
|
|
182
|
+
return matchingIssue.component;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Rules like slow-lcp, slow-ttfb, heavy-payload, high-dom-nodes,
|
|
187
|
+
// low-performance-score are page-level — no single component to blame.
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Builds all suggestions from a list of fired rules.
|
|
193
|
+
* Sorts them by severity: critical → warning → info.
|
|
194
|
+
*
|
|
195
|
+
* @param firedRules — rules whose conditions evaluated to true
|
|
196
|
+
* @param context — the evaluation context for placeholder resolution
|
|
197
|
+
*/
|
|
198
|
+
export function buildAllSuggestions(
|
|
199
|
+
firedRules: RuleDefinition[],
|
|
200
|
+
context: EvaluationContext,
|
|
201
|
+
): Suggestion[] {
|
|
202
|
+
const severityOrder = { critical: 0, warning: 1, info: 2 };
|
|
203
|
+
|
|
204
|
+
return firedRules
|
|
205
|
+
.map(rule => buildSuggestion(rule, context))
|
|
206
|
+
.sort((a, b) =>
|
|
207
|
+
(severityOrder[a.severity] ?? 3) - (severityOrder[b.severity] ?? 3),
|
|
208
|
+
);
|
|
209
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Test runner for the Rule Engine.
|
|
2
|
+
// Reads the existing staticreport.json and runtimereport.json
|
|
3
|
+
// from core/reports/ and runs the Rule Engine against them.
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// npx ts-node --compiler-options '{"module":"commonjs"}'
|
|
7
|
+
// rule-engine/test-rule-engine.ts
|
|
8
|
+
// ─────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
import path from "path";
|
|
11
|
+
import fs from "fs-extra";
|
|
12
|
+
import { RuleEngine } from "./index";
|
|
13
|
+
import { StaticReport, RuntimeReport } from "../../shared/src/types";
|
|
14
|
+
import { RuleEngineResult } from "./types";
|
|
15
|
+
|
|
16
|
+
// ── Severity badge helpers ────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
function severityIcon(s: string): string {
|
|
19
|
+
if (s === "critical") return "❌";
|
|
20
|
+
if (s === "warning") return "⚠️ ";
|
|
21
|
+
return "ℹ️ ";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function severityLabel(s: string): string {
|
|
25
|
+
if (s === "critical") return "CRITICAL";
|
|
26
|
+
if (s === "warning") return "WARNING";
|
|
27
|
+
return "INFO";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Load reports ─────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
async function loadReports(): Promise<{
|
|
33
|
+
staticReport: StaticReport | null;
|
|
34
|
+
runtimeReports: Record<string, RuntimeReport>;
|
|
35
|
+
}> {
|
|
36
|
+
// Reports live in core/reports/ — one level up from rule-engine/
|
|
37
|
+
const reportsDir = path.resolve(__dirname, "..", "reports");
|
|
38
|
+
|
|
39
|
+
const staticPath = path.join(reportsDir, "staticreport.json");
|
|
40
|
+
const runtimePath = path.join(reportsDir, "runtimereport.json");
|
|
41
|
+
|
|
42
|
+
let staticReport: StaticReport | null = null;
|
|
43
|
+
let runtimeReports: Record<string, RuntimeReport> = {};
|
|
44
|
+
|
|
45
|
+
// Load static report if it exists
|
|
46
|
+
if (fs.existsSync(staticPath)) {
|
|
47
|
+
staticReport = await fs.readJson(staticPath);
|
|
48
|
+
console.log(`✅ Loaded staticreport.json`);
|
|
49
|
+
} else {
|
|
50
|
+
console.log(`⚠️ staticreport.json not found — running without static data`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Load runtime report if it exists
|
|
54
|
+
if (fs.existsSync(runtimePath)) {
|
|
55
|
+
runtimeReports = await fs.readJson(runtimePath);
|
|
56
|
+
const routeCount = Object.keys(runtimeReports).length;
|
|
57
|
+
console.log(`✅ Loaded runtimereport.json (${routeCount} route(s))`);
|
|
58
|
+
} else {
|
|
59
|
+
console.log(`⚠️ runtimereport.json not found — running without runtime data`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { staticReport, runtimeReports };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Print results ─────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
function printResults(results: RuleEngineResult[]): void {
|
|
68
|
+
for (const result of results) {
|
|
69
|
+
console.log(`\n${"=".repeat(60)}`);
|
|
70
|
+
console.log(`📍 Route: ${result.route} [${result.device}]`);
|
|
71
|
+
console.log(`${"=".repeat(60)}`);
|
|
72
|
+
console.log(` Total suggestions: ${result.summary.total}`);
|
|
73
|
+
console.log(` ❌ Critical: ${result.summary.critical}`);
|
|
74
|
+
console.log(` ⚠️ Warnings: ${result.summary.warning}`);
|
|
75
|
+
console.log(` ℹ️ Info: ${result.summary.info}`);
|
|
76
|
+
|
|
77
|
+
if (result.suggestions.length === 0) {
|
|
78
|
+
console.log("\n ✅ No issues found — great work!\n");
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log();
|
|
83
|
+
|
|
84
|
+
for (const suggestion of result.suggestions) {
|
|
85
|
+
const icon = severityIcon(suggestion.severity);
|
|
86
|
+
const label = severityLabel(suggestion.severity);
|
|
87
|
+
|
|
88
|
+
console.log(`${icon} [${label}] ${suggestion.title}`);
|
|
89
|
+
|
|
90
|
+
if (suggestion.affectedComponent) {
|
|
91
|
+
console.log(` Component: ${suggestion.affectedComponent}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
console.log(` ${suggestion.description}`);
|
|
95
|
+
console.log();
|
|
96
|
+
console.log(` Fix:`);
|
|
97
|
+
|
|
98
|
+
// Print each line of the fix indented for readability
|
|
99
|
+
suggestion.fix.split("\n").forEach(line => {
|
|
100
|
+
console.log(` ${line}`);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
console.log(`${"─".repeat(60)}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Main ──────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
async function main() {
|
|
111
|
+
console.log("=========================================================");
|
|
112
|
+
console.log("🧠 REACT DOCTOR — RULE ENGINE TEST");
|
|
113
|
+
console.log("=========================================================\n");
|
|
114
|
+
|
|
115
|
+
// Load reports from disk
|
|
116
|
+
const { staticReport, runtimeReports } = await loadReports();
|
|
117
|
+
|
|
118
|
+
if (!staticReport && Object.keys(runtimeReports).length === 0) {
|
|
119
|
+
console.error(
|
|
120
|
+
"\n❌ No reports found.\n" +
|
|
121
|
+
" Run the static analyzer or profiler first:\n" +
|
|
122
|
+
" npx ts-node ... static-ana/test-analyzer.ts\n" +
|
|
123
|
+
" npx ts-node ... runtime/test-runtime-profiler.ts <path>\n",
|
|
124
|
+
);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Run the Rule Engine
|
|
129
|
+
const engine = new RuleEngine();
|
|
130
|
+
const results = await engine.run(staticReport, runtimeReports);
|
|
131
|
+
|
|
132
|
+
// Print results to terminal
|
|
133
|
+
printResults(results);
|
|
134
|
+
|
|
135
|
+
console.log("\n=========================================================");
|
|
136
|
+
console.log("✅ Rule Engine test complete.");
|
|
137
|
+
console.log(`📄 Suggestions saved to: core/reports/suggestions.json`);
|
|
138
|
+
console.log("=========================================================\n");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
main().catch(err => {
|
|
142
|
+
console.error("\n❌ Rule Engine error:", err.message);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { StaticReport, RuntimeReport, Suggestion } from "../../shared/src/types";
|
|
2
|
+
// ─────────────────────────────────────────────────────────────
|
|
3
|
+
// RULE DEFINITION
|
|
4
|
+
//
|
|
5
|
+
// This is the shape of each entry inside rules.json.
|
|
6
|
+
// When you add a new rule, you write it as this interface.
|
|
7
|
+
//
|
|
8
|
+
// Example rule:
|
|
9
|
+
// {
|
|
10
|
+
// "id": "slow-lcp",
|
|
11
|
+
// "category": "runtime",
|
|
12
|
+
// "severity": "critical",
|
|
13
|
+
// "title": "LCP is too slow",
|
|
14
|
+
// "condition": { "runtime": "metrics.lcp > 2500" },
|
|
15
|
+
// "message": "Your LCP is {metrics.lcp}ms. Consider lazy-loading your hero image.",
|
|
16
|
+
// "fix": "Add loading='lazy' to your largest above-the-fold image."
|
|
17
|
+
// }
|
|
18
|
+
// ─────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
export interface RuleCondition {
|
|
21
|
+
// A JavaScript expression string evaluated against the runtime report.
|
|
22
|
+
// Example: "metrics.lcp > 2500"
|
|
23
|
+
// Example: "rerenders['Divider'] >= 5"
|
|
24
|
+
// Can be undefined if this rule doesn't check runtime data.
|
|
25
|
+
runtime?: string;
|
|
26
|
+
|
|
27
|
+
// A JavaScript expression string evaluated against the static report.
|
|
28
|
+
// Example: "issues.some(i => i.id.startsWith('missing-memo'))"
|
|
29
|
+
// Example: "issues.filter(i => i.id.startsWith('console-log')).length > 0"
|
|
30
|
+
// Can be undefined if this rule doesn't check static data.
|
|
31
|
+
static?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RuleDefinition {
|
|
35
|
+
// Unique identifier for this rule — used to deduplicate suggestions
|
|
36
|
+
// and reference specific rules in tests.
|
|
37
|
+
// Example: "slow-lcp", "missing-memo-with-rerenders"
|
|
38
|
+
id: string;
|
|
39
|
+
|
|
40
|
+
// Which category this rule belongs to:
|
|
41
|
+
// "runtime" — only uses data from the runtime profiler
|
|
42
|
+
// "static" — only uses data from the static analyzer
|
|
43
|
+
// "cross" — combines both reports for a deeper insight
|
|
44
|
+
// Cross rules are the most powerful — they catch
|
|
45
|
+
// problems that neither analysis alone could find.
|
|
46
|
+
category: "runtime" | "static" | "cross";
|
|
47
|
+
|
|
48
|
+
// How serious this problem is:
|
|
49
|
+
// "critical" — must fix, directly hurts users
|
|
50
|
+
// "warning" — should fix, noticeable impact
|
|
51
|
+
// "info" — nice to fix, minor improvement
|
|
52
|
+
severity: "critical" | "warning" | "info";
|
|
53
|
+
|
|
54
|
+
// Short headline shown in the dashboard and terminal output.
|
|
55
|
+
// Example: "LCP is too slow"
|
|
56
|
+
title: string;
|
|
57
|
+
|
|
58
|
+
// The condition(s) that must be true for this rule to fire.
|
|
59
|
+
// For "runtime" category: only condition.runtime is required.
|
|
60
|
+
// For "static" category: only condition.static is required.
|
|
61
|
+
// For "cross" category: BOTH must be true simultaneously.
|
|
62
|
+
condition: RuleCondition;
|
|
63
|
+
|
|
64
|
+
// The explanation message shown to the developer.
|
|
65
|
+
// Supports {placeholder} tokens that get replaced with real values.
|
|
66
|
+
// Available tokens:
|
|
67
|
+
// {metrics.lcp} — actual LCP value from the runtime report
|
|
68
|
+
// {metrics.fcp} — actual FCP value
|
|
69
|
+
// {renderTime} — total render time
|
|
70
|
+
// {component} — name of the affected component (if found)
|
|
71
|
+
// {rerenders} — re-render count of the affected component
|
|
72
|
+
// {commitDuration} — slowest commit duration
|
|
73
|
+
// Example: "Your LCP is {metrics.lcp}ms — users wait too long."
|
|
74
|
+
message: string;
|
|
75
|
+
|
|
76
|
+
// A concrete, actionable fix the developer can apply right now.
|
|
77
|
+
// Be specific — not "improve performance" but "add React.lazy() to..."
|
|
78
|
+
fix: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─────────────────────────────────────────────────────────────
|
|
82
|
+
// EVALUATION CONTEXT
|
|
83
|
+
//
|
|
84
|
+
// This object is assembled from both reports and passed to the
|
|
85
|
+
// evaluator. It contains everything a rule condition can access.
|
|
86
|
+
//
|
|
87
|
+
// When a condition string like "metrics.lcp > 2500" is evaluated,
|
|
88
|
+
// it runs as a JS expression with this object as its scope.
|
|
89
|
+
// ─────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
export interface EvaluationContext {
|
|
92
|
+
// ── Runtime fields ──────────────────────────────────────────
|
|
93
|
+
// The 5 web vitals (all in ms except cls which is a score)
|
|
94
|
+
metrics: {
|
|
95
|
+
lcp: number;
|
|
96
|
+
fcp: number;
|
|
97
|
+
cls: number;
|
|
98
|
+
inp: number;
|
|
99
|
+
ttfb: number;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// Total time from navigation start to networkidle0 (ms)
|
|
103
|
+
renderTime: number;
|
|
104
|
+
|
|
105
|
+
// Re-render counts per component: { "Divider": 5, "App": 1, ... }
|
|
106
|
+
rerenders: Record<string, number>;
|
|
107
|
+
|
|
108
|
+
// How long each React commit took in ms: [40.2, 1.1, 8.7]
|
|
109
|
+
commitDurations: number[];
|
|
110
|
+
|
|
111
|
+
// Slowest single commit duration in ms (0 if no commits recorded)
|
|
112
|
+
slowestCommit: number;
|
|
113
|
+
|
|
114
|
+
// Average commit duration in ms (0 if no commits recorded)
|
|
115
|
+
avgCommit: number;
|
|
116
|
+
|
|
117
|
+
// Number of commits that exceeded the 16ms 60fps budget
|
|
118
|
+
slowCommitCount: number;
|
|
119
|
+
|
|
120
|
+
// Total number of DOM elements on the page
|
|
121
|
+
domNodes: number;
|
|
122
|
+
|
|
123
|
+
// Total page payload in MB (as a number, not a string)
|
|
124
|
+
payloadMB: number;
|
|
125
|
+
|
|
126
|
+
// JS heap usage in MB (as a number, not a string)
|
|
127
|
+
jsHeapMB: number;
|
|
128
|
+
|
|
129
|
+
// JS errors caught during page load
|
|
130
|
+
errors: Array<{ type: "error" | "warning"; message: string }>;
|
|
131
|
+
|
|
132
|
+
// Number of JS errors (shorthand for errors.filter(...).length)
|
|
133
|
+
errorCount: number;
|
|
134
|
+
|
|
135
|
+
// Number of console warnings
|
|
136
|
+
warningCount: number;
|
|
137
|
+
|
|
138
|
+
// The 0–100 performance score calculated by the profiler
|
|
139
|
+
performanceScore: number;
|
|
140
|
+
|
|
141
|
+
// ── Static fields ────────────────────────────────────────────
|
|
142
|
+
// All issues found by the static analyzer
|
|
143
|
+
issues: Array<{
|
|
144
|
+
id: string;
|
|
145
|
+
component: string;
|
|
146
|
+
file: string;
|
|
147
|
+
line: number;
|
|
148
|
+
severity: string;
|
|
149
|
+
message: string;
|
|
150
|
+
}>;
|
|
151
|
+
|
|
152
|
+
// Shorthand counts by severity
|
|
153
|
+
criticalIssueCount: number;
|
|
154
|
+
warningIssueCount: number;
|
|
155
|
+
|
|
156
|
+
// True if any issue ID starts with the given prefix.
|
|
157
|
+
// Used in conditions like: hasIssue('missing-memo')
|
|
158
|
+
// This is a function placed on the context object so conditions
|
|
159
|
+
// can call it: "hasIssue('missing-memo')"
|
|
160
|
+
hasIssue: (prefix: string) => boolean;
|
|
161
|
+
|
|
162
|
+
// Returns all issues whose ID starts with the given prefix.
|
|
163
|
+
// Used when you need the actual issue objects in a condition.
|
|
164
|
+
// Example: "getIssues('large-component').length > 0"
|
|
165
|
+
getIssues: (prefix: string) => EvaluationContext["issues"];
|
|
166
|
+
|
|
167
|
+
// The name of the component most affected (highest re-render count).
|
|
168
|
+
// Used in message placeholders: "Wrap {component} in React.memo()"
|
|
169
|
+
topRerenderComponent: string;
|
|
170
|
+
|
|
171
|
+
// The re-render count of the top component above
|
|
172
|
+
topRerenderCount: number;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ─────────────────────────────────────────────────────────────
|
|
176
|
+
// RULE ENGINE RESULT
|
|
177
|
+
//
|
|
178
|
+
// The final output of the Rule Engine — what gets written to
|
|
179
|
+
// suggestions.json and passed to the Report Compiler.
|
|
180
|
+
// ─────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
export interface RuleEngineResult {
|
|
183
|
+
// ISO timestamp of when the rule engine ran
|
|
184
|
+
timestamp: string;
|
|
185
|
+
|
|
186
|
+
// Which route was analyzed (e.g. "/" or "/about")
|
|
187
|
+
route: string;
|
|
188
|
+
|
|
189
|
+
// Which device the runtime report came from
|
|
190
|
+
device: string;
|
|
191
|
+
|
|
192
|
+
// All generated suggestions, sorted by severity (critical first)
|
|
193
|
+
suggestions: Suggestion[];
|
|
194
|
+
|
|
195
|
+
// Summary counts for quick display
|
|
196
|
+
summary: {
|
|
197
|
+
critical: number;
|
|
198
|
+
warning: number;
|
|
199
|
+
info: number;
|
|
200
|
+
total: number;
|
|
201
|
+
};
|
|
202
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import os from "os";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import fs from "fs-extra";
|
|
4
|
+
import { execSync } from "child_process";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Finds and returns the path to the Chrome/Chromium executable.
|
|
8
|
+
*
|
|
9
|
+
* Windows: checks common installation paths including per-user AppData.
|
|
10
|
+
* Linux: checks all known system paths + tries `which` as a fallback.
|
|
11
|
+
* macOS: checks the standard Applications folder.
|
|
12
|
+
*
|
|
13
|
+
* Throws a clear error if no browser is found.
|
|
14
|
+
*/
|
|
15
|
+
export function getBrowserPath(): string {
|
|
16
|
+
const platform = os.platform();
|
|
17
|
+
|
|
18
|
+
if (platform === "win32") {
|
|
19
|
+
const winPaths = [
|
|
20
|
+
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
|
21
|
+
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
|
22
|
+
`C:\\Users\\${os.userInfo().username}\\AppData\\Local\\Google\\Chrome\\Application\\chrome.exe`,
|
|
23
|
+
"C:\\Program Files\\Chromium\\Application\\chrome.exe",
|
|
24
|
+
];
|
|
25
|
+
for (const p of winPaths) {
|
|
26
|
+
if (fs.existsSync(p)) return p;
|
|
27
|
+
}
|
|
28
|
+
throw new Error(
|
|
29
|
+
"❌ Chrome not found! Please install Google Chrome on Windows.\n" +
|
|
30
|
+
" Download: https://www.google.com/chrome/",
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (platform === "darwin") {
|
|
35
|
+
const macPaths = [
|
|
36
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
37
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
38
|
+
];
|
|
39
|
+
for (const p of macPaths) {
|
|
40
|
+
if (fs.existsSync(p)) return p;
|
|
41
|
+
}
|
|
42
|
+
throw new Error(
|
|
43
|
+
"❌ Chrome not found! Please install Google Chrome on macOS.\n" +
|
|
44
|
+
" Download: https://www.google.com/chrome/",
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Linux — check all known install locations
|
|
49
|
+
const linuxPaths = [
|
|
50
|
+
"/usr/bin/google-chrome",
|
|
51
|
+
"/usr/bin/google-chrome-stable",
|
|
52
|
+
"/usr/bin/chromium",
|
|
53
|
+
"/usr/bin/chromium-browser",
|
|
54
|
+
"/usr/local/bin/chrome",
|
|
55
|
+
"/usr/local/bin/chromium",
|
|
56
|
+
"/opt/google/chrome/chrome",
|
|
57
|
+
"/opt/google/chrome/google-chrome",
|
|
58
|
+
"/snap/bin/chromium",
|
|
59
|
+
"/snap/bin/google-chrome",
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
for (const p of linuxPaths) {
|
|
63
|
+
if (fs.existsSync(p)) return p;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Last resort: try `which` to find it anywhere on PATH
|
|
67
|
+
for (const cmd of ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"]) {
|
|
68
|
+
try {
|
|
69
|
+
const result = execSync(`which ${cmd}`, { stdio: ["ignore", "pipe", "ignore"] })
|
|
70
|
+
.toString()
|
|
71
|
+
.trim();
|
|
72
|
+
if (result && fs.existsSync(result)) return result;
|
|
73
|
+
} catch {
|
|
74
|
+
// not found via which, try next
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
throw new Error(
|
|
79
|
+
"❌ No compatible browser found on Linux!\n\n" +
|
|
80
|
+
" Install one of the following:\n" +
|
|
81
|
+
" Ubuntu/Debian: sudo apt install chromium-browser\n" +
|
|
82
|
+
" Fedora: sudo dnf install chromium\n" +
|
|
83
|
+
" Snap: sudo snap install chromium\n" +
|
|
84
|
+
" Or install Google Chrome from: https://www.google.com/chrome/",
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Reads the web-vitals IIFE bundle from local node_modules.
|
|
90
|
+
*
|
|
91
|
+
* Uses { content } injection — NO network request, works offline.
|
|
92
|
+
*/
|
|
93
|
+
export function getWebVitalsScript(projectPath: string, profilerDir: string): string {
|
|
94
|
+
const filename = "web-vitals.iife.js";
|
|
95
|
+
|
|
96
|
+
const candidates: string[] = [
|
|
97
|
+
path.resolve(profilerDir, "..", "..", "..", "node_modules", "web-vitals", "dist", filename),
|
|
98
|
+
path.resolve(profilerDir, "..", "..", "..", "..", "node_modules", "web-vitals", "dist", filename),
|
|
99
|
+
path.join(projectPath, "node_modules", "web-vitals", "dist", filename),
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const pkgJson = require.resolve("web-vitals/package.json");
|
|
104
|
+
candidates.push(path.join(path.dirname(pkgJson), "dist", filename));
|
|
105
|
+
} catch {
|
|
106
|
+
// not resolvable from this module — fine, we have other candidates
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const candidate of candidates) {
|
|
110
|
+
if (fs.existsSync(candidate)) {
|
|
111
|
+
console.log(` ✅ web-vitals loaded from disk (offline-safe)`);
|
|
112
|
+
return fs.readFileSync(candidate, "utf-8");
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const searched = candidates.map(c => `\n ${c}`).join("");
|
|
117
|
+
throw new Error(
|
|
118
|
+
`❌ web-vitals not found.\n\n Searched in:${searched}\n\n` +
|
|
119
|
+
` Fix: run "npm install web-vitals" inside react-tool/\n`,
|
|
120
|
+
);
|
|
121
|
+
}
|