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,359 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// report-compiler/index.ts
|
|
3
|
+
//
|
|
4
|
+
// The Report Compiler — the final step inside core.
|
|
5
|
+
//
|
|
6
|
+
// WHAT IT DOES:
|
|
7
|
+
// Reads the three separate JSON files produced by the other
|
|
8
|
+
// core components and merges them into one clean FinalReport.
|
|
9
|
+
//
|
|
10
|
+
// WHY IT EXISTS:
|
|
11
|
+
// The Static Analyzer, Runtime Profiler, and Rule Engine each
|
|
12
|
+
// save their own JSON files independently. The CLI needs a
|
|
13
|
+
// single object to upload to the backend and show to the user.
|
|
14
|
+
// This compiler does that merging so the CLI stays clean.
|
|
15
|
+
//
|
|
16
|
+
// INPUT FILES (all read from core/reports/):
|
|
17
|
+
// staticreport.json — produced by the Static Analyzer
|
|
18
|
+
// runtimereport.json — produced by the Runtime Profiler
|
|
19
|
+
// suggestions.json — produced by the Rule Engine
|
|
20
|
+
//
|
|
21
|
+
// OUTPUT FILE (saved to core/reports/):
|
|
22
|
+
// finalreport.json — the complete merged report
|
|
23
|
+
//
|
|
24
|
+
// FULL FLOW:
|
|
25
|
+
// 1. Load all three input files from disk
|
|
26
|
+
// 2. Flatten the suggestions array (Rule Engine saves one
|
|
27
|
+
// entry per route — we merge them into one deduplicated list)
|
|
28
|
+
// 3. Calculate one overall performance score (average across
|
|
29
|
+
// all routes and devices)
|
|
30
|
+
// 4. Assemble the FinalReport object
|
|
31
|
+
// 5. Save finalreport.json
|
|
32
|
+
// 6. Return the FinalReport for the CLI to use
|
|
33
|
+
// ─────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
import path from "path";
|
|
36
|
+
import fs from "fs-extra";
|
|
37
|
+
|
|
38
|
+
import {
|
|
39
|
+
StaticReport,
|
|
40
|
+
RuntimeReport,
|
|
41
|
+
Suggestion,
|
|
42
|
+
FinalReport,
|
|
43
|
+
} from "../../shared/src/types";
|
|
44
|
+
|
|
45
|
+
// ─────────────────────────────────────────────────────────────
|
|
46
|
+
// INLINE TYPE: RuleEngineResult
|
|
47
|
+
//
|
|
48
|
+
// The Rule Engine saves suggestions.json as an array of these.
|
|
49
|
+
// We define it inline here so the Report Compiler doesn't need
|
|
50
|
+
// to import from the rule-engine folder — keeping dependencies
|
|
51
|
+
// one-directional (compiler reads files, not code).
|
|
52
|
+
// ─────────────────────────────────────────────────────────────
|
|
53
|
+
interface RuleEngineResult {
|
|
54
|
+
timestamp: string;
|
|
55
|
+
route: string;
|
|
56
|
+
device: string;
|
|
57
|
+
suggestions: Suggestion[];
|
|
58
|
+
summary: {
|
|
59
|
+
critical: number;
|
|
60
|
+
warning: number;
|
|
61
|
+
info: number;
|
|
62
|
+
total: number;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class ReportCompiler {
|
|
67
|
+
|
|
68
|
+
// All report files live in core/reports/ — one level up from
|
|
69
|
+
// this file's location at core/report-compiler/
|
|
70
|
+
private reportsDir: string;
|
|
71
|
+
|
|
72
|
+
constructor(outputDir?: string) {
|
|
73
|
+
if (outputDir) {
|
|
74
|
+
this.reportsDir = outputDir;
|
|
75
|
+
} else {
|
|
76
|
+
this.reportsDir = path.resolve(__dirname, "..", "reports");
|
|
77
|
+
}
|
|
78
|
+
fs.ensureDirSync(this.reportsDir);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ───────────────────────────────────────────────────────────
|
|
82
|
+
// PUBLIC: compile
|
|
83
|
+
// ───────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Main entry point. Loads all three reports, merges them,
|
|
87
|
+
* saves finalreport.json, and returns the FinalReport object.
|
|
88
|
+
*
|
|
89
|
+
* @param projectName — display name for the project
|
|
90
|
+
* (used in the dashboard and backend)
|
|
91
|
+
* e.g. "my-react-app" or "retest"
|
|
92
|
+
*
|
|
93
|
+
* @returns The complete FinalReport ready for the CLI to upload
|
|
94
|
+
*
|
|
95
|
+
* @throws If any required report file is missing — the caller
|
|
96
|
+
* should run the analyzer/profiler/rule-engine first.
|
|
97
|
+
*/
|
|
98
|
+
/**
|
|
99
|
+
* Overloads:
|
|
100
|
+
* compile(projectName)
|
|
101
|
+
* — reads all three reports from disk (standalone / test use)
|
|
102
|
+
*
|
|
103
|
+
* compile(staticReport, runtimeReports, ruleResults)
|
|
104
|
+
* — receives data directly from the CLI pipeline in memory
|
|
105
|
+
* (no disk read needed — faster and no stale-file issues)
|
|
106
|
+
*
|
|
107
|
+
* The CLI always passes data directly. The standalone test runner
|
|
108
|
+
* calls compile(projectName) which reads from core/reports/.
|
|
109
|
+
*/
|
|
110
|
+
async compile(
|
|
111
|
+
staticReportOrName: StaticReport | string | null,
|
|
112
|
+
runtimeReportsArg?: Record<string, RuntimeReport>,
|
|
113
|
+
ruleResultsArg?: any[],
|
|
114
|
+
): Promise<FinalReport> {
|
|
115
|
+
console.log("\n📦 Report Compiler starting...");
|
|
116
|
+
|
|
117
|
+
let staticReport: StaticReport;
|
|
118
|
+
let runtimeReports: Record<string, RuntimeReport>;
|
|
119
|
+
let ruleResults: any[];
|
|
120
|
+
let projectName: string;
|
|
121
|
+
|
|
122
|
+
// ── Determine call mode ────────────────────────────────────
|
|
123
|
+
if (typeof staticReportOrName === "string" || staticReportOrName === null) {
|
|
124
|
+
// Called as compile(projectName) — read from disk
|
|
125
|
+
projectName = (staticReportOrName as string) ?? "react-app";
|
|
126
|
+
staticReport = this.loadStaticReport();
|
|
127
|
+
runtimeReports = this.loadRuntimeReports();
|
|
128
|
+
ruleResults = this.loadSuggestions();
|
|
129
|
+
} else {
|
|
130
|
+
// Called as compile(staticReport, runtimeReports, ruleResults)
|
|
131
|
+
// — data passed directly from the CLI pipeline
|
|
132
|
+
staticReport = staticReportOrName;
|
|
133
|
+
runtimeReports = runtimeReportsArg ?? {};
|
|
134
|
+
ruleResults = ruleResultsArg ?? [];
|
|
135
|
+
// Derive project name from the runtime report URL if available
|
|
136
|
+
const firstUrl = Object.values(runtimeReports)[0]?.url ?? "";
|
|
137
|
+
projectName = firstUrl ? new URL(firstUrl).hostname : "react-app";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Print a quick summary of what was loaded
|
|
141
|
+
console.log(` Static report: ${staticReport?.issues?.length ?? 0} issue(s)`);
|
|
142
|
+
console.log(` Runtime report: ${Object.keys(runtimeReports).length} route+device entry(s)`);
|
|
143
|
+
console.log(` Rule results: ${ruleResults.length} route+device entry(s)`);
|
|
144
|
+
|
|
145
|
+
// ── Step 2: Flatten and deduplicate suggestions ────────────
|
|
146
|
+
//
|
|
147
|
+
// The Rule Engine produces one RuleEngineResult per route+device.
|
|
148
|
+
// Example: for "/::desktop" and "/::mobile" you get two results,
|
|
149
|
+
// each with their own suggestions array.
|
|
150
|
+
//
|
|
151
|
+
// The FinalReport has ONE flat suggestions array. We merge all
|
|
152
|
+
// per-route arrays together and deduplicate by suggestion ID so
|
|
153
|
+
// the same fix isn't shown twice just because it fired on both
|
|
154
|
+
// desktop and mobile.
|
|
155
|
+
const suggestions = this.mergeSuggestions(ruleResults);
|
|
156
|
+
console.log(` Suggestions: ${suggestions.length} (deduplicated from all routes)`);
|
|
157
|
+
|
|
158
|
+
// Log severity breakdown for quick visibility
|
|
159
|
+
const critical = suggestions.filter(s => s.severity === "critical").length;
|
|
160
|
+
const warning = suggestions.filter(s => s.severity === "warning").length;
|
|
161
|
+
const info = suggestions.filter(s => s.severity === "info").length;
|
|
162
|
+
if (critical > 0) console.log(` ❌ ${critical} critical`);
|
|
163
|
+
if (warning > 0) console.log(` ⚠️ ${warning} warnings`);
|
|
164
|
+
if (info > 0) console.log(` ℹ️ ${info} info`);
|
|
165
|
+
|
|
166
|
+
// ── Step 3: Calculate one overall performance score ────────
|
|
167
|
+
//
|
|
168
|
+
// The profiler calculates a 0-100 score per route+device.
|
|
169
|
+
// Here we average them all into a single project-level score
|
|
170
|
+
// that the dashboard can display as the main health metric.
|
|
171
|
+
const performanceScore = this.calculateOverallScore(runtimeReports);
|
|
172
|
+
console.log(` Overall score: ${performanceScore}/100`);
|
|
173
|
+
|
|
174
|
+
// ── Step 4: Assemble the FinalReport ──────────────────────
|
|
175
|
+
const finalReport: FinalReport = {
|
|
176
|
+
projectName,
|
|
177
|
+
analyzedAt: new Date().toISOString(),
|
|
178
|
+
static: staticReport,
|
|
179
|
+
runtime: runtimeReports,
|
|
180
|
+
suggestions,
|
|
181
|
+
performanceScore,
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// ── Step 5: Save to disk ───────────────────────────────────
|
|
185
|
+
await this.save(finalReport);
|
|
186
|
+
|
|
187
|
+
console.log("✅ Report Compiler complete.\n");
|
|
188
|
+
return finalReport;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ───────────────────────────────────────────────────────────
|
|
192
|
+
// PRIVATE: loaders
|
|
193
|
+
// ───────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Loads staticreport.json.
|
|
197
|
+
* Throws a clear error message if the file is missing so the
|
|
198
|
+
* developer knows exactly which command they need to run first.
|
|
199
|
+
*/
|
|
200
|
+
private loadStaticReport(): StaticReport {
|
|
201
|
+
const filePath = path.join(this.reportsDir, "staticreport.json");
|
|
202
|
+
|
|
203
|
+
if (!fs.existsSync(filePath)) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`❌ staticreport.json not found at: ${filePath}\n\n` +
|
|
206
|
+
` Run the static analyzer first:\n` +
|
|
207
|
+
` npx ts-node --compiler-options '{"module":"commonjs"}' ` +
|
|
208
|
+
`static-ana/test-analyzer.ts`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
console.log(` ✅ Loaded staticreport.json`);
|
|
213
|
+
return fs.readJsonSync(filePath) as StaticReport;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Loads runtimereport.json.
|
|
218
|
+
* The file is keyed by route+device, e.g.:
|
|
219
|
+
* { "/::desktop": {...}, "/::mobile": {...} }
|
|
220
|
+
*/
|
|
221
|
+
private loadRuntimeReports(): Record<string, RuntimeReport> {
|
|
222
|
+
const filePath = path.join(this.reportsDir, "runtimereport.json");
|
|
223
|
+
|
|
224
|
+
if (!fs.existsSync(filePath)) {
|
|
225
|
+
throw new Error(
|
|
226
|
+
`❌ runtimereport.json not found at: ${filePath}\n\n` +
|
|
227
|
+
` Run the runtime profiler first:\n` +
|
|
228
|
+
` npx ts-node --compiler-options '{"module":"commonjs"}' ` +
|
|
229
|
+
`runtime/test-runtime-profiler.ts <path-to-react-app>`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
console.log(` ✅ Loaded runtimereport.json`);
|
|
234
|
+
return fs.readJsonSync(filePath) as Record<string, RuntimeReport>;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Loads suggestions.json.
|
|
239
|
+
* The Rule Engine saves this as an array — one RuleEngineResult
|
|
240
|
+
* per route+device combination that was analyzed.
|
|
241
|
+
*/
|
|
242
|
+
private loadSuggestions(): RuleEngineResult[] {
|
|
243
|
+
const filePath = path.join(this.reportsDir, "suggestions.json");
|
|
244
|
+
|
|
245
|
+
if (!fs.existsSync(filePath)) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
`❌ suggestions.json not found at: ${filePath}\n\n` +
|
|
248
|
+
` Run the rule engine first:\n` +
|
|
249
|
+
` npx ts-node --compiler-options '{"module":"commonjs"}' ` +
|
|
250
|
+
`rule-engine/test-rule-engine.ts`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
console.log(` ✅ Loaded suggestions.json`);
|
|
255
|
+
return fs.readJsonSync(filePath) as RuleEngineResult[];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ───────────────────────────────────────────────────────────
|
|
259
|
+
// PRIVATE: mergeSuggestions
|
|
260
|
+
// ───────────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Flattens all per-route suggestion arrays into one flat list
|
|
264
|
+
* and removes duplicates by suggestion ID.
|
|
265
|
+
*
|
|
266
|
+
* WHY DEDUPLICATION IS NEEDED:
|
|
267
|
+
* When the profiler runs both desktop and mobile, the Rule Engine
|
|
268
|
+
* produces two RuleEngineResults for the same route — one for
|
|
269
|
+
* each device. Many rules will fire on both (e.g. "missing key
|
|
270
|
+
* props" is a static issue that applies regardless of device).
|
|
271
|
+
* Without deduplication, the final report would show the same
|
|
272
|
+
* suggestion twice.
|
|
273
|
+
*
|
|
274
|
+
* WHAT "SAME" MEANS:
|
|
275
|
+
* Two suggestions are the same if they have the same id. The id
|
|
276
|
+
* comes from the rule definition (e.g. "missing-list-keys") so
|
|
277
|
+
* it's stable across devices.
|
|
278
|
+
*
|
|
279
|
+
* WHAT WE KEEP:
|
|
280
|
+
* The first occurrence. Suggestions from the same rule are
|
|
281
|
+
* identical in title, description, and fix — only the route
|
|
282
|
+
* they came from differs, which is not part of the Suggestion
|
|
283
|
+
* interface.
|
|
284
|
+
*
|
|
285
|
+
* SORT ORDER:
|
|
286
|
+
* critical → warning → info (most important first).
|
|
287
|
+
*/
|
|
288
|
+
private mergeSuggestions(ruleResults: RuleEngineResult[]): Suggestion[] {
|
|
289
|
+
const seen = new Set<string>();
|
|
290
|
+
const merged: Suggestion[] = [];
|
|
291
|
+
|
|
292
|
+
for (const result of ruleResults) {
|
|
293
|
+
for (const suggestion of result.suggestions) {
|
|
294
|
+
// Skip if we've already added a suggestion with this id
|
|
295
|
+
if (!seen.has(suggestion.id)) {
|
|
296
|
+
seen.add(suggestion.id);
|
|
297
|
+
merged.push(suggestion);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Sort by severity so critical issues appear first
|
|
303
|
+
const severityOrder = { critical: 0, warning: 1, info: 2 };
|
|
304
|
+
return merged.sort(
|
|
305
|
+
(a, b) =>
|
|
306
|
+
(severityOrder[a.severity] ?? 3) - (severityOrder[b.severity] ?? 3),
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ───────────────────────────────────────────────────────────
|
|
311
|
+
// PRIVATE: calculateOverallScore
|
|
312
|
+
// ───────────────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Averages the performanceScore from all route+device entries
|
|
316
|
+
* in the runtime report into one overall project health score.
|
|
317
|
+
*
|
|
318
|
+
* EXAMPLE:
|
|
319
|
+
* /::desktop → score 63
|
|
320
|
+
* /::mobile → score 91
|
|
321
|
+
* overall → Math.round((63 + 91) / 2) = 77
|
|
322
|
+
*
|
|
323
|
+
* Returns 0 if no runtime data exists (static-only run).
|
|
324
|
+
*/
|
|
325
|
+
private calculateOverallScore(
|
|
326
|
+
runtimeReports: Record<string, RuntimeReport>,
|
|
327
|
+
): number {
|
|
328
|
+
const entries = Object.values(runtimeReports);
|
|
329
|
+
if (entries.length === 0) return 0;
|
|
330
|
+
|
|
331
|
+
const total = entries.reduce(
|
|
332
|
+
(sum, report) => sum + (report.performanceScore ?? 0),
|
|
333
|
+
0,
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
return Math.round(total / entries.length);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ───────────────────────────────────────────────────────────
|
|
340
|
+
// PRIVATE: save
|
|
341
|
+
// ───────────────────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Saves the FinalReport to core/reports/finalreport.json.
|
|
345
|
+
*
|
|
346
|
+
* NOTE ON FILE SIZE:
|
|
347
|
+
* The runtime report embeds screenshot data URLs (base64-encoded
|
|
348
|
+
* PNGs) which can make this file large — typically 2-5MB per
|
|
349
|
+
* screenshot. The PNG files already exist in core/reports/screenshots/
|
|
350
|
+
* so the CLI can strip the base64 data URLs before uploading to
|
|
351
|
+
* the backend if bandwidth is a concern.
|
|
352
|
+
*/
|
|
353
|
+
private async save(report: FinalReport): Promise<void> {
|
|
354
|
+
fs.ensureDirSync(this.reportsDir);
|
|
355
|
+
const outputPath = path.join(this.reportsDir, "finalreport.json");
|
|
356
|
+
await fs.writeJson(outputPath, report, { spaces: 2 });
|
|
357
|
+
console.log(`📄 Final report saved to: ${outputPath}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// report-compiler/test-report-compiler.ts
|
|
3
|
+
//
|
|
4
|
+
// Test runner for the Report Compiler.
|
|
5
|
+
// Reads the existing three report files from core/reports/
|
|
6
|
+
// and compiles them into finalreport.json.
|
|
7
|
+
//
|
|
8
|
+
// PREREQUISITES — run these first (from the core/ directory):
|
|
9
|
+
// npx ts-node --compiler-options '{"module":"commonjs"}' static-ana/test-analyzer.ts
|
|
10
|
+
// npx ts-node --compiler-options '{"module":"commonjs"}' runtime/test-runtime-profiler.ts <path>
|
|
11
|
+
// npx ts-node --compiler-options '{"module":"commonjs"}' rule-engine/test-rule-engine.ts
|
|
12
|
+
//
|
|
13
|
+
// THEN run this (from the core/ directory):
|
|
14
|
+
// npx ts-node --compiler-options '{"module":"commonjs"}' report-compiler/test-report-compiler.ts
|
|
15
|
+
// ─────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
import { ReportCompiler } from "./index";
|
|
18
|
+
import { FinalReport } from "../../shared/src/types";
|
|
19
|
+
import path from "path";
|
|
20
|
+
|
|
21
|
+
// ── Helpers ───────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
function scoreBadge(score: number): string {
|
|
24
|
+
if (score >= 90) return `${score}/100 🟢 Excellent`;
|
|
25
|
+
if (score >= 70) return `${score}/100 🟡 Good`;
|
|
26
|
+
if (score >= 50) return `${score}/100 🟠 Needs Work`;
|
|
27
|
+
return `${score}/100 🔴 Poor`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function printSummary(report: FinalReport): void {
|
|
31
|
+
console.log("=".repeat(60));
|
|
32
|
+
console.log(`📋 FINAL REPORT SUMMARY`);
|
|
33
|
+
console.log("=".repeat(60));
|
|
34
|
+
|
|
35
|
+
console.log(`\n Project: ${report.projectName}`);
|
|
36
|
+
console.log(` Analyzed: ${new Date(report.analyzedAt).toLocaleString()}`);
|
|
37
|
+
console.log(` Score: ${scoreBadge(report.performanceScore)}`);
|
|
38
|
+
|
|
39
|
+
// Static summary
|
|
40
|
+
console.log(`\n 📂 Static Analysis`);
|
|
41
|
+
console.log(` Files analyzed: ${report.static.filesAnalyzed}`);
|
|
42
|
+
console.log(` Issues found: ${report.static.issues.length}`);
|
|
43
|
+
console.log(` Grade: ${report.static.grade}`);
|
|
44
|
+
|
|
45
|
+
const critical = report.static.issues.filter(i => i.severity === "critical").length;
|
|
46
|
+
const warnings = report.static.issues.filter(i => i.severity === "warning").length;
|
|
47
|
+
const infos = report.static.issues.filter(i => i.severity === "info").length;
|
|
48
|
+
if (critical > 0) console.log(` ❌ ${critical} critical`);
|
|
49
|
+
if (warnings > 0) console.log(` ⚠️ ${warnings} warnings`);
|
|
50
|
+
if (infos > 0) console.log(` ℹ️ ${infos} info`);
|
|
51
|
+
|
|
52
|
+
// Runtime summary — one line per route+device
|
|
53
|
+
console.log(`\n ⚡ Runtime Profiling`);
|
|
54
|
+
for (const [key, entry] of Object.entries(report.runtime)) {
|
|
55
|
+
const [route, device] = key.includes("::") ? key.split("::") : [key, "desktop"];
|
|
56
|
+
const score = entry.performanceScore ?? 0;
|
|
57
|
+
const dot = score >= 90 ? "🟢" : score >= 70 ? "🟡" : score >= 50 ? "🟠" : "🔴";
|
|
58
|
+
console.log(` ${dot} ${route} [${device}] ${score}/100 — LCP: ${entry.metrics.lcp.toFixed(0)}ms Render: ${entry.renderTime}ms`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Suggestions summary
|
|
62
|
+
console.log(`\n 🧠 Suggestions (${report.suggestions.length} total)`);
|
|
63
|
+
const critSug = report.suggestions.filter(s => s.severity === "critical");
|
|
64
|
+
const warnSug = report.suggestions.filter(s => s.severity === "warning");
|
|
65
|
+
const infoSug = report.suggestions.filter(s => s.severity === "info");
|
|
66
|
+
|
|
67
|
+
if (critSug.length > 0) {
|
|
68
|
+
console.log(`\n ❌ Critical (${critSug.length}):`);
|
|
69
|
+
critSug.forEach(s => {
|
|
70
|
+
const comp = s.affectedComponent ? ` [${s.affectedComponent}]` : "";
|
|
71
|
+
console.log(` • ${s.title}${comp}`);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (warnSug.length > 0) {
|
|
76
|
+
console.log(`\n ⚠️ Warnings (${warnSug.length}):`);
|
|
77
|
+
warnSug.forEach(s => {
|
|
78
|
+
const comp = s.affectedComponent ? ` [${s.affectedComponent}]` : "";
|
|
79
|
+
console.log(` • ${s.title}${comp}`);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (infoSug.length > 0) {
|
|
84
|
+
console.log(`\n ℹ️ Info (${infoSug.length}):`);
|
|
85
|
+
infoSug.forEach(s => {
|
|
86
|
+
const comp = s.affectedComponent ? ` [${s.affectedComponent}]` : "";
|
|
87
|
+
console.log(` • ${s.title}${comp}`);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
console.log("\n" + "=".repeat(60));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Main ──────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
async function main() {
|
|
97
|
+
console.log("=========================================================");
|
|
98
|
+
console.log("📦 REACT DOCTOR — REPORT COMPILER");
|
|
99
|
+
console.log("=========================================================");
|
|
100
|
+
|
|
101
|
+
// The project name is taken from the command line argument,
|
|
102
|
+
// or defaults to "react-app" if none is provided.
|
|
103
|
+
// Usage: npx ts-node ... test-report-compiler.ts my-project-name
|
|
104
|
+
const projectName = process.argv[2] ?? "react-app";
|
|
105
|
+
|
|
106
|
+
const compiler = new ReportCompiler();
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const finalReport = await compiler.compile(projectName);
|
|
110
|
+
printSummary(finalReport);
|
|
111
|
+
|
|
112
|
+
console.log("✅ Done. finalreport.json is ready for the backend.");
|
|
113
|
+
console.log("=========================================================\n");
|
|
114
|
+
|
|
115
|
+
} catch (err: unknown) {
|
|
116
|
+
console.error("\n❌ Report Compiler error:");
|
|
117
|
+
console.error((err as Error).message);
|
|
118
|
+
console.error("\nMake sure you have run all three steps first:");
|
|
119
|
+
console.error(" 1. static-ana/test-analyzer.ts");
|
|
120
|
+
console.error(" 2. runtime/test-runtime-profiler.ts <path>");
|
|
121
|
+
console.error(" 3. rule-engine/test-rule-engine.ts\n");
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
main();
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Builds the EvaluationContext object from the static and
|
|
2
|
+
// runtime reports. This is the "scope" that rule conditions
|
|
3
|
+
// run inside — every field a condition can access lives here.
|
|
4
|
+
//
|
|
5
|
+
// WHY THIS FILE EXISTS:
|
|
6
|
+
// The reports store data in their own formats (strings for MB
|
|
7
|
+
// values, arrays for commits, etc). The EvaluationContext
|
|
8
|
+
// normalises everything into clean, easy-to-use values so
|
|
9
|
+
// condition expressions like "payloadMB > 3" just work without
|
|
10
|
+
// needing to call parseFloat() or .length inside the rule itself.
|
|
11
|
+
// ─────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
import { StaticReport, RuntimeReport } from "../../shared/src/types";
|
|
14
|
+
import { EvaluationContext } from "./types";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Assembles a flat EvaluationContext from the static and
|
|
18
|
+
* runtime reports.
|
|
19
|
+
*
|
|
20
|
+
* Both parameters are optional because:
|
|
21
|
+
* - A "static-only" rule only needs the static report
|
|
22
|
+
* - A "runtime-only" rule only needs the runtime report
|
|
23
|
+
* - A "cross" rule needs both
|
|
24
|
+
*
|
|
25
|
+
* If a report is missing, its corresponding fields are set
|
|
26
|
+
* to safe zero/empty defaults so conditions that check them
|
|
27
|
+
* simply evaluate to false instead of throwing an error.
|
|
28
|
+
*
|
|
29
|
+
* @param staticReport — result of the Static Analyzer (or null)
|
|
30
|
+
* @param runtimeReport — result of the Runtime Profiler (or null)
|
|
31
|
+
*/
|
|
32
|
+
export function buildContext(
|
|
33
|
+
staticReport: StaticReport | null,
|
|
34
|
+
runtimeReport: RuntimeReport | null,
|
|
35
|
+
): EvaluationContext {
|
|
36
|
+
|
|
37
|
+
// ── Runtime fields ──────────────────────────────────────────
|
|
38
|
+
// Pull every value we need from the runtime report.
|
|
39
|
+
// Default to 0 / empty if the runtime report wasn't provided.
|
|
40
|
+
|
|
41
|
+
const metrics = runtimeReport?.metrics ?? {
|
|
42
|
+
lcp: 0, fcp: 0, cls: 0, inp: 0, ttfb: 0,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const renderTime = runtimeReport?.renderTime ?? 0;
|
|
46
|
+
const rerenders = runtimeReport?.rerenders ?? {};
|
|
47
|
+
const commitDurations = runtimeReport?.commitDurations ?? [];
|
|
48
|
+
const domNodes = runtimeReport?.stats?.domNodes ?? 0;
|
|
49
|
+
const performanceScore = runtimeReport?.performanceScore ?? 0;
|
|
50
|
+
const errors = runtimeReport?.errors ?? [];
|
|
51
|
+
|
|
52
|
+
// payloadMB and jsHeapMB are stored as strings ("1.49") in the
|
|
53
|
+
// report for display purposes. Convert them to numbers here so
|
|
54
|
+
// conditions can compare them with > < operators directly.
|
|
55
|
+
const payloadMB = parseFloat(runtimeReport?.stats?.payloadMB ?? "0");
|
|
56
|
+
const jsHeapMB = parseFloat(runtimeReport?.stats?.jsHeapMB ?? "0");
|
|
57
|
+
|
|
58
|
+
// ── Derived runtime values ──────────────────────────────────
|
|
59
|
+
// These are calculated from the raw data above for convenience.
|
|
60
|
+
// Putting the math here keeps the rule condition strings clean.
|
|
61
|
+
|
|
62
|
+
// Slowest single commit — 0 if no commits were recorded
|
|
63
|
+
const slowestCommit = commitDurations.length > 0
|
|
64
|
+
? Math.max(...commitDurations)
|
|
65
|
+
: 0;
|
|
66
|
+
|
|
67
|
+
// Average commit duration — 0 if no commits were recorded
|
|
68
|
+
const avgCommit = commitDurations.length > 0
|
|
69
|
+
? commitDurations.reduce((a, b) => a + b, 0) / commitDurations.length
|
|
70
|
+
: 0;
|
|
71
|
+
|
|
72
|
+
// How many commits exceeded the 16ms 60fps frame budget
|
|
73
|
+
// Any commit over 16ms causes a visible dropped frame
|
|
74
|
+
const slowCommitCount = commitDurations.filter(d => d > 16).length;
|
|
75
|
+
|
|
76
|
+
// Error/warning counts — shorthand so rules don't need to
|
|
77
|
+
// write errors.filter(e => e.type === 'error').length every time
|
|
78
|
+
const errorCount = errors.filter(e => e.type === "error").length;
|
|
79
|
+
const warningCount = errors.filter(e => e.type === "warning").length;
|
|
80
|
+
|
|
81
|
+
// Find the component with the most re-renders — this is the
|
|
82
|
+
// primary suspect when the "excessive rerenders" rule fires.
|
|
83
|
+
// Returns ["ComponentName", count] or ["", 0] if no data.
|
|
84
|
+
const rerenderEntries = Object.entries(rerenders);
|
|
85
|
+
const [topRerenderComponent, topRerenderCount] =
|
|
86
|
+
rerenderEntries.length > 0
|
|
87
|
+
? rerenderEntries.reduce(
|
|
88
|
+
(max, entry) => (entry[1] > max[1] ? entry : max),
|
|
89
|
+
["", 0] as [string, number],
|
|
90
|
+
)
|
|
91
|
+
: ["", 0];
|
|
92
|
+
|
|
93
|
+
// ── Static fields ────────────────────────────────────────────
|
|
94
|
+
// Pull the issues array from the static report.
|
|
95
|
+
// Default to empty array if the static report wasn't provided.
|
|
96
|
+
|
|
97
|
+
const issues = staticReport?.issues ?? [];
|
|
98
|
+
|
|
99
|
+
const criticalIssueCount = issues.filter(i => i.severity === "critical").length;
|
|
100
|
+
const warningIssueCount = issues.filter(i => i.severity === "warning").length;
|
|
101
|
+
|
|
102
|
+
// hasIssue(prefix) — returns true if any issue ID starts with
|
|
103
|
+
// the given prefix. Used in conditions like: hasIssue('missing-memo')
|
|
104
|
+
//
|
|
105
|
+
// The issue IDs are generated by the detectors like this:
|
|
106
|
+
// generateIssueId('missing-memo', filePath, line)
|
|
107
|
+
// → "missing-memo-src/components/Button.tsx-42"
|
|
108
|
+
//
|
|
109
|
+
// So checking startsWith('missing-memo') catches all of them
|
|
110
|
+
// regardless of which file or line they came from.
|
|
111
|
+
const hasIssue = (prefix: string): boolean =>
|
|
112
|
+
issues.some(i => i.id.startsWith(prefix));
|
|
113
|
+
|
|
114
|
+
// getIssues(prefix) — returns all matching issue objects.
|
|
115
|
+
// Used when a condition or message needs the actual issue data.
|
|
116
|
+
const getIssues = (prefix: string) =>
|
|
117
|
+
issues.filter(i => i.id.startsWith(prefix));
|
|
118
|
+
|
|
119
|
+
// ── Assemble the full context ─────────────────────────────
|
|
120
|
+
return {
|
|
121
|
+
// Runtime
|
|
122
|
+
metrics,
|
|
123
|
+
renderTime,
|
|
124
|
+
rerenders,
|
|
125
|
+
commitDurations,
|
|
126
|
+
slowestCommit,
|
|
127
|
+
avgCommit,
|
|
128
|
+
slowCommitCount,
|
|
129
|
+
domNodes,
|
|
130
|
+
payloadMB,
|
|
131
|
+
jsHeapMB,
|
|
132
|
+
errors,
|
|
133
|
+
errorCount,
|
|
134
|
+
warningCount,
|
|
135
|
+
performanceScore,
|
|
136
|
+
topRerenderComponent,
|
|
137
|
+
topRerenderCount,
|
|
138
|
+
|
|
139
|
+
// Static
|
|
140
|
+
issues,
|
|
141
|
+
criticalIssueCount,
|
|
142
|
+
warningIssueCount,
|
|
143
|
+
hasIssue,
|
|
144
|
+
getIssues,
|
|
145
|
+
};
|
|
146
|
+
}
|