find-bottleneck-perf 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/README.md +169 -0
- package/index.js +114 -0
- package/package.json +44 -0
- package/src/analyzer.js +432 -0
- package/src/extractor.js +302 -0
- package/src/reporter.js +249 -0
- package/src/thresholds.js +138 -0
- package/src/utils.js +103 -0
package/src/analyzer.js
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Performance Analyzer
|
|
3
|
+
*
|
|
4
|
+
* Analyzes extracted metrics using heuristics to determine the primary
|
|
5
|
+
* bottleneck and generate actionable evidence.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const T = require('./thresholds');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Analyze metrics and determine bottleneck.
|
|
12
|
+
* @param {object} metrics - Extracted metrics from extractor
|
|
13
|
+
* @returns {object} Analysis result with verdict, evidence, and scores
|
|
14
|
+
*/
|
|
15
|
+
function analyze(metrics) {
|
|
16
|
+
const nav = metrics.navigation;
|
|
17
|
+
const vitals = metrics.vitals;
|
|
18
|
+
const res = metrics.resources;
|
|
19
|
+
const api = metrics.api;
|
|
20
|
+
|
|
21
|
+
// === Calculate Derived Metrics ===
|
|
22
|
+
const jsSizeMB = res.javascript.totalBytes / 1024 / 1024;
|
|
23
|
+
const cssSizeMB = res.css.totalBytes / 1024 / 1024;
|
|
24
|
+
const imageSizeMB = res.images.totalBytes / 1024 / 1024;
|
|
25
|
+
const totalSizeMB = jsSizeMB + cssSizeMB + imageSizeMB +
|
|
26
|
+
(res.fonts.totalBytes / 1024 / 1024) + (res.other.totalBytes / 1024 / 1024);
|
|
27
|
+
|
|
28
|
+
const networkOverhead = nav.dnsLookup + nav.tcpConnection + nav.tlsHandshake;
|
|
29
|
+
const networkOverheadRatio = nav.ttfb > 0 ? networkOverhead / nav.ttfb : 0;
|
|
30
|
+
const clientRenderTime = vitals.largestContentfulPaint - nav.ttfb;
|
|
31
|
+
|
|
32
|
+
metrics.derived = {
|
|
33
|
+
jsSizeMB: parseFloat(jsSizeMB.toFixed(2)),
|
|
34
|
+
cssSizeMB: parseFloat(cssSizeMB.toFixed(2)),
|
|
35
|
+
imageSizeMB: parseFloat(imageSizeMB.toFixed(2)),
|
|
36
|
+
totalSizeMB: parseFloat(totalSizeMB.toFixed(2)),
|
|
37
|
+
networkOverhead,
|
|
38
|
+
networkOverheadRatio: parseFloat(networkOverheadRatio.toFixed(2)),
|
|
39
|
+
clientRenderTime: Math.round(clientRenderTime),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// === Scoring System ===
|
|
43
|
+
const scores = {
|
|
44
|
+
Frontend: 0,
|
|
45
|
+
Backend: 0,
|
|
46
|
+
Infra: 0,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const evidence = [];
|
|
50
|
+
|
|
51
|
+
// =========================================
|
|
52
|
+
// INFRASTRUCTURE / NETWORK ANALYSIS
|
|
53
|
+
// =========================================
|
|
54
|
+
|
|
55
|
+
// DNS Lookup
|
|
56
|
+
if (nav.dnsLookup > T.DNS.POOR) {
|
|
57
|
+
scores.Infra += 5;
|
|
58
|
+
evidence.push({
|
|
59
|
+
category: 'Infra',
|
|
60
|
+
severity: 'high',
|
|
61
|
+
metric: 'DNS Lookup',
|
|
62
|
+
value: nav.dnsLookup,
|
|
63
|
+
threshold: T.DNS.POOR,
|
|
64
|
+
message: `Slow DNS resolution (${nav.dnsLookup}ms). Consider DNS prefetching or faster resolver.`,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// TCP Connection
|
|
69
|
+
if (nav.tcpConnection > T.TCP.POOR) {
|
|
70
|
+
scores.Infra += 5;
|
|
71
|
+
evidence.push({
|
|
72
|
+
category: 'Infra',
|
|
73
|
+
severity: 'high',
|
|
74
|
+
metric: 'TCP Connection',
|
|
75
|
+
value: nav.tcpConnection,
|
|
76
|
+
threshold: T.TCP.POOR,
|
|
77
|
+
message: `High TCP latency (${nav.tcpConnection}ms). Server may be geographically distant.`,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// TLS Handshake
|
|
82
|
+
if (nav.tlsHandshake > T.TLS.POOR) {
|
|
83
|
+
scores.Infra += 4;
|
|
84
|
+
evidence.push({
|
|
85
|
+
category: 'Infra',
|
|
86
|
+
severity: 'medium',
|
|
87
|
+
metric: 'TLS Handshake',
|
|
88
|
+
value: nav.tlsHandshake,
|
|
89
|
+
threshold: T.TLS.POOR,
|
|
90
|
+
message: `Slow TLS handshake (${nav.tlsHandshake}ms). Consider TLS 1.3 or session resumption.`,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// TTFB Analysis (nuanced: check if it's network or backend)
|
|
95
|
+
if (nav.ttfb > T.TTFB.POOR) {
|
|
96
|
+
// If network overhead is high relative to TTFB, it's Infra
|
|
97
|
+
if (networkOverheadRatio > T.NETWORK_OVERHEAD_RATIO.HIGH) {
|
|
98
|
+
scores.Infra += 8;
|
|
99
|
+
evidence.push({
|
|
100
|
+
category: 'Infra',
|
|
101
|
+
severity: 'high',
|
|
102
|
+
metric: 'TTFB (Network)',
|
|
103
|
+
value: nav.ttfb,
|
|
104
|
+
threshold: T.TTFB.POOR,
|
|
105
|
+
message: `High TTFB (${nav.ttfb}ms) dominated by network overhead (${Math.round(networkOverheadRatio * 100)}%).`,
|
|
106
|
+
});
|
|
107
|
+
} else {
|
|
108
|
+
// Server processing is the bottleneck
|
|
109
|
+
scores.Backend += 8;
|
|
110
|
+
evidence.push({
|
|
111
|
+
category: 'Backend',
|
|
112
|
+
severity: 'high',
|
|
113
|
+
metric: 'TTFB (Server)',
|
|
114
|
+
value: nav.ttfb,
|
|
115
|
+
threshold: T.TTFB.POOR,
|
|
116
|
+
message: `High TTFB (${nav.ttfb}ms) due to slow server processing (${nav.serverProcessing}ms).`,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
} else if (nav.ttfb > T.TTFB.MODERATE) {
|
|
120
|
+
scores.Backend += 3;
|
|
121
|
+
scores.Infra += 2;
|
|
122
|
+
evidence.push({
|
|
123
|
+
category: 'Backend',
|
|
124
|
+
severity: 'medium',
|
|
125
|
+
metric: 'TTFB',
|
|
126
|
+
value: nav.ttfb,
|
|
127
|
+
threshold: T.TTFB.MODERATE,
|
|
128
|
+
message: `Elevated TTFB (${nav.ttfb}ms). Room for improvement.`,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// =========================================
|
|
133
|
+
// BACKEND ANALYSIS
|
|
134
|
+
// =========================================
|
|
135
|
+
|
|
136
|
+
// API Latency - Average
|
|
137
|
+
if (api.avgLatency > T.API_LATENCY.POOR) {
|
|
138
|
+
scores.Backend += 10;
|
|
139
|
+
evidence.push({
|
|
140
|
+
category: 'Backend',
|
|
141
|
+
severity: 'high',
|
|
142
|
+
metric: 'API Latency (Avg)',
|
|
143
|
+
value: api.avgLatency,
|
|
144
|
+
threshold: T.API_LATENCY.POOR,
|
|
145
|
+
message: `Slow API responses (avg ${api.avgLatency}ms). Profile database queries and add caching.`,
|
|
146
|
+
});
|
|
147
|
+
} else if (api.avgLatency > T.API_LATENCY.MODERATE) {
|
|
148
|
+
scores.Backend += 4;
|
|
149
|
+
evidence.push({
|
|
150
|
+
category: 'Backend',
|
|
151
|
+
severity: 'medium',
|
|
152
|
+
metric: 'API Latency (Avg)',
|
|
153
|
+
value: api.avgLatency,
|
|
154
|
+
threshold: T.API_LATENCY.MODERATE,
|
|
155
|
+
message: `Moderate API latency (avg ${api.avgLatency}ms).`,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// API Latency - Max (single slow endpoint)
|
|
160
|
+
if (api.maxLatency > 500) {
|
|
161
|
+
scores.Backend += 5;
|
|
162
|
+
evidence.push({
|
|
163
|
+
category: 'Backend',
|
|
164
|
+
severity: 'medium',
|
|
165
|
+
metric: 'API Latency (Max)',
|
|
166
|
+
value: api.maxLatency,
|
|
167
|
+
threshold: 500,
|
|
168
|
+
message: `Slowest API call took ${api.maxLatency}ms. Identify and optimize this endpoint.`,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Failed API Calls
|
|
173
|
+
if (api.failedCount > 0) {
|
|
174
|
+
scores.Backend += 6;
|
|
175
|
+
evidence.push({
|
|
176
|
+
category: 'Backend',
|
|
177
|
+
severity: 'high',
|
|
178
|
+
metric: 'Failed API Calls',
|
|
179
|
+
value: api.failedCount,
|
|
180
|
+
threshold: 0,
|
|
181
|
+
message: `${api.failedCount} API calls failed (4xx/5xx). Fix broken endpoints.`,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// =========================================
|
|
186
|
+
// FRONTEND ANALYSIS
|
|
187
|
+
// =========================================
|
|
188
|
+
|
|
189
|
+
// JavaScript Bundle Size
|
|
190
|
+
if (jsSizeMB > T.BUNDLE_SIZE_MB.POOR) {
|
|
191
|
+
scores.Frontend += 10;
|
|
192
|
+
evidence.push({
|
|
193
|
+
category: 'Frontend',
|
|
194
|
+
severity: 'high',
|
|
195
|
+
metric: 'JS Bundle Size',
|
|
196
|
+
value: jsSizeMB,
|
|
197
|
+
threshold: T.BUNDLE_SIZE_MB.POOR,
|
|
198
|
+
message: `Massive JS bundle (${jsSizeMB.toFixed(2)} MB). Implement code splitting and tree shaking.`,
|
|
199
|
+
});
|
|
200
|
+
} else if (jsSizeMB > T.BUNDLE_SIZE_MB.MODERATE) {
|
|
201
|
+
scores.Frontend += 5;
|
|
202
|
+
evidence.push({
|
|
203
|
+
category: 'Frontend',
|
|
204
|
+
severity: 'medium',
|
|
205
|
+
metric: 'JS Bundle Size',
|
|
206
|
+
value: jsSizeMB,
|
|
207
|
+
threshold: T.BUNDLE_SIZE_MB.MODERATE,
|
|
208
|
+
message: `Large JS bundle (${jsSizeMB.toFixed(2)} MB). Consider optimization.`,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Total Blocking Time (Long Tasks)
|
|
213
|
+
if (vitals.totalBlockingTime > T.JS_EXECUTION.POOR) {
|
|
214
|
+
scores.Frontend += 10;
|
|
215
|
+
evidence.push({
|
|
216
|
+
category: 'Frontend',
|
|
217
|
+
severity: 'high',
|
|
218
|
+
metric: 'Total Blocking Time',
|
|
219
|
+
value: vitals.totalBlockingTime,
|
|
220
|
+
threshold: T.JS_EXECUTION.POOR,
|
|
221
|
+
message: `Heavy main thread blocking (${vitals.totalBlockingTime}ms). Move work to Web Workers.`,
|
|
222
|
+
});
|
|
223
|
+
} else if (vitals.totalBlockingTime > T.JS_EXECUTION.MODERATE) {
|
|
224
|
+
scores.Frontend += 4;
|
|
225
|
+
evidence.push({
|
|
226
|
+
category: 'Frontend',
|
|
227
|
+
severity: 'medium',
|
|
228
|
+
metric: 'Total Blocking Time',
|
|
229
|
+
value: vitals.totalBlockingTime,
|
|
230
|
+
threshold: T.JS_EXECUTION.MODERATE,
|
|
231
|
+
message: `Moderate main thread blocking (${vitals.totalBlockingTime}ms).`,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Client Render Time (LCP - TTFB)
|
|
236
|
+
if (clientRenderTime > T.CLIENT_RENDER_TIME.POOR) {
|
|
237
|
+
scores.Frontend += 8;
|
|
238
|
+
evidence.push({
|
|
239
|
+
category: 'Frontend',
|
|
240
|
+
severity: 'high',
|
|
241
|
+
metric: 'Client Render Time',
|
|
242
|
+
value: clientRenderTime,
|
|
243
|
+
threshold: T.CLIENT_RENDER_TIME.POOR,
|
|
244
|
+
message: `Slow client rendering (${clientRenderTime}ms from TTFB to LCP). Optimize critical path.`,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// LCP (absolute)
|
|
249
|
+
if (vitals.largestContentfulPaint > T.LCP.POOR) {
|
|
250
|
+
scores.Frontend += 6;
|
|
251
|
+
evidence.push({
|
|
252
|
+
category: 'Frontend',
|
|
253
|
+
severity: 'high',
|
|
254
|
+
metric: 'LCP',
|
|
255
|
+
value: vitals.largestContentfulPaint,
|
|
256
|
+
threshold: T.LCP.POOR,
|
|
257
|
+
message: `Poor LCP (${vitals.largestContentfulPaint}ms). Largest element is loading too slowly.`,
|
|
258
|
+
});
|
|
259
|
+
} else if (vitals.largestContentfulPaint > T.LCP.GOOD) {
|
|
260
|
+
scores.Frontend += 3;
|
|
261
|
+
evidence.push({
|
|
262
|
+
category: 'Frontend',
|
|
263
|
+
severity: 'medium',
|
|
264
|
+
metric: 'LCP',
|
|
265
|
+
value: vitals.largestContentfulPaint,
|
|
266
|
+
threshold: T.LCP.GOOD,
|
|
267
|
+
message: `LCP needs improvement (${vitals.largestContentfulPaint}ms).`,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Excessive API Calls (Frontend is requesting too much)
|
|
272
|
+
if (api.callCount > T.API_CALLS.EXCESSIVE) {
|
|
273
|
+
scores.Frontend += 8;
|
|
274
|
+
scores.Backend += 3;
|
|
275
|
+
evidence.push({
|
|
276
|
+
category: 'Frontend',
|
|
277
|
+
severity: 'high',
|
|
278
|
+
metric: 'API Call Count',
|
|
279
|
+
value: api.callCount,
|
|
280
|
+
threshold: T.API_CALLS.EXCESSIVE,
|
|
281
|
+
message: `Excessive API calls (${api.callCount}). Implement request batching or BFF pattern.`,
|
|
282
|
+
});
|
|
283
|
+
} else if (api.callCount > T.API_CALLS.HIGH) {
|
|
284
|
+
scores.Frontend += 4;
|
|
285
|
+
evidence.push({
|
|
286
|
+
category: 'Frontend',
|
|
287
|
+
severity: 'medium',
|
|
288
|
+
metric: 'API Call Count',
|
|
289
|
+
value: api.callCount,
|
|
290
|
+
threshold: T.API_CALLS.HIGH,
|
|
291
|
+
message: `High number of API calls (${api.callCount}). Consider aggregation.`,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Render-blocking resources
|
|
296
|
+
if (metrics.renderBlocking.total > 5) {
|
|
297
|
+
scores.Frontend += 5;
|
|
298
|
+
evidence.push({
|
|
299
|
+
category: 'Frontend',
|
|
300
|
+
severity: 'medium',
|
|
301
|
+
metric: 'Render Blocking Resources',
|
|
302
|
+
value: metrics.renderBlocking.total,
|
|
303
|
+
threshold: 5,
|
|
304
|
+
message: `${metrics.renderBlocking.total} render-blocking resources. Use async/defer for scripts.`,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Image Size
|
|
309
|
+
if (imageSizeMB > T.IMAGE_SIZE_MB.POOR) {
|
|
310
|
+
scores.Frontend += 6;
|
|
311
|
+
evidence.push({
|
|
312
|
+
category: 'Frontend',
|
|
313
|
+
severity: 'medium',
|
|
314
|
+
metric: 'Image Size',
|
|
315
|
+
value: imageSizeMB,
|
|
316
|
+
threshold: T.IMAGE_SIZE_MB.POOR,
|
|
317
|
+
message: `Heavy images (${imageSizeMB.toFixed(2)} MB). Use WebP/AVIF and lazy loading.`,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// =========================================
|
|
322
|
+
// DETERMINE VERDICT
|
|
323
|
+
// =========================================
|
|
324
|
+
|
|
325
|
+
const maxScore = Math.max(scores.Frontend, scores.Backend, scores.Infra);
|
|
326
|
+
let verdict;
|
|
327
|
+
|
|
328
|
+
if (maxScore < 3) {
|
|
329
|
+
verdict = 'Optimized';
|
|
330
|
+
} else {
|
|
331
|
+
verdict = Object.keys(scores).find(key => scores[key] === maxScore);
|
|
332
|
+
|
|
333
|
+
// Tie-breaking: If scores are close, report as "Mixed"
|
|
334
|
+
const sortedScores = Object.values(scores).sort((a, b) => b - a);
|
|
335
|
+
if (sortedScores[0] - sortedScores[1] < 3 && sortedScores[1] > 5) {
|
|
336
|
+
verdict = 'Mixed';
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
verdict,
|
|
342
|
+
scores,
|
|
343
|
+
evidence,
|
|
344
|
+
metrics,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Generate recommendations based on verdict.
|
|
350
|
+
* @param {string} verdict - Primary bottleneck
|
|
351
|
+
* @param {array} evidence - Evidence items
|
|
352
|
+
* @returns {array} Recommendations
|
|
353
|
+
*/
|
|
354
|
+
function getRecommendations(verdict, evidence) {
|
|
355
|
+
const recs = [];
|
|
356
|
+
|
|
357
|
+
// Specific recommendations based on evidence
|
|
358
|
+
const categories = evidence.map(e => e.category);
|
|
359
|
+
|
|
360
|
+
if (verdict === 'Frontend' || categories.includes('Frontend')) {
|
|
361
|
+
const hasLargeBundle = evidence.some(e => e.metric.includes('Bundle Size'));
|
|
362
|
+
const hasBlockingTime = evidence.some(e => e.metric.includes('Blocking'));
|
|
363
|
+
const hasRenderIssues = evidence.some(e => e.metric.includes('Render') || e.metric === 'LCP');
|
|
364
|
+
const hasChattyApi = evidence.some(e => e.metric.includes('API Call'));
|
|
365
|
+
|
|
366
|
+
if (hasLargeBundle) {
|
|
367
|
+
recs.push('Implement code splitting (dynamic imports) and tree shaking.');
|
|
368
|
+
recs.push('Analyze bundle with webpack-bundle-analyzer or source-map-explorer.');
|
|
369
|
+
}
|
|
370
|
+
if (hasBlockingTime) {
|
|
371
|
+
recs.push('Profile JavaScript with Chrome DevTools Performance tab.');
|
|
372
|
+
recs.push('Move heavy computations to Web Workers.');
|
|
373
|
+
}
|
|
374
|
+
if (hasRenderIssues) {
|
|
375
|
+
recs.push('Inline critical CSS and defer non-essential styles.');
|
|
376
|
+
recs.push('Preload LCP image/element with <link rel="preload">.');
|
|
377
|
+
}
|
|
378
|
+
if (hasChattyApi) {
|
|
379
|
+
recs.push('Implement a Backend-for-Frontend (BFF) to aggregate requests.');
|
|
380
|
+
recs.push('Consider GraphQL for flexible data fetching.');
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (verdict === 'Backend' || categories.includes('Backend')) {
|
|
385
|
+
const hasSlowApi = evidence.some(e => e.metric.includes('API Latency'));
|
|
386
|
+
const hasFailed = evidence.some(e => e.metric.includes('Failed'));
|
|
387
|
+
|
|
388
|
+
if (hasSlowApi) {
|
|
389
|
+
recs.push('Profile database queries - check for missing indexes and N+1 queries.');
|
|
390
|
+
recs.push('Add caching layer (Redis/Memcached) for frequent queries.');
|
|
391
|
+
recs.push('Consider denormalization or read replicas for heavy queries.');
|
|
392
|
+
}
|
|
393
|
+
if (hasFailed) {
|
|
394
|
+
recs.push('Fix failing endpoints immediately - check logs for errors.');
|
|
395
|
+
recs.push('Implement proper error handling and fallbacks.');
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (verdict === 'Infra' || categories.includes('Infra')) {
|
|
400
|
+
const hasDns = evidence.some(e => e.metric.includes('DNS'));
|
|
401
|
+
const hasTcp = evidence.some(e => e.metric.includes('TCP'));
|
|
402
|
+
const hasTtfb = evidence.some(e => e.metric.includes('TTFB'));
|
|
403
|
+
|
|
404
|
+
if (hasDns) {
|
|
405
|
+
recs.push('Use faster DNS provider (e.g., Cloudflare DNS).');
|
|
406
|
+
recs.push('Add <link rel="dns-prefetch"> for third-party domains.');
|
|
407
|
+
}
|
|
408
|
+
if (hasTcp) {
|
|
409
|
+
recs.push('Deploy servers/CDN edges closer to users.');
|
|
410
|
+
recs.push('Enable HTTP/2 or HTTP/3 for multiplexing.');
|
|
411
|
+
}
|
|
412
|
+
if (hasTtfb) {
|
|
413
|
+
recs.push('Review CDN configuration and cache hit rates.');
|
|
414
|
+
recs.push('Enable Brotli/Gzip compression on responses.');
|
|
415
|
+
recs.push('For serverless: investigate cold start optimization.');
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (verdict === 'Optimized') {
|
|
420
|
+
recs.push('Performance looks healthy! Consider implementing RUM for ongoing monitoring.');
|
|
421
|
+
recs.push('Set up alerting for performance regressions.');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (verdict === 'Mixed') {
|
|
425
|
+
recs.push('Multiple areas need attention. Prioritize based on user impact.');
|
|
426
|
+
recs.push('Consider a holistic performance audit.');
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return recs;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
module.exports = { analyze, getRecommendations };
|