vibepro 0.1.0-alpha.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.
Files changed (89) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +9 -0
  3. package/README.ja.md +448 -0
  4. package/README.md +520 -0
  5. package/agent-instructions/codex/AGENTS.vibepro.md +45 -0
  6. package/bin/vibepro.js +9 -0
  7. package/docs/assets/vibepro-header.png +0 -0
  8. package/package.json +51 -0
  9. package/skills/vibepro-diagnosis-packages/SKILL.md +133 -0
  10. package/skills/vibepro-human-review/SKILL.md +73 -0
  11. package/skills/vibepro-story-refactor/SKILL.md +89 -0
  12. package/skills/vibepro-workflow/SKILL.md +139 -0
  13. package/src/agent-harness-map.js +230 -0
  14. package/src/agent-harness-scanner.js +337 -0
  15. package/src/agent-review.js +2180 -0
  16. package/src/api-boundary-scanner.js +452 -0
  17. package/src/architecture-profiler.js +423 -0
  18. package/src/authorization-scoring.js +149 -0
  19. package/src/brainbase-importer.js +534 -0
  20. package/src/change-risk-classifier.js +195 -0
  21. package/src/check-packs.js +605 -0
  22. package/src/checkpoint-manager.js +233 -0
  23. package/src/cli.js +2213 -0
  24. package/src/code-quality-scanner.js +310 -0
  25. package/src/codex-manager.js +143 -0
  26. package/src/component-style-scanner.js +336 -0
  27. package/src/coverage-report.js +99 -0
  28. package/src/database-access-scanner.js +163 -0
  29. package/src/decision-records.js +315 -0
  30. package/src/design-modernize.js +1435 -0
  31. package/src/design-system.js +1732 -0
  32. package/src/diagnostic-engine.js +1945 -0
  33. package/src/diagram-requirement-resolver.js +194 -0
  34. package/src/doctor.js +677 -0
  35. package/src/environment-graph.js +424 -0
  36. package/src/execution-state.js +849 -0
  37. package/src/explore-evidence.js +425 -0
  38. package/src/flow-design-scanner.js +896 -0
  39. package/src/flow-verifier.js +887 -0
  40. package/src/gesture-interaction-scanner.js +330 -0
  41. package/src/graph-context.js +263 -0
  42. package/src/graphify-adapter.js +189 -0
  43. package/src/html-report.js +1035 -0
  44. package/src/journey-map.js +1299 -0
  45. package/src/language.js +48 -0
  46. package/src/lazy-pattern-detector.js +182 -0
  47. package/src/local-dev-scanner.js +135 -0
  48. package/src/managed-worktree-gate.js +187 -0
  49. package/src/managed-worktree.js +766 -0
  50. package/src/merge-manager.js +501 -0
  51. package/src/network-contract-scanner.js +442 -0
  52. package/src/nocodb-story-sync.js +386 -0
  53. package/src/oss-readiness-scanner.js +417 -0
  54. package/src/performance-evidence.js +756 -0
  55. package/src/performance-measurer.js +591 -0
  56. package/src/pr-manager.js +8220 -0
  57. package/src/presets.js +682 -0
  58. package/src/public-discovery-scanner.js +519 -0
  59. package/src/refactoring-delta-reporter.js +367 -0
  60. package/src/refactoring-opportunity-generator.js +797 -0
  61. package/src/regression-risk-scanner.js +146 -0
  62. package/src/repo-status.js +266 -0
  63. package/src/report-fingerprint.js +188 -0
  64. package/src/report-pr-body-prompt-template.md +108 -0
  65. package/src/report-pr-body-schema.json +95 -0
  66. package/src/report-store.js +135 -0
  67. package/src/report-validator.js +192 -0
  68. package/src/requirement-consistency.js +1066 -0
  69. package/src/runtime-info.js +134 -0
  70. package/src/self-dogfood-scanner.js +476 -0
  71. package/src/session-learning.js +164 -0
  72. package/src/skills-manager.js +157 -0
  73. package/src/spec-drift.js +378 -0
  74. package/src/spec-fingerprint.js +445 -0
  75. package/src/spec-prompt-template.md +155 -0
  76. package/src/spec-schema.json +219 -0
  77. package/src/spec-store.js +258 -0
  78. package/src/spec-validator.js +459 -0
  79. package/src/static-site-scanner.js +316 -0
  80. package/src/story-candidate-generator.js +85 -0
  81. package/src/story-catalog-generator.js +2813 -0
  82. package/src/story-html.js +156 -0
  83. package/src/story-manager.js +2144 -0
  84. package/src/story-task-generator.js +522 -0
  85. package/src/task-manager.js +1029 -0
  86. package/src/terminal-link-scanner.js +238 -0
  87. package/src/usage-report.js +417 -0
  88. package/src/verification-evidence.js +284 -0
  89. package/src/workspace.js +126 -0
