@ogygia/cli 0.0.0-stage → 0.11.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/LICENSE +21 -0
- package/README.md +27 -2
- package/ai/SKILL.md +363 -0
- package/dist/cli.mjs +40652 -0
- package/dist/mcp.js +1245 -0
- package/dist/perf/bisect.js +69 -0
- package/dist/perf/cli.js +221 -0
- package/dist/perf/compare.js +166 -0
- package/dist/perf/measure.js +102 -0
- package/dist/perf/report.js +55 -0
- package/package.json +59 -3
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// src/perf/bisect.ts
|
|
2
|
+
async function bisect(opts) {
|
|
3
|
+
const { commits, good_value, bad_value } = opts;
|
|
4
|
+
const margin = opts.margin ?? .5;
|
|
5
|
+
const bar = good_value + (bad_value - good_value) * margin;
|
|
6
|
+
const steps = [];
|
|
7
|
+
const skipped = [];
|
|
8
|
+
if (bad_value <= good_value) return {
|
|
9
|
+
culprit: null,
|
|
10
|
+
last_good: null,
|
|
11
|
+
good_value,
|
|
12
|
+
bad_value,
|
|
13
|
+
bar,
|
|
14
|
+
steps,
|
|
15
|
+
skipped
|
|
16
|
+
};
|
|
17
|
+
let lo = -1;
|
|
18
|
+
let hi = commits.length - 1;
|
|
19
|
+
const known = /* @__PURE__ */ new Map();
|
|
20
|
+
if (hi >= 0) known.set(commits[hi], true);
|
|
21
|
+
while (hi - lo > 1) {
|
|
22
|
+
const mid = lo + Math.floor((hi - lo) / 2);
|
|
23
|
+
let pick = -1;
|
|
24
|
+
for (let d = 0; mid - d > lo || mid + d < hi; d++) {
|
|
25
|
+
if (mid + d < hi && !skipped.includes(commits[mid + d])) {
|
|
26
|
+
pick = mid + d;
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
if (mid - d > lo && !skipped.includes(commits[mid - d])) {
|
|
30
|
+
pick = mid - d;
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (pick === -1) break;
|
|
35
|
+
const sha = commits[pick];
|
|
36
|
+
const t = Date.now();
|
|
37
|
+
let value = null;
|
|
38
|
+
try {
|
|
39
|
+
value = await opts.measure(sha);
|
|
40
|
+
} catch {
|
|
41
|
+
value = null;
|
|
42
|
+
}
|
|
43
|
+
const step = {
|
|
44
|
+
sha,
|
|
45
|
+
value,
|
|
46
|
+
bad: value !== null && value > bar,
|
|
47
|
+
ms: Date.now() - t
|
|
48
|
+
};
|
|
49
|
+
steps.push(step);
|
|
50
|
+
opts.on_step?.(step);
|
|
51
|
+
if (value === null) {
|
|
52
|
+
skipped.push(sha);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
known.set(sha, step.bad);
|
|
56
|
+
if (step.bad) hi = pick;
|
|
57
|
+
else lo = pick;
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
culprit: hi >= 0 && hi < commits.length ? commits[hi] : null,
|
|
61
|
+
last_good: lo >= 0 ? commits[lo] : null,
|
|
62
|
+
good_value,
|
|
63
|
+
bad_value,
|
|
64
|
+
bar,
|
|
65
|
+
steps,
|
|
66
|
+
skipped
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export { bisect };
|
package/dist/perf/cli.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { measure } from "./measure.js";
|
|
2
|
+
import { verdict } from "./compare.js";
|
|
3
|
+
import { markdown, summary } from "./report.js";
|
|
4
|
+
import { bisect } from "./bisect.js";
|
|
5
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { execSync, spawn } from "node:child_process";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
// src/perf/cli.ts
|
|
10
|
+
/**
|
|
11
|
+
* `ogygia perf` and `ogygia bisect` — the runners around the pure modules. Loaded by the CLI on
|
|
12
|
+
* demand (a sibling in dist), so the CLI bundle stays small.
|
|
13
|
+
*
|
|
14
|
+
* ogygia perf --url http://127.0.0.1:4173 --pages /,/docs [--runs 5] [--key $SECRET]
|
|
15
|
+
* [--baseline perf-baseline.json] [--budget budget.json] [--out perf-snapshot.json]
|
|
16
|
+
* [--md perf-comment.md] [--label "$GITHUB_SHA"] [--no-fail]
|
|
17
|
+
*
|
|
18
|
+
* ogygia bisect --good <sha> --bad <sha> --page /slow --build "pnpm build" --start "node build"
|
|
19
|
+
* --url http://127.0.0.1:3000 [--key $SECRET] [--runs 3] [--margin 0.5] [--ready 60000]
|
|
20
|
+
*/
|
|
21
|
+
function arg(argv, name) {
|
|
22
|
+
const i = argv.indexOf(name);
|
|
23
|
+
if (i === -1) return void 0;
|
|
24
|
+
const v = argv[i + 1];
|
|
25
|
+
return v && !v.startsWith("--") ? v : "";
|
|
26
|
+
}
|
|
27
|
+
const has = (argv, name) => argv.includes(name);
|
|
28
|
+
/** A number flag: its value whenever it parses, `0` included (`Number(v) || d` turns a real 0 into
|
|
29
|
+
* the default); `fallback` when the flag is absent, empty or not a number. */
|
|
30
|
+
function number_arg(argv, name, fallback) {
|
|
31
|
+
const raw = arg(argv, name);
|
|
32
|
+
if (raw === void 0 || raw.trim() === "") return fallback;
|
|
33
|
+
const n = Number(raw);
|
|
34
|
+
return Number.isFinite(n) ? n : fallback;
|
|
35
|
+
}
|
|
36
|
+
function read_json(file) {
|
|
37
|
+
if (!file || !existsSync(file)) return null;
|
|
38
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
39
|
+
}
|
|
40
|
+
async function runPerf(argv) {
|
|
41
|
+
const url = arg(argv, "--url");
|
|
42
|
+
const pages = (arg(argv, "--pages") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
43
|
+
const pages_file = arg(argv, "--pages-file");
|
|
44
|
+
if (pages_file) {
|
|
45
|
+
const list = read_json(pages_file);
|
|
46
|
+
if (Array.isArray(list)) pages.push(...list);
|
|
47
|
+
else if (list?.pages) pages.push(...list.pages);
|
|
48
|
+
}
|
|
49
|
+
if (!url || !pages.length) {
|
|
50
|
+
process.stderr.write("ogygia perf: --url <origin> and --pages /a,/b (or --pages-file) are required\n");
|
|
51
|
+
return 2;
|
|
52
|
+
}
|
|
53
|
+
const runs = Number(arg(argv, "--runs")) || 5;
|
|
54
|
+
const key = arg(argv, "--key") ?? process.env.OGYGIA_PROFILER_SECRET;
|
|
55
|
+
const base = arg(argv, "--base");
|
|
56
|
+
const label = arg(argv, "--label");
|
|
57
|
+
const budget = read_json(arg(argv, "--budget")) ?? {};
|
|
58
|
+
const baseline = read_json(arg(argv, "--baseline"));
|
|
59
|
+
process.stdout.write(`ogygia perf · ${pages.length} page${pages.length === 1 ? "" : "s"} × ${runs} runs on ${url}${baseline ? " · against the baseline" : " · no baseline (caps only)"}\n`);
|
|
60
|
+
let snap;
|
|
61
|
+
try {
|
|
62
|
+
snap = await measure({
|
|
63
|
+
url,
|
|
64
|
+
pages,
|
|
65
|
+
runs,
|
|
66
|
+
key,
|
|
67
|
+
base,
|
|
68
|
+
label,
|
|
69
|
+
on_page: (p) => process.stdout.write(` measured ${p.page}: ${p.render_p50.toFixed(1)} ms\n`)
|
|
70
|
+
});
|
|
71
|
+
} catch (e) {
|
|
72
|
+
process.stderr.write(`ogygia perf: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
73
|
+
return 2;
|
|
74
|
+
}
|
|
75
|
+
const out = arg(argv, "--out");
|
|
76
|
+
if (out) writeFileSync(out, JSON.stringify(snap, null, 2));
|
|
77
|
+
const v = verdict(baseline, snap, budget);
|
|
78
|
+
const md = arg(argv, "--md");
|
|
79
|
+
if (md) writeFileSync(md, markdown(v, { title: label ? `Performance (${label})` : "Performance" }));
|
|
80
|
+
process.stdout.write(summary(v) + "\n");
|
|
81
|
+
if (out) process.stdout.write(`snapshot: ${out}${md ? ` · comment: ${md}` : ""}\n`);
|
|
82
|
+
return v.ok || has(argv, "--no-fail") ? 0 : 1;
|
|
83
|
+
}
|
|
84
|
+
function sh(cmd, cwd, timeout_ms) {
|
|
85
|
+
execSync(cmd, {
|
|
86
|
+
cwd,
|
|
87
|
+
stdio: "inherit",
|
|
88
|
+
timeout: timeout_ms,
|
|
89
|
+
env: {
|
|
90
|
+
...process.env,
|
|
91
|
+
CI: "1"
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
/** Start a server command, wait until `url` answers (or `ready_ms`), return a stopper. */
|
|
96
|
+
async function serve(cmd, cwd, url, ready_ms) {
|
|
97
|
+
const child = spawn(cmd, {
|
|
98
|
+
cwd,
|
|
99
|
+
shell: true,
|
|
100
|
+
stdio: "ignore",
|
|
101
|
+
detached: true,
|
|
102
|
+
env: { ...process.env }
|
|
103
|
+
});
|
|
104
|
+
const stop = () => {
|
|
105
|
+
try {
|
|
106
|
+
if (child.pid) process.kill(-child.pid, "SIGTERM");
|
|
107
|
+
} catch {}
|
|
108
|
+
};
|
|
109
|
+
const until = Date.now() + ready_ms;
|
|
110
|
+
while (Date.now() < until) {
|
|
111
|
+
try {
|
|
112
|
+
if ((await fetch(url, { redirect: "manual" })).status < 500) return stop;
|
|
113
|
+
} catch {}
|
|
114
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
115
|
+
}
|
|
116
|
+
stop();
|
|
117
|
+
throw new Error(`server did not answer at ${url} within ${ready_ms} ms`);
|
|
118
|
+
}
|
|
119
|
+
async function runBisect(argv) {
|
|
120
|
+
const good = arg(argv, "--good");
|
|
121
|
+
const bad = arg(argv, "--bad") ?? "HEAD";
|
|
122
|
+
const page = arg(argv, "--page");
|
|
123
|
+
const build = arg(argv, "--build");
|
|
124
|
+
const start = arg(argv, "--start");
|
|
125
|
+
const url = arg(argv, "--url");
|
|
126
|
+
if (!good || !page || !build || !start || !url) {
|
|
127
|
+
process.stderr.write("ogygia bisect: --good <sha> --page /x --build \"<cmd>\" --start \"<cmd>\" --url <origin> are required (--bad defaults to HEAD)\n");
|
|
128
|
+
return 2;
|
|
129
|
+
}
|
|
130
|
+
const runs = Number(arg(argv, "--runs")) || 3;
|
|
131
|
+
const key = arg(argv, "--key") ?? process.env.OGYGIA_PROFILER_SECRET;
|
|
132
|
+
const margin = number_arg(argv, "--margin", .5);
|
|
133
|
+
const ready_ms = Number(arg(argv, "--ready")) || 6e4;
|
|
134
|
+
const build_ms = Number(arg(argv, "--build-timeout")) || 9e5;
|
|
135
|
+
const repo = process.cwd();
|
|
136
|
+
const commits = execSync(`git rev-list --reverse ${good}..${bad}`, {
|
|
137
|
+
cwd: repo,
|
|
138
|
+
encoding: "utf8"
|
|
139
|
+
}).trim().split("\n").filter(Boolean);
|
|
140
|
+
if (!commits.length) {
|
|
141
|
+
process.stderr.write(`ogygia bisect: no commits between ${good} and ${bad}\n`);
|
|
142
|
+
return 2;
|
|
143
|
+
}
|
|
144
|
+
process.stdout.write(`ogygia bisect · ${commits.length} commits between ${good.slice(0, 7)} and ${bad.slice(0, 7)} · page ${page}\n`);
|
|
145
|
+
const worktree = mkdtempSync(path.join(tmpdir(), "ogygia-bisect-"));
|
|
146
|
+
const running = { stop: null };
|
|
147
|
+
const measure_sha = async (sha) => {
|
|
148
|
+
try {
|
|
149
|
+
const dir = path.join(worktree, sha.slice(0, 12));
|
|
150
|
+
if (!existsSync(dir)) sh(`git worktree add --detach "${dir}" ${sha}`, repo, 6e4);
|
|
151
|
+
try {
|
|
152
|
+
sh(build, dir, build_ms);
|
|
153
|
+
} catch {
|
|
154
|
+
process.stdout.write(` ${sha.slice(0, 7)}: build failed — skipped\n`);
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
running.stop = await serve(start, dir, url, ready_ms);
|
|
158
|
+
try {
|
|
159
|
+
return (await measure({
|
|
160
|
+
url,
|
|
161
|
+
pages: [page],
|
|
162
|
+
runs,
|
|
163
|
+
key
|
|
164
|
+
})).pages[0].render_p50;
|
|
165
|
+
} finally {
|
|
166
|
+
running.stop();
|
|
167
|
+
running.stop = null;
|
|
168
|
+
}
|
|
169
|
+
} catch (e) {
|
|
170
|
+
process.stdout.write(` ${sha.slice(0, 7)}: ${e instanceof Error ? e.message : String(e)} — skipped\n`);
|
|
171
|
+
return null;
|
|
172
|
+
} finally {
|
|
173
|
+
try {
|
|
174
|
+
sh(`git worktree remove --force "${path.join(worktree, sha.slice(0, 12))}"`, repo, 6e4);
|
|
175
|
+
} catch {}
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
try {
|
|
179
|
+
const good_sha = execSync(`git rev-parse ${good}`, {
|
|
180
|
+
cwd: repo,
|
|
181
|
+
encoding: "utf8"
|
|
182
|
+
}).trim();
|
|
183
|
+
const bad_sha = execSync(`git rev-parse ${bad}`, {
|
|
184
|
+
cwd: repo,
|
|
185
|
+
encoding: "utf8"
|
|
186
|
+
}).trim();
|
|
187
|
+
process.stdout.write(` measuring the ends…\n`);
|
|
188
|
+
const good_value = await measure_sha(good_sha);
|
|
189
|
+
const bad_value = await measure_sha(bad_sha);
|
|
190
|
+
if (good_value === null || bad_value === null) {
|
|
191
|
+
process.stderr.write("ogygia bisect: could not measure the good or the bad end\n");
|
|
192
|
+
return 2;
|
|
193
|
+
}
|
|
194
|
+
process.stdout.write(` good ${good_value.toFixed(1)} ms · bad ${bad_value.toFixed(1)} ms\n`);
|
|
195
|
+
const result = await bisect({
|
|
196
|
+
commits,
|
|
197
|
+
good_value,
|
|
198
|
+
bad_value,
|
|
199
|
+
margin,
|
|
200
|
+
measure: measure_sha,
|
|
201
|
+
on_step: (s) => process.stdout.write(` ${s.sha.slice(0, 7)}: ${s.value === null ? "skipped" : `${s.value.toFixed(1)} ms → ${s.bad ? "bad" : "good"}`} (${Math.round(s.ms / 1e3)} s)\n`)
|
|
202
|
+
});
|
|
203
|
+
if (!result.culprit) {
|
|
204
|
+
process.stdout.write(`no commit in the range crossed the bar (${result.bar.toFixed(1)} ms): the regression is not between these commits, or it is noise\n`);
|
|
205
|
+
return 1;
|
|
206
|
+
}
|
|
207
|
+
const subject = execSync(`git log -1 --format=%s ${result.culprit}`, {
|
|
208
|
+
cwd: repo,
|
|
209
|
+
encoding: "utf8"
|
|
210
|
+
}).trim();
|
|
211
|
+
process.stdout.write(`\nfirst bad commit: ${result.culprit.slice(0, 7)} — ${subject}\n${result.last_good ? `last good: ${result.last_good.slice(0, 7)}\n` : ""}bar: ${result.bar.toFixed(1)} ms · ${result.steps.length} steps${result.skipped.length ? ` · skipped ${result.skipped.length}` : ""}\n`);
|
|
212
|
+
return 0;
|
|
213
|
+
} finally {
|
|
214
|
+
running.stop?.();
|
|
215
|
+
rmSync(worktree, {
|
|
216
|
+
recursive: true,
|
|
217
|
+
force: true
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
export { number_arg, runBisect, runPerf };
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// src/perf/compare.ts
|
|
2
|
+
const DEFAULT_BUDGET = {
|
|
3
|
+
render_pct: 8,
|
|
4
|
+
render_floor_ms: 5,
|
|
5
|
+
busy_pct: 15,
|
|
6
|
+
component_pct: 25,
|
|
7
|
+
seed_kb: 20,
|
|
8
|
+
props_kb: 30,
|
|
9
|
+
islands_js_kb: 30,
|
|
10
|
+
cold_extra_ms: 100,
|
|
11
|
+
fail_on: []
|
|
12
|
+
};
|
|
13
|
+
const r1 = (n) => Math.round(n * 10) / 10;
|
|
14
|
+
/** The budget for one page: defaults, the top level, then the page's overrides. */
|
|
15
|
+
function budget_for(page, budget) {
|
|
16
|
+
const { pages, ...top } = budget;
|
|
17
|
+
const over = pages?.[page] ?? {};
|
|
18
|
+
return {
|
|
19
|
+
...DEFAULT_BUDGET,
|
|
20
|
+
render_max_ms: Infinity,
|
|
21
|
+
seed_max_kb: Infinity,
|
|
22
|
+
islands_js_max_kb: Infinity,
|
|
23
|
+
...strip(top),
|
|
24
|
+
...strip(over)
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
const strip = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== void 0));
|
|
28
|
+
function verdict(baseline, current, budget = {}) {
|
|
29
|
+
const base_pages = new Map((baseline?.pages ?? []).map((p) => [p.page, p]));
|
|
30
|
+
const pages = current.pages.map((cur) => judge(cur, base_pages.get(cur.page) ?? null, budget_for(cur.page, budget)));
|
|
31
|
+
const measured = new Set(current.pages.map((p) => p.page));
|
|
32
|
+
const missing = [...base_pages.keys()].filter((p) => !measured.has(p));
|
|
33
|
+
const failures = pages.flatMap((p) => p.failures.map((f) => `${p.page}: ${f}`));
|
|
34
|
+
return {
|
|
35
|
+
ok: failures.length === 0,
|
|
36
|
+
pages,
|
|
37
|
+
missing,
|
|
38
|
+
failures
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function judge(cur, base, b) {
|
|
42
|
+
const rows = [];
|
|
43
|
+
const failures = [];
|
|
44
|
+
const row = (metric, unit, c, bv, judge_) => {
|
|
45
|
+
const delta = bv === null ? null : r1(c - bv);
|
|
46
|
+
const pct = bv === null || bv === 0 ? null : r1((c - bv) / bv * 100);
|
|
47
|
+
const j = judge_(delta, c);
|
|
48
|
+
rows.push({
|
|
49
|
+
metric,
|
|
50
|
+
unit,
|
|
51
|
+
base: bv,
|
|
52
|
+
cur: r1(c),
|
|
53
|
+
delta,
|
|
54
|
+
pct,
|
|
55
|
+
limit: j.limit,
|
|
56
|
+
over: j.over
|
|
57
|
+
});
|
|
58
|
+
if (j.over) failures.push(j.why ?? `${metric} over budget`);
|
|
59
|
+
};
|
|
60
|
+
const grew = (delta, base_v, pct, floor) => delta !== null && base_v !== null && delta > floor && delta > base_v * pct / 100;
|
|
61
|
+
row("render (median)", "ms", cur.render_p50, base?.render_p50 ?? null, (d, c) => {
|
|
62
|
+
const rel = grew(d, base?.render_p50 ?? null, b.render_pct, b.render_floor_ms);
|
|
63
|
+
const cap = c > b.render_max_ms;
|
|
64
|
+
return {
|
|
65
|
+
over: rel || cap,
|
|
66
|
+
limit: `+${b.render_pct}% (≥ ${b.render_floor_ms} ms)${Number.isFinite(b.render_max_ms) ? `, max ${b.render_max_ms} ms` : ""}`,
|
|
67
|
+
why: cap ? `render ${r1(c)} ms is over the ${b.render_max_ms} ms cap` : `render grew ${r1(d ?? 0)} ms (${b.render_pct}% allowed)`
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
row("CPU busy", "ms", cur.busy_ms, base?.busy_ms ?? null, (d) => ({
|
|
71
|
+
over: grew(d, base?.busy_ms ?? null, b.busy_pct, b.render_floor_ms),
|
|
72
|
+
limit: `+${b.busy_pct}%`,
|
|
73
|
+
why: `CPU busy grew ${r1(d ?? 0)} ms (${b.busy_pct}% allowed)`
|
|
74
|
+
}));
|
|
75
|
+
row("waiting on calls", "ms", cur.wait_ms, base?.wait_ms ?? null, () => ({
|
|
76
|
+
over: false,
|
|
77
|
+
limit: "informational"
|
|
78
|
+
}));
|
|
79
|
+
row("cold render extra", "ms", cur.cold_extra_ms, base?.cold_extra_ms ?? null, (d) => ({
|
|
80
|
+
over: d !== null && d > b.cold_extra_ms,
|
|
81
|
+
limit: `+${b.cold_extra_ms} ms`,
|
|
82
|
+
why: `the cold render's extra grew ${r1(d ?? 0)} ms (${b.cold_extra_ms} allowed)`
|
|
83
|
+
}));
|
|
84
|
+
row("page seed", "KB", cur.seed_kb, base?.seed_kb ?? null, (d, c) => ({
|
|
85
|
+
over: d !== null && d > b.seed_kb || c > b.seed_max_kb,
|
|
86
|
+
limit: `+${b.seed_kb} KB${Number.isFinite(b.seed_max_kb) ? `, max ${b.seed_max_kb} KB` : ""}`,
|
|
87
|
+
why: c > b.seed_max_kb ? `the seed is ${r1(c)} KB, over the ${b.seed_max_kb} KB cap` : `the seed grew ${r1(d ?? 0)} KB (${b.seed_kb} allowed)`
|
|
88
|
+
}));
|
|
89
|
+
row("island props", "KB", cur.props_kb, base?.props_kb ?? null, (d) => ({
|
|
90
|
+
over: d !== null && d > b.props_kb,
|
|
91
|
+
limit: `+${b.props_kb} KB`,
|
|
92
|
+
why: `island props grew ${r1(d ?? 0)} KB (${b.props_kb} allowed)`
|
|
93
|
+
}));
|
|
94
|
+
row("islands JS", "KB", cur.islands_js_kb, base?.islands_js_kb ?? null, (d, c) => ({
|
|
95
|
+
over: d !== null && d > b.islands_js_kb || c > b.islands_js_max_kb,
|
|
96
|
+
limit: `+${b.islands_js_kb} KB${Number.isFinite(b.islands_js_max_kb) ? `, max ${b.islands_js_max_kb} KB` : ""}`,
|
|
97
|
+
why: c > b.islands_js_max_kb ? `the islands load ${r1(c)} KB of JS, over the ${b.islands_js_max_kb} KB cap` : `the islands' JS grew ${r1(d ?? 0)} KB (${b.islands_js_kb} allowed)`
|
|
98
|
+
}));
|
|
99
|
+
row("islands", "n", cur.islands, base?.islands ?? null, () => ({
|
|
100
|
+
over: false,
|
|
101
|
+
limit: "informational"
|
|
102
|
+
}));
|
|
103
|
+
const movers = [];
|
|
104
|
+
if (base) {
|
|
105
|
+
const bc = new Map(base.components.map((c) => [c.name, c.self_ms]));
|
|
106
|
+
for (const c of cur.components) {
|
|
107
|
+
const bv = bc.get(c.name) ?? 0;
|
|
108
|
+
const delta = r1(c.self_ms - bv);
|
|
109
|
+
const over = bv > 0 && delta > 2 && delta > bv * b.component_pct / 100;
|
|
110
|
+
if (Math.abs(delta) >= .5) movers.push({
|
|
111
|
+
kind: "component",
|
|
112
|
+
name: c.name,
|
|
113
|
+
base: bv,
|
|
114
|
+
cur: c.self_ms,
|
|
115
|
+
delta,
|
|
116
|
+
over
|
|
117
|
+
});
|
|
118
|
+
if (over) failures.push(`${c.name} self time grew ${delta} ms (${b.component_pct}% allowed)`);
|
|
119
|
+
}
|
|
120
|
+
const bf = new Map(base.functions.map((f) => [f.name + "|" + f.file, f.self_ms]));
|
|
121
|
+
for (const f of cur.functions) {
|
|
122
|
+
const bv = bf.get(f.name + "|" + f.file) ?? 0;
|
|
123
|
+
const delta = r1(f.self_ms - bv);
|
|
124
|
+
if (Math.abs(delta) >= 1) movers.push({
|
|
125
|
+
kind: "function",
|
|
126
|
+
name: f.name,
|
|
127
|
+
file: f.file,
|
|
128
|
+
base: bv,
|
|
129
|
+
cur: f.self_ms,
|
|
130
|
+
delta,
|
|
131
|
+
over: false
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
const bp = new Map(base.paths.map((p) => [p.owner, p.ms]));
|
|
135
|
+
for (const p of cur.paths) {
|
|
136
|
+
const bv = bp.get(p.owner) ?? 0;
|
|
137
|
+
const delta = r1(p.ms - bv);
|
|
138
|
+
if (Math.abs(delta) >= 1) movers.push({
|
|
139
|
+
kind: "path",
|
|
140
|
+
name: p.owner,
|
|
141
|
+
base: bv,
|
|
142
|
+
cur: p.ms,
|
|
143
|
+
delta,
|
|
144
|
+
over: false
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
movers.sort((x, y) => Math.abs(y.delta) - Math.abs(x.delta));
|
|
148
|
+
}
|
|
149
|
+
const base_w = new Set(base?.warnings ?? []);
|
|
150
|
+
const cur_w = new Set(cur.warnings);
|
|
151
|
+
const added = [...cur_w].filter((w) => !base_w.has(w));
|
|
152
|
+
const gone = [...base_w].filter((w) => !cur_w.has(w));
|
|
153
|
+
for (const code of b.fail_on ?? []) if (added.includes(code) || !base && cur_w.has(code)) failures.push(`finding ${code} appeared`);
|
|
154
|
+
return {
|
|
155
|
+
page: cur.page,
|
|
156
|
+
fresh: !base,
|
|
157
|
+
rows,
|
|
158
|
+
movers: movers.slice(0, 12),
|
|
159
|
+
warnings: {
|
|
160
|
+
added,
|
|
161
|
+
gone
|
|
162
|
+
},
|
|
163
|
+
failures
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
export { DEFAULT_BUDGET, verdict };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// src/perf/measure.ts
|
|
2
|
+
const r1 = (n) => Math.round(n * 10) / 10;
|
|
3
|
+
const median = (xs) => xs.length ? [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)] : 0;
|
|
4
|
+
/** One report → one page's snapshot. */
|
|
5
|
+
function snapshot_page(page, report) {
|
|
6
|
+
const runs = (report.target?.runs ?? []).filter((n) => typeof n === "number");
|
|
7
|
+
const p50 = median(runs);
|
|
8
|
+
const cold = report.cold?.ms ?? 0;
|
|
9
|
+
const rows = report.ogygia?.island_rows ?? [];
|
|
10
|
+
const seen = /* @__PURE__ */ new Set();
|
|
11
|
+
let js = 0;
|
|
12
|
+
for (const r of rows) {
|
|
13
|
+
if (r.js_bytes == null) continue;
|
|
14
|
+
const mods = r.modules ?? [];
|
|
15
|
+
const fresh = mods.filter((m) => !seen.has(m));
|
|
16
|
+
if (!mods.length) js += r.js_bytes;
|
|
17
|
+
else js += r.js_bytes * fresh.length / mods.length;
|
|
18
|
+
for (const m of mods) seen.add(m);
|
|
19
|
+
}
|
|
20
|
+
const phases = {};
|
|
21
|
+
for (const ph of report.timeline?.phases ?? []) phases[ph.phase ?? ph.label ?? "other"] = r1((ph.cpu_ms ?? 0) + (ph.wait_ms ?? 0));
|
|
22
|
+
return {
|
|
23
|
+
page,
|
|
24
|
+
render_p50: r1(p50),
|
|
25
|
+
runs: runs.map(r1),
|
|
26
|
+
busy_ms: r1(report.summary?.busy_ms ?? 0),
|
|
27
|
+
wait_ms: r1(report.timeline?.wait_ms ?? 0),
|
|
28
|
+
cold_extra_ms: r1(cold && p50 ? Math.max(0, cold - p50) : 0),
|
|
29
|
+
seed_kb: r1((report.ogygia?.seed_bytes ?? 0) / 1024),
|
|
30
|
+
props_kb: r1((report.ogygia?.tail_bytes ?? 0) / 1024),
|
|
31
|
+
islands_js_kb: r1(js / 1024),
|
|
32
|
+
islands: report.ogygia?.islands ?? 0,
|
|
33
|
+
components: (report.components ?? []).slice().sort((a, b) => b.self_ms - a.self_ms).slice(0, 15).map((c) => ({
|
|
34
|
+
name: c.name,
|
|
35
|
+
self_ms: r1(c.self_ms),
|
|
36
|
+
total_ms: r1(c.total_ms)
|
|
37
|
+
})),
|
|
38
|
+
functions: (report.hot_functions ?? []).slice(0, 15).map((f) => ({
|
|
39
|
+
name: f.name,
|
|
40
|
+
file: f.file,
|
|
41
|
+
self_ms: r1(f.self_ms)
|
|
42
|
+
})),
|
|
43
|
+
paths: (report.paths ?? []).slice(0, 5).map((p) => ({
|
|
44
|
+
owner: p.owner.name,
|
|
45
|
+
ms: r1(p.ms),
|
|
46
|
+
fns: p.functions.length
|
|
47
|
+
})),
|
|
48
|
+
warnings: (report.findings ?? []).filter((f) => f.severity === "warn").map((f) => f.code),
|
|
49
|
+
phases
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Render every page under the profiler and fold the reports into a snapshot. Pages are profiled
|
|
53
|
+
* one at a time (the server has one recorder); a page that fails throws with the reason. */
|
|
54
|
+
async function measure(opts) {
|
|
55
|
+
const f = opts.fetch ?? globalThis.fetch;
|
|
56
|
+
const base = (opts.base ?? "/__profiler").replace(/\/$/, "");
|
|
57
|
+
const origin = opts.url.replace(/\/$/, "");
|
|
58
|
+
const runs = Math.max(1, Math.min(50, opts.runs ?? 5));
|
|
59
|
+
const pages = [];
|
|
60
|
+
let node = "";
|
|
61
|
+
for (const page of opts.pages) {
|
|
62
|
+
if (!page.startsWith("/")) throw new Error(`page must be a path on the site: ${page}`);
|
|
63
|
+
const url = `${origin}${base}/page?p=${encodeURIComponent(page)}&runs=${runs}&format=json`;
|
|
64
|
+
const ctrl = new AbortController();
|
|
65
|
+
const timer = setTimeout(() => ctrl.abort(), opts.timeout_ms ?? 12e4);
|
|
66
|
+
let res;
|
|
67
|
+
try {
|
|
68
|
+
res = await f(url, {
|
|
69
|
+
headers: {
|
|
70
|
+
accept: "application/json",
|
|
71
|
+
...opts.key ? { "x-profiler-key": opts.key } : {}
|
|
72
|
+
},
|
|
73
|
+
redirect: "follow",
|
|
74
|
+
signal: ctrl.signal
|
|
75
|
+
});
|
|
76
|
+
} catch (e) {
|
|
77
|
+
throw new Error(`${page}: the profiler did not answer (${e instanceof Error ? e.message : String(e)})`);
|
|
78
|
+
} finally {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
}
|
|
81
|
+
if (res.status === 409) throw new Error(`${page}: the profiler is busy on that server (another recording is running)`);
|
|
82
|
+
if (!res.ok) throw new Error(`${page}: the profiler answered ${res.status}${res.status === 401 || res.status === 302 || res.status === 303 ? " — is the key right (x-profiler-key)?" : ""}`);
|
|
83
|
+
let report;
|
|
84
|
+
try {
|
|
85
|
+
report = await res.json();
|
|
86
|
+
} catch {
|
|
87
|
+
throw new Error(`${page}: the profiler's answer was not JSON — is ${base} the profiler's path?`);
|
|
88
|
+
}
|
|
89
|
+
if (!node && report.node) node = report.node;
|
|
90
|
+
const snap = snapshot_page(page, report);
|
|
91
|
+
pages.push(snap);
|
|
92
|
+
opts.on_page?.(snap);
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
version: 1,
|
|
96
|
+
at: Date.now(),
|
|
97
|
+
node,
|
|
98
|
+
...opts.label ? { label: opts.label } : {},
|
|
99
|
+
pages
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
export { measure, snapshot_page };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// src/perf/report.ts
|
|
2
|
+
const fmt = (n, unit) => n === null ? "—" : unit === "ms" ? `${n.toFixed(1)} ms` : unit === "KB" ? `${n.toFixed(1)} KB` : String(Math.round(n));
|
|
3
|
+
const sign = (d, unit) => d === null ? "new" : `${d > 0 ? "+" : ""}${unit === "n" ? Math.round(d) : d.toFixed(1)}${unit === "ms" ? " ms" : unit === "KB" ? " KB" : ""}`;
|
|
4
|
+
const pct = (p) => p === null ? "" : ` (${p > 0 ? "+" : ""}${p.toFixed(0)}%)`;
|
|
5
|
+
function markdown(v, opts = {}) {
|
|
6
|
+
const lines = [];
|
|
7
|
+
lines.push(`### ${opts.title ?? "Performance"}: ${v.ok ? "✅ within budget" : `❌ ${v.failures.length} budget failure${v.failures.length === 1 ? "" : "s"}`}`);
|
|
8
|
+
lines.push("");
|
|
9
|
+
if (!v.ok) {
|
|
10
|
+
for (const f of v.failures) lines.push(`- ❌ ${f}`);
|
|
11
|
+
lines.push("");
|
|
12
|
+
}
|
|
13
|
+
for (const p of v.pages) lines.push(...page_md(p, opts.links?.[p.page]));
|
|
14
|
+
if (v.missing.length) lines.push(`_Not measured this run (in the baseline): ${v.missing.join(", ")}_`, "");
|
|
15
|
+
lines.push("<sub>ogygia profiler · medians over the runs · a positive Δ is slower or bigger</sub>");
|
|
16
|
+
return lines.join("\n");
|
|
17
|
+
}
|
|
18
|
+
function page_md(p, links) {
|
|
19
|
+
const out = [];
|
|
20
|
+
const head = p.fresh ? `${p.page} (new page, no baseline)` : p.page;
|
|
21
|
+
const link = links?.cur ? ` · [report](${links.cur})${links.base ? ` · [baseline](${links.base})` : ""}` : "";
|
|
22
|
+
out.push(`#### ${p.failures.length ? "❌" : "✅"} \`${head}\`${link}`);
|
|
23
|
+
out.push("");
|
|
24
|
+
out.push("| metric | base | PR | Δ | budget |");
|
|
25
|
+
out.push("|---|---:|---:|---:|---|");
|
|
26
|
+
for (const r of p.rows) {
|
|
27
|
+
const flag = r.over ? " ❌" : "";
|
|
28
|
+
out.push(`| ${r.metric} | ${fmt(r.base, r.unit)} | ${fmt(r.cur, r.unit)} | ${sign(r.delta, r.unit)}${pct(r.pct)}${flag} | ${r.limit} |`);
|
|
29
|
+
}
|
|
30
|
+
out.push("");
|
|
31
|
+
const movers = p.movers.slice(0, 3);
|
|
32
|
+
if (movers.length) {
|
|
33
|
+
out.push("**Biggest movers**");
|
|
34
|
+
for (const m of movers) out.push(`- ${m.over ? "❌ " : ""}${m.kind} \`${m.name}\`${m.file ? ` (${m.file})` : ""}: ${m.base.toFixed(1)} → ${m.cur.toFixed(1)} ms (${m.delta > 0 ? "+" : ""}${m.delta.toFixed(1)})`);
|
|
35
|
+
out.push("");
|
|
36
|
+
}
|
|
37
|
+
if (p.warnings.added.length || p.warnings.gone.length) {
|
|
38
|
+
if (p.warnings.added.length) out.push(`**Findings that appeared:** ${p.warnings.added.map((w) => `\`${w}\``).join(", ")}`);
|
|
39
|
+
if (p.warnings.gone.length) out.push(`**Findings that went away:** ${p.warnings.gone.map((w) => `\`${w}\``).join(", ")}`);
|
|
40
|
+
out.push("");
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
/** The terminal's view: one line per page, the failures spelled out. */
|
|
45
|
+
function summary(v) {
|
|
46
|
+
const lines = [];
|
|
47
|
+
for (const p of v.pages) {
|
|
48
|
+
const render = p.rows.find((r) => r.metric === "render (median)");
|
|
49
|
+
lines.push(`${p.failures.length ? "✗" : "✓"} ${p.page} render ${fmt(render.cur, "ms")}${render.delta !== null ? ` (${sign(render.delta, "ms")}${pct(render.pct)})` : " (new)"}${p.failures.length ? `\n ${p.failures.join("\n ")}` : ""}`);
|
|
50
|
+
}
|
|
51
|
+
if (v.missing.length) lines.push(` not measured: ${v.missing.join(", ")}`);
|
|
52
|
+
lines.push(v.ok ? "within budget" : `${v.failures.length} budget failure${v.failures.length === 1 ? "" : "s"}`);
|
|
53
|
+
return lines.join("\n");
|
|
54
|
+
}
|
|
55
|
+
export { markdown, summary };
|
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ogygia/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.11.0",
|
|
4
|
+
"description": "The ogygia command line: init and site scaffolds, the AI skill, the MCP server, perf budgets and bisect, federation keys and catalogs",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"cli",
|
|
7
|
+
"mcp",
|
|
8
|
+
"ogygia",
|
|
9
|
+
"sveltekit"
|
|
10
|
+
],
|
|
11
|
+
"homepage": "https://ogygiajs.dev",
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"author": "Puru Vijayvargia <devpuruvj@gmail.com>",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/ogygiajs/ogygia.git",
|
|
17
|
+
"directory": "packages/cli"
|
|
18
|
+
},
|
|
19
|
+
"bin": {
|
|
20
|
+
"ogygia-cli": "./dist/cli.mjs"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"ai",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"type": "module",
|
|
28
|
+
"exports": {
|
|
29
|
+
"./bin": "./dist/cli.mjs",
|
|
30
|
+
"./package.json": "./package.json"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@sveltejs/sv-utils": "^0.3.3",
|
|
37
|
+
"@types/node": "^26.1.2",
|
|
38
|
+
"@typescript/native": "npm:typescript@^7.0.2",
|
|
39
|
+
"svelte": "5.56.8",
|
|
40
|
+
"tsdown": "^0.23.4",
|
|
41
|
+
"typescript": "^6.0.3",
|
|
42
|
+
"vitest": "^4.1.12",
|
|
43
|
+
"@ogygia/core": "0.11.0",
|
|
44
|
+
"@ogygia/devtools": "0.11.0",
|
|
45
|
+
"@ogygia/tooling": "0.0.0"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"playwright": "^1.62.0",
|
|
49
|
+
"svelte": "^5.56.8",
|
|
50
|
+
"@ogygia/core": "^0.11.0"
|
|
51
|
+
},
|
|
52
|
+
"peerDependenciesMeta": {
|
|
53
|
+
"playwright": {
|
|
54
|
+
"optional": true
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"scripts": {
|
|
58
|
+
"build": "tsdown",
|
|
59
|
+
"check": "tsc -p . && tsc -p test && vitest run",
|
|
60
|
+
"test": "vitest run"
|
|
61
|
+
}
|
|
6
62
|
}
|