install-glo 2.0.0 → 2.0.2
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/CHANGELOG.md +30 -0
- package/__tests__/ai-analysis.test.mjs +108 -0
- package/__tests__/lighthouse.test.mjs +192 -0
- package/__tests__/model.test.mjs +59 -0
- package/__tests__/source-discovery.test.mjs +78 -0
- package/__tests__/vitals.test.mjs +24 -0
- package/glo-loop.mjs +28 -463
- package/lib/ai-analysis.mjs +107 -0
- package/lib/display.mjs +103 -0
- package/lib/lighthouse.mjs +86 -0
- package/lib/model.mjs +21 -0
- package/lib/source-discovery.mjs +111 -0
- package/lib/vitals.mjs +40 -0
- package/package.json +5 -3
package/glo-loop.mjs
CHANGED
|
@@ -1,415 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { createInterface } from "node:readline/promises";
|
|
4
|
-
import { execSync } from "node:child_process";
|
|
5
|
-
import { readFileSync, existsSync } from "node:fs";
|
|
6
|
-
import { join, relative } from "node:path";
|
|
7
|
-
import { generateText } from "ai";
|
|
8
|
-
import { createAnthropic } from "@ai-sdk/anthropic";
|
|
9
|
-
import { createOpenAI } from "@ai-sdk/openai";
|
|
10
4
|
import chalk from "chalk";
|
|
11
5
|
|
|
12
|
-
|
|
6
|
+
import { VITALS } from "./lib/vitals.mjs";
|
|
7
|
+
import { getModel } from "./lib/model.mjs";
|
|
8
|
+
import { runLighthouse, extractMetrics, extractDiagnostics } from "./lib/lighthouse.mjs";
|
|
9
|
+
import { discoverPageFiles } from "./lib/source-discovery.mjs";
|
|
10
|
+
import { analyzeWithAI } from "./lib/ai-analysis.mjs";
|
|
11
|
+
import { printBanner, formatVital, printScore, printSuggestion } from "./lib/display.mjs";
|
|
13
12
|
|
|
14
|
-
|
|
15
|
-
if (process.env.ANTHROPIC_API_KEY) {
|
|
16
|
-
const anthropic = createAnthropic();
|
|
17
|
-
return {
|
|
18
|
-
model: anthropic("claude-sonnet-4-20250514"),
|
|
19
|
-
label: "Claude (Anthropic)",
|
|
20
|
-
};
|
|
21
|
-
}
|
|
22
|
-
if (process.env.OPENAI_API_KEY) {
|
|
23
|
-
const openai = createOpenAI();
|
|
24
|
-
return { model: openai("gpt-4o-mini"), label: "GPT-4o-mini (OpenAI)" };
|
|
25
|
-
}
|
|
26
|
-
return null;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// ── Web Vitals ─────────────────────────────────────────────────────────
|
|
30
|
-
|
|
31
|
-
const VITALS = {
|
|
32
|
-
LCP: {
|
|
33
|
-
good: 2500,
|
|
34
|
-
unit: "ms",
|
|
35
|
-
name: "Largest Contentful Paint",
|
|
36
|
-
audit: "largest-contentful-paint",
|
|
37
|
-
},
|
|
38
|
-
FCP: {
|
|
39
|
-
good: 1800,
|
|
40
|
-
unit: "ms",
|
|
41
|
-
name: "First Contentful Paint",
|
|
42
|
-
audit: "first-contentful-paint",
|
|
43
|
-
},
|
|
44
|
-
CLS: {
|
|
45
|
-
good: 0.1,
|
|
46
|
-
unit: "",
|
|
47
|
-
name: "Cumulative Layout Shift",
|
|
48
|
-
audit: "cumulative-layout-shift",
|
|
49
|
-
},
|
|
50
|
-
TBT: {
|
|
51
|
-
good: 200,
|
|
52
|
-
unit: "ms",
|
|
53
|
-
name: "Total Blocking Time",
|
|
54
|
-
audit: "total-blocking-time",
|
|
55
|
-
},
|
|
56
|
-
SI: {
|
|
57
|
-
good: 3400,
|
|
58
|
-
unit: "ms",
|
|
59
|
-
name: "Speed Index",
|
|
60
|
-
audit: "speed-index",
|
|
61
|
-
},
|
|
62
|
-
TTFB: {
|
|
63
|
-
good: 800,
|
|
64
|
-
unit: "ms",
|
|
65
|
-
name: "Time to First Byte",
|
|
66
|
-
audit: "server-response-time",
|
|
67
|
-
},
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
// ── Lighthouse ─────────────────────────────────────────────────────────
|
|
71
|
-
|
|
72
|
-
function checkLighthouse() {
|
|
73
|
-
try {
|
|
74
|
-
execSync("npx -y lighthouse --version", { stdio: "pipe" });
|
|
75
|
-
return true;
|
|
76
|
-
} catch {
|
|
77
|
-
return false;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function runLighthouse(url) {
|
|
82
|
-
const cmd = [
|
|
83
|
-
"npx -y lighthouse",
|
|
84
|
-
`"${url}"`,
|
|
85
|
-
"--output=json",
|
|
86
|
-
'--chrome-flags="--headless --no-sandbox"',
|
|
87
|
-
"--only-categories=performance",
|
|
88
|
-
"--quiet",
|
|
89
|
-
].join(" ");
|
|
90
|
-
|
|
91
|
-
const result = execSync(cmd, {
|
|
92
|
-
maxBuffer: 100 * 1024 * 1024,
|
|
93
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
94
|
-
});
|
|
95
|
-
return JSON.parse(result.toString());
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function extractMetrics(report) {
|
|
99
|
-
const metrics = {};
|
|
100
|
-
for (const [key, info] of Object.entries(VITALS)) {
|
|
101
|
-
const audit = report.audits?.[info.audit];
|
|
102
|
-
if (audit) {
|
|
103
|
-
metrics[key] = {
|
|
104
|
-
value: audit.numericValue,
|
|
105
|
-
display: audit.displayValue,
|
|
106
|
-
score: audit.score,
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
metrics.performanceScore = Math.round(
|
|
111
|
-
(report.categories?.performance?.score || 0) * 100
|
|
112
|
-
);
|
|
113
|
-
return metrics;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function extractDiagnostics(report) {
|
|
117
|
-
const relevant = [
|
|
118
|
-
"render-blocking-resources",
|
|
119
|
-
"unused-css-rules",
|
|
120
|
-
"unused-javascript",
|
|
121
|
-
"modern-image-formats",
|
|
122
|
-
"uses-optimized-images",
|
|
123
|
-
"uses-responsive-images",
|
|
124
|
-
"offscreen-images",
|
|
125
|
-
"unminified-css",
|
|
126
|
-
"unminified-javascript",
|
|
127
|
-
"dom-size",
|
|
128
|
-
"critical-request-chains",
|
|
129
|
-
"largest-contentful-paint-element",
|
|
130
|
-
"layout-shift-elements",
|
|
131
|
-
"long-tasks",
|
|
132
|
-
"mainthread-work-breakdown",
|
|
133
|
-
"bootup-time",
|
|
134
|
-
"font-display",
|
|
135
|
-
"uses-text-compression",
|
|
136
|
-
"duplicated-javascript",
|
|
137
|
-
"legacy-javascript",
|
|
138
|
-
"total-byte-weight",
|
|
139
|
-
];
|
|
140
|
-
|
|
141
|
-
const issues = [];
|
|
142
|
-
for (const id of relevant) {
|
|
143
|
-
const audit = report.audits?.[id];
|
|
144
|
-
if (audit && audit.score !== null && audit.score < 1) {
|
|
145
|
-
issues.push({
|
|
146
|
-
id,
|
|
147
|
-
title: audit.title,
|
|
148
|
-
displayValue: audit.displayValue || "",
|
|
149
|
-
score: audit.score,
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
return issues.sort((a, b) => a.score - b.score); // worst first
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// ── Source File Discovery ──────────────────────────────────────────────
|
|
157
|
-
|
|
158
|
-
function discoverPageFiles(projectRoot, route) {
|
|
159
|
-
const routePath = route === "/" ? "" : route.replace(/^\//, "");
|
|
160
|
-
const files = [];
|
|
161
|
-
|
|
162
|
-
const candidates = [
|
|
163
|
-
// Next.js App Router
|
|
164
|
-
join("app", routePath, "page.tsx"),
|
|
165
|
-
join("app", routePath, "page.jsx"),
|
|
166
|
-
join("app", routePath, "page.js"),
|
|
167
|
-
join("app", routePath, "layout.tsx"),
|
|
168
|
-
join("app", routePath, "layout.jsx"),
|
|
169
|
-
join("app", "layout.tsx"),
|
|
170
|
-
join("app", "layout.jsx"),
|
|
171
|
-
// src/app
|
|
172
|
-
join("src", "app", routePath, "page.tsx"),
|
|
173
|
-
join("src", "app", routePath, "layout.tsx"),
|
|
174
|
-
join("src", "app", "layout.tsx"),
|
|
175
|
-
// Next.js Pages Router
|
|
176
|
-
join("pages", routePath + ".tsx"),
|
|
177
|
-
join("pages", routePath + ".jsx"),
|
|
178
|
-
join("pages", routePath, "index.tsx"),
|
|
179
|
-
join("pages", routePath, "index.jsx"),
|
|
180
|
-
// Config files relevant to performance
|
|
181
|
-
"next.config.ts",
|
|
182
|
-
"next.config.js",
|
|
183
|
-
"next.config.mjs",
|
|
184
|
-
"vite.config.ts",
|
|
185
|
-
"vite.config.js",
|
|
186
|
-
];
|
|
187
|
-
|
|
188
|
-
for (const rel of candidates) {
|
|
189
|
-
const full = join(projectRoot, rel);
|
|
190
|
-
if (existsSync(full)) {
|
|
191
|
-
try {
|
|
192
|
-
const content = readFileSync(full, "utf8");
|
|
193
|
-
if (content.length < 15000) {
|
|
194
|
-
files.push({ path: rel, content });
|
|
195
|
-
} else {
|
|
196
|
-
files.push({
|
|
197
|
-
path: rel,
|
|
198
|
-
content: content.slice(0, 15000) + "\n// ... truncated",
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
|
-
} catch {}
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// Scan for component imports from the page file
|
|
206
|
-
if (files.length > 0) {
|
|
207
|
-
const pageFile = files[0];
|
|
208
|
-
const importRegex = /from\s+["']([./][^"']+)["']/g;
|
|
209
|
-
let match;
|
|
210
|
-
while ((match = importRegex.exec(pageFile.content)) !== null) {
|
|
211
|
-
const importPath = match[1];
|
|
212
|
-
const possiblePaths = [
|
|
213
|
-
importPath + ".tsx",
|
|
214
|
-
importPath + ".jsx",
|
|
215
|
-
importPath + ".ts",
|
|
216
|
-
importPath + ".js",
|
|
217
|
-
join(importPath, "index.tsx"),
|
|
218
|
-
];
|
|
219
|
-
for (const p of possiblePaths) {
|
|
220
|
-
const resolved = join(
|
|
221
|
-
projectRoot,
|
|
222
|
-
files[0].path.replace(/[^/]+$/, ""),
|
|
223
|
-
p
|
|
224
|
-
);
|
|
225
|
-
if (existsSync(resolved)) {
|
|
226
|
-
try {
|
|
227
|
-
const content = readFileSync(resolved, "utf8");
|
|
228
|
-
const relPath = relative(projectRoot, resolved);
|
|
229
|
-
if (!files.find((f) => f.path === relPath)) {
|
|
230
|
-
files.push({
|
|
231
|
-
path: relPath,
|
|
232
|
-
content:
|
|
233
|
-
content.length > 8000
|
|
234
|
-
? content.slice(0, 8000) + "\n// ... truncated"
|
|
235
|
-
: content,
|
|
236
|
-
});
|
|
237
|
-
}
|
|
238
|
-
} catch {}
|
|
239
|
-
break;
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
return files;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// ── Display Helpers ────────────────────────────────────────────────────
|
|
249
|
-
|
|
250
|
-
function formatVital(key, value) {
|
|
251
|
-
const info = VITALS[key];
|
|
252
|
-
if (!info || value === undefined || value === null) return null;
|
|
253
|
-
const passed = key === "CLS" ? value <= info.good : value <= info.good;
|
|
254
|
-
const formatted =
|
|
255
|
-
info.unit === "ms" ? `${Math.round(value)}ms` : value.toFixed(3);
|
|
256
|
-
const icon = passed ? chalk.green("✓") : chalk.red("✗");
|
|
257
|
-
const color = passed ? chalk.green : chalk.red;
|
|
258
|
-
return (
|
|
259
|
-
` ${chalk.white(key.padEnd(6))}` +
|
|
260
|
-
`${color(formatted.padStart(9))}` +
|
|
261
|
-
` ${chalk.dim(`(good: <${info.good}${info.unit})`)} ${icon}`
|
|
262
|
-
);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
function printBanner() {
|
|
266
|
-
const lines = [
|
|
267
|
-
"",
|
|
268
|
-
chalk.hex("#FF8C00").bold(
|
|
269
|
-
" ┌─────────────────────────────────────────────────────┐"
|
|
270
|
-
),
|
|
271
|
-
chalk.hex("#FF8C00").bold(" │") +
|
|
272
|
-
chalk.hex("#FF8C00").bold(
|
|
273
|
-
" T H E G L O L O O P "
|
|
274
|
-
) +
|
|
275
|
-
chalk.hex("#FF8C00").bold("│"),
|
|
276
|
-
chalk.hex("#FF8C00").bold(" │") +
|
|
277
|
-
chalk.dim(
|
|
278
|
-
" Web Vitals Optimization Engine "
|
|
279
|
-
) +
|
|
280
|
-
chalk.hex("#FF8C00").bold("│"),
|
|
281
|
-
chalk.hex("#FF8C00").bold(" │") +
|
|
282
|
-
chalk.dim(
|
|
283
|
-
" "
|
|
284
|
-
) +
|
|
285
|
-
chalk.hex("#FF8C00").bold("│"),
|
|
286
|
-
chalk.hex("#FF8C00").bold(" │") +
|
|
287
|
-
chalk.hex("#4AF626")(" G") +
|
|
288
|
-
chalk.white("ather → ") +
|
|
289
|
-
chalk.dim("run Lighthouse, extract metrics ") +
|
|
290
|
-
chalk.hex("#FF8C00").bold("│"),
|
|
291
|
-
chalk.hex("#FF8C00").bold(" │") +
|
|
292
|
-
chalk.hex("#4AF626")(" L") +
|
|
293
|
-
chalk.white("everage → ") +
|
|
294
|
-
chalk.dim("AI analyzes code + diagnostics ") +
|
|
295
|
-
chalk.hex("#FF8C00").bold("│"),
|
|
296
|
-
chalk.hex("#FF8C00").bold(" │") +
|
|
297
|
-
chalk.hex("#4AF626")(" O") +
|
|
298
|
-
chalk.white("perate → ") +
|
|
299
|
-
chalk.dim("apply fix, re-measure, repeat ") +
|
|
300
|
-
chalk.hex("#FF8C00").bold("│"),
|
|
301
|
-
chalk.hex("#FF8C00").bold(" │") +
|
|
302
|
-
chalk.dim(
|
|
303
|
-
" "
|
|
304
|
-
) +
|
|
305
|
-
chalk.hex("#FF8C00").bold("│"),
|
|
306
|
-
chalk.hex("#FF8C00").bold(" │") +
|
|
307
|
-
chalk.dim(" ↻ repeat until target met") +
|
|
308
|
-
chalk.dim(
|
|
309
|
-
" "
|
|
310
|
-
) +
|
|
311
|
-
chalk.hex("#FF8C00").bold("│"),
|
|
312
|
-
chalk.hex("#FF8C00").bold(
|
|
313
|
-
" └─────────────────────────────────────────────────────┘"
|
|
314
|
-
),
|
|
315
|
-
"",
|
|
316
|
-
];
|
|
317
|
-
console.log(lines.join("\n"));
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
// ── AI Analysis ────────────────────────────────────────────────────────
|
|
321
|
-
|
|
322
|
-
async function analyzeWithAI(
|
|
323
|
-
model,
|
|
324
|
-
targetVital,
|
|
325
|
-
metrics,
|
|
326
|
-
diagnostics,
|
|
327
|
-
sourceFiles,
|
|
328
|
-
loopNumber,
|
|
329
|
-
previousSuggestions
|
|
330
|
-
) {
|
|
331
|
-
const vitalInfo = VITALS[targetVital];
|
|
332
|
-
|
|
333
|
-
const sourceContext = sourceFiles
|
|
334
|
-
.map((f) => `--- ${f.path} ---\n${f.content}`)
|
|
335
|
-
.join("\n\n");
|
|
336
|
-
|
|
337
|
-
const diagText = diagnostics
|
|
338
|
-
.slice(0, 10)
|
|
339
|
-
.map(
|
|
340
|
-
(d) =>
|
|
341
|
-
`- ${d.title}${d.displayValue ? ` (${d.displayValue})` : ""} [score: ${d.score}]`
|
|
342
|
-
)
|
|
343
|
-
.join("\n");
|
|
344
|
-
|
|
345
|
-
const metricsText = Object.entries(metrics)
|
|
346
|
-
.filter(([k]) => k !== "performanceScore")
|
|
347
|
-
.map(([k, v]) => {
|
|
348
|
-
const info = VITALS[k];
|
|
349
|
-
if (!info) return null;
|
|
350
|
-
const val = info.unit === "ms" ? `${Math.round(v.value)}ms` : v.value?.toFixed(3);
|
|
351
|
-
const status = v.value <= info.good ? "PASS" : "FAIL";
|
|
352
|
-
return ` ${k}: ${val} (good: <${info.good}${info.unit}) [${status}]`;
|
|
353
|
-
})
|
|
354
|
-
.filter(Boolean)
|
|
355
|
-
.join("\n");
|
|
356
|
-
|
|
357
|
-
const prevContext =
|
|
358
|
-
previousSuggestions.length > 0
|
|
359
|
-
? `\n\nPrevious suggestions already applied:\n${previousSuggestions.map((s, i) => `${i + 1}. ${s}`).join("\n")}\n\nDo NOT repeat these. Find the NEXT optimization.`
|
|
360
|
-
: "";
|
|
361
|
-
|
|
362
|
-
const prompt = `You are a frontend infrastructure engineer optimizing web performance.
|
|
363
|
-
|
|
364
|
-
## Current Metrics (Loop ${loopNumber})
|
|
365
|
-
Performance Score: ${metrics.performanceScore}/100
|
|
366
|
-
${metricsText}
|
|
367
|
-
|
|
368
|
-
## Target
|
|
369
|
-
Optimize ${targetVital} (${vitalInfo.name}): currently ${metrics[targetVital]?.value !== undefined ? (vitalInfo.unit === "ms" ? Math.round(metrics[targetVital].value) + "ms" : metrics[targetVital].value.toFixed(3)) : "unknown"}, target: <${vitalInfo.good}${vitalInfo.unit}
|
|
370
|
-
|
|
371
|
-
## Lighthouse Diagnostics (sorted by severity)
|
|
372
|
-
${diagText || "No failing diagnostics."}
|
|
373
|
-
|
|
374
|
-
## Source Files
|
|
375
|
-
${sourceContext || "No source files found for this route."}
|
|
376
|
-
${prevContext}
|
|
377
|
-
|
|
378
|
-
## Instructions
|
|
379
|
-
|
|
380
|
-
Respond in EXACTLY this format (no markdown fences):
|
|
381
|
-
|
|
382
|
-
DIAGNOSIS: One sentence explaining what is causing the ${targetVital} issue.
|
|
383
|
-
|
|
384
|
-
FILE: path/to/file.ext
|
|
385
|
-
LINE: approximate line number (or "new" if adding to config)
|
|
386
|
-
|
|
387
|
-
BEFORE:
|
|
388
|
-
<exact code to replace, or "N/A" if adding new code>
|
|
389
|
-
|
|
390
|
-
AFTER:
|
|
391
|
-
<optimized code>
|
|
392
|
-
|
|
393
|
-
WHY: One sentence on expected impact with estimated improvement in ${vitalInfo.unit || "score"}.
|
|
394
|
-
|
|
395
|
-
Keep the fix SURGICAL — one change per loop. Prefer high-impact, low-risk changes:
|
|
396
|
-
- Image optimization (priority, sizes, lazy/eager, format)
|
|
397
|
-
- Font loading (display: swap, preload, subset)
|
|
398
|
-
- Bundle optimization (dynamic imports, tree shaking)
|
|
399
|
-
- Render-blocking resources (async, defer, preload)
|
|
400
|
-
- Layout shift prevention (explicit dimensions, aspect-ratio)
|
|
401
|
-
- Server response (caching, compression, CDN)
|
|
402
|
-
- Component-level code splitting`;
|
|
403
|
-
|
|
404
|
-
const result = await generateText({
|
|
405
|
-
model,
|
|
406
|
-
prompt,
|
|
407
|
-
});
|
|
408
|
-
|
|
409
|
-
return result.text;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
// ── Main ───────────────────────────────────────────────────────────────
|
|
13
|
+
// ── Main ───────────────────────────────────────────────────────────
|
|
413
14
|
|
|
414
15
|
async function main() {
|
|
415
16
|
printBanner();
|
|
@@ -443,11 +44,9 @@ async function main() {
|
|
|
443
44
|
);
|
|
444
45
|
const targetUrl = url.trim() || "http://localhost:3000";
|
|
445
46
|
|
|
446
|
-
// Extract route from URL for source file discovery
|
|
447
47
|
let route = "/";
|
|
448
48
|
try {
|
|
449
|
-
|
|
450
|
-
route = parsed.pathname;
|
|
49
|
+
route = new URL(targetUrl).pathname;
|
|
451
50
|
} catch {}
|
|
452
51
|
|
|
453
52
|
console.log("");
|
|
@@ -463,7 +62,7 @@ async function main() {
|
|
|
463
62
|
const vitalInput = await rl.question(
|
|
464
63
|
chalk.hex("#FF8C00")(" Target vital ") + chalk.dim("(default: LCP): ")
|
|
465
64
|
);
|
|
466
|
-
const targetVital =
|
|
65
|
+
const targetVital = vitalInput.trim().toUpperCase() || "LCP";
|
|
467
66
|
if (!VITALS[targetVital]) {
|
|
468
67
|
console.log(chalk.red(`\n Unknown vital: ${targetVital}`));
|
|
469
68
|
console.log(chalk.dim(` Choose from: ${Object.keys(VITALS).join(", ")}\n`));
|
|
@@ -488,9 +87,8 @@ async function main() {
|
|
|
488
87
|
chalk.hex("#FF8C00")(" Loops: ") + chalk.white(`${maxLoops} max`)
|
|
489
88
|
);
|
|
490
89
|
|
|
491
|
-
// Discover source files
|
|
492
90
|
const projectRoot = process.env.INIT_CWD || process.cwd();
|
|
493
|
-
|
|
91
|
+
let sourceFiles = discoverPageFiles(projectRoot, route);
|
|
494
92
|
if (sourceFiles.length > 0) {
|
|
495
93
|
console.log(
|
|
496
94
|
chalk.hex("#FF8C00")(" Files: ") +
|
|
@@ -514,9 +112,9 @@ async function main() {
|
|
|
514
112
|
console.log(chalk.hex("#4AF626").bold("\n GATHER"));
|
|
515
113
|
console.log(chalk.dim(" Running Lighthouse...\n"));
|
|
516
114
|
|
|
517
|
-
let
|
|
115
|
+
let metrics, diagnostics;
|
|
518
116
|
try {
|
|
519
|
-
report = runLighthouse(targetUrl);
|
|
117
|
+
const report = runLighthouse(targetUrl);
|
|
520
118
|
metrics = extractMetrics(report);
|
|
521
119
|
diagnostics = extractDiagnostics(report);
|
|
522
120
|
} catch (err) {
|
|
@@ -532,17 +130,7 @@ async function main() {
|
|
|
532
130
|
break;
|
|
533
131
|
}
|
|
534
132
|
|
|
535
|
-
|
|
536
|
-
chalk.white(` Score `) +
|
|
537
|
-
chalk.bold(
|
|
538
|
-
metrics.performanceScore >= 90
|
|
539
|
-
? chalk.green(`${metrics.performanceScore}/100`)
|
|
540
|
-
: metrics.performanceScore >= 50
|
|
541
|
-
? chalk.yellow(`${metrics.performanceScore}/100`)
|
|
542
|
-
: chalk.red(`${metrics.performanceScore}/100`)
|
|
543
|
-
)
|
|
544
|
-
);
|
|
545
|
-
|
|
133
|
+
printScore(metrics.performanceScore);
|
|
546
134
|
for (const key of Object.keys(VITALS)) {
|
|
547
135
|
const line = formatVital(key, metrics[key]?.value);
|
|
548
136
|
if (line) console.log(line);
|
|
@@ -551,22 +139,22 @@ async function main() {
|
|
|
551
139
|
// Check if target is already met
|
|
552
140
|
const currentValue = metrics[targetVital]?.value;
|
|
553
141
|
if (currentValue !== undefined && currentValue <= VITALS[targetVital].good) {
|
|
142
|
+
const vitalInfo = VITALS[targetVital];
|
|
143
|
+
const display = vitalInfo.unit === "ms"
|
|
144
|
+
? Math.round(currentValue) + "ms"
|
|
145
|
+
: currentValue.toFixed(3);
|
|
554
146
|
console.log(
|
|
555
147
|
chalk.green.bold(
|
|
556
|
-
`\n Target met! ${targetVital} = ${
|
|
148
|
+
`\n Target met! ${targetVital} = ${display} (good: <${vitalInfo.good}${vitalInfo.unit})`
|
|
149
|
+
)
|
|
150
|
+
);
|
|
151
|
+
console.log(
|
|
152
|
+
chalk.dim(
|
|
153
|
+
loop === 1
|
|
154
|
+
? ` This page is already performing well for ${targetVital}.`
|
|
155
|
+
: ` Took ${loop - 1} optimization loop(s).`
|
|
557
156
|
)
|
|
558
157
|
);
|
|
559
|
-
if (loop === 1) {
|
|
560
|
-
console.log(
|
|
561
|
-
chalk.dim(
|
|
562
|
-
" This page is already performing well for " + targetVital + "."
|
|
563
|
-
)
|
|
564
|
-
);
|
|
565
|
-
} else {
|
|
566
|
-
console.log(
|
|
567
|
-
chalk.dim(` Took ${loop - 1} optimization loop(s).`)
|
|
568
|
-
);
|
|
569
|
-
}
|
|
570
158
|
console.log(
|
|
571
159
|
chalk.dim(
|
|
572
160
|
"\n Want deeper optimization? " +
|
|
@@ -598,25 +186,7 @@ async function main() {
|
|
|
598
186
|
break;
|
|
599
187
|
}
|
|
600
188
|
|
|
601
|
-
|
|
602
|
-
for (const line of suggestion.split("\n")) {
|
|
603
|
-
if (
|
|
604
|
-
line.startsWith("DIAGNOSIS:") ||
|
|
605
|
-
line.startsWith("FILE:") ||
|
|
606
|
-
line.startsWith("LINE:") ||
|
|
607
|
-
line.startsWith("WHY:")
|
|
608
|
-
) {
|
|
609
|
-
const [label, ...rest] = line.split(":");
|
|
610
|
-
console.log(
|
|
611
|
-
chalk.hex("#FF8C00")(` ${label}:`) +
|
|
612
|
-
chalk.white(rest.join(":"))
|
|
613
|
-
);
|
|
614
|
-
} else if (line.startsWith("BEFORE:") || line.startsWith("AFTER:")) {
|
|
615
|
-
console.log(chalk.hex("#FF8C00")(` ${line}`));
|
|
616
|
-
} else {
|
|
617
|
-
console.log(chalk.hex("#89b4fa")(` ${line}`));
|
|
618
|
-
}
|
|
619
|
-
}
|
|
189
|
+
printSuggestion(suggestion);
|
|
620
190
|
|
|
621
191
|
// ── OPERATE ─────────────────────────────────────────────────
|
|
622
192
|
|
|
@@ -633,19 +203,14 @@ async function main() {
|
|
|
633
203
|
}
|
|
634
204
|
|
|
635
205
|
if (choice === "y" || choice === "yes") {
|
|
636
|
-
previousSuggestions.push(suggestion.split("\n")[0]);
|
|
206
|
+
previousSuggestions.push(suggestion.split("\n")[0]);
|
|
637
207
|
console.log(
|
|
638
208
|
chalk.dim(
|
|
639
209
|
" Apply the change above to your code, then press Enter to re-measure."
|
|
640
210
|
)
|
|
641
211
|
);
|
|
642
212
|
await rl.question(chalk.dim(" Press Enter when ready..."));
|
|
643
|
-
|
|
644
|
-
const updatedFiles = discoverPageFiles(projectRoot, route);
|
|
645
|
-
if (updatedFiles.length > 0) {
|
|
646
|
-
sourceFiles.length = 0;
|
|
647
|
-
sourceFiles.push(...updatedFiles);
|
|
648
|
-
}
|
|
213
|
+
sourceFiles = discoverPageFiles(projectRoot, route);
|
|
649
214
|
} else {
|
|
650
215
|
previousSuggestions.push(`SKIPPED: ${suggestion.split("\n")[0]}`);
|
|
651
216
|
console.log(chalk.dim(" Skipped. Moving to next suggestion.\n"));
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { generateText as defaultGenerateText } from "ai";
|
|
2
|
+
import { VITALS } from "./vitals.mjs";
|
|
3
|
+
|
|
4
|
+
function buildPrompt(targetVital, metrics, diagnostics, sourceFiles, loopNumber, previousSuggestions) {
|
|
5
|
+
const vitalInfo = VITALS[targetVital];
|
|
6
|
+
|
|
7
|
+
const sourceContext = sourceFiles
|
|
8
|
+
.map((f) => `--- ${f.path} ---\n${f.content}`)
|
|
9
|
+
.join("\n\n");
|
|
10
|
+
|
|
11
|
+
const diagText = diagnostics
|
|
12
|
+
.slice(0, 10)
|
|
13
|
+
.map(
|
|
14
|
+
(d) =>
|
|
15
|
+
`- ${d.title}${d.displayValue ? ` (${d.displayValue})` : ""} [score: ${d.score}]`
|
|
16
|
+
)
|
|
17
|
+
.join("\n");
|
|
18
|
+
|
|
19
|
+
const metricsText = Object.entries(metrics)
|
|
20
|
+
.filter(([k]) => k !== "performanceScore")
|
|
21
|
+
.map(([k, v]) => {
|
|
22
|
+
const info = VITALS[k];
|
|
23
|
+
if (!info) return null;
|
|
24
|
+
const val = info.unit === "ms" ? `${Math.round(v.value)}ms` : v.value?.toFixed(3);
|
|
25
|
+
const status = v.value <= info.good ? "PASS" : "FAIL";
|
|
26
|
+
return ` ${k}: ${val} (good: <${info.good}${info.unit}) [${status}]`;
|
|
27
|
+
})
|
|
28
|
+
.filter(Boolean)
|
|
29
|
+
.join("\n");
|
|
30
|
+
|
|
31
|
+
const prevContext =
|
|
32
|
+
previousSuggestions.length > 0
|
|
33
|
+
? `\n\nPrevious suggestions already applied:\n${previousSuggestions.map((s, i) => `${i + 1}. ${s}`).join("\n")}\n\nDo NOT repeat these. Find the NEXT optimization.`
|
|
34
|
+
: "";
|
|
35
|
+
|
|
36
|
+
const currentVal = metrics[targetVital]?.value !== undefined
|
|
37
|
+
? (vitalInfo.unit === "ms" ? Math.round(metrics[targetVital].value) + "ms" : metrics[targetVital].value.toFixed(3))
|
|
38
|
+
: "unknown";
|
|
39
|
+
|
|
40
|
+
return `You are a frontend infrastructure engineer optimizing web performance.
|
|
41
|
+
|
|
42
|
+
## Current Metrics (Loop ${loopNumber})
|
|
43
|
+
Performance Score: ${metrics.performanceScore}/100
|
|
44
|
+
${metricsText}
|
|
45
|
+
|
|
46
|
+
## Target
|
|
47
|
+
Optimize ${targetVital} (${vitalInfo.name}): currently ${currentVal}, target: <${vitalInfo.good}${vitalInfo.unit}
|
|
48
|
+
|
|
49
|
+
## Lighthouse Diagnostics (sorted by severity)
|
|
50
|
+
${diagText || "No failing diagnostics."}
|
|
51
|
+
|
|
52
|
+
## Source Files
|
|
53
|
+
${sourceContext || "No source files found for this route."}
|
|
54
|
+
${prevContext}
|
|
55
|
+
|
|
56
|
+
## Instructions
|
|
57
|
+
|
|
58
|
+
Respond in EXACTLY this format (no markdown fences):
|
|
59
|
+
|
|
60
|
+
DIAGNOSIS: One sentence explaining what is causing the ${targetVital} issue.
|
|
61
|
+
|
|
62
|
+
FILE: path/to/file.ext
|
|
63
|
+
LINE: approximate line number (or "new" if adding to config)
|
|
64
|
+
|
|
65
|
+
BEFORE:
|
|
66
|
+
<exact code to replace, or "N/A" if adding new code>
|
|
67
|
+
|
|
68
|
+
AFTER:
|
|
69
|
+
<optimized code>
|
|
70
|
+
|
|
71
|
+
WHY: One sentence on expected impact with estimated improvement in ${vitalInfo.unit || "score"}.
|
|
72
|
+
|
|
73
|
+
Keep the fix SURGICAL — one change per loop. Prefer high-impact, low-risk changes:
|
|
74
|
+
- Image optimization (priority, sizes, lazy/eager, format)
|
|
75
|
+
- Font loading (display: swap, preload, subset)
|
|
76
|
+
- Bundle optimization (dynamic imports, tree shaking)
|
|
77
|
+
- Render-blocking resources (async, defer, preload)
|
|
78
|
+
- Layout shift prevention (explicit dimensions, aspect-ratio)
|
|
79
|
+
- Server response (caching, compression, CDN)
|
|
80
|
+
- Component-level code splitting`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function analyzeWithAI(
|
|
84
|
+
model,
|
|
85
|
+
targetVital,
|
|
86
|
+
metrics,
|
|
87
|
+
diagnostics,
|
|
88
|
+
sourceFiles,
|
|
89
|
+
loopNumber,
|
|
90
|
+
previousSuggestions,
|
|
91
|
+
{ generateText = defaultGenerateText } = {}
|
|
92
|
+
) {
|
|
93
|
+
const prompt = buildPrompt(
|
|
94
|
+
targetVital,
|
|
95
|
+
metrics,
|
|
96
|
+
diagnostics,
|
|
97
|
+
sourceFiles,
|
|
98
|
+
loopNumber,
|
|
99
|
+
previousSuggestions
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const result = await generateText({ model, prompt });
|
|
103
|
+
return result.text;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Exported for testing
|
|
107
|
+
export { buildPrompt };
|