@@ -0,0 +1,591 @@
1
+ import { execFile, spawn } from 'node:child_process';
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import http from 'node:http';
4
+ import https from 'node:https';
5
+ import path from 'node:path';
6
+ import { performance } from 'node:perf_hooks';
7
+ import { promisify } from 'node:util';
8
+
9
+ import { getWorkspaceDir, initWorkspace, readManifest, toWorkspaceRelative, writeManifest } from './workspace.js';
10
+
11
+ const execFileAsync = promisify(execFile);
12
+
13
+ export async function runPerformanceMeasurement(repoRoot, options = {}) {
14
+ await initWorkspace(repoRoot);
15
+ const root = path.resolve(repoRoot);
16
+ const runId = options.runId ?? new Date().toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/:/g, '');
17
+ const runDir = path.join(getWorkspaceDir(root), 'performance', runId);
18
+ await mkdir(runDir, { recursive: true });
19
+
20
+ const packageJson = await readPackageJson(root);
21
+ const packageScripts = packageJson?.scripts ?? {};
22
+ const samples = normalizeSampleCount(options.samples);
23
+ const startedAt = new Date().toISOString();
24
+
25
+ const commandMeasurements = [];
26
+ if (options.typecheck !== false && packageScripts.typecheck) {
27
+ commandMeasurements.push(await measureCommand(root, {
28
+ id: 'typecheck',
29
+ command: ['npm', ['run', 'typecheck']]
30
+ }));
31
+ }
32
+ if (options.build === true && packageScripts.build) {
33
+ commandMeasurements.push(await measureCommand(root, {
34
+ id: 'build',
35
+ command: ['npm', ['run', 'build']]
36
+ }));
37
+ }
38
+ for (const customCommand of options.commands ?? []) {
39
+ commandMeasurements.push(await measureShellCommand(root, customCommand));
40
+ }
41
+
42
+ const httpMeasurements = options.baseUrl
43
+ ? await measureHttpTargets({
44
+ baseUrl: options.baseUrl,
45
+ pages: options.pages ?? [],
46
+ apis: options.apis ?? [],
47
+ samples,
48
+ headers: options.headers ?? {}
49
+ })
50
+ : [];
51
+
52
+ const startupMeasurements = [];
53
+ for (const startup of options.startups ?? []) {
54
+ startupMeasurements.push(await measureStartup(root, startup));
55
+ }
56
+
57
+ const prismaLog = options.prismaLog
58
+ ? await analyzePrismaLog(path.resolve(root, options.prismaLog))
59
+ : null;
60
+
61
+ const measurement = {
62
+ schema_version: '0.1.0',
63
+ run_id: runId,
64
+ created_at: startedAt,
65
+ repo: {
66
+ root: '.',
67
+ package_name: packageJson?.name ?? null
68
+ },
69
+ options: {
70
+ samples,
71
+ base_url: options.baseUrl ?? null,
72
+ pages: options.pages ?? [],
73
+ apis: options.apis ?? [],
74
+ typecheck: options.typecheck !== false && Boolean(packageScripts.typecheck),
75
+ build: options.build === true && Boolean(packageScripts.build),
76
+ startup_count: startupMeasurements.length,
77
+ prisma_log: options.prismaLog ?? null
78
+ },
79
+ commands: commandMeasurements,
80
+ http: httpMeasurements,
81
+ prisma_log: prismaLog,
82
+ summary: buildMeasurementSummary({ commandMeasurements, httpMeasurements, startupMeasurements, prismaLog })
83
+ };
84
+ if (startupMeasurements.length > 0) measurement.startup = startupMeasurements;
85
+
86
+ const jsonPath = path.join(runDir, 'performance.json');
87
+ const markdownPath = path.join(runDir, 'performance.md');
88
+ await writeFile(jsonPath, `${JSON.stringify(measurement, null, 2)}\n`);
89
+ await writeFile(markdownPath, renderPerformanceReport(measurement));
90
+
91
+ const manifest = await readManifest(root);
92
+ manifest.latest_performance_run = runId;
93
+ manifest.performance_runs = [
94
+ {
95
+ run_id: runId,
96
+ created_at: measurement.created_at,
97
+ artifacts: {
98
+ performance_json: toWorkspaceRelative(root, jsonPath),
99
+ performance_report: toWorkspaceRelative(root, markdownPath)
100
+ },
101
+ summary: measurement.summary
102
+ },
103
+ ...(manifest.performance_runs ?? []).filter((run) => run.run_id !== runId)
104
+ ];
105
+ await writeManifest(root, manifest);
106
+
107
+ return {
108
+ runDir,
109
+ artifacts: {
110
+ json: toWorkspaceRelative(root, jsonPath),
111
+ markdown: toWorkspaceRelative(root, markdownPath)
112
+ },
113
+ measurement
114
+ };
115
+ }
116
+
117
+ export async function comparePerformanceMeasurements(repoRoot, options = {}) {
118
+ const root = path.resolve(repoRoot);
119
+ const before = await readMeasurement(root, options.before);
120
+ const after = await readMeasurement(root, options.after);
121
+ const comparison = {
122
+ schema_version: '0.1.0',
123
+ created_at: new Date().toISOString(),
124
+ before: {
125
+ run_id: before.run_id,
126
+ created_at: before.created_at
127
+ },
128
+ after: {
129
+ run_id: after.run_id,
130
+ created_at: after.created_at
131
+ },
132
+ commands: compareById(before.commands ?? [], after.commands ?? [], compareCommand),
133
+ http: compareById(before.http ?? [], after.http ?? [], compareHttp),
134
+ startup: compareById(before.startup ?? [], after.startup ?? [], compareStartup),
135
+ prisma_log: comparePrisma(before.prisma_log, after.prisma_log)
136
+ };
137
+ return { comparison, markdown: renderPerformanceComparison(comparison) };
138
+ }
139
+
140
+ export function renderPerformanceSummary(result) {
141
+ const lines = [
142
+ '# VibePro Performance Measurement',
143
+ '',
144
+ `Run ID: ${result.measurement.run_id}`,
145
+ `Report: ${result.artifacts.markdown}`,
146
+ '',
147
+ '## Summary'
148
+ ];
149
+ for (const item of result.measurement.summary.items) {
150
+ lines.push(`- ${item.label}: ${item.value}`);
151
+ }
152
+ if (result.measurement.summary.items.length === 0) {
153
+ lines.push('- No measurement targets were selected.');
154
+ }
155
+ return `${lines.join('\n')}\n`;
156
+ }
157
+
158
+ export function renderPerformanceComparison(comparison) {
159
+ const lines = [
160
+ '# VibePro Performance Comparison',
161
+ '',
162
+ `Before: ${comparison.before.run_id}`,
163
+ `After: ${comparison.after.run_id}`,
164
+ '',
165
+ '## Commands',
166
+ '',
167
+ '| ID | Before | After | Delta |',
168
+ '| -- | ------ | ----- | ----- |'
169
+ ];
170
+ for (const item of comparison.commands) {
171
+ lines.push(`| ${item.id} | ${formatMs(item.before_ms)} | ${formatMs(item.after_ms)} | ${formatDeltaMs(item.delta_ms)} |`);
172
+ }
173
+ if (comparison.commands.length === 0) lines.push('| - | - | - | - |');
174
+
175
+ lines.push('', '## HTTP', '', '| ID | Metric | Before | After | Delta |', '| -- | ------ | ------ | ----- | ----- |');
176
+ for (const item of comparison.http) {
177
+ lines.push(`| ${item.id} | p95 total | ${formatMs(item.before_p95_ms)} | ${formatMs(item.after_p95_ms)} | ${formatDeltaMs(item.delta_p95_ms)} |`);
178
+ lines.push(`| ${item.id} | p95 TTFB | ${formatMs(item.before_ttfb_p95_ms)} | ${formatMs(item.after_ttfb_p95_ms)} | ${formatDeltaMs(item.delta_ttfb_p95_ms)} |`);
179
+ }
180
+ if (comparison.http.length === 0) lines.push('| - | - | - | - | - |');
181
+
182
+ lines.push('', '## Startup', '', '| ID | Before | After | Delta |', '| -- | ------ | ----- | ----- |');
183
+ for (const item of comparison.startup) {
184
+ lines.push(`| ${item.id} | ${formatMs(item.before_ready_ms)} | ${formatMs(item.after_ready_ms)} | ${formatDeltaMs(item.delta_ready_ms)} |`);
185
+ }
186
+ if (comparison.startup.length === 0) lines.push('| - | - | - | - |');
187
+
188
+ if (comparison.prisma_log) {
189
+ lines.push('', '## Prisma Query Log', '');
190
+ lines.push(`- query count: ${comparison.prisma_log.before_query_count} -> ${comparison.prisma_log.after_query_count} (${formatSignedNumber(comparison.prisma_log.delta_query_count)})`);
191
+ lines.push(`- unique query shapes: ${comparison.prisma_log.before_unique_query_shape_count} -> ${comparison.prisma_log.after_unique_query_shape_count} (${formatSignedNumber(comparison.prisma_log.delta_unique_query_shape_count)})`);
192
+ }
193
+ return `${lines.join('\n')}\n`;
194
+ }
195
+
196
+ function renderPerformanceReport(measurement) {
197
+ const lines = [
198
+ '# VibePro Performance Measurement',
199
+ '',
200
+ `Run ID: ${measurement.run_id}`,
201
+ `Created: ${measurement.created_at}`,
202
+ '',
203
+ '## Summary'
204
+ ];
205
+ for (const item of measurement.summary.items) {
206
+ lines.push(`- ${item.label}: ${item.value}`);
207
+ }
208
+ if (measurement.summary.items.length === 0) lines.push('- No measurement targets were selected.');
209
+
210
+ lines.push('', '## Commands', '', '| ID | Status | Duration | Exit |', '| -- | ------ | -------- | ---- |');
211
+ for (const command of measurement.commands) {
212
+ lines.push(`| ${command.id} | ${command.status} | ${formatMs(command.duration_ms)} | ${command.exit_code ?? '-'} |`);
213
+ }
214
+ if (measurement.commands.length === 0) lines.push('| - | - | - | - |');
215
+
216
+ lines.push('', '## HTTP', '', '| ID | Kind | Path | p50 total | p95 total | p95 TTFB | Errors |', '| -- | ---- | ---- | --------- | --------- | -------- | ------ |');
217
+ for (const target of measurement.http) {
218
+ lines.push(`| ${target.id} | ${target.kind} | ${target.path} | ${formatMs(target.summary.total_ms.p50)} | ${formatMs(target.summary.total_ms.p95)} | ${formatMs(target.summary.ttfb_ms.p95)} | ${target.summary.error_count} |`);
219
+ }
220
+ if (measurement.http.length === 0) lines.push('| - | - | - | - | - | - | - |');
221
+
222
+ lines.push('', '## Startup', '', '| ID | Status | Ready | Timeout |', '| -- | ------ | ----- | ------- |');
223
+ for (const startup of measurement.startup ?? []) {
224
+ lines.push(`| ${startup.id} | ${startup.status} | ${formatMs(startup.ready_ms)} | ${startup.timeout_ms}ms |`);
225
+ }
226
+ if ((measurement.startup ?? []).length === 0) lines.push('| - | - | - | - |');
227
+
228
+ if (measurement.prisma_log) {
229
+ lines.push('', '## Prisma Query Log', '');
230
+ lines.push(`- query count: ${measurement.prisma_log.query_count}`);
231
+ lines.push(`- unique query shapes: ${measurement.prisma_log.unique_query_shape_count}`);
232
+ lines.push(`- repeated query shapes: ${measurement.prisma_log.repeated_query_shapes.length}`);
233
+ }
234
+
235
+ return `${lines.join('\n')}\n`;
236
+ }
237
+
238
+ async function readPackageJson(repoRoot) {
239
+ try {
240
+ return JSON.parse(await readFile(path.join(repoRoot, 'package.json'), 'utf8'));
241
+ } catch (error) {
242
+ if (error.code === 'ENOENT') return null;
243
+ throw error;
244
+ }
245
+ }
246
+
247
+ async function measureCommand(repoRoot, { id, command }) {
248
+ const [file, args] = command;
249
+ const start = performance.now();
250
+ try {
251
+ const result = await execFileAsync(file, args, {
252
+ cwd: repoRoot,
253
+ encoding: 'utf8',
254
+ maxBuffer: 20 * 1024 * 1024
255
+ });
256
+ return {
257
+ id,
258
+ command: [file, ...args].join(' '),
259
+ status: 'pass',
260
+ duration_ms: Math.round(performance.now() - start),
261
+ exit_code: 0,
262
+ stdout_tail: tail(result.stdout),
263
+ stderr_tail: tail(result.stderr)
264
+ };
265
+ } catch (error) {
266
+ return {
267
+ id,
268
+ command: [file, ...args].join(' '),
269
+ status: 'fail',
270
+ duration_ms: Math.round(performance.now() - start),
271
+ exit_code: error.code ?? 1,
272
+ stdout_tail: tail(error.stdout ?? ''),
273
+ stderr_tail: tail(error.stderr ?? error.message)
274
+ };
275
+ }
276
+ }
277
+
278
+ async function measureShellCommand(repoRoot, rawCommand) {
279
+ const [id, command] = rawCommand.includes('=')
280
+ ? rawCommand.split(/=(.*)/s, 2)
281
+ : [`custom-${rawCommand.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').toLowerCase()}`, rawCommand];
282
+ return measureCommand(repoRoot, {
283
+ id,
284
+ command: process.platform === 'win32'
285
+ ? ['cmd.exe', ['/d', '/s', '/c', command]]
286
+ : ['sh', ['-lc', command]]
287
+ });
288
+ }
289
+
290
+ async function measureHttpTargets({ baseUrl, pages, apis, samples, headers }) {
291
+ const targets = [
292
+ ...pages.map((route) => ({ kind: 'page', route })),
293
+ ...apis.map((route) => ({ kind: 'api', route }))
294
+ ];
295
+ const measurements = [];
296
+ for (const target of targets) {
297
+ const samplesResult = [];
298
+ for (let index = 0; index < samples; index += 1) {
299
+ samplesResult.push(await measureHttpRequest(new URL(target.route, baseUrl), headers));
300
+ }
301
+ measurements.push({
302
+ id: `${target.kind}:${target.route}`,
303
+ kind: target.kind,
304
+ path: target.route,
305
+ samples: samplesResult,
306
+ summary: summarizeHttpSamples(samplesResult)
307
+ });
308
+ }
309
+ return measurements;
310
+ }
311
+
312
+ function measureHttpRequest(url, headers = {}) {
313
+ return new Promise((resolve) => {
314
+ const client = url.protocol === 'https:' ? https : http;
315
+ const start = performance.now();
316
+ let firstByteAt = null;
317
+ let bytes = 0;
318
+ const request = client.request(url, {
319
+ method: 'GET',
320
+ headers
321
+ }, (response) => {
322
+ response.on('data', (chunk) => {
323
+ if (firstByteAt === null) firstByteAt = performance.now();
324
+ bytes += chunk.length;
325
+ });
326
+ response.on('end', () => {
327
+ const end = performance.now();
328
+ resolve({
329
+ status: 'pass',
330
+ status_code: response.statusCode ?? null,
331
+ ok: response.statusCode ? response.statusCode < 500 : false,
332
+ total_ms: Math.round(end - start),
333
+ ttfb_ms: firstByteAt === null ? Math.round(end - start) : Math.round(firstByteAt - start),
334
+ bytes,
335
+ content_type: response.headers['content-type'] ?? null
336
+ });
337
+ });
338
+ });
339
+ request.on('error', (error) => {
340
+ resolve({
341
+ status: 'fail',
342
+ status_code: null,
343
+ ok: false,
344
+ total_ms: Math.round(performance.now() - start),
345
+ ttfb_ms: null,
346
+ bytes,
347
+ error: error.message
348
+ });
349
+ });
350
+ request.end();
351
+ });
352
+ }
353
+
354
+ async function measureStartup(repoRoot, startup) {
355
+ const timeoutMs = startup.timeoutMs ?? 30000;
356
+ const readyPattern = new RegExp(startup.readyPattern ?? 'ready|started server|local:', 'i');
357
+ const script = startup.script;
358
+ const command = startup.command ?? `npm run ${script}`;
359
+ const childArgs = process.platform === 'win32'
360
+ ? ['cmd.exe', ['/d', '/s', '/c', command]]
361
+ : ['sh', ['-lc', command]];
362
+ const start = performance.now();
363
+ return new Promise((resolve) => {
364
+ let output = '';
365
+ let settled = false;
366
+ const child = spawn(childArgs[0], childArgs[1], {
367
+ cwd: repoRoot,
368
+ detached: process.platform !== 'win32',
369
+ stdio: ['ignore', 'pipe', 'pipe']
370
+ });
371
+ const finish = (status, extra = {}) => {
372
+ if (settled) return;
373
+ settled = true;
374
+ clearTimeout(timer);
375
+ terminateProcess(child);
376
+ resolve({
377
+ id: startup.id ?? `startup:${script ?? command}`,
378
+ command,
379
+ status,
380
+ ready_ms: extra.readyMs ?? null,
381
+ timeout_ms: timeoutMs,
382
+ output_tail: tail(output)
383
+ });
384
+ };
385
+ const onData = (chunk) => {
386
+ output += chunk.toString();
387
+ if (readyPattern.test(output)) {
388
+ finish('pass', { readyMs: Math.round(performance.now() - start) });
389
+ }
390
+ };
391
+ child.stdout.on('data', onData);
392
+ child.stderr.on('data', onData);
393
+ child.on('error', (error) => {
394
+ output += error.message;
395
+ finish('fail');
396
+ });
397
+ child.on('exit', (code) => {
398
+ if (!settled) {
399
+ output += `\nprocess exited before ready pattern; code=${code}`;
400
+ finish('fail');
401
+ }
402
+ });
403
+ const timer = setTimeout(() => finish('timeout'), timeoutMs);
404
+ });
405
+ }
406
+
407
+ function terminateProcess(child) {
408
+ try {
409
+ if (process.platform !== 'win32' && child.pid) {
410
+ process.kill(-child.pid, 'SIGTERM');
411
+ } else {
412
+ child.kill('SIGTERM');
413
+ }
414
+ } catch {
415
+ // Process may already have exited.
416
+ }
417
+ }
418
+
419
+ async function analyzePrismaLog(filePath) {
420
+ const content = await readFile(filePath, 'utf8');
421
+ const lines = content.split(/\r?\n/).filter(Boolean);
422
+ const queryLines = lines.filter((line) => /prisma:query|\b(SELECT|INSERT|UPDATE|DELETE)\b/i.test(line));
423
+ const shapes = new Map();
424
+ for (const line of queryLines) {
425
+ const shape = normalizeQueryShape(line);
426
+ shapes.set(shape, (shapes.get(shape) ?? 0) + 1);
427
+ }
428
+ const repeatedQueryShapes = [...shapes.entries()]
429
+ .filter(([, count]) => count > 1)
430
+ .sort((a, b) => b[1] - a[1])
431
+ .slice(0, 20)
432
+ .map(([shape, count]) => ({ shape, count }));
433
+ return {
434
+ file: filePath,
435
+ line_count: lines.length,
436
+ query_count: queryLines.length,
437
+ unique_query_shape_count: shapes.size,
438
+ repeated_query_shapes: repeatedQueryShapes
439
+ };
440
+ }
441
+
442
+ function normalizeQueryShape(line) {
443
+ return line
444
+ .replace(/\b\d+\b/g, '?')
445
+ .replace(/'[^']*'/g, '?')
446
+ .replace(/"[^"]*"/g, '?')
447
+ .replace(/\s+/g, ' ')
448
+ .trim()
449
+ .slice(0, 300);
450
+ }
451
+
452
+ async function readMeasurement(repoRoot, artifactPath) {
453
+ if (!artifactPath) throw new Error('compare requires --before and --after');
454
+ return JSON.parse(await readFile(path.resolve(repoRoot, artifactPath), 'utf8'));
455
+ }
456
+
457
+ function buildMeasurementSummary({ commandMeasurements, httpMeasurements, startupMeasurements, prismaLog }) {
458
+ const items = [];
459
+ for (const command of commandMeasurements) {
460
+ items.push({ label: `${command.id} duration`, value: formatMs(command.duration_ms) });
461
+ }
462
+ for (const target of httpMeasurements) {
463
+ items.push({ label: `${target.id} p95`, value: formatMs(target.summary.total_ms.p95) });
464
+ }
465
+ for (const startup of startupMeasurements) {
466
+ items.push({ label: `${startup.id} ready`, value: formatMs(startup.ready_ms) });
467
+ }
468
+ if (prismaLog) {
469
+ items.push({ label: 'Prisma query count', value: String(prismaLog.query_count) });
470
+ items.push({ label: 'Prisma unique query shapes', value: String(prismaLog.unique_query_shape_count) });
471
+ }
472
+ return { items };
473
+ }
474
+
475
+ function summarizeHttpSamples(samples) {
476
+ return {
477
+ count: samples.length,
478
+ error_count: samples.filter((sample) => sample.status !== 'pass' || !sample.ok).length,
479
+ status_codes: countBy(samples.map((sample) => sample.status_code ?? 'error')),
480
+ total_ms: summarizeNumbers(samples.map((sample) => sample.total_ms).filter((value) => value !== null)),
481
+ ttfb_ms: summarizeNumbers(samples.map((sample) => sample.ttfb_ms).filter((value) => value !== null)),
482
+ bytes: summarizeNumbers(samples.map((sample) => sample.bytes).filter((value) => value !== null))
483
+ };
484
+ }
485
+
486
+ function summarizeNumbers(values) {
487
+ if (values.length === 0) return { min: null, p50: null, p95: null, max: null, avg: null };
488
+ const sorted = [...values].sort((a, b) => a - b);
489
+ return {
490
+ min: sorted[0],
491
+ p50: percentile(sorted, 0.5),
492
+ p95: percentile(sorted, 0.95),
493
+ max: sorted[sorted.length - 1],
494
+ avg: Math.round(sorted.reduce((sum, value) => sum + value, 0) / sorted.length)
495
+ };
496
+ }
497
+
498
+ function percentile(sorted, ratio) {
499
+ if (sorted.length === 0) return null;
500
+ const index = Math.ceil(sorted.length * ratio) - 1;
501
+ return sorted[Math.min(Math.max(index, 0), sorted.length - 1)];
502
+ }
503
+
504
+ function countBy(values) {
505
+ return values.reduce((acc, value) => {
506
+ acc[value] = (acc[value] ?? 0) + 1;
507
+ return acc;
508
+ }, {});
509
+ }
510
+
511
+ function compareById(beforeItems, afterItems, mapper) {
512
+ const beforeById = new Map(beforeItems.map((item) => [item.id, item]));
513
+ return afterItems
514
+ .filter((after) => beforeById.has(after.id))
515
+ .map((after) => mapper(beforeById.get(after.id), after));
516
+ }
517
+
518
+ function compareCommand(before, after) {
519
+ return {
520
+ id: after.id,
521
+ before_ms: before.duration_ms,
522
+ after_ms: after.duration_ms,
523
+ delta_ms: nullableDelta(after.duration_ms, before.duration_ms)
524
+ };
525
+ }
526
+
527
+ function compareHttp(before, after) {
528
+ return {
529
+ id: after.id,
530
+ before_p95_ms: before.summary?.total_ms?.p95 ?? null,
531
+ after_p95_ms: after.summary?.total_ms?.p95 ?? null,
532
+ delta_p95_ms: nullableDelta(after.summary?.total_ms?.p95, before.summary?.total_ms?.p95),
533
+ before_ttfb_p95_ms: before.summary?.ttfb_ms?.p95 ?? null,
534
+ after_ttfb_p95_ms: after.summary?.ttfb_ms?.p95 ?? null,
535
+ delta_ttfb_p95_ms: nullableDelta(after.summary?.ttfb_ms?.p95, before.summary?.ttfb_ms?.p95)
536
+ };
537
+ }
538
+
539
+ function compareStartup(before, after) {
540
+ return {
541
+ id: after.id,
542
+ before_ready_ms: before.ready_ms,
543
+ after_ready_ms: after.ready_ms,
544
+ delta_ready_ms: nullableDelta(after.ready_ms, before.ready_ms)
545
+ };
546
+ }
547
+
548
+ function comparePrisma(before, after) {
549
+ if (!before || !after) return null;
550
+ return {
551
+ before_query_count: before.query_count,
552
+ after_query_count: after.query_count,
553
+ delta_query_count: nullableDelta(after.query_count, before.query_count),
554
+ before_unique_query_shape_count: before.unique_query_shape_count,
555
+ after_unique_query_shape_count: after.unique_query_shape_count,
556
+ delta_unique_query_shape_count: nullableDelta(after.unique_query_shape_count, before.unique_query_shape_count)
557
+ };
558
+ }
559
+
560
+ function nullableDelta(after, before) {
561
+ if (after === null || after === undefined || before === null || before === undefined) return null;
562
+ return after - before;
563
+ }
564
+
565
+ function normalizeSampleCount(value) {
566
+ const samples = Number(value ?? 5);
567
+ if (!Number.isFinite(samples) || samples < 1) throw new Error('--samples must be a positive number');
568
+ return Math.floor(samples);
569
+ }
570
+
571
+ function tail(text, maxLength = 4000) {
572
+ if (!text) return '';
573
+ return text.length <= maxLength ? text : text.slice(-maxLength);
574
+ }
575
+
576
+ function formatMs(value) {
577
+ if (value === null || value === undefined) return '-';
578
+ if (value >= 1000) return `${(value / 1000).toFixed(2)}s`;
579
+ return `${value}ms`;
580
+ }
581
+
582
+ function formatDeltaMs(value) {
583
+ if (value === null || value === undefined) return '-';
584
+ const sign = value > 0 ? '+' : '';
585
+ return `${sign}${formatMs(value)}`;
586
+ }
587
+
588
+ function formatSignedNumber(value) {
589
+ if (value === null || value === undefined) return '-';
590
+ return value > 0 ? `+${value}` : String(value);
591
+ }