@zeitzeuge/node 0.1.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/dist/index.js ADDED
@@ -0,0 +1,3873 @@
1
+ import { createRequire } from "node:module";
2
+ var __create = Object.create;
3
+ var __getProtoOf = Object.getPrototypeOf;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ var __toESM = (mod, isNodeMode, target) => {
8
+ target = mod != null ? __create(__getProtoOf(mod)) : {};
9
+ const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
10
+ for (let key of __getOwnPropNames(mod))
11
+ if (!__hasOwnProp.call(to, key))
12
+ __defProp(to, key, {
13
+ get: () => mod[key],
14
+ enumerable: true
15
+ });
16
+ return to;
17
+ };
18
+ var __require = /* @__PURE__ */ createRequire(import.meta.url);
19
+
20
+ // src/reporter.ts
21
+ import { readdirSync, readFileSync, existsSync, rmSync, statSync } from "node:fs";
22
+ import { join, resolve as resolve2 } from "node:path";
23
+ import ora2 from "ora";
24
+
25
+ // ../utils/src/types.ts
26
+ var LISTENER_IMBALANCE_THRESHOLD = 5;
27
+ function getListenerImbalances(tracking) {
28
+ return [
29
+ ...Object.entries(tracking.eventTargetCounts).map(([t, c]) => ({
30
+ api: "EventTarget",
31
+ type: t,
32
+ ...c
33
+ })),
34
+ ...Object.entries(tracking.emitterCounts).map(([t, c]) => ({
35
+ api: "EventEmitter",
36
+ type: t,
37
+ ...c
38
+ }))
39
+ ].filter((c) => c.addCount > c.removeCount + LISTENER_IMBALANCE_THRESHOLD).sort((a, b) => b.addCount - b.removeCount - (a.addCount - a.removeCount));
40
+ }
41
+ // ../utils/src/schema.ts
42
+ import { z } from "zod";
43
+ var ALL_CATEGORIES = [
44
+ "memory-leak",
45
+ "large-retained-object",
46
+ "detached-dom",
47
+ "render-blocking",
48
+ "long-task",
49
+ "unused-code",
50
+ "waterfall-bottleneck",
51
+ "large-asset",
52
+ "frame-blocking-function",
53
+ "listener-leak",
54
+ "gc-pressure",
55
+ "slow-test",
56
+ "expensive-setup",
57
+ "hot-function",
58
+ "unnecessary-computation",
59
+ "import-overhead",
60
+ "dependency-bottleneck",
61
+ "algorithm",
62
+ "serialization",
63
+ "allocation",
64
+ "event-handling",
65
+ "blocking-io",
66
+ "other"
67
+ ];
68
+ var FindingSchema = z.object({
69
+ severity: z.enum(["critical", "warning", "info"]),
70
+ title: z.string().describe("Short title for the finding"),
71
+ description: z.string().describe("Detailed explanation of the issue"),
72
+ category: z.string().describe(`Category of the performance issue. Use one of: ${ALL_CATEGORIES.join(", ")}`),
73
+ resourceUrl: z.string().optional().describe("URL of the resource involved"),
74
+ workspacePath: z.string().optional().describe("Path in the VFS workspace"),
75
+ impactMs: z.number().optional().describe("Estimated current cost in ms (e.g. selfTime of the hot function)"),
76
+ estimatedSavingsMs: z.number().optional().describe("Estimated time savings in ms if the fix is applied. Computed as impactMs × fraction eliminated by the fix."),
77
+ confidence: z.enum(["high", "medium", "low"]).optional().describe("How confident you are in this finding. high = verified in source code, medium = strong signal but partial verification, low = inferred from data patterns"),
78
+ retainedSize: z.number().optional().describe("Retained heap size in bytes"),
79
+ retainerPath: z.array(z.string()).optional().describe("Object retention path in the heap"),
80
+ sourceFile: z.string().optional().describe("Primary source file where the issue occurs (workspace path or original path)"),
81
+ lineNumber: z.number().optional().describe("Line number in the source file where the issue occurs (1-based)"),
82
+ suggestedFix: z.string().describe("Code snippet or guidance to fix the issue"),
83
+ beforeCode: z.string().optional().describe("The current problematic code snippet from the source file"),
84
+ afterCode: z.string().optional().describe("The improved code snippet that fixes the issue"),
85
+ testFile: z.string().optional().describe("Test file path (for test performance findings)"),
86
+ affectedTests: z.array(z.string()).optional().describe("Test names that would benefit from this fix (for test performance findings)"),
87
+ hotFunction: z.object({
88
+ name: z.string(),
89
+ scriptUrl: z.string(),
90
+ lineNumber: z.number(),
91
+ selfTime: z.number(),
92
+ selfPercent: z.number()
93
+ }).optional().describe("Hot function details (for hot-function findings)")
94
+ });
95
+ var FindingsSchema = z.object({
96
+ findings: z.array(FindingSchema)
97
+ });
98
+ // ../utils/src/models/init.ts
99
+ async function initModel() {
100
+ const modelOverride = process.env.ZEITZEUGE_MODEL;
101
+ const openaiKey = process.env.OPENAI_API_KEY;
102
+ const anthropicKey = process.env.ANTHROPIC_API_KEY;
103
+ if (openaiKey) {
104
+ const { ChatOpenAI } = await import("@langchain/openai");
105
+ return new ChatOpenAI({
106
+ model: modelOverride ?? "gpt-5.2",
107
+ apiKey: openaiKey
108
+ });
109
+ }
110
+ if (anthropicKey) {
111
+ const { ChatAnthropic } = await import("@langchain/anthropic");
112
+ return new ChatAnthropic({
113
+ model: modelOverride ?? "claude-opus-4-6",
114
+ apiKey: anthropicKey
115
+ });
116
+ }
117
+ throw new Error(`No API key found. Set OPENAI_API_KEY or ANTHROPIC_API_KEY in your environment.
118
+
119
+ export OPENAI_API_KEY=sk-...
120
+ # or
121
+ export ANTHROPIC_API_KEY=sk-ant-...
122
+ `);
123
+ }
124
+ // ../utils/src/analysis/agent.ts
125
+ import { setMaxListeners } from "node:events";
126
+
127
+ // ../utils/src/output/progress.ts
128
+ import pc from "picocolors";
129
+
130
+ class TodoProgressRenderer {
131
+ spinner;
132
+ lastStatusByKey = new Map;
133
+ lastInProgressKey;
134
+ baseSpinnerText;
135
+ printedHeader = false;
136
+ lastMainToolKey;
137
+ seenTaskKeys = new Set;
138
+ lastSubagentToolCallKeys = new Map;
139
+ dispatchedSubagents = [];
140
+ namespaceToSubagentName = new Map;
141
+ currentInProgressContent;
142
+ totalTodos = 0;
143
+ completedTodos = 0;
144
+ subagentTodos = new Map;
145
+ pendingAutoTasks = new Set;
146
+ canAnimate;
147
+ constructor(spinner, { animate = true } = {}) {
148
+ this.spinner = spinner;
149
+ this.baseSpinnerText = spinner.text;
150
+ this.canAnimate = animate;
151
+ }
152
+ printHeaderOnce() {
153
+ if (this.printedHeader)
154
+ return;
155
+ this.printedHeader = true;
156
+ const header = "Performance analysis progress:";
157
+ this.spinner.stopAndPersist({ symbol: " ", text: header });
158
+ if (this.canAnimate)
159
+ this.spinner.start();
160
+ }
161
+ progressPrefix() {
162
+ if (this.totalTodos === 0)
163
+ return "";
164
+ return pc.dim(`[${this.completedTodos}/${this.totalTodos}]`) + " ";
165
+ }
166
+ recomputeCounts() {
167
+ let total = 0;
168
+ let completed = 0;
169
+ for (const status of this.lastStatusByKey.values()) {
170
+ if (status !== "cancelled")
171
+ total++;
172
+ if (status === "completed")
173
+ completed++;
174
+ }
175
+ this.totalTodos = total;
176
+ this.completedTodos = completed;
177
+ }
178
+ updateSpinnerText(contextLabel) {
179
+ const prefix = this.progressPrefix();
180
+ const base = this.baseSpinnerText ?? "";
181
+ const ctx = contextLabel ? ` (${contextLabel})` : "";
182
+ this.spinner.text = `${prefix}${base}${ctx}`;
183
+ }
184
+ persistLine(symbol, text) {
185
+ this.spinner.stopAndPersist({ symbol, text });
186
+ if (this.canAnimate) {
187
+ this.spinner.start();
188
+ this.updateSpinnerText(this.currentInProgressContent);
189
+ }
190
+ }
191
+ resolveSubagentName(nsKey, namespace) {
192
+ return this.namespaceToSubagentName.get(nsKey) ?? extractSubagentNameFromNamespace(namespace, this.dispatchedSubagents) ?? this.dispatchedSubagents[this.dispatchedSubagents.length - 1] ?? "subagent";
193
+ }
194
+ subagentProgressPrefix() {
195
+ const numSubagents = this.dispatchedSubagents.length;
196
+ if (numSubagents === 0)
197
+ return " ";
198
+ const weightPerSubagent = 1 / numSubagents;
199
+ let totalProgress = 0;
200
+ for (const stateMap of this.subagentTodos.values()) {
201
+ let subTotal = 0;
202
+ let subCompleted = 0;
203
+ for (const status of stateMap.values()) {
204
+ if (status !== "cancelled")
205
+ subTotal++;
206
+ if (status === "completed")
207
+ subCompleted++;
208
+ }
209
+ if (subTotal > 0) {
210
+ totalProgress += subCompleted / subTotal * weightPerSubagent;
211
+ }
212
+ }
213
+ const pct = Math.round(totalProgress * 100);
214
+ return pc.dim(`${String(pct).padStart(3)}%`);
215
+ }
216
+ handleSubagentTodos(todos, nsKey, displayName) {
217
+ if (!this.subagentTodos.has(nsKey)) {
218
+ this.subagentTodos.set(nsKey, new Map);
219
+ }
220
+ const stateMap = this.subagentTodos.get(nsKey);
221
+ const transitions = [];
222
+ for (const todo of todos) {
223
+ const key = todo.content;
224
+ const prevStatus = stateMap.get(key);
225
+ const nextStatus = todo.status;
226
+ if (prevStatus === nextStatus)
227
+ continue;
228
+ stateMap.set(key, nextStatus);
229
+ transitions.push({ todo, prevStatus });
230
+ }
231
+ for (const { todo, prevStatus } of transitions) {
232
+ if (todo.status === "completed" && prevStatus !== "completed") {
233
+ this.printHeaderOnce();
234
+ const pct = this.subagentProgressPrefix();
235
+ const label = ` ${pct} ${pc.cyan(`[${displayName}]`)} ${pc.green("✓")} ${todo.content}`;
236
+ this.persistLine(" ", label);
237
+ } else if (todo.status === "in_progress" && prevStatus !== "in_progress") {
238
+ this.printHeaderOnce();
239
+ const pct = this.subagentProgressPrefix();
240
+ const label = ` ${pct} ${pc.cyan(`[${displayName}]`)} ${pc.yellow("▸")} ${pc.dim(todo.content)}`;
241
+ this.persistLine(" ", label);
242
+ }
243
+ }
244
+ }
245
+ handleChunk(chunk, meta) {
246
+ const isSubagent = meta?.isSubagent === true;
247
+ const nsKey = normalizeNamespace(meta?.namespace);
248
+ if (isSubagent && nsKey && !this.namespaceToSubagentName.has(nsKey)) {
249
+ const name = extractSubagentNameFromChunk(chunk);
250
+ if (name)
251
+ this.namespaceToSubagentName.set(nsKey, name);
252
+ }
253
+ const toolCalls = extractToolCallsFromStreamChunk(chunk);
254
+ if (toolCalls && toolCalls.length > 0) {
255
+ const newlyDispatched = [];
256
+ for (const tc of toolCalls) {
257
+ if (!isSubagent && tc.name === "task") {
258
+ const subagentType = tc.args.subagent_type;
259
+ if (typeof subagentType === "string" && !this.dispatchedSubagents.includes(subagentType)) {
260
+ this.dispatchedSubagents.push(subagentType);
261
+ newlyDispatched.push(subagentType);
262
+ }
263
+ }
264
+ if (tc.name === "write_todos") {
265
+ if (isSubagent) {
266
+ const todos2 = tc.args.todos;
267
+ if (Array.isArray(todos2)) {
268
+ const displayName = this.resolveSubagentName(nsKey, meta?.namespace);
269
+ this.handleSubagentTodos(todos2, nsKey, displayName);
270
+ const autoNsKey = `auto:${displayName}`;
271
+ if (this.subagentTodos.has(autoNsKey)) {
272
+ this.subagentTodos.delete(autoNsKey);
273
+ this.pendingAutoTasks.delete(displayName);
274
+ }
275
+ }
276
+ }
277
+ continue;
278
+ }
279
+ if (tc.name.startsWith("extract"))
280
+ continue;
281
+ const dedupKey = tc.name === "task" && typeof tc.args.subagent_type === "string" ? `task:${tc.args.subagent_type}` : tc.name;
282
+ const signature = formatToolCall(tc);
283
+ let isDuplicate;
284
+ if (isSubagent) {
285
+ const lastKey = this.lastSubagentToolCallKeys.get(nsKey);
286
+ isDuplicate = dedupKey === lastKey;
287
+ if (!isDuplicate)
288
+ this.lastSubagentToolCallKeys.set(nsKey, dedupKey);
289
+ } else if (dedupKey.startsWith("task:")) {
290
+ isDuplicate = this.seenTaskKeys.has(dedupKey);
291
+ if (!isDuplicate)
292
+ this.seenTaskKeys.add(dedupKey);
293
+ } else {
294
+ isDuplicate = dedupKey === this.lastMainToolKey;
295
+ this.lastMainToolKey = dedupKey;
296
+ }
297
+ if (!isDuplicate) {
298
+ this.printHeaderOnce();
299
+ const displayName = isSubagent ? this.resolveSubagentName(nsKey, meta?.namespace) : "";
300
+ const label = isSubagent ? ` ↳ ${pc.cyan(`[${displayName}]`)} ${signature}` : ` ↳ ${signature}`;
301
+ this.persistLine(" ", pc.dim(label));
302
+ }
303
+ }
304
+ for (const name of newlyDispatched) {
305
+ const autoNsKey = `auto:${name}`;
306
+ this.handleSubagentTodos([{ content: "analyzing", status: "in_progress" }], autoNsKey, name);
307
+ this.pendingAutoTasks.add(name);
308
+ }
309
+ if (!isSubagent && this.pendingAutoTasks.size > 0) {
310
+ const hasNonTaskCalls = toolCalls.some((tc) => tc.name !== "task" && tc.name !== "write_todos");
311
+ if (hasNonTaskCalls) {
312
+ for (const name of this.pendingAutoTasks) {
313
+ const autoNsKey = `auto:${name}`;
314
+ this.handleSubagentTodos([{ content: "analyzing", status: "completed" }], autoNsKey, name);
315
+ }
316
+ this.pendingAutoTasks.clear();
317
+ }
318
+ }
319
+ }
320
+ if (isSubagent)
321
+ return;
322
+ const todos = extractTodosFromStreamChunk(chunk);
323
+ if (!todos)
324
+ return;
325
+ for (const todo of todos) {
326
+ const key = todo.id && String(todo.id) || todo.content;
327
+ const prevStatus = this.lastStatusByKey.get(key);
328
+ const nextStatus = todo.status;
329
+ if (prevStatus !== nextStatus) {
330
+ this.lastStatusByKey.set(key, nextStatus);
331
+ this.recomputeCounts();
332
+ if (nextStatus === "completed" && prevStatus !== "completed") {
333
+ this.printHeaderOnce();
334
+ this.persistLine(" ", ` ${this.progressPrefix()}${pc.green("✓")} ${todo.content}`);
335
+ this.lastMainToolKey = undefined;
336
+ this.seenTaskKeys.clear();
337
+ this.lastSubagentToolCallKeys.clear();
338
+ }
339
+ if (nextStatus === "in_progress" && this.lastInProgressKey !== key) {
340
+ this.lastInProgressKey = key;
341
+ this.currentInProgressContent = todo.content;
342
+ this.printHeaderOnce();
343
+ if (this.canAnimate) {
344
+ this.updateSpinnerText(todo.content);
345
+ }
346
+ this.lastMainToolKey = undefined;
347
+ this.seenTaskKeys.clear();
348
+ this.lastSubagentToolCallKeys.clear();
349
+ }
350
+ }
351
+ }
352
+ }
353
+ }
354
+ function extractTodosFromStreamChunk(chunk) {
355
+ if (!chunk || typeof chunk !== "object")
356
+ return;
357
+ const direct = chunk;
358
+ if (Array.isArray(direct.todos))
359
+ return direct.todos;
360
+ for (const value of Object.values(chunk)) {
361
+ if (!value || typeof value !== "object")
362
+ continue;
363
+ const nested = value;
364
+ if (Array.isArray(nested.todos))
365
+ return nested.todos;
366
+ }
367
+ }
368
+ function extractToolCallsFromStreamChunk(chunk) {
369
+ if (!chunk || typeof chunk !== "object")
370
+ return;
371
+ const results = [];
372
+ const extractFromMessage = (msg) => {
373
+ if (!msg || typeof msg !== "object")
374
+ return;
375
+ const m = msg;
376
+ if (!Array.isArray(m.tool_calls) || m.tool_calls.length === 0)
377
+ return;
378
+ for (const tc of m.tool_calls) {
379
+ if (tc.name) {
380
+ results.push({ name: tc.name, args: tc.args ?? {} });
381
+ }
382
+ }
383
+ };
384
+ const extractFromMessages = (messages) => {
385
+ if (!Array.isArray(messages))
386
+ return;
387
+ const last = messages[messages.length - 1];
388
+ extractFromMessage(last);
389
+ };
390
+ const direct = chunk;
391
+ if (Array.isArray(direct.messages)) {
392
+ extractFromMessages(direct.messages);
393
+ if (results.length > 0)
394
+ return results;
395
+ }
396
+ for (const value of Object.values(chunk)) {
397
+ if (!value || typeof value !== "object")
398
+ continue;
399
+ const nested = value;
400
+ if (Array.isArray(nested.messages)) {
401
+ extractFromMessages(nested.messages);
402
+ if (results.length > 0)
403
+ return results;
404
+ }
405
+ }
406
+ return results.length > 0 ? results : undefined;
407
+ }
408
+ function formatToolCall(tc) {
409
+ const args = tc.args;
410
+ const keys = Object.keys(args);
411
+ if (keys.length === 0)
412
+ return `${tc.name}()`;
413
+ if (keys.length === 1) {
414
+ const key = keys[0];
415
+ const val = args[key];
416
+ if (typeof val === "string" && val.length <= 80) {
417
+ return `${tc.name}(${key}: ${JSON.stringify(val)})`;
418
+ }
419
+ }
420
+ const parts = [];
421
+ for (const key of keys.slice(0, 3)) {
422
+ const val = args[key];
423
+ parts.push(`${key}: ${truncateValue(val)}`);
424
+ }
425
+ if (keys.length > 3)
426
+ parts.push("...");
427
+ return `${tc.name}(${parts.join(", ")})`;
428
+ }
429
+ function normalizeNamespace(ns) {
430
+ if (typeof ns === "string")
431
+ return ns;
432
+ if (Array.isArray(ns))
433
+ return ns.filter((s) => typeof s === "string").join("|");
434
+ return "";
435
+ }
436
+ function extractSubagentNameFromNamespace(ns, knownNames) {
437
+ const nsStr = normalizeNamespace(ns).toLowerCase();
438
+ if (!nsStr)
439
+ return;
440
+ for (const name of knownNames) {
441
+ if (nsStr.includes(name.toLowerCase()))
442
+ return name;
443
+ }
444
+ return;
445
+ }
446
+ function extractSubagentNameFromChunk(chunk) {
447
+ if (!chunk || typeof chunk !== "object")
448
+ return;
449
+ const nameFromMessage = (msg) => {
450
+ if (!msg || typeof msg !== "object")
451
+ return;
452
+ const kwargs = msg.kwargs;
453
+ if (kwargs && typeof kwargs.name === "string" && kwargs.name.length > 0) {
454
+ return kwargs.name;
455
+ }
456
+ const direct = msg;
457
+ if (typeof direct.name === "string" && direct.name.length > 0) {
458
+ return direct.name;
459
+ }
460
+ };
461
+ const obj = chunk;
462
+ const modelReq = obj.model_request;
463
+ if (modelReq && Array.isArray(modelReq.messages)) {
464
+ for (const msg of modelReq.messages) {
465
+ const name = nameFromMessage(msg);
466
+ if (name)
467
+ return name;
468
+ }
469
+ }
470
+ if (Array.isArray(obj.messages) && obj.messages.length > 0) {
471
+ const last = obj.messages[obj.messages.length - 1];
472
+ const name = nameFromMessage(last);
473
+ if (name)
474
+ return name;
475
+ }
476
+ for (const value of Object.values(obj)) {
477
+ if (!value || typeof value !== "object" || value === modelReq)
478
+ continue;
479
+ const nested = value;
480
+ if (!Array.isArray(nested.messages))
481
+ continue;
482
+ for (const msg of nested.messages) {
483
+ const name = nameFromMessage(msg);
484
+ if (name)
485
+ return name;
486
+ }
487
+ }
488
+ }
489
+ function truncateValue(val, maxLen = 40) {
490
+ if (typeof val === "string") {
491
+ return val.length > maxLen ? JSON.stringify(val.slice(0, maxLen - 3) + "...") : JSON.stringify(val);
492
+ }
493
+ if (typeof val === "number" || typeof val === "boolean")
494
+ return String(val);
495
+ const json = JSON.stringify(val);
496
+ if (json && json.length > maxLen)
497
+ return json.slice(0, maxLen - 3) + "...";
498
+ return json ?? "undefined";
499
+ }
500
+
501
+ // ../utils/src/analysis/agent.ts
502
+ function isSubagentNamespace(ns) {
503
+ if (typeof ns === "string")
504
+ return ns.includes("tools:");
505
+ if (Array.isArray(ns))
506
+ return ns.some((s) => typeof s === "string" && s.includes("tools:"));
507
+ return false;
508
+ }
509
+ async function invokeWithTodoStreaming(agent, userMessage, spinner, { animateProgress = true } = {}) {
510
+ const renderer = new TodoProgressRenderer(spinner, { animate: animateProgress });
511
+ setMaxListeners(0);
512
+ const controller = new AbortController;
513
+ setMaxListeners(0, controller.signal);
514
+ const stream = await agent.stream({ messages: [{ role: "user", content: userMessage }] }, { streamMode: ["updates", "values"], subgraphs: true, signal: controller.signal });
515
+ let lastValues;
516
+ for await (const item of stream) {
517
+ if (!Array.isArray(item)) {
518
+ renderer.handleChunk(item);
519
+ lastValues = item;
520
+ continue;
521
+ }
522
+ if (item.length === 3) {
523
+ const [ns, mode, chunk] = item;
524
+ const isSubagent = isSubagentNamespace(ns);
525
+ renderer.handleChunk(chunk, { isSubagent, namespace: ns });
526
+ if (!isSubagent && mode === "values")
527
+ lastValues = chunk;
528
+ continue;
529
+ }
530
+ if (item.length === 2) {
531
+ const [mode, chunk] = item;
532
+ renderer.handleChunk(chunk);
533
+ if (mode === "values")
534
+ lastValues = chunk;
535
+ continue;
536
+ }
537
+ }
538
+ return lastValues;
539
+ }
540
+ // ../utils/src/analysis/merge-findings.ts
541
+ var FINDINGS_DIR = "/findings";
542
+ var MERGED_FILENAME = "merged.json";
543
+ function toAbsoluteFindingsPath(entryPath) {
544
+ const lastSlash = entryPath.lastIndexOf("/");
545
+ const filename = lastSlash >= 0 ? entryPath.slice(lastSlash + 1) : entryPath;
546
+ return `${FINDINGS_DIR}/${filename}`;
547
+ }
548
+ async function mergeFindings(backend) {
549
+ let entries;
550
+ try {
551
+ entries = await backend.lsInfo(FINDINGS_DIR);
552
+ } catch {
553
+ return [];
554
+ }
555
+ const jsonFiles = entries.filter((e) => e.path.endsWith(".json") && !e.path.endsWith(MERGED_FILENAME));
556
+ if (jsonFiles.length === 0) {
557
+ return [];
558
+ }
559
+ const allFindings = [];
560
+ for (const entry of jsonFiles) {
561
+ const filePath = toAbsoluteFindingsPath(entry.path);
562
+ try {
563
+ const fileData = await backend.readRaw(filePath);
564
+ const raw = fileData.content.join(`
565
+ `);
566
+ const parsed = JSON.parse(raw);
567
+ const validated = FindingsSchema.safeParse(parsed);
568
+ if (validated.success) {
569
+ allFindings.push(...validated.data.findings);
570
+ } else {
571
+ console.warn(`[merge-findings] Skipping ${filePath}: schema validation failed`);
572
+ }
573
+ } catch (err) {
574
+ console.warn(`[merge-findings] Skipping ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
575
+ }
576
+ }
577
+ try {
578
+ const mergedContent = JSON.stringify({ findings: allFindings }, null, 2);
579
+ await backend.write(`${FINDINGS_DIR}/${MERGED_FILENAME}`, mergedContent);
580
+ } catch {}
581
+ return allFindings;
582
+ }
583
+ // ../utils/src/analysis/deduplication.ts
584
+ function extractFunctionName(finding) {
585
+ if (finding.hotFunction?.name) {
586
+ return finding.hotFunction.name;
587
+ }
588
+ const text = `${finding.title ?? ""} ${finding.description ?? ""}`;
589
+ const callMatch = text.match(/\b([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(\)/);
590
+ if (callMatch)
591
+ return callMatch[1];
592
+ const backtickMatch = text.match(/`([a-zA-Z_$][a-zA-Z0-9_$]*)`/);
593
+ if (backtickMatch)
594
+ return backtickMatch[1];
595
+ return null;
596
+ }
597
+ var SEVERITY_ORDER = {
598
+ critical: 0,
599
+ warning: 1,
600
+ info: 2
601
+ };
602
+ var CONFIDENCE_ORDER = {
603
+ high: 0,
604
+ medium: 1,
605
+ low: 2
606
+ };
607
+ function severityRank(s) {
608
+ return SEVERITY_ORDER[s ?? "info"] ?? 3;
609
+ }
610
+ function confidenceRank(c) {
611
+ return CONFIDENCE_ORDER[c ?? ""] ?? 3;
612
+ }
613
+ function findingQualityScore(f) {
614
+ let score = 0;
615
+ if (f.beforeCode && f.afterCode)
616
+ score += 100;
617
+ else if (f.beforeCode || f.afterCode)
618
+ score += 30;
619
+ score += (3 - confidenceRank(f.confidence)) * 20;
620
+ score += (3 - severityRank(f.severity)) * 15;
621
+ score += Math.min((f.description?.length ?? 0) / 50, 10);
622
+ if (f.sourceFile)
623
+ score += 10;
624
+ if (f.lineNumber)
625
+ score += 5;
626
+ return score;
627
+ }
628
+ function deduplicateFindings(findings) {
629
+ const ungroupable = [];
630
+ const groups = new Map;
631
+ for (const finding of findings) {
632
+ const funcName = extractFunctionName(finding);
633
+ const sourceFile = finding.sourceFile ?? finding.workspacePath;
634
+ if (!sourceFile || !funcName) {
635
+ ungroupable.push(finding);
636
+ continue;
637
+ }
638
+ const normalizedFile = sourceFile.toLowerCase().split("/").pop() ?? sourceFile;
639
+ const groupKey = `${normalizedFile}::${funcName.toLowerCase()}`;
640
+ const category = (finding.category ?? "other").toLowerCase();
641
+ if (!groups.has(groupKey)) {
642
+ groups.set(groupKey, new Map);
643
+ }
644
+ const categoryMap = groups.get(groupKey);
645
+ if (!categoryMap.has(category)) {
646
+ categoryMap.set(category, []);
647
+ }
648
+ categoryMap.get(category).push(finding);
649
+ }
650
+ const deduped = [...ungroupable];
651
+ for (const categoryMap of groups.values()) {
652
+ for (const candidatesInCategory of categoryMap.values()) {
653
+ if (candidatesInCategory.length === 0)
654
+ continue;
655
+ let best = candidatesInCategory[0];
656
+ let bestScore = findingQualityScore(best);
657
+ for (let i = 1;i < candidatesInCategory.length; i++) {
658
+ const score = findingQualityScore(candidatesInCategory[i]);
659
+ if (score > bestScore) {
660
+ best = candidatesInCategory[i];
661
+ bestScore = score;
662
+ }
663
+ }
664
+ deduped.push(best);
665
+ }
666
+ }
667
+ return deduped;
668
+ }
669
+ function rankFindings(findings) {
670
+ return [...findings].sort((a, b) => {
671
+ const sevDiff = severityRank(a.severity) - severityRank(b.severity);
672
+ if (sevDiff !== 0)
673
+ return sevDiff;
674
+ const impactDiff = (b.impactMs ?? 0) - (a.impactMs ?? 0);
675
+ if (impactDiff !== 0)
676
+ return impactDiff;
677
+ return confidenceRank(a.confidence) - confidenceRank(b.confidence);
678
+ });
679
+ }
680
+ // ../utils/src/prompts/shared.ts
681
+ var VERIFICATION_RULES = `## Verification rules (mandatory for every finding)
682
+
683
+ 1. **ALWAYS read the source file** before reporting a finding. You MUST have
684
+ read the actual code. Never report based on function names or profiling data alone.
685
+ 2. **Copy code verbatim** — beforeCode must be copied exactly from the file you
686
+ read, not paraphrased. Line numbers must match what you observed.
687
+ 3. **Provide a working fix** — afterCode must be a complete drop-in replacement
688
+ that compiles, preserves the EXACT function signature, and only fixes the perf issue.
689
+ 4. **Never omit beforeCode/afterCode** — every finding MUST have both fields set.
690
+ 5. **Do NOT change sync to async** — if a function is synchronous, afterCode must
691
+ also be synchronous. Replace the inefficient implementation with a faster sync
692
+ alternative (e.g., use crypto.createHash instead of a manual loop). Mention the
693
+ async alternative in the description, but keep afterCode as a sync drop-in.`;
694
+ var OUTPUT_FORMAT = `## Output requirements
695
+
696
+ - Report ALL findings within YOUR scope — typically 3–5 per subagent.
697
+ Exhaustively analyze every function but stay within your assigned categories.
698
+ - Each finding MUST have sourceFile, beforeCode, and afterCode
699
+ - Be specific — name exact files, functions, and line numbers
700
+ - Provide concrete code-level fixes, not generic advice
701
+ - Do NOT report findings about test files — only about application source files
702
+
703
+ ### CRITICAL: Multiple findings per function and per file
704
+
705
+ - A single function CAN have multiple distinct issues WITHIN YOUR SCOPE —
706
+ report each as a SEPARATE finding with a different category.
707
+ - A single file often has MANY issues across different functions. Read the
708
+ ENTIRE file top-to-bottom and report EVERY issue you find within your scope.
709
+ - If function A calls function B and both have issues, report findings for
710
+ BOTH functions separately.
711
+ - Do NOT skip issues you consider "minor" — report them with severity: info.
712
+ - Do NOT report issues that belong to another subagent's scope.`;
713
+ var FINDING_CATEGORIES = `## Finding categories
714
+
715
+ Each finding MUST use one of these EXACT category values — do NOT invent new categories:
716
+
717
+ - **algorithm** — Inefficient algorithm: O(n²) loops, brute-force search, repeated work
718
+ - **serialization** — Excessive JSON.stringify/parse, string concatenation, encoding
719
+ - **allocation** — Excessive object/array creation, per-call instantiation causing GC pressure
720
+ - **event-handling** — Listener leaks, unbounded event handler accumulation
721
+ - **hot-function** — Generic CPU-hot function that doesn't fit a more specific category
722
+ - **gc-pressure** — Memory leaks, closure-captured references, unbounded data structures
723
+ that grow without eviction, or high garbage collection overhead. Use this for ANY
724
+ finding about memory growth, retained references, or missing cleanup/eviction.
725
+ - **listener-leak** — Event listeners not cleaned up properly
726
+ - **unnecessary-computation** — Redundant work that could be cached or eliminated,
727
+ including regex recompilation with constant patterns
728
+ - **blocking-io** — Synchronous I/O or blocking operations in hot paths
729
+ - **memory-leak** — Memory leaks from unbounded arrays, maps, caches
730
+ - **large-retained-object** — Single objects retaining disproportionate memory
731
+ - **detached-dom** — Detached DOM nodes still referenced in memory
732
+ - **render-blocking** — Render-blocking scripts or stylesheets
733
+ - **long-task** — Long tasks blocking the main thread
734
+ - **waterfall-bottleneck** — Sequential resource chains that could load in parallel
735
+ - **large-asset** — Oversized bundles or assets
736
+ - **frame-blocking-function** — Functions blocking the main thread > 50ms
737
+ - **other** — Doesn't fit any of the above
738
+
739
+ Prefer more specific categories (algorithm, serialization, allocation, event-handling,
740
+ blocking-io, listener-leak, gc-pressure) over generic ones (hot-function, other).`;
741
+ var PARALLEL_TOOL_CALLS = `## CRITICAL: Tool call strategy — scripts for data, read_file for source
742
+
743
+ Your FIRST turn MUST:
744
+ 1. Run analysis scripts (execute_command) to query the JSON data files.
745
+ Use pre-built helper scripts in skills/ or write your own using the
746
+ data-scripting skill.
747
+ 2. Call read_file for ALL application source files listed above.
748
+
749
+ Batch everything into ONE turn. Do NOT read data files one-at-a-time.
750
+
751
+ For data files: run a helper script or write a custom one. This is faster
752
+ and uses fewer tokens than reading raw JSON.
753
+
754
+ For source files: use read_file since you need to see the exact code for
755
+ beforeCode/afterCode suggestions.
756
+
757
+ FORBIDDEN actions:
758
+ - ls — NEVER call ls. File paths are already listed above.
759
+ - glob — NEVER call glob. File paths are already listed above.
760
+ - Reading JSON data files with read_file — use scripts instead.`;
761
+ var WRITE_FINDINGS_REQUIREMENT = `## CRITICAL — Persist your findings to a file
762
+
763
+ When your analysis is complete, you MUST write ALL findings to a JSON file using write_file.
764
+
765
+ 1. Call write_file with path: \`/findings/<YOUR_AGENT_NAME>.json\`
766
+ Use your agent name as the filename (e.g. memory-heap, page-load, runtime-blocking,
767
+ code-pattern, cpu-hotspot, listener-leak, memory-closure).
768
+
769
+ 2. The file content MUST be a JSON object with this exact structure:
770
+ { "findings": [ { "severity": "...", "title": "...", ... }, ... ] }
771
+ Each finding must include ALL required fields: severity, title, description,
772
+ category, sourceFile, lineNumber, suggestedFix, beforeCode, afterCode,
773
+ confidence, impactMs, estimatedSavingsMs.
774
+
775
+ 3. Write ALL findings in a SINGLE write_file call. Do NOT write findings one at a time.
776
+
777
+ 4. After writing the file, respond with ONLY a brief summary like:
778
+ "Found 4 issues: 2 critical, 1 warning, 1 info. Written to /findings/memory-heap.json"
779
+
780
+ The orchestrator reads findings from the file directly — your text response is only for
781
+ progress display. If a finding is not in the JSON file, it does not exist.`;
782
+ var STRUCTURED_OUTPUT_FIELDS = `## Structured output fields — REQUIRED for every finding
783
+
784
+ Every finding MUST include ALL of these fields:
785
+
786
+ - \`sourceFile\` — (REQUIRED) the workspace path (e.g. src/utils/parser.ts or scripts/app.js)
787
+ - \`lineNumber\` — (REQUIRED) the 1-based line number, verified by reading the file
788
+ - \`confidence\` — \`high\` if you read the source, \`medium\` if strongly suggested,
789
+ \`low\` if inferred
790
+ - \`beforeCode\` — (REQUIRED) the CURRENT problematic code, COPIED VERBATIM from the
791
+ source file you read. Include the full function or the relevant 5–20 lines.
792
+ This MUST be actual code from the file, not a paraphrase or summary.
793
+ - \`afterCode\` — (REQUIRED) the IMPROVED code showing the fix. This MUST be a
794
+ complete, working drop-in replacement for \`beforeCode\`:
795
+ - Same function signature and exports
796
+ - Same return type and API contract
797
+ - Only changes the performance issue — preserves all other behavior
798
+ - Include ALL the code from beforeCode with just the fix applied
799
+ - \`estimatedSavingsMs\` — your estimate of time saved if the fix is applied
800
+ - \`impactMs\` — the current measured cost (e.g. selfTime of the hot function,
801
+ blocking function duration, resource load time)
802
+
803
+ ### beforeCode / afterCode rules
804
+
805
+ - NEVER leave beforeCode or afterCode empty. Every finding must have both.
806
+ - beforeCode must be VERBATIM from the source file — do not abbreviate or paraphrase.
807
+ Copy the COMPLETE function (or the complete relevant section of 5-30 lines).
808
+ Do NOT use "..." or "// ..." to skip lines. Include the full code block.
809
+ - afterCode must be a COMPLETE, WORKING replacement for the beforeCode block:
810
+ - SAME function signature — same name, same parameters, same return type
811
+ - SAME sync/async — if the original is sync, afterCode MUST be sync. Do NOT
812
+ add async/await, Promises, or callbacks. Replace the slow implementation
813
+ with a faster synchronous alternative instead.
814
+ - SAME exports — if the function is exported, afterCode must also export it
815
+ - Must compile and produce identical behavior except for the performance fix
816
+ - Include ALL the code from beforeCode, not just the changed lines
817
+ - If the fix requires adding a module-level constant (e.g., hoisting a RegExp or
818
+ TextEncoder), include that declaration ABOVE the function in afterCode
819
+ - For blocking CPU loops: replace with a faster sync algorithm (e.g., use
820
+ crypto.createHash() instead of a manual loop). Mention async alternatives
821
+ in the finding description, not in afterCode.
822
+ - For excessive instantiation: hoist the construction to module level and reuse it.
823
+ Show the module-level const AND the modified function in afterCode.
824
+ - For listener leaks: show the fix (e.g., .once() instead of .on(), or return
825
+ an unsubscribe function). The beforeCode/afterCode should show the same function
826
+ with only the listener fix changed.
827
+ - afterCode must NOT be a diff, pseudocode, or description of changes
828
+ - If you cannot provide a concrete fix, still include beforeCode and describe
829
+ the fix approach in afterCode as a code comment within the actual code
830
+
831
+ ### Code fix quality rules
832
+
833
+ 1. **Named functions for event handlers**: NEVER use anonymous functions with
834
+ .on() or .addEventListener(). Always define a named function or const so
835
+ it can be removed with .off(event, handler). Example:
836
+ - BAD: emitter.on('change', () => { cache = null; })
837
+ - GOOD: const invalidateCache = () => { cache = null; };
838
+ emitter.on('change', invalidateCache);
839
+ 2. **Surgical listener removal**: Use .off(event, specificHandler) instead of
840
+ .removeAllListeners(). The cleanup/reset function must remove the EXACT
841
+ handler that was added.
842
+ 3. **Complete guard logic**: If you add a guard flag (e.g., listenerRegistered),
843
+ the cleanup function MUST reset the flag AND remove the specific listener.
844
+ 4. **Include surrounding context**: If the fix adds module-level variables
845
+ (guard flags, hoisted constants, named handlers), include ALL of them in
846
+ afterCode so it is self-contained.
847
+ 5. **Preserve existing functions**: If the original file has a cleanup/reset
848
+ function, update it in afterCode to properly undo whatever your fix added.
849
+ Do NOT ignore existing cleanup functions.`;
850
+ // ../utils/src/prompts/file-list.ts
851
+ function buildFileListPromptSection(config) {
852
+ const { dataFiles, sourceFiles, testFiles, additionalSections } = config;
853
+ const lines = [
854
+ "## FILES IN THIS WORKSPACE — Read these directly. Do NOT use ls or glob.",
855
+ "",
856
+ "### Data files"
857
+ ];
858
+ for (const file of dataFiles) {
859
+ if (file.description) {
860
+ lines.push(`- ${file.path} ${file.description}`);
861
+ } else {
862
+ lines.push(`- ${file.path}`);
863
+ }
864
+ }
865
+ if (sourceFiles && sourceFiles.length > 0) {
866
+ lines.push("", "### Application source files — you MUST read ALL of these in your FIRST turn");
867
+ for (const f of sourceFiles) {
868
+ lines.push(`- ${f}`);
869
+ }
870
+ }
871
+ if (testFiles && testFiles.length > 0) {
872
+ lines.push("", "### Test files");
873
+ for (const f of testFiles) {
874
+ lines.push(`- ${f}`);
875
+ }
876
+ }
877
+ if (additionalSections) {
878
+ for (const section of additionalSections) {
879
+ lines.push("", `### ${section.title}`);
880
+ for (const f of section.files) {
881
+ lines.push(`- ${f}`);
882
+ }
883
+ }
884
+ }
885
+ lines.push("", "> IMPORTANT: The file paths above are COMPLETE. Do NOT use ls or glob to");
886
+ lines.push(sourceFiles && sourceFiles.length > 0 ? "> discover files. Just call read_file for each path listed above." : "> discover files. Read the data files first, then read source files selectively.");
887
+ return lines.join(`
888
+ `);
889
+ }
890
+ function insertFileListIntoPrompt(prompt, fileSection) {
891
+ if (!fileSection)
892
+ return prompt;
893
+ const firstHeadingIdx = prompt.indexOf(`
894
+ ## `);
895
+ if (firstHeadingIdx === -1) {
896
+ return prompt + `
897
+
898
+ ` + fileSection;
899
+ }
900
+ return prompt.slice(0, firstHeadingIdx) + `
901
+
902
+ ` + fileSection + `
903
+ ` + prompt.slice(firstHeadingIdx);
904
+ }
905
+ // ../utils/src/workspace/builder.ts
906
+ import { VfsSandbox } from "@langchain/node-vfs";
907
+
908
+ class PerfAgentSandbox extends VfsSandbox {
909
+ static #toRelative(p) {
910
+ const stripped = p.startsWith("/") ? p.slice(1) : p;
911
+ return stripped || ".";
912
+ }
913
+ async read(filePath, offset = 0, limit = 500) {
914
+ return super.read(PerfAgentSandbox.#toRelative(filePath), offset, limit);
915
+ }
916
+ async lsInfo(dirPath) {
917
+ return super.lsInfo(PerfAgentSandbox.#toRelative(dirPath));
918
+ }
919
+ async grepRaw(pattern, searchPath = "/", glob = null) {
920
+ return super.grepRaw(pattern, PerfAgentSandbox.#toRelative(searchPath), glob);
921
+ }
922
+ async globInfo(pattern, searchPath = "/") {
923
+ return super.globInfo(pattern, PerfAgentSandbox.#toRelative(searchPath));
924
+ }
925
+ static async create(options) {
926
+ const sandbox = new PerfAgentSandbox(options);
927
+ await sandbox.initialize();
928
+ return sandbox;
929
+ }
930
+ }
931
+ async function createWorkspaceFromFiles(files) {
932
+ const sandbox = await PerfAgentSandbox.create({ initialFiles: files });
933
+ const cleanup = async () => {
934
+ try {
935
+ await sandbox.stop();
936
+ } catch {}
937
+ };
938
+ return { backend: sandbox, cleanup };
939
+ }
940
+ // ../utils/src/skills/data-scripting.ts
941
+ var SKILL_MD = `---
942
+ name: data-scripting
943
+ description: Use this skill when you need to analyze JSON data files in the workspace. Provides instructions for writing Node.js scripts to query, filter, aggregate, and cross-reference data files instead of reading them raw. Includes helper scripts and data file schemas.
944
+ ---
945
+
946
+ # Data Scripting
947
+
948
+ ## Overview
949
+
950
+ You have a full Node.js runtime available via execute_command. Instead of
951
+ reading large JSON data files with read_file (which consumes many tokens),
952
+ write short scripts that extract exactly what you need.
953
+
954
+ ## When to use scripts vs. read_file
955
+
956
+ - **read_file**: Source code files you need to see verbatim for
957
+ beforeCode/afterCode, or small files (<50 lines)
958
+ - **Scripts**: JSON data files, any file >100 lines, cross-referencing
959
+ multiple files, computing aggregations, filtering by thresholds
960
+
961
+ ## How to run a script
962
+
963
+ IMPORTANT: All file paths in scripts must use relative paths (no leading
964
+ \`/\`). The execute_command tool runs with the workspace as the current
965
+ directory, so \`fs.readFileSync('hot-functions/application.json')\` resolves
966
+ to \`<workspace>/hot-functions/application.json\`.
967
+
968
+ Option 1 — inline:
969
+ execute_command: node -e "
970
+ const data = JSON.parse(require('fs').readFileSync('path/to/file', 'utf8'));
971
+ const results = data.filter(x => x.duration > 100);
972
+ console.log(JSON.stringify(results, null, 2));
973
+ "
974
+
975
+ Option 2 — use a pre-built helper:
976
+ execute_command: node skills/data-scripting/helpers/top-items.js hot-functions/application.json selfTime 10
977
+
978
+ Option 3 — write a custom script:
979
+ write_file: tmp/my-analysis.js
980
+ execute_command: node tmp/my-analysis.js
981
+
982
+ ## Pre-built helper scripts
983
+
984
+ ### skills/data-scripting/helpers/top-items.js
985
+ Usage: \`node top-items.js <file> <sortField> [limit]\`
986
+ Reads a JSON array, sorts by the given field descending, prints top N items.
987
+
988
+ ### skills/data-scripting/helpers/cross-reference.js
989
+ Usage: \`node cross-reference.js <file1> <field1> <file2> <field2>\`
990
+ Finds items in file1 whose field1 value also appears as field2 in file2.
991
+
992
+ ## Data file schemas
993
+
994
+ See skills/data-scripting/schemas.md for the JSON structure of every
995
+ data file in this workspace. Read it before writing custom scripts.
996
+ `;
997
+ var SCHEMAS_MD = `# Workspace Data File Schemas
998
+
999
+ ---
1000
+ # Workspace files
1001
+ ---
1002
+
1003
+ ## summary.json
1004
+ {
1005
+ totalTests: number,
1006
+ totalDuration: number, // ms
1007
+ passCount: number,
1008
+ failCount: number,
1009
+ profileCount: number,
1010
+ slowestFile: string | null, // file:// URL
1011
+ slowestFileDuration: number,
1012
+ totalGcTime: number,
1013
+ gcPercentage: number
1014
+ }
1015
+
1016
+ ## hot-functions/application.json
1017
+ Array of application-code hot functions (filtered to sourceCategory "application"):
1018
+ {
1019
+ functionName: string,
1020
+ workspacePath: string, // e.g. "/src/services/crypto.ts" (has leading /)
1021
+ lineNumber: number,
1022
+ columnNumber: number,
1023
+ selfTime: number, // ms of CPU self-time
1024
+ totalTime: number, // ms including callees
1025
+ hitCount: number,
1026
+ selfPercent: number, // % of total profile duration
1027
+ sourceCategory: "application",
1028
+ sourceSnippet?: string, // source code context around the hot line
1029
+ callerChain?: [{ functionName, workspacePath, lineNumber }]
1030
+ }
1031
+
1032
+ ## hot-functions/dependencies.json
1033
+ Same structure as hot-functions/application.json but sourceCategory "dependency".
1034
+
1035
+ ## hot-functions/global.json
1036
+ All hot functions across all categories (application, dependency, framework, unknown).
1037
+ Same item structure. Can be very large (2000+ lines).
1038
+
1039
+ ## scripts/application.json
1040
+ Per-script summary for application code:
1041
+ [{ workspacePath: string, selfTime: number, selfPercent: number, functionCount: number }]
1042
+
1043
+ ## scripts/dependencies.json
1044
+ Same structure as scripts/application.json but for dependency scripts.
1045
+
1046
+ ## src/index.json
1047
+ Maps source file paths to their hot functions. Key = workspacePath, value = array:
1048
+ {
1049
+ "/src/services/notification-service.ts": [
1050
+ { functionName: string, lineNumber: number, selfTime: number, selfPercent: number }
1051
+ ],
1052
+ "/src/utils/crypto.ts": [...]
1053
+ }
1054
+ Use this to know WHICH source files have CPU-hot functions.
1055
+
1056
+ ## listener-tracking.json
1057
+ {
1058
+ eventTargetCounts: {}, // browser EventTarget counts (usually empty in Node)
1059
+ emitterCounts: { // keyed by event name
1060
+ "<eventName>": { addCount: number, removeCount: number },
1061
+ ...
1062
+ },
1063
+ exceedances: [{ // maxListeners threshold exceeded
1064
+ targetType: string, // e.g. "EventEmitter"
1065
+ eventType: string, // e.g. "task:changed"
1066
+ listenerCount: number, // current count that exceeded threshold
1067
+ threshold: number, // the maxListeners value (default 10)
1068
+ stack: string // stack trace showing where listener was added
1069
+ }]
1070
+ }
1071
+
1072
+ ## metrics/current.json
1073
+ Comprehensive pre-computed metrics (large file):
1074
+ {
1075
+ version: number,
1076
+ timestamp: string,
1077
+ suite: { totalDuration, totalTests, passCount, failCount, averageTestDuration,
1078
+ medianTestDuration, p95TestDuration, slowestTestDuration, slowestTestName },
1079
+ cpu: { gcPercentage, gcTime, idlePercentage, idleTime, applicationTime,
1080
+ applicationPercent, dependencyTime, dependencyPercent, testFrameworkTime,
1081
+ testFrameworkPercent },
1082
+ files: { "<file:// URL>": { duration, testCount, setupTime, gcPercentage } },
1083
+ tests: { "<file::testName>": { duration, status } },
1084
+ hotFunctions: [{ key, functionName, scriptUrl, lineNumber, selfTime, selfPercent,
1085
+ sourceCategory }],
1086
+ listenerTracking: { eventTargetCounts, emitterCounts, exceedances }
1087
+ }
1088
+
1089
+ ## timing/overview.json
1090
+ Array of per-file timing data:
1091
+ [{
1092
+ file: string, // file:// URL
1093
+ duration: number,
1094
+ testCount: number,
1095
+ passCount: number,
1096
+ failCount: number,
1097
+ setupTime: number,
1098
+ tests: [{ name: string, duration: number, status: string }]
1099
+ }]
1100
+
1101
+ ## timing/slow-tests.json
1102
+ Array of slow tests sorted by duration descending:
1103
+ [{ file: string, name: string, duration: number }]
1104
+
1105
+ ## profiles/index.json
1106
+ Manifest mapping test files to their CPU profile paths:
1107
+ [{ testFile: string, profilePath: string }]
1108
+
1109
+ ## profiles/<file>.json
1110
+ Per-test-file profile summary:
1111
+ {
1112
+ profilePath: string,
1113
+ duration: number,
1114
+ sampleCount: number,
1115
+ hotFunctions: [{
1116
+ functionName, lineNumber, columnNumber, selfTime, totalTime,
1117
+ hitCount, selfPercent, callerChain, sourceCategory, workspacePath
1118
+ }]
1119
+ }
1120
+
1121
+ ---
1122
+ # Browser workspace files (CLI agent only — not present in Vitest workspaces)
1123
+ ---
1124
+
1125
+ ## heap/summary.json
1126
+ {
1127
+ metadata: { url, capturedAt, totalSize, nodeCount, edgeCount },
1128
+ largestObjects: [{ name, type, selfSize, retainedSize, retainerPath: string[] }],
1129
+ typeStats: [{ type, count, totalSize, avgSize }],
1130
+ constructorStats: [{ constructor, count, totalSize, avgSize }],
1131
+ detachedNodes: { count, totalSize, examples: [{ name, retainerPath }] },
1132
+ closureStats: { count, totalSize, topClosures: [{ name, contextSize, retainerPath }] }
1133
+ }
1134
+
1135
+ ## trace/summary.json
1136
+ {
1137
+ url: string,
1138
+ timing: { loadComplete, firstContentfulPaint, largestContentfulPaint, totalBlockingTime, longTasks: [...] },
1139
+ requestCount: number,
1140
+ totalTransferSize: number,
1141
+ totalDecodedSize: number,
1142
+ renderBlockingResources: [{ url, type, size, duration, path }],
1143
+ resourceBreakdown: { scripts: { count, totalSize }, stylesheets: {...}, fonts: {...}, images: {...}, other: {...} }
1144
+ }
1145
+
1146
+ ## trace/runtime/blocking-functions.json
1147
+ Array of (up to 50 entries, sorted by duration descending):
1148
+ {
1149
+ functionName: string,
1150
+ scriptUrl: string, // URL of the script
1151
+ lineNumber: number,
1152
+ columnNumber: number,
1153
+ duration: number, // ms blocked on main thread
1154
+ startTime: number, // ms relative to navigation start
1155
+ callStack: [{ // caller chain (array of objects, NOT strings)
1156
+ functionName: string,
1157
+ scriptUrl: string,
1158
+ lineNumber: number
1159
+ }],
1160
+ category: string // "scripting" | "layout" | "paint" | etc.
1161
+ }
1162
+ To get the workspace file path for a scriptUrl, extract the filename:
1163
+ e.g. "https://example.com/static/abc123.js" -> "scripts/abc123.js"
1164
+
1165
+ ## trace/runtime/event-listeners.json
1166
+ Array of (only listeners with addCount > 0):
1167
+ {
1168
+ eventType: string,
1169
+ targetType: string,
1170
+ addCount: number,
1171
+ removeCount: number,
1172
+ activeCount: number,
1173
+ stackSnippets: string[]
1174
+ }
1175
+
1176
+ ## trace/runtime/summary.json
1177
+ {
1178
+ totalEvents: number,
1179
+ traceDuration: number, // ms
1180
+ mainThreadId: number,
1181
+ frameBreakdown: { scripting, layout, paint, gc, other }, // all in ms
1182
+ blockingFunctionCount: number,
1183
+ listenerImbalances: number,
1184
+ gcPauseCount: number,
1185
+ gcTotalDuration: number, // ms
1186
+ frequentEventTypes: string[] // event types dispatched >10 times
1187
+ }
1188
+
1189
+ ## trace/runtime/frame-breakdown.json
1190
+ {
1191
+ scripting: number, // ms spent in script execution
1192
+ layout: number, // ms spent in layout calculations
1193
+ paint: number, // ms spent painting
1194
+ gc: number, // ms spent in garbage collection
1195
+ other: number // ms spent in other tasks
1196
+ }
1197
+
1198
+ ## trace/network-waterfall.json
1199
+ Array of (sorted by startTime):
1200
+ {
1201
+ url: string,
1202
+ type: string, // "Script" | "Stylesheet" | "Font" | "Document" | "Image"
1203
+ status: number,
1204
+ size: number, // decoded size in bytes
1205
+ startTime: number, // ms from navigation start
1206
+ endTime: number,
1207
+ duration: number,
1208
+ isRenderBlocking: boolean,
1209
+ priority: string,
1210
+ path: string | null // workspace path to stored content (e.g. "/scripts/abc.js")
1211
+ }
1212
+
1213
+ ## trace/asset-manifest.json
1214
+ Array of all network assets:
1215
+ {
1216
+ url: string,
1217
+ type: string,
1218
+ size: number, // decoded size in bytes
1219
+ duration: number,
1220
+ isRenderBlocking: boolean,
1221
+ stored: boolean, // true if content was captured and stored
1222
+ path: string | null // workspace path if stored
1223
+ }
1224
+
1225
+ ## trace/runtime/raw-events.json
1226
+ Array of raw Chrome trace events (can be very large). Each entry has:
1227
+ {
1228
+ name: string, // event name (e.g. "FunctionCall", "Layout", "GCEvent")
1229
+ cat: string, // category
1230
+ ph: string, // phase ("X" = complete, "B"/"E" = begin/end)
1231
+ ts: number, // timestamp in microseconds
1232
+ dur: number, // duration in microseconds
1233
+ tid: number, // thread ID
1234
+ pid: number, // process ID
1235
+ args: object // event-specific arguments
1236
+ }
1237
+ Only use for deep investigation — prefer the summary files first.
1238
+ `;
1239
+ var TOP_ITEMS_JS = `'use strict';
1240
+
1241
+ const fs = require('fs');
1242
+
1243
+ const [filePath, sortField, limitArg] = process.argv.slice(2);
1244
+ if (!filePath || !sortField) {
1245
+ console.error('Usage: node top-items.js <file> <sortField> [limit]');
1246
+ process.exit(1);
1247
+ }
1248
+
1249
+ const limit = parseInt(limitArg, 10) || 10;
1250
+
1251
+ let raw;
1252
+ try {
1253
+ raw = fs.readFileSync(filePath, 'utf8');
1254
+ } catch (err) {
1255
+ console.error(\`Error reading \${filePath}: \${err.message}\`);
1256
+ process.exit(1);
1257
+ }
1258
+
1259
+ let data;
1260
+ try {
1261
+ data = JSON.parse(raw);
1262
+ } catch (err) {
1263
+ console.error(\`Error parsing JSON from \${filePath}: \${err.message}\`);
1264
+ process.exit(1);
1265
+ }
1266
+
1267
+ if (!Array.isArray(data)) {
1268
+ console.error(\`Expected a JSON array in \${filePath}, got \${typeof data}\`);
1269
+ process.exit(1);
1270
+ }
1271
+
1272
+ if (data.length > 0 && !(sortField in data[0])) {
1273
+ console.error(\`Field "\${sortField}" not found in items. Available fields: \${Object.keys(data[0]).join(', ')}\`);
1274
+ process.exit(1);
1275
+ }
1276
+
1277
+ const sorted = data
1278
+ .slice()
1279
+ .sort((a, b) => (Number(b[sortField]) || 0) - (Number(a[sortField]) || 0))
1280
+ .slice(0, limit);
1281
+
1282
+ console.log(JSON.stringify(sorted, null, 2));
1283
+ `;
1284
+ var CROSS_REFERENCE_JS = `'use strict';
1285
+
1286
+ const fs = require('fs');
1287
+
1288
+ const [file1Path, field1, file2Path, field2] = process.argv.slice(2);
1289
+ if (!file1Path || !field1 || !file2Path || !field2) {
1290
+ console.error('Usage: node cross-reference.js <file1> <field1> <file2> <field2>');
1291
+ process.exit(1);
1292
+ }
1293
+
1294
+ function readJsonArray(filePath) {
1295
+ let raw;
1296
+ try {
1297
+ raw = fs.readFileSync(filePath, 'utf8');
1298
+ } catch (err) {
1299
+ console.error(\`Error reading \${filePath}: \${err.message}\`);
1300
+ process.exit(1);
1301
+ }
1302
+ let data;
1303
+ try {
1304
+ data = JSON.parse(raw);
1305
+ } catch (err) {
1306
+ console.error(\`Error parsing JSON from \${filePath}: \${err.message}\`);
1307
+ process.exit(1);
1308
+ }
1309
+ if (!Array.isArray(data)) {
1310
+ console.error(\`Expected a JSON array in \${filePath}, got \${typeof data}\`);
1311
+ process.exit(1);
1312
+ }
1313
+ return data;
1314
+ }
1315
+
1316
+ const data1 = readJsonArray(file1Path);
1317
+ const data2 = readJsonArray(file2Path);
1318
+
1319
+ const lookupValues = new Set(data2.map(item => item[field2]));
1320
+ const matches = data1.filter(item => lookupValues.has(item[field1]));
1321
+
1322
+ if (matches.length === 0) {
1323
+ console.log(\`No items in \${file1Path} have \${field1} matching \${field2} values from \${file2Path}.\`);
1324
+ process.exit(0);
1325
+ }
1326
+
1327
+ console.log(\`Found \${matches.length} item(s) in \${file1Path} where \${field1} matches \${field2} in \${file2Path}:\\n\`);
1328
+ for (const item of matches) {
1329
+ console.log(\` [\${field1}=\${JSON.stringify(item[field1])}]\`);
1330
+ console.log(JSON.stringify(item, null, 2));
1331
+ console.log();
1332
+ }
1333
+ `;
1334
+ var DATA_SCRIPTING_SKILL_FILES = {
1335
+ "skills/data-scripting/SKILL.md": SKILL_MD,
1336
+ "skills/data-scripting/schemas.md": SCHEMAS_MD,
1337
+ "skills/data-scripting/helpers/top-items.js": TOP_ITEMS_JS,
1338
+ "skills/data-scripting/helpers/cross-reference.js": CROSS_REFERENCE_JS
1339
+ };
1340
+ // ../utils/src/skills/profile-analysis.ts
1341
+ var SKILL_MD2 = `---
1342
+ name: profile-analysis
1343
+ description: Use this skill when analyzing V8 CPU profiles, hot functions, event listener tracking, and heap allocation data from Vitest test runs. Provides pre-built analysis scripts for common test performance patterns.
1344
+ ---
1345
+
1346
+ # Profile Analysis Scripts
1347
+
1348
+ ## Overview
1349
+
1350
+ Pre-built scripts for analyzing Vitest performance data. Run these
1351
+ directly or use them as templates for custom analysis.
1352
+
1353
+ ## START HERE — Workspace overview
1354
+
1355
+ Run this FIRST to get a prioritized summary of the workspace:
1356
+
1357
+ execute_command: node skills/profile-analysis/helpers/analyze-workspace.js
1358
+
1359
+ Reads summary.json, src/index.json, hot-functions/application.json,
1360
+ listener-tracking.json exceedances, and timing/slow-tests.json. Outputs:
1361
+ - Suite stats (test count, duration, GC)
1362
+ - Which source files have hot functions (from src/index.json)
1363
+ - Application hot functions with source snippets
1364
+ - Listener exceedances and imbalances
1365
+ - Slow tests
1366
+ - Full list of source and test files
1367
+
1368
+ Use this output to decide which source files to read with read_file.
1369
+
1370
+ ## Additional scripts
1371
+
1372
+ ### Analyze hot functions (detailed)
1373
+ execute_command: node skills/profile-analysis/helpers/analyze-hotfunctions.js [--threshold 1]
1374
+
1375
+ Reads hot-functions/application.json, groups by file, and outputs:
1376
+ - Per-file CPU time breakdown
1377
+ - Hot functions above threshold (default 1% selfPercent)
1378
+ - Caller chains for compound blocker detection
1379
+
1380
+ ### Analyze listener tracking (detailed)
1381
+ execute_command: node skills/profile-analysis/helpers/analyze-listeners.js
1382
+
1383
+ Reads listener-tracking.json (emitterCounts + exceedances), outputs:
1384
+ - Per-event add/remove counts
1385
+ - Events with adds but zero removes (leak candidates)
1386
+ - MaxListeners exceedances with stack traces and listener counts
1387
+ - Suggested source files to investigate
1388
+
1389
+ ### Find closure and leak patterns
1390
+ execute_command: node skills/profile-analysis/helpers/find-leaks.js
1391
+
1392
+ Searches all src/ files for common leak patterns:
1393
+ - Maps/Sets/Arrays with .set/.push/.add but no .delete/.clear
1394
+ - Closures stored in long-lived data structures
1395
+ - .on()/.addEventListener() without corresponding removal
1396
+ - Unbounded caches without TTL or maxSize
1397
+
1398
+ ## Key data files
1399
+
1400
+ - src/index.json — maps source files to their hot functions (READ THIS FIRST)
1401
+ - hot-functions/application.json — application hot functions with source snippets
1402
+ - listener-tracking.json — emitterCounts and exceedances
1403
+ - metrics/current.json — comprehensive aggregate metrics (large)
1404
+ - profiles/<file>.json — per-test-file profile summaries
1405
+
1406
+ ## Writing custom scripts
1407
+
1408
+ Read skills/data-scripting/schemas.md for JSON structures.
1409
+ All source files are under src/ and test files under tests/.
1410
+ `;
1411
+ var ANALYZE_HOTFUNCTIONS_JS = `'use strict';
1412
+
1413
+ const fs = require('fs');
1414
+
1415
+ const args = process.argv.slice(2);
1416
+ let threshold = 1;
1417
+ const thresholdIdx = args.indexOf('--threshold');
1418
+ if (thresholdIdx !== -1 && args[thresholdIdx + 1]) {
1419
+ threshold = parseFloat(args[thresholdIdx + 1]);
1420
+ }
1421
+
1422
+ let hotFunctions;
1423
+ try {
1424
+ hotFunctions = JSON.parse(fs.readFileSync('hot-functions/application.json', 'utf8'));
1425
+ } catch (err) {
1426
+ console.error('Could not read hot-functions/application.json:', err.message);
1427
+ process.exit(1);
1428
+ }
1429
+
1430
+ if (!Array.isArray(hotFunctions) || hotFunctions.length === 0) {
1431
+ console.log('No hot functions found.');
1432
+ process.exit(0);
1433
+ }
1434
+
1435
+ let summary = null;
1436
+ try {
1437
+ summary = JSON.parse(fs.readFileSync('summary.json', 'utf8'));
1438
+ } catch (_) {}
1439
+
1440
+ const byFile = new Map();
1441
+ for (const fn of hotFunctions) {
1442
+ const key = fn.workspacePath || '(unknown)';
1443
+ if (!byFile.has(key)) byFile.set(key, []);
1444
+ byFile.get(key).push(fn);
1445
+ }
1446
+
1447
+ const fileTotals = [];
1448
+ for (const [filePath, fns] of byFile) {
1449
+ const totalSelfTime = fns.reduce((sum, f) => sum + (f.selfTime || 0), 0);
1450
+ fileTotals.push({ filePath, totalSelfTime, functions: fns });
1451
+ }
1452
+ fileTotals.sort((a, b) => b.totalSelfTime - a.totalSelfTime);
1453
+
1454
+ console.log('=== Per-File CPU Breakdown ===\\n');
1455
+ for (const { filePath, totalSelfTime, functions: fns } of fileTotals) {
1456
+ console.log(\`\${filePath} (total selfTime: \${totalSelfTime.toFixed(2)}ms)\`);
1457
+ const sorted = fns.slice().sort((a, b) => (b.selfTime || 0) - (a.selfTime || 0));
1458
+ for (const f of sorted) {
1459
+ console.log(\` \${f.functionName || '(anonymous)'} line \${f.lineNumber || '?'} selfTime=\${(f.selfTime || 0).toFixed(2)}ms selfPercent=\${(f.selfPercent || 0).toFixed(2)}%\`);
1460
+ }
1461
+ console.log();
1462
+ }
1463
+
1464
+ const aboveThreshold = hotFunctions
1465
+ .filter(f => (f.selfPercent || 0) >= threshold)
1466
+ .sort((a, b) => (b.selfPercent || 0) - (a.selfPercent || 0));
1467
+
1468
+ console.log(\`=== Hot Functions Above \${threshold}% Threshold ===\\n\`);
1469
+ if (aboveThreshold.length === 0) {
1470
+ console.log(\`No functions above \${threshold}% selfPercent.\\n\`);
1471
+ } else {
1472
+ for (const f of aboveThreshold) {
1473
+ console.log(\`\${f.functionName || '(anonymous)'} \${f.workspacePath || '?'}:\${f.lineNumber || '?'}\`);
1474
+ console.log(\` selfTime=\${(f.selfTime || 0).toFixed(2)}ms selfPercent=\${(f.selfPercent || 0).toFixed(2)}%\`);
1475
+ }
1476
+ console.log();
1477
+ }
1478
+
1479
+ const withCallers = hotFunctions.filter(f => f.callerChain && f.callerChain.length > 0);
1480
+ if (withCallers.length > 0) {
1481
+ console.log('=== Caller Chains ===\\n');
1482
+ for (const f of withCallers) {
1483
+ console.log(\`\${f.functionName || '(anonymous)'} \${f.workspacePath || '?'}:\${f.lineNumber || '?'}\`);
1484
+ for (const caller of f.callerChain) {
1485
+ console.log(\` <- \${caller.functionName || '(anonymous)'} \${caller.workspacePath || '?'}:\${caller.lineNumber || '?'}\`);
1486
+ }
1487
+ console.log();
1488
+ }
1489
+ }
1490
+
1491
+ console.log('=== Summary ===\\n');
1492
+ const totalAppSelfTime = hotFunctions.reduce((sum, f) => sum + (f.selfTime || 0), 0);
1493
+ console.log(\`Total application selfTime: \${totalAppSelfTime.toFixed(2)}ms\`);
1494
+ console.log(\`Total hot functions: \${hotFunctions.length}\`);
1495
+ console.log(\`Functions above threshold: \${aboveThreshold.length}\`);
1496
+ if (summary) {
1497
+ if (summary.totalGcTime != null) console.log(\`GC time: \${summary.totalGcTime.toFixed(2)}ms\`);
1498
+ if (summary.gcPercentage != null) console.log(\`GC percentage: \${summary.gcPercentage.toFixed(2)}%\`);
1499
+ if (summary.totalDuration != null) console.log(\`Total profile duration: \${summary.totalDuration.toFixed(2)}ms\`);
1500
+ }
1501
+ `;
1502
+ var ANALYZE_LISTENERS_JS = `'use strict';
1503
+
1504
+ const fs = require('fs');
1505
+
1506
+ let data;
1507
+ try {
1508
+ data = JSON.parse(fs.readFileSync('listener-tracking.json', 'utf8'));
1509
+ } catch (err) {
1510
+ console.log('No listener tracking data available (listener-tracking.json not found or unreadable).');
1511
+ process.exit(0);
1512
+ }
1513
+
1514
+ var emitterCounts = data.emitterCounts || {};
1515
+ var eventTargetCounts = data.eventTargetCounts || {};
1516
+ var exceedances = data.exceedances || [];
1517
+
1518
+ var totalAdds = 0, totalRemoves = 0;
1519
+ var entries = Object.entries(emitterCounts);
1520
+ for (var i = 0; i < entries.length; i++) {
1521
+ totalAdds += entries[i][1].addCount || 0;
1522
+ totalRemoves += entries[i][1].removeCount || 0;
1523
+ }
1524
+
1525
+ console.log('=== Listener Tracking Summary ===\\n');
1526
+ console.log('Total adds: ' + totalAdds);
1527
+ console.log('Total removes: ' + totalRemoves);
1528
+ console.log('Net active (adds - removes): ' + (totalAdds - totalRemoves) + '\\n');
1529
+
1530
+ console.log('=== Per-Event Breakdown ===\\n');
1531
+ var sorted = entries.slice().sort(function (a, b) {
1532
+ return (b[1].addCount || 0) - (a[1].addCount || 0);
1533
+ });
1534
+ for (var i = 0; i < sorted.length; i++) {
1535
+ var name = sorted[i][0];
1536
+ var info = sorted[i][1];
1537
+ var net = (info.addCount || 0) - (info.removeCount || 0);
1538
+ console.log(' ' + name + ' adds=' + (info.addCount || 0) + ' removes=' + (info.removeCount || 0) + ' net=' + net);
1539
+ }
1540
+ console.log();
1541
+
1542
+ var imbalances = entries.filter(function (e) {
1543
+ return (e[1].addCount || 0) > 0 && (e[1].removeCount || 0) === 0;
1544
+ });
1545
+ console.log('=== Add/Remove Imbalances (adds > 0 with zero removes) ===\\n');
1546
+ if (imbalances.length === 0) {
1547
+ console.log('No significant add/remove imbalances detected.\\n');
1548
+ } else {
1549
+ for (var i = 0; i < imbalances.length; i++) {
1550
+ var name = imbalances[i][0];
1551
+ var info = imbalances[i][1];
1552
+ console.log(' ' + name + ' adds=' + (info.addCount || 0) + ' removes=0 *** LEAK CANDIDATE ***');
1553
+ }
1554
+ console.log();
1555
+ }
1556
+
1557
+ console.log('=== MaxListeners Exceedances ===\\n');
1558
+ if (exceedances.length === 0) {
1559
+ console.log('No maxListeners exceedances.\\n');
1560
+ } else {
1561
+ for (var i = 0; i < exceedances.length; i++) {
1562
+ var e = exceedances[i];
1563
+ console.log(e.eventType + ' on ' + (e.targetType || '(unknown)') + ' listenerCount=' + e.listenerCount + ' threshold=' + e.threshold);
1564
+ if (e.stack) {
1565
+ console.log(' Stack trace:');
1566
+ var lines = e.stack.split('\\n').slice(0, 5);
1567
+ for (var j = 0; j < lines.length; j++) {
1568
+ console.log(' ' + lines[j].trim());
1569
+ }
1570
+ }
1571
+ console.log();
1572
+ }
1573
+ }
1574
+
1575
+ var filePattern = /(?:src\\/[^\\s:)]+|[a-zA-Z0-9_\\-./]+\\.[jt]sx?)/g;
1576
+ var suggestedFiles = {};
1577
+
1578
+ for (var i = 0; i < exceedances.length; i++) {
1579
+ if (exceedances[i].stack) {
1580
+ var matches = exceedances[i].stack.match(filePattern);
1581
+ if (matches) {
1582
+ for (var j = 0; j < matches.length; j++) suggestedFiles[matches[j]] = true;
1583
+ }
1584
+ }
1585
+ }
1586
+
1587
+ var fileList = Object.keys(suggestedFiles);
1588
+ console.log('=== Suggested Files to Investigate ===\\n');
1589
+ if (fileList.length === 0) {
1590
+ console.log('No file paths extracted from stack traces.');
1591
+ } else {
1592
+ for (var i = 0; i < fileList.length; i++) {
1593
+ console.log(' ' + fileList[i]);
1594
+ }
1595
+ }
1596
+ `;
1597
+ var ANALYZE_WORKSPACE_JS = `'use strict';
1598
+
1599
+ var fs = require('fs');
1600
+
1601
+ function tryRead(path) {
1602
+ try { return JSON.parse(fs.readFileSync(path, 'utf8')); }
1603
+ catch (_) { return null; }
1604
+ }
1605
+
1606
+ var summary = tryRead('summary.json');
1607
+ var srcIndex = tryRead('src/index.json');
1608
+ var hotApp = tryRead('hot-functions/application.json');
1609
+ var listeners = tryRead('listener-tracking.json');
1610
+ var slowTests = tryRead('timing/slow-tests.json');
1611
+
1612
+ console.log('=== Workspace Overview ===\\n');
1613
+
1614
+ if (summary) {
1615
+ console.log('Suite: ' + summary.totalTests + ' tests, ' + summary.totalDuration + 'ms total');
1616
+ console.log('Pass: ' + summary.passCount + ' Fail: ' + summary.failCount);
1617
+ if (summary.totalGcTime != null) console.log('GC: ' + summary.totalGcTime.toFixed(1) + 'ms (' + (summary.gcPercentage || 0).toFixed(2) + '%)');
1618
+ console.log();
1619
+ }
1620
+
1621
+ console.log('=== Source Files with Hot Functions (src/index.json) ===\\n');
1622
+ if (srcIndex && typeof srcIndex === 'object') {
1623
+ var files = Object.keys(srcIndex);
1624
+ if (files.length === 0) {
1625
+ console.log('No application source files have hot functions.\\n');
1626
+ } else {
1627
+ for (var i = 0; i < files.length; i++) {
1628
+ var fns = srcIndex[files[i]];
1629
+ if (Array.isArray(fns) && fns.length > 0) {
1630
+ var names = fns.map(function (f) { return f.functionName + ' (' + (f.selfTime || 0).toFixed(1) + 'ms, ' + (f.selfPercent || 0).toFixed(1) + '%)'; });
1631
+ console.log(' ' + files[i]);
1632
+ for (var j = 0; j < names.length; j++) console.log(' -> ' + names[j]);
1633
+ } else {
1634
+ console.log(' ' + files[i] + ' (no hot functions)');
1635
+ }
1636
+ }
1637
+ console.log();
1638
+ }
1639
+ } else {
1640
+ console.log('src/index.json not available.\\n');
1641
+ }
1642
+
1643
+ console.log('=== Application Hot Functions ===\\n');
1644
+ if (Array.isArray(hotApp) && hotApp.length > 0) {
1645
+ hotApp.sort(function (a, b) { return (b.selfTime || 0) - (a.selfTime || 0); });
1646
+ for (var i = 0; i < hotApp.length; i++) {
1647
+ var f = hotApp[i];
1648
+ console.log(' ' + (f.functionName || '(anon)') + ' ' + (f.workspacePath || '?') + ':' + (f.lineNumber || '?'));
1649
+ console.log(' selfTime=' + (f.selfTime || 0).toFixed(2) + 'ms selfPercent=' + (f.selfPercent || 0).toFixed(2) + '%');
1650
+ if (f.sourceSnippet) {
1651
+ var snipLines = f.sourceSnippet.split('\\n').slice(0, 4);
1652
+ for (var j = 0; j < snipLines.length; j++) console.log(' | ' + snipLines[j]);
1653
+ }
1654
+ }
1655
+ console.log();
1656
+ } else {
1657
+ console.log('No application hot functions found.\\n');
1658
+ }
1659
+
1660
+ console.log('=== Listener Exceedances ===\\n');
1661
+ if (listeners && Array.isArray(listeners.exceedances) && listeners.exceedances.length > 0) {
1662
+ for (var i = 0; i < listeners.exceedances.length; i++) {
1663
+ var e = listeners.exceedances[i];
1664
+ console.log(' ' + e.eventType + ' on ' + (e.targetType || '?') + ' listenerCount=' + e.listenerCount + ' threshold=' + e.threshold);
1665
+ if (e.stack) {
1666
+ var lines = e.stack.split('\\n').slice(0, 3);
1667
+ for (var j = 0; j < lines.length; j++) console.log(' ' + lines[j].trim());
1668
+ }
1669
+ }
1670
+ console.log();
1671
+ } else {
1672
+ console.log('No maxListeners exceedances.\\n');
1673
+ }
1674
+
1675
+ if (listeners && listeners.emitterCounts) {
1676
+ var leaky = Object.entries(listeners.emitterCounts).filter(function (e) {
1677
+ return (e[1].addCount || 0) > 0 && (e[1].removeCount || 0) === 0;
1678
+ });
1679
+ if (leaky.length > 0) {
1680
+ console.log('=== Listener Imbalances (adds without removes) ===\\n');
1681
+ for (var i = 0; i < leaky.length; i++) {
1682
+ console.log(' ' + leaky[i][0] + ' adds=' + leaky[i][1].addCount + ' removes=0');
1683
+ }
1684
+ console.log();
1685
+ }
1686
+ }
1687
+
1688
+ console.log('=== Slow Tests ===\\n');
1689
+ if (Array.isArray(slowTests) && slowTests.length > 0) {
1690
+ for (var i = 0; i < Math.min(5, slowTests.length); i++) {
1691
+ var t = slowTests[i];
1692
+ console.log(' ' + (t.name || t.file || '?') + ' ' + (t.duration || 0).toFixed(1) + 'ms');
1693
+ }
1694
+ console.log();
1695
+ } else {
1696
+ console.log('No slow tests data.\\n');
1697
+ }
1698
+
1699
+ console.log('=== All Source Files ===\\n');
1700
+ try {
1701
+ var srcFiles = fs.readdirSync('src', { recursive: true });
1702
+ for (var i = 0; i < srcFiles.length; i++) {
1703
+ var full = 'src/' + srcFiles[i];
1704
+ try {
1705
+ if (fs.statSync(full).isFile() && /\\.[jt]sx?$/.test(full)) console.log(' ' + full);
1706
+ } catch (_) {}
1707
+ }
1708
+ } catch (_) {
1709
+ console.log(' (could not list src/)');
1710
+ }
1711
+ try {
1712
+ var testFiles = fs.readdirSync('tests', { recursive: true });
1713
+ for (var i = 0; i < testFiles.length; i++) {
1714
+ var full = 'tests/' + testFiles[i];
1715
+ try {
1716
+ if (fs.statSync(full).isFile() && /\\.[jt]sx?$/.test(full)) console.log(' ' + full);
1717
+ } catch (_) {}
1718
+ }
1719
+ } catch (_) {}
1720
+ console.log();
1721
+ `;
1722
+ var FIND_LEAKS_JS = `'use strict';
1723
+
1724
+ const fs = require('fs');
1725
+
1726
+ function normalizePath(p) {
1727
+ return p.startsWith('/') ? p.slice(1) : p;
1728
+ }
1729
+
1730
+ let hotFunctions;
1731
+ try {
1732
+ hotFunctions = JSON.parse(fs.readFileSync('hot-functions/application.json', 'utf8'));
1733
+ } catch (err) {
1734
+ console.error('Could not read hot-functions/application.json:', err.message);
1735
+ process.exit(1);
1736
+ }
1737
+
1738
+ const filePaths = [...new Set(hotFunctions.map(f => f.workspacePath).filter(Boolean))];
1739
+
1740
+ if (filePaths.length === 0) {
1741
+ console.log('No source file paths found in hot-functions data.');
1742
+ process.exit(0);
1743
+ }
1744
+
1745
+ const findings = [];
1746
+
1747
+ for (const rawPath of filePaths) {
1748
+ const filePath = normalizePath(rawPath);
1749
+ let content;
1750
+ try {
1751
+ content = fs.readFileSync(filePath, 'utf8');
1752
+ } catch (_) {
1753
+ continue;
1754
+ }
1755
+
1756
+ const lines = content.split('\\n');
1757
+ const hasDelete = content.includes('.delete(') || content.includes('.clear(') || content.includes('.splice(');
1758
+ const hasRemoveListener = content.includes('.off(') || content.includes('.removeEventListener(') || content.includes('.removeListener(');
1759
+
1760
+ for (let i = 0; i < lines.length; i++) {
1761
+ const line = lines[i];
1762
+ const lineNum = i + 1;
1763
+
1764
+ if (/\\.(set|push|add)\\(/.test(line) && !hasDelete) {
1765
+ findings.push({ filePath, lineNum, pattern: 'unbounded-collection', line: line.trim() });
1766
+ }
1767
+
1768
+ if ((/\\.on\\(/.test(line) || /\\.addEventListener\\(/.test(line)) && !hasRemoveListener) {
1769
+ findings.push({ filePath, lineNum, pattern: 'listener-leak', line: line.trim() });
1770
+ }
1771
+
1772
+ if (/\\.(set|push)\\(.*(?:=>|function\\s*\\()/.test(line)) {
1773
+ findings.push({ filePath, lineNum, pattern: 'closure-capture', line: line.trim() });
1774
+ }
1775
+
1776
+ if (/\\b(?:cache|Cache|CACHE|memo|Memo|store|Store)\\b/.test(line)
1777
+ && /\\.(set|push|add)\\(/.test(line)
1778
+ && !/(?:ttl|TTL|maxSize|maxAge|expire|limit)/i.test(content)) {
1779
+ findings.push({ filePath, lineNum, pattern: 'unbounded-cache', line: line.trim() });
1780
+ }
1781
+ }
1782
+ }
1783
+
1784
+ if (findings.length === 0) {
1785
+ console.log('No potential leak patterns found in source files.');
1786
+ process.exit(0);
1787
+ }
1788
+
1789
+ console.log(\`Found \${findings.length} potential leak pattern(s):\\n\`);
1790
+
1791
+ const grouped = new Map();
1792
+ for (const f of findings) {
1793
+ if (!grouped.has(f.pattern)) grouped.set(f.pattern, []);
1794
+ grouped.get(f.pattern).push(f);
1795
+ }
1796
+
1797
+ for (const [pattern, items] of grouped) {
1798
+ console.log(\`--- [\${pattern}] ---\\n\`);
1799
+ for (const { filePath, lineNum, line } of items) {
1800
+ console.log(\`\${filePath}:\${lineNum}: [\${pattern}] \${line}\`);
1801
+ }
1802
+ console.log();
1803
+ }
1804
+ `;
1805
+ var PROFILE_ANALYSIS_SKILL_FILES = {
1806
+ "skills/profile-analysis/SKILL.md": SKILL_MD2,
1807
+ "skills/profile-analysis/helpers/analyze-workspace.js": ANALYZE_WORKSPACE_JS,
1808
+ "skills/profile-analysis/helpers/analyze-hotfunctions.js": ANALYZE_HOTFUNCTIONS_JS,
1809
+ "skills/profile-analysis/helpers/analyze-listeners.js": ANALYZE_LISTENERS_JS,
1810
+ "skills/profile-analysis/helpers/find-leaks.js": FIND_LEAKS_JS
1811
+ };
1812
+ // ../utils/src/profiling/profile-parser.ts
1813
+ var MAX_HOT_FUNCTIONS = 50;
1814
+ var MAX_CALL_TREES = 10;
1815
+ var CALL_TREE_PRUNE_THRESHOLD = 0.005;
1816
+ var MAX_CALLER_CHAIN_DEPTH = 10;
1817
+ function parseCpuProfile(profile, profilePath) {
1818
+ if (!profile.samples || profile.samples.length === 0) {
1819
+ return emptySummary(profilePath);
1820
+ }
1821
+ const totalDurationUs = profile.endTime - profile.startTime;
1822
+ const totalDurationMs = totalDurationUs / 1000;
1823
+ const statsMap = buildNodeStats(profile);
1824
+ computeSelfTime(profile, statsMap);
1825
+ computeTotalTime(profile, statsMap);
1826
+ const hotFunctions = extractHotFunctions(statsMap, totalDurationUs);
1827
+ const expensiveCallTrees = extractCallTrees(profile, statsMap, totalDurationUs);
1828
+ const { gcSamples, gcTimeUs, idleTimeUs } = computeSpecialCategories(profile, statsMap);
1829
+ const scriptBreakdown = buildScriptBreakdown(statsMap, totalDurationUs);
1830
+ return {
1831
+ profilePath,
1832
+ duration: round(totalDurationMs),
1833
+ sampleCount: profile.samples.length,
1834
+ hotFunctions,
1835
+ expensiveCallTrees,
1836
+ gcSamples,
1837
+ gcPercentage: totalDurationUs > 0 ? round(gcTimeUs / totalDurationUs * 100) : 0,
1838
+ idlePercentage: totalDurationUs > 0 ? round(idleTimeUs / totalDurationUs * 100) : 0,
1839
+ scriptBreakdown
1840
+ };
1841
+ }
1842
+ function buildNodeStats(profile) {
1843
+ const statsMap = new Map;
1844
+ for (const node of profile.nodes) {
1845
+ statsMap.set(node.id, {
1846
+ node,
1847
+ selfTime: 0,
1848
+ totalTime: 0,
1849
+ parentIds: new Set
1850
+ });
1851
+ }
1852
+ for (const node of profile.nodes) {
1853
+ if (node.children) {
1854
+ for (const childId of node.children) {
1855
+ const childStats = statsMap.get(childId);
1856
+ if (childStats) {
1857
+ childStats.parentIds.add(node.id);
1858
+ }
1859
+ }
1860
+ }
1861
+ }
1862
+ return statsMap;
1863
+ }
1864
+ function computeSelfTime(profile, statsMap) {
1865
+ const { samples, timeDeltas } = profile;
1866
+ for (let i = 0;i < samples.length; i++) {
1867
+ const nodeId = samples[i];
1868
+ const delta = timeDeltas[i] ?? 0;
1869
+ const stats = statsMap.get(nodeId);
1870
+ if (stats) {
1871
+ stats.selfTime += Math.max(0, delta);
1872
+ }
1873
+ }
1874
+ }
1875
+ function computeTotalTime(profile, statsMap) {
1876
+ const childCount = new Map;
1877
+ const queue = [];
1878
+ for (const node of profile.nodes) {
1879
+ const numChildren = node.children?.length ?? 0;
1880
+ childCount.set(node.id, numChildren);
1881
+ if (numChildren === 0) {
1882
+ queue.push(node.id);
1883
+ }
1884
+ }
1885
+ while (queue.length > 0) {
1886
+ const nodeId = queue.shift();
1887
+ const stats = statsMap.get(nodeId);
1888
+ if (!stats)
1889
+ continue;
1890
+ stats.totalTime = stats.selfTime;
1891
+ const children = stats.node.children ?? [];
1892
+ for (const childId of children) {
1893
+ const childStats = statsMap.get(childId);
1894
+ if (childStats) {
1895
+ stats.totalTime += childStats.totalTime;
1896
+ }
1897
+ }
1898
+ for (const parentId of stats.parentIds) {
1899
+ const remaining = (childCount.get(parentId) ?? 1) - 1;
1900
+ childCount.set(parentId, remaining);
1901
+ if (remaining <= 0) {
1902
+ queue.push(parentId);
1903
+ }
1904
+ }
1905
+ }
1906
+ }
1907
+ var META_FUNCTIONS = new Set(["(root)", "(idle)", "(program)"]);
1908
+ function buildCallerChain(nodeId, statsMap) {
1909
+ const chain = [];
1910
+ const visited = new Set;
1911
+ let currentId = nodeId;
1912
+ for (let depth = 0;depth < MAX_CALLER_CHAIN_DEPTH; depth++) {
1913
+ const stats = statsMap.get(currentId);
1914
+ if (!stats || stats.parentIds.size === 0)
1915
+ break;
1916
+ let bestParentId = null;
1917
+ let bestTotalTime = -1;
1918
+ for (const pid of stats.parentIds) {
1919
+ if (visited.has(pid))
1920
+ continue;
1921
+ const parentStats2 = statsMap.get(pid);
1922
+ if (parentStats2 && parentStats2.totalTime > bestTotalTime) {
1923
+ bestTotalTime = parentStats2.totalTime;
1924
+ bestParentId = pid;
1925
+ }
1926
+ }
1927
+ if (bestParentId === null)
1928
+ break;
1929
+ visited.add(bestParentId);
1930
+ const parentStats = statsMap.get(bestParentId);
1931
+ const parentFn = parentStats.node.callFrame.functionName;
1932
+ if (META_FUNCTIONS.has(parentFn))
1933
+ break;
1934
+ if (parentStats.node.callFrame.url) {
1935
+ chain.push({
1936
+ functionName: parentFn || "(anonymous)",
1937
+ scriptUrl: parentStats.node.callFrame.url,
1938
+ lineNumber: parentStats.node.callFrame.lineNumber
1939
+ });
1940
+ }
1941
+ currentId = bestParentId;
1942
+ }
1943
+ return chain;
1944
+ }
1945
+ function extractHotFunctions(statsMap, totalDurationUs) {
1946
+ const results = [];
1947
+ for (const [nodeId, stats] of statsMap.entries()) {
1948
+ if (stats.selfTime <= 0)
1949
+ continue;
1950
+ const fn = stats.node.callFrame.functionName;
1951
+ if (META_FUNCTIONS.has(fn))
1952
+ continue;
1953
+ const callerChain = buildCallerChain(nodeId, statsMap);
1954
+ results.push({
1955
+ functionName: stats.node.callFrame.functionName || "(anonymous)",
1956
+ scriptUrl: stats.node.callFrame.url,
1957
+ lineNumber: stats.node.callFrame.lineNumber,
1958
+ columnNumber: stats.node.callFrame.columnNumber,
1959
+ selfTime: round(stats.selfTime / 1000),
1960
+ totalTime: round(stats.totalTime / 1000),
1961
+ hitCount: stats.node.hitCount,
1962
+ selfPercent: totalDurationUs > 0 ? round(stats.selfTime / totalDurationUs * 100) : 0,
1963
+ ...callerChain.length > 0 ? { callerChain } : {}
1964
+ });
1965
+ }
1966
+ results.sort((a, b) => b.selfTime - a.selfTime);
1967
+ return results.slice(0, MAX_HOT_FUNCTIONS);
1968
+ }
1969
+ function extractCallTrees(_profile, statsMap, totalDurationUs) {
1970
+ const rootIds = [];
1971
+ for (const stats of statsMap.values()) {
1972
+ if (stats.parentIds.size === 0) {
1973
+ rootIds.push(stats.node.id);
1974
+ }
1975
+ }
1976
+ const trees = [];
1977
+ for (const rootId of rootIds) {
1978
+ const tree = buildCallTreeNode(rootId, statsMap, totalDurationUs);
1979
+ if (tree && tree.totalTime > 0) {
1980
+ trees.push(tree);
1981
+ }
1982
+ }
1983
+ trees.sort((a, b) => b.totalTime - a.totalTime);
1984
+ return trees.slice(0, MAX_CALL_TREES);
1985
+ }
1986
+ function buildCallTreeNode(nodeId, statsMap, totalDurationUs) {
1987
+ const stats = statsMap.get(nodeId);
1988
+ if (!stats)
1989
+ return null;
1990
+ const totalPercent = totalDurationUs > 0 ? stats.totalTime / totalDurationUs : 0;
1991
+ if (totalPercent < CALL_TREE_PRUNE_THRESHOLD)
1992
+ return null;
1993
+ const children = [];
1994
+ for (const childId of stats.node.children ?? []) {
1995
+ const childTree = buildCallTreeNode(childId, statsMap, totalDurationUs);
1996
+ if (childTree) {
1997
+ children.push(childTree);
1998
+ }
1999
+ }
2000
+ children.sort((a, b) => b.totalTime - a.totalTime);
2001
+ return {
2002
+ functionName: stats.node.callFrame.functionName || "(anonymous)",
2003
+ scriptUrl: stats.node.callFrame.url,
2004
+ lineNumber: stats.node.callFrame.lineNumber,
2005
+ totalTime: round(stats.totalTime / 1000),
2006
+ totalPercent: round(totalPercent * 100),
2007
+ children
2008
+ };
2009
+ }
2010
+ function computeSpecialCategories(_profile, statsMap) {
2011
+ let gcSamples = 0;
2012
+ let gcTimeUs = 0;
2013
+ let idleTimeUs = 0;
2014
+ for (const stats of statsMap.values()) {
2015
+ const fn = stats.node.callFrame.functionName;
2016
+ if (fn.includes("(garbage collector)") || fn === "(GC)") {
2017
+ gcSamples += stats.node.hitCount;
2018
+ gcTimeUs += stats.selfTime;
2019
+ }
2020
+ if (fn === "(idle)") {
2021
+ idleTimeUs += stats.selfTime;
2022
+ }
2023
+ }
2024
+ return { gcSamples, gcTimeUs, idleTimeUs };
2025
+ }
2026
+ function buildScriptBreakdown(statsMap, totalDurationUs) {
2027
+ const scriptMap = new Map;
2028
+ for (const stats of statsMap.values()) {
2029
+ const url = stats.node.callFrame.url;
2030
+ if (!url)
2031
+ continue;
2032
+ let entry = scriptMap.get(url);
2033
+ if (!entry) {
2034
+ entry = { selfTime: 0, functions: new Set };
2035
+ scriptMap.set(url, entry);
2036
+ }
2037
+ entry.selfTime += stats.selfTime;
2038
+ if (stats.selfTime > 0) {
2039
+ entry.functions.add(`${stats.node.callFrame.functionName}:${stats.node.callFrame.lineNumber}`);
2040
+ }
2041
+ }
2042
+ const results = [];
2043
+ for (const [scriptUrl, data] of scriptMap) {
2044
+ results.push({
2045
+ scriptUrl,
2046
+ selfTime: round(data.selfTime / 1000),
2047
+ selfPercent: totalDurationUs > 0 ? round(data.selfTime / totalDurationUs * 100) : 0,
2048
+ functionCount: data.functions.size
2049
+ });
2050
+ }
2051
+ results.sort((a, b) => b.selfTime - a.selfTime);
2052
+ return results;
2053
+ }
2054
+ function round(n) {
2055
+ return Math.round(n * 100) / 100;
2056
+ }
2057
+ function emptySummary(profilePath) {
2058
+ return {
2059
+ profilePath,
2060
+ duration: 0,
2061
+ sampleCount: 0,
2062
+ hotFunctions: [],
2063
+ expensiveCallTrees: [],
2064
+ gcSamples: 0,
2065
+ gcPercentage: 0,
2066
+ idlePercentage: 0,
2067
+ scriptBreakdown: []
2068
+ };
2069
+ }
2070
+ // ../utils/src/profiling/classify.ts
2071
+ import { resolve, relative } from "node:path";
2072
+ var TEST_FILE_PATTERNS = [/\.test\./, /\.spec\./, /\.bench\./, /__tests__\//, /__mocks__\//];
2073
+ var FRAMEWORK_PATTERNS = [
2074
+ /\/vitest\//,
2075
+ /\/tinybench\//,
2076
+ /\/vite\//,
2077
+ /\/@vitest\//,
2078
+ /node:internal\//,
2079
+ /node:v8/,
2080
+ /node:worker_threads/,
2081
+ /node:test/,
2082
+ /bun:test/,
2083
+ /bun:jsc/,
2084
+ /bun:internal/,
2085
+ /\.XdZDrNZV\./,
2086
+ /\.CJqBMi0u\./
2087
+ ];
2088
+ function classifyScript(scriptUrl, projectRoot, testFiles) {
2089
+ if (!scriptUrl)
2090
+ return "unknown";
2091
+ let filePath = scriptUrl;
2092
+ if (filePath.startsWith("file://")) {
2093
+ try {
2094
+ filePath = new URL(filePath).pathname;
2095
+ } catch {}
2096
+ }
2097
+ if (filePath.startsWith("node:") || filePath.startsWith("v8:") || filePath.startsWith("bun:")) {
2098
+ return "framework";
2099
+ }
2100
+ if (filePath.includes("/node_modules/") || filePath.includes("\\node_modules\\")) {
2101
+ return "dependency";
2102
+ }
2103
+ const resolvedProject = resolve(projectRoot);
2104
+ const resolvedFile = resolve(filePath);
2105
+ const rel = relative(resolvedProject, resolvedFile);
2106
+ if (!rel.startsWith("..") && !rel.startsWith("/")) {
2107
+ if (testFiles) {
2108
+ if (testFiles.has(resolvedFile)) {
2109
+ return "test";
2110
+ }
2111
+ }
2112
+ for (const pattern of TEST_FILE_PATTERNS) {
2113
+ if (pattern.test(filePath)) {
2114
+ return "test";
2115
+ }
2116
+ }
2117
+ return "application";
2118
+ }
2119
+ for (const pattern of FRAMEWORK_PATTERNS) {
2120
+ if (pattern.test(filePath)) {
2121
+ return "framework";
2122
+ }
2123
+ }
2124
+ return "unknown";
2125
+ }
2126
+ // ../utils/src/profiling/merge-hot-functions.ts
2127
+ function mergeHotFunctions(profiles) {
2128
+ const merged = new Map;
2129
+ let totalDuration = 0;
2130
+ for (const profile of profiles) {
2131
+ totalDuration += profile.summary.duration;
2132
+ for (const fn of profile.summary.hotFunctions) {
2133
+ const key = `${fn.scriptUrl}:${fn.functionName}:${fn.lineNumber}`;
2134
+ const existing = merged.get(key);
2135
+ if (existing) {
2136
+ existing.selfTime += fn.selfTime;
2137
+ existing.totalTime += fn.totalTime;
2138
+ existing.hitCount += fn.hitCount;
2139
+ } else {
2140
+ merged.set(key, { ...fn });
2141
+ }
2142
+ }
2143
+ }
2144
+ if (totalDuration > 0) {
2145
+ for (const fn of merged.values()) {
2146
+ fn.selfPercent = round2(fn.selfTime / totalDuration * 100);
2147
+ }
2148
+ }
2149
+ const results = Array.from(merged.values());
2150
+ results.sort((a, b) => b.selfTime - a.selfTime);
2151
+ return results.slice(0, 50);
2152
+ }
2153
+ function round2(n) {
2154
+ return Math.round(n * 100) / 100;
2155
+ }
2156
+ // ../utils/src/profiling/metrics.ts
2157
+ function computeMetrics(testTiming, profiles, heapProfiles, projectRoot, listenerTracking) {
2158
+ const allTestDurations = testTiming.flatMap((t) => t.tests.map((tc) => tc.duration)).sort((a, b) => a - b);
2159
+ const totalDuration = testTiming.reduce((s, t) => s + t.duration, 0);
2160
+ const totalTests = testTiming.reduce((s, t) => s + t.testCount, 0);
2161
+ const passCount = testTiming.reduce((s, t) => s + t.passCount, 0);
2162
+ const failCount = testTiming.reduce((s, t) => s + t.failCount, 0);
2163
+ const totalSetupTime = testTiming.reduce((s, t) => s + t.setupTime, 0);
2164
+ const averageTestDuration = totalTests > 0 ? totalDuration / totalTests : 0;
2165
+ const medianTestDuration = percentile(allTestDurations, 50);
2166
+ const p95TestDuration = percentile(allTestDurations, 95);
2167
+ const slowestTest = allTestDurations.length > 0 ? testTiming.flatMap((t) => t.tests.map((tc) => ({ ...tc, file: t.file }))).reduce((a, b) => a.duration > b.duration ? a : b) : null;
2168
+ const slowestFile = testTiming.length > 0 ? testTiming.reduce((a, b) => a.duration > b.duration ? a : b) : null;
2169
+ const suite = {
2170
+ totalDuration: round3(totalDuration),
2171
+ totalTests,
2172
+ passCount,
2173
+ failCount,
2174
+ totalSetupTime: round3(totalSetupTime),
2175
+ averageTestDuration: round3(averageTestDuration),
2176
+ medianTestDuration: round3(medianTestDuration),
2177
+ p95TestDuration: round3(p95TestDuration),
2178
+ slowestTestDuration: round3(slowestTest?.duration ?? 0),
2179
+ slowestTestName: slowestTest?.name ?? "",
2180
+ slowestFileDuration: round3(slowestFile?.duration ?? 0),
2181
+ slowestFile: relativize(slowestFile?.file ?? "", projectRoot)
2182
+ };
2183
+ const cpu = computeCpuMetrics(profiles);
2184
+ const files = {};
2185
+ for (const timing of testTiming) {
2186
+ const relPath = relativize(timing.file, projectRoot);
2187
+ const profile = profiles.find((p) => p.testFile === timing.file);
2188
+ files[relPath] = {
2189
+ duration: round3(timing.duration),
2190
+ testCount: timing.testCount,
2191
+ setupTime: round3(timing.setupTime),
2192
+ gcPercentage: round3(profile?.summary.gcPercentage ?? 0)
2193
+ };
2194
+ }
2195
+ const tests = {};
2196
+ for (const timing of testTiming) {
2197
+ const relPath = relativize(timing.file, projectRoot);
2198
+ for (const test of timing.tests) {
2199
+ const key = `${relPath}::${test.name}`;
2200
+ tests[key] = {
2201
+ duration: round3(test.duration),
2202
+ status: test.status
2203
+ };
2204
+ }
2205
+ }
2206
+ const merged = mergeHotFunctions(profiles);
2207
+ const hotFunctions = merged.slice(0, 20).map((fn) => ({
2208
+ key: `${fn.scriptUrl}:${fn.functionName}:${fn.lineNumber}`,
2209
+ functionName: fn.functionName,
2210
+ scriptUrl: relativize(fn.scriptUrl, projectRoot),
2211
+ lineNumber: fn.lineNumber,
2212
+ selfTime: round3(fn.selfTime),
2213
+ selfPercent: round3(fn.selfPercent),
2214
+ sourceCategory: fn.sourceCategory ?? "unknown"
2215
+ }));
2216
+ const heap = heapProfiles && heapProfiles.length > 0 ? {
2217
+ totalAllocatedBytes: heapProfiles.reduce((s, hp) => s + hp.summary.totalAllocatedBytes, 0)
2218
+ } : undefined;
2219
+ return {
2220
+ version: 1,
2221
+ timestamp: new Date().toISOString(),
2222
+ suite,
2223
+ cpu,
2224
+ files,
2225
+ tests,
2226
+ hotFunctions,
2227
+ heap,
2228
+ listenerTracking
2229
+ };
2230
+ }
2231
+ function computeCpuMetrics(profiles) {
2232
+ if (profiles.length === 0) {
2233
+ return {
2234
+ gcPercentage: 0,
2235
+ gcTime: 0,
2236
+ idlePercentage: 0,
2237
+ idleTime: 0,
2238
+ applicationTime: 0,
2239
+ applicationPercent: 0,
2240
+ dependencyTime: 0,
2241
+ dependencyPercent: 0,
2242
+ testFrameworkTime: 0,
2243
+ testFrameworkPercent: 0
2244
+ };
2245
+ }
2246
+ const totalProfileDuration = profiles.reduce((s, p) => s + p.summary.duration, 0);
2247
+ const totalGcTime = profiles.reduce((s, p) => s + p.summary.duration * p.summary.gcPercentage / 100, 0);
2248
+ const gcPercentage = totalProfileDuration > 0 ? totalGcTime / totalProfileDuration * 100 : 0;
2249
+ const totalIdleTime = profiles.reduce((s, p) => s + p.summary.duration * p.summary.idlePercentage / 100, 0);
2250
+ const idlePercentage = totalProfileDuration > 0 ? totalIdleTime / totalProfileDuration * 100 : 0;
2251
+ let applicationTime = 0;
2252
+ let dependencyTime = 0;
2253
+ let testFrameworkTime = 0;
2254
+ for (const profile of profiles) {
2255
+ for (const script of profile.summary.scriptBreakdown) {
2256
+ switch (script.sourceCategory) {
2257
+ case "application":
2258
+ applicationTime += script.selfTime;
2259
+ break;
2260
+ case "dependency":
2261
+ dependencyTime += script.selfTime;
2262
+ break;
2263
+ case "test":
2264
+ case "framework":
2265
+ testFrameworkTime += script.selfTime;
2266
+ break;
2267
+ }
2268
+ }
2269
+ }
2270
+ const applicationPercent = totalProfileDuration > 0 ? applicationTime / totalProfileDuration * 100 : 0;
2271
+ const dependencyPercent = totalProfileDuration > 0 ? dependencyTime / totalProfileDuration * 100 : 0;
2272
+ const testFrameworkPercent = totalProfileDuration > 0 ? testFrameworkTime / totalProfileDuration * 100 : 0;
2273
+ return {
2274
+ gcPercentage: round3(gcPercentage),
2275
+ gcTime: round3(totalGcTime),
2276
+ idlePercentage: round3(idlePercentage),
2277
+ idleTime: round3(totalIdleTime),
2278
+ applicationTime: round3(applicationTime),
2279
+ applicationPercent: round3(applicationPercent),
2280
+ dependencyTime: round3(dependencyTime),
2281
+ dependencyPercent: round3(dependencyPercent),
2282
+ testFrameworkTime: round3(testFrameworkTime),
2283
+ testFrameworkPercent: round3(testFrameworkPercent)
2284
+ };
2285
+ }
2286
+ function percentile(sortedValues, p) {
2287
+ if (sortedValues.length === 0)
2288
+ return 0;
2289
+ const idx = Math.ceil(p / 100 * sortedValues.length) - 1;
2290
+ return sortedValues[Math.max(0, idx)];
2291
+ }
2292
+ function round3(n) {
2293
+ return Math.round(n * 100) / 100;
2294
+ }
2295
+ function relativize(filePath, projectRoot) {
2296
+ if (!projectRoot || !filePath)
2297
+ return filePath;
2298
+ if (filePath.startsWith(projectRoot)) {
2299
+ const rel = filePath.slice(projectRoot.length);
2300
+ return rel.startsWith("/") ? rel.slice(1) : rel;
2301
+ }
2302
+ return filePath;
2303
+ }
2304
+ // ../utils/src/profiling/workspace.ts
2305
+ var SOURCE_SNIPPET_CONTEXT = 5;
2306
+ var SLOW_TEST_THRESHOLD = 100;
2307
+ async function createTestWorkspace(options) {
2308
+ const {
2309
+ testTiming,
2310
+ profiles,
2311
+ heapProfiles,
2312
+ testSources,
2313
+ sourcePaths,
2314
+ metrics,
2315
+ listenerTracking
2316
+ } = options;
2317
+ const files = {};
2318
+ const totalTests = testTiming.reduce((s, t) => s + t.testCount, 0);
2319
+ const totalDuration = testTiming.reduce((s, t) => s + t.duration, 0);
2320
+ const passCount = testTiming.reduce((s, t) => s + t.passCount, 0);
2321
+ const failCount = testTiming.reduce((s, t) => s + t.failCount, 0);
2322
+ const slowest = testTiming.length > 0 ? testTiming.reduce((a, b) => a.duration > b.duration ? a : b) : null;
2323
+ const totalGcTime = profiles.reduce((s, p) => s + p.summary.duration * p.summary.gcPercentage / 100, 0);
2324
+ const gcPercentage = totalDuration > 0 ? round4(totalGcTime / totalDuration * 100) : 0;
2325
+ files["/summary.json"] = JSON.stringify({
2326
+ totalTests,
2327
+ totalDuration: round4(totalDuration),
2328
+ passCount,
2329
+ failCount,
2330
+ profileCount: profiles.length,
2331
+ slowestFile: slowest?.file ?? null,
2332
+ slowestFileDuration: slowest ? round4(slowest.duration) : 0,
2333
+ totalGcTime: round4(totalGcTime),
2334
+ gcPercentage
2335
+ }, null, 2);
2336
+ files["/timing/overview.json"] = JSON.stringify(testTiming, null, 2);
2337
+ const slowTests = [];
2338
+ for (const fileTiming of testTiming) {
2339
+ for (const test of fileTiming.tests) {
2340
+ if (test.duration > SLOW_TEST_THRESHOLD) {
2341
+ slowTests.push({ file: fileTiming.file, name: test.name, duration: test.duration });
2342
+ }
2343
+ }
2344
+ }
2345
+ slowTests.sort((a, b) => b.duration - a.duration);
2346
+ files["/timing/slow-tests.json"] = JSON.stringify(slowTests, null, 2);
2347
+ files["/profiles/index.json"] = JSON.stringify(profiles.map((p) => ({ testFile: p.testFile, profilePath: p.profilePath })), null, 2);
2348
+ for (const profile of profiles) {
2349
+ const safeName = sanitizeFilename(profile.testFile);
2350
+ const sanitized = {
2351
+ ...profile.summary,
2352
+ hotFunctions: profile.summary.hotFunctions.map((fn) => {
2353
+ const { scriptUrl: _s, ...rest } = fn;
2354
+ let relPath = relativizePath(fn.scriptUrl, options.projectRoot);
2355
+ if (relPath.startsWith("src/"))
2356
+ relPath = relPath.slice(4);
2357
+ return { ...rest, workspacePath: `/src/${relPath}` };
2358
+ }),
2359
+ scriptBreakdown: profile.summary.scriptBreakdown.map((s) => {
2360
+ const { scriptUrl: _s, ...rest } = s;
2361
+ let relPath = relativizePath(s.scriptUrl, options.projectRoot);
2362
+ if (relPath.startsWith("src/"))
2363
+ relPath = relPath.slice(4);
2364
+ return { ...rest, workspacePath: `/src/${relPath}` };
2365
+ })
2366
+ };
2367
+ files[`/profiles/${safeName}.json`] = JSON.stringify(sanitized, null, 2);
2368
+ }
2369
+ if (heapProfiles && heapProfiles.length > 0) {
2370
+ files["/heap-profiles/index.json"] = JSON.stringify(heapProfiles.map((p) => ({ testFile: p.testFile, profilePath: p.profilePath })), null, 2);
2371
+ for (const hp of heapProfiles) {
2372
+ const safeName = sanitizeFilename(hp.testFile);
2373
+ files[`/heap-profiles/${safeName}.json`] = JSON.stringify(hp.summary, null, 2);
2374
+ }
2375
+ }
2376
+ const mergedHotFunctions = mergeHotFunctions(profiles);
2377
+ files["/hot-functions/global.json"] = JSON.stringify(mergedHotFunctions, null, 2);
2378
+ const appHotFunctions = mergedHotFunctions.filter((fn) => fn.sourceCategory === "application");
2379
+ files["/hot-functions/application.json"] = JSON.stringify(appHotFunctions, null, 2);
2380
+ const depHotFunctions = mergedHotFunctions.filter((fn) => fn.sourceCategory === "dependency");
2381
+ files["/hot-functions/dependencies.json"] = JSON.stringify(depHotFunctions, null, 2);
2382
+ const appScripts = profiles.flatMap((p) => p.summary.scriptBreakdown.filter((s) => s.sourceCategory === "application"));
2383
+ const appScriptMap = new Map;
2384
+ for (const s of appScripts) {
2385
+ const existing = appScriptMap.get(s.scriptUrl);
2386
+ if (existing) {
2387
+ existing.selfTime += s.selfTime;
2388
+ existing.functionCount = Math.max(existing.functionCount, s.functionCount);
2389
+ } else {
2390
+ appScriptMap.set(s.scriptUrl, { selfTime: s.selfTime, functionCount: s.functionCount });
2391
+ }
2392
+ }
2393
+ const totalDurationMs = testTiming.reduce((s, t) => s + t.duration, 0);
2394
+ const appScriptSummary = Array.from(appScriptMap.entries()).map(([scriptUrl, data]) => {
2395
+ let relPath = relativizePath(scriptUrl, options.projectRoot);
2396
+ if (relPath.startsWith("src/"))
2397
+ relPath = relPath.slice(4);
2398
+ return {
2399
+ workspacePath: `/src/${relPath}`,
2400
+ selfTime: round4(data.selfTime),
2401
+ selfPercent: totalDurationMs > 0 ? round4(data.selfTime / totalDurationMs * 100) : 0,
2402
+ functionCount: data.functionCount
2403
+ };
2404
+ }).sort((a, b) => b.selfTime - a.selfTime);
2405
+ files["/scripts/application.json"] = JSON.stringify(appScriptSummary, null, 2);
2406
+ const depScripts = profiles.flatMap((p) => p.summary.scriptBreakdown.filter((s) => s.sourceCategory === "dependency"));
2407
+ const depScriptMap = new Map;
2408
+ for (const s of depScripts) {
2409
+ const existing = depScriptMap.get(s.scriptUrl);
2410
+ if (existing) {
2411
+ existing.selfTime += s.selfTime;
2412
+ existing.functionCount = Math.max(existing.functionCount, s.functionCount);
2413
+ } else {
2414
+ depScriptMap.set(s.scriptUrl, { selfTime: s.selfTime, functionCount: s.functionCount });
2415
+ }
2416
+ }
2417
+ const depScriptSummary = Array.from(depScriptMap.entries()).map(([scriptUrl, data]) => {
2418
+ const relPath = relativizePath(scriptUrl, options.projectRoot);
2419
+ return {
2420
+ workspacePath: relPath.startsWith("node_modules/") ? relPath : scriptUrl,
2421
+ selfTime: round4(data.selfTime),
2422
+ selfPercent: totalDurationMs > 0 ? round4(data.selfTime / totalDurationMs * 100) : 0,
2423
+ functionCount: data.functionCount
2424
+ };
2425
+ }).sort((a, b) => b.selfTime - a.selfTime);
2426
+ files["/scripts/dependencies.json"] = JSON.stringify(depScriptSummary, null, 2);
2427
+ if (metrics) {
2428
+ files["/metrics/current.json"] = JSON.stringify(metrics, null, 2);
2429
+ }
2430
+ if (listenerTracking) {
2431
+ const sanitizedTracking = {
2432
+ ...listenerTracking,
2433
+ exceedances: listenerTracking.exceedances?.map((exc) => {
2434
+ if (!exc.stack || !options.projectRoot)
2435
+ return exc;
2436
+ return { ...exc, stack: exc.stack.replaceAll(options.projectRoot + "/", "") };
2437
+ })
2438
+ };
2439
+ files["/listener-tracking.json"] = JSON.stringify(sanitizedTracking, null, 2);
2440
+ }
2441
+ for (const [filePath, source] of testSources) {
2442
+ let relPath = relativizePath(filePath, options.projectRoot);
2443
+ if (relPath.startsWith("tests/"))
2444
+ relPath = relPath.slice(6);
2445
+ files[`/tests/${relPath}`] = source;
2446
+ }
2447
+ const scriptUrlToWorkspacePath = new Map;
2448
+ if (sourcePaths) {
2449
+ for (const [scriptUrl, source] of sourcePaths) {
2450
+ let relPath = relativizePath(scriptUrl, options.projectRoot);
2451
+ if (relPath.startsWith("src/"))
2452
+ relPath = relPath.slice(4);
2453
+ const wsPath = `/src/${relPath}`;
2454
+ files[wsPath] = source;
2455
+ scriptUrlToWorkspacePath.set(scriptUrl, wsPath);
2456
+ scriptUrlToWorkspacePath.set(`file://${scriptUrl}`, wsPath);
2457
+ }
2458
+ }
2459
+ if (sourcePaths && sourcePaths.size > 0) {
2460
+ const sanitizeCallerChain = (chain) => {
2461
+ if (!chain || chain.length === 0)
2462
+ return;
2463
+ return chain.map(({ scriptUrl, ...rest }) => {
2464
+ let relPath = relativizePath(scriptUrl, options.projectRoot);
2465
+ if (relPath.startsWith("src/"))
2466
+ relPath = relPath.slice(4);
2467
+ const wsPath = relPath.startsWith("node_modules/") || relPath.startsWith("node:") ? relPath : `/src/${relPath}`;
2468
+ return { ...rest, workspacePath: wsPath };
2469
+ });
2470
+ };
2471
+ const enrichHotFunction = (fn) => {
2472
+ const source = sourcePaths.get(fn.scriptUrl) ?? sourcePaths.get(normalizeFileUrl(fn.scriptUrl));
2473
+ const wsPath = scriptUrlToWorkspacePath.get(fn.scriptUrl);
2474
+ const { scriptUrl: _stripped, callerChain, ...fnWithoutScriptUrl } = fn;
2475
+ const sanitizedChain = sanitizeCallerChain(callerChain);
2476
+ const base = {
2477
+ ...fnWithoutScriptUrl,
2478
+ workspacePath: wsPath,
2479
+ ...sanitizedChain ? { callerChain: sanitizedChain } : {}
2480
+ };
2481
+ if (!source || fn.lineNumber < 0)
2482
+ return base;
2483
+ const sourceLines = source.split(`
2484
+ `);
2485
+ const targetLine = fn.lineNumber;
2486
+ const start = Math.max(0, targetLine - SOURCE_SNIPPET_CONTEXT);
2487
+ const end = Math.min(sourceLines.length, targetLine + SOURCE_SNIPPET_CONTEXT + 1);
2488
+ const snippet = sourceLines.slice(start, end).map((line, i) => {
2489
+ const lineNum = start + i + 1;
2490
+ const marker = lineNum === targetLine + 1 ? ">" : " ";
2491
+ return `${marker} ${String(lineNum).padStart(4)} | ${line}`;
2492
+ }).join(`
2493
+ `);
2494
+ return { ...base, sourceSnippet: snippet };
2495
+ };
2496
+ const enrichedAll = mergedHotFunctions.map(enrichHotFunction);
2497
+ files["/hot-functions/global.json"] = JSON.stringify(enrichedAll, null, 2);
2498
+ files["/hot-functions/application.json"] = JSON.stringify(enrichedAll.filter((fn) => fn.sourceCategory === "application"), null, 2);
2499
+ files["/hot-functions/dependencies.json"] = JSON.stringify(enrichedAll.filter((fn) => fn.sourceCategory === "dependency"), null, 2);
2500
+ }
2501
+ const fileIndex = {};
2502
+ for (const fn of mergedHotFunctions) {
2503
+ const wsPath = scriptUrlToWorkspacePath.get(fn.scriptUrl);
2504
+ if (!wsPath)
2505
+ continue;
2506
+ if (!fileIndex[wsPath])
2507
+ fileIndex[wsPath] = [];
2508
+ fileIndex[wsPath]?.push({
2509
+ functionName: fn.functionName,
2510
+ lineNumber: fn.lineNumber,
2511
+ selfTime: fn.selfTime,
2512
+ selfPercent: fn.selfPercent
2513
+ });
2514
+ }
2515
+ if (Object.keys(fileIndex).length > 0) {
2516
+ files["/src/index.json"] = JSON.stringify(fileIndex, null, 2);
2517
+ }
2518
+ const sourceFilesList = [];
2519
+ const testFilesList = [];
2520
+ for (const key of Object.keys(files)) {
2521
+ if (key.startsWith("/src/") && !key.endsWith("/index.json") && !key.endsWith(".json")) {
2522
+ sourceFilesList.push(key);
2523
+ } else if (key.startsWith("/tests/")) {
2524
+ testFilesList.push(key);
2525
+ }
2526
+ }
2527
+ Object.assign(files, DATA_SCRIPTING_SKILL_FILES);
2528
+ Object.assign(files, PROFILE_ANALYSIS_SKILL_FILES);
2529
+ const { backend, cleanup } = await createWorkspaceFromFiles(files);
2530
+ return { backend, cleanup, sourceFiles: sourceFilesList, testFiles: testFilesList };
2531
+ }
2532
+ function normalizeFileUrl(url) {
2533
+ if (url.startsWith("file://")) {
2534
+ try {
2535
+ return new URL(url).pathname;
2536
+ } catch {
2537
+ return url.slice(7);
2538
+ }
2539
+ }
2540
+ return url;
2541
+ }
2542
+ function sanitizeFilename(filePath) {
2543
+ return filePath.replace(/[/\\]/g, "_").replace(/[^a-zA-Z0-9._-]/g, "_").replace(/^_+/, "");
2544
+ }
2545
+ function round4(n) {
2546
+ return Math.round(n * 100) / 100;
2547
+ }
2548
+ function relativizePath(filePath, projectRoot) {
2549
+ let normalized = filePath;
2550
+ if (normalized.startsWith("file://")) {
2551
+ try {
2552
+ normalized = new URL(normalized).pathname;
2553
+ } catch {}
2554
+ }
2555
+ if (projectRoot && normalized.startsWith(projectRoot)) {
2556
+ let rel = normalized.slice(projectRoot.length);
2557
+ if (rel.startsWith("/"))
2558
+ rel = rel.slice(1);
2559
+ return rel || normalized.split("/").pop() || normalized;
2560
+ }
2561
+ const nmIdx = normalized.indexOf("node_modules/");
2562
+ if (nmIdx >= 0) {
2563
+ return normalized.slice(nmIdx);
2564
+ }
2565
+ return normalized.split("/").pop() || normalized;
2566
+ }
2567
+ // ../utils/src/profiling/agent.ts
2568
+ import { createDeepAgent } from "deepagents";
2569
+
2570
+ // ../utils/src/profiling/prompts/test-shared.ts
2571
+ var SEVERITY_RULES = `## Severity classification
2572
+
2573
+ Assign severity based on the nature and measured impact of the issue:
2574
+
2575
+ - **critical** — Any of:
2576
+ - Synchronous blocking of the event loop (CPU-bound loops, sync crypto, sync I/O)
2577
+ - Functions that CALL blocking functions (compound blockers)
2578
+ - Listener exceedances (count exceeding maxListeners threshold)
2579
+ - GC overhead >10% of total profile duration
2580
+ - A single function consuming >15% of APPLICATION code self-time
2581
+ - **warning** — Any of:
2582
+ - Listener add/remove imbalance (addCount > 2× removeCount) without exceedance
2583
+ - O(n²) or worse algorithms on collections
2584
+ - Unnecessary serialization (JSON.parse/JSON.stringify) on hot paths
2585
+ - Closure-based memory leaks or unbounded data structures
2586
+ - A function consuming 5–15% of application self-time
2587
+ - **info** — Minor inefficiencies, small optimisation opportunities, per-call
2588
+ object allocation (TextEncoder, RegExp, DateTimeFormat), or patterns that only
2589
+ matter at scale
2590
+
2591
+ IMPORTANT: Blocking/event-loop-blocking operations are ALWAYS critical, regardless
2592
+ of measured self-time percentage. Even a short blocking call prevents the event loop
2593
+ from processing other work and is a correctness issue, not just a performance issue.`;
2594
+
2595
+ // ../utils/src/profiling/prompts/cpu-hotspot.ts
2596
+ var CPU_HOTSPOT_PROMPT = `You are a specialist in detecting CPU-blocking operations and excessive object instantiation in JavaScript/TypeScript code.
2597
+
2598
+ You have access to a workspace with V8 CPU profiling data from a Vitest test run.
2599
+
2600
+ ## Your focus areas
2601
+
2602
+ ### 1. Blocking / Event-Loop-Blocking Operations (HIGHEST PRIORITY)
2603
+
2604
+ Look for functions that block the event loop with synchronous CPU-intensive work:
2605
+ - Synchronous crypto operations (hashing, encryption) that should use async APIs
2606
+ - CPU-bound loops (e.g., manual hashing with many iterations, busy-waits)
2607
+ - Functions that CALL other blocking functions (compound blocking). Report the
2608
+ CALLER as a separate finding.
2609
+ - Synchronous file I/O in hot paths (readFileSync, writeFileSync, etc.)
2610
+ - Heavy computation without yielding (e.g., large matrix operations, parsing)
2611
+
2612
+ **How to detect:** Read hot-functions/application.json for functions with high selfTime.
2613
+ For each one with >= 1% selfPercent, read the source code and check for:
2614
+ - Loops with many iterations doing CPU work
2615
+ - Calls to other blocking functions (trace the call chain — read the callee's source!)
2616
+ - Missing async/await for operations that have async alternatives (e.g., crypto.pbkdf2 vs crypto.pbkdf2Sync)
2617
+
2618
+ **IMPORTANT — Compound blockers are SEPARATE findings:**
2619
+ If function A calls function B and B is blocking, you MUST report TWO findings:
2620
+ 1. Function B: the primary blocking operation
2621
+ 2. Function A: a "compound blocker" that calls B, inheriting and compounding B's cost
2622
+ Do NOT just report B and skip A. The developer needs to know both call sites.
2623
+
2624
+ ### 2. Excessive Object Instantiation (SECONDARY)
2625
+
2626
+ Look for functions creating stateless objects on every call that should be
2627
+ module-level singletons or hoisted out of loops:
2628
+ - \`new TextEncoder()\` / \`new TextDecoder()\` — stateless, should be module-level
2629
+ - \`new Intl.DateTimeFormat()\` / \`new Intl.NumberFormat()\` — locale-dependent but cacheable
2630
+ - \`new Map()\` / \`new Set()\` — if used as temporary lookup then discarded each call
2631
+ - \`new RegExp()\` — if the pattern is constant, compile once at module level
2632
+ - \`new Date()\` inside sort comparators — called O(n log n) times
2633
+ - Any constructor call inside a hot loop that produces a stateless, reusable object
2634
+
2635
+ **How to detect:** Read source files for hot functions and look for object
2636
+ construction inside function bodies that could be hoisted to module scope.
2637
+
2638
+ **IMPORTANT:** If a function has BOTH a blocking issue AND an instantiation issue,
2639
+ report them as TWO separate findings with different categories (blocking-io vs allocation).
2640
+ Do NOT skip the instantiation finding just because you already reported a blocking finding
2641
+ for the same function.
2642
+
2643
+ ## Your scope — categories YOU own
2644
+
2645
+ You are one of four parallel subagents. Use ONLY these categories:
2646
+ - **blocking-io** — for event-loop-blocking operations (sync crypto, CPU loops, sync I/O)
2647
+ - **allocation** — for per-call object instantiation (new TextEncoder, new Intl.DateTimeFormat, new Map per call)
2648
+
2649
+ Do NOT report findings with categories: algorithm, serialization, gc-pressure,
2650
+ listener-leak, event-handling, unnecessary-computation. Other subagents handle those.
2651
+ Do NOT report findings about test files (tests/*.ts) — only about src/ files.
2652
+
2653
+ ## Your workflow
2654
+
2655
+ 1. In your FIRST turn, do ALL of these in ONE batch:
2656
+ a. Run the workspace overview script:
2657
+ execute_command: node skills/profile-analysis/helpers/analyze-workspace.js
2658
+ b. Run the detailed hot functions script:
2659
+ execute_command: node skills/profile-analysis/helpers/analyze-hotfunctions.js
2660
+ c. Call read_file for EVERY src/ file listed in "FILES IN THIS WORKSPACE" above.
2661
+ Do NOT use ls or glob. Batch everything into ONE turn.
2662
+ 2. From the script outputs, identify hot functions (>= 1% selfPercent) and match
2663
+ them to the source code you read.
2664
+ 3. For EACH hot function, analyze its source for blocking patterns or unnecessary instantiation.
2665
+ 4. Check EVERY source file top-to-bottom, not just the hot ones.
2666
+ 5. For compound blockers, trace the call chain using the callerChain data from the script output.
2667
+
2668
+ ${PARALLEL_TOOL_CALLS}
2669
+ ${VERIFICATION_RULES}
2670
+ ${SEVERITY_RULES}
2671
+ ${FINDING_CATEGORIES}
2672
+ ${OUTPUT_FORMAT}
2673
+ ${STRUCTURED_OUTPUT_FIELDS}
2674
+ ${WRITE_FINDINGS_REQUIREMENT}`;
2675
+ // ../utils/src/profiling/prompts/listener-leak.ts
2676
+ var LISTENER_LEAK_PROMPT = `You are a specialist in detecting event listener leaks and event handling imbalances in JavaScript/TypeScript code.
2677
+
2678
+ You have access to a workspace with V8 CPU profiling data and event listener tracking from a Vitest test run.
2679
+
2680
+ ## Your SOLE focus: Event Listener Leaks
2681
+
2682
+ You look for ONE thing: code that registers event listeners without proper cleanup,
2683
+ causing listener accumulation, memory growth, and MaxListenersExceededWarning.
2684
+
2685
+ ### Pattern A — Listener accumulation per call
2686
+
2687
+ A function that adds a new listener EVERY TIME it is called, but never removes
2688
+ old ones. After N calls, there are N active listeners.
2689
+
2690
+ \`\`\`typescript
2691
+ // BAD: adds a new listener on every call
2692
+ function getData() {
2693
+ emitter.on('update', handler); // accumulates!
2694
+ }
2695
+ \`\`\`
2696
+
2697
+ **Correct fix pattern — use a guard flag + named handler:**
2698
+
2699
+ \`\`\`typescript
2700
+ // GOOD: register once, named handler, proper cleanup
2701
+ let registered = false;
2702
+ const onUpdate = () => { cache = null; };
2703
+
2704
+ function getData() {
2705
+ if (!registered) {
2706
+ emitter.on('update', onUpdate);
2707
+ registered = true;
2708
+ }
2709
+ // ... rest of function
2710
+ }
2711
+
2712
+ function reset() {
2713
+ if (registered) {
2714
+ emitter.off('update', onUpdate); // surgical removal
2715
+ registered = false;
2716
+ }
2717
+ }
2718
+ \`\`\`
2719
+
2720
+ CRITICAL for afterCode: always use a NAMED handler (const onUpdate = ...) so
2721
+ it can be removed with .off(). NEVER use anonymous functions with .on().
2722
+ If the file has an existing cleanup/reset function, update it to call
2723
+ .off(event, handler) and reset the guard flag.
2724
+
2725
+ ### Pattern B — Missing unsubscribe mechanism
2726
+
2727
+ A subscribe-style function that adds listeners but returns no way to remove them.
2728
+
2729
+ \`\`\`typescript
2730
+ // BAD: no way to unsubscribe
2731
+ function subscribe(channel) {
2732
+ emitter.on(channel, handler); // no return value, no cleanup
2733
+ }
2734
+ \`\`\`
2735
+
2736
+ **Correct fix pattern — return an unsubscribe function:**
2737
+
2738
+ \`\`\`typescript
2739
+ // GOOD: returns cleanup function
2740
+ function subscribe(channel, handler) {
2741
+ emitter.on(channel, handler);
2742
+ return () => { emitter.off(channel, handler); };
2743
+ }
2744
+ \`\`\`
2745
+
2746
+ ### Pattern C — MaxListeners exceeded (MUST report as a SEPARATE finding)
2747
+
2748
+ When listener counts exceed the default maxListeners threshold (10), this
2749
+ triggers a MaxListenersExceededWarning at runtime. Check listener-tracking.json
2750
+ for the "exceedances" array — each entry shows an event type where the listener
2751
+ count exceeded the threshold.
2752
+
2753
+ **This is a SEPARATE finding from Pattern A/B**, even if the same function causes
2754
+ both the accumulation AND the exceedance. You MUST report:
2755
+ 1. Pattern A or B finding: the code that adds listeners without cleanup
2756
+ 2. Pattern C finding: the maxListeners threshold being exceeded, with the
2757
+ specific count, threshold, and event name from the exceedance data
2758
+
2759
+ The Pattern C finding MUST have:
2760
+ - category: **"event-handling"** (do NOT use "listener-leak" — use a DIFFERENT
2761
+ category from Pattern A/B so both findings survive deduplication)
2762
+ - severity: "critical" (exceedances are always critical)
2763
+ - title: focus on the event name and threshold, e.g. "maxListeners threshold
2764
+ exceeded for task:changed event (11 listeners, threshold 10)"
2765
+ - description: MUST mention ALL of these terms: "maxListeners", "threshold",
2766
+ "exceeded", the event name (e.g. "task:changed"), and the numeric count
2767
+ (e.g. "11") and threshold (e.g. "10") from the tracking data
2768
+
2769
+ ## Your scope — categories YOU own
2770
+
2771
+ You are one of four parallel subagents. Use ONLY these categories:
2772
+ - **listener-leak** — for Pattern A (accumulation) and Pattern B (missing unsubscribe)
2773
+ - **event-handling** — for Pattern C (maxListeners exceeded)
2774
+
2775
+ Do NOT report findings with categories: blocking-io, allocation, algorithm,
2776
+ serialization, gc-pressure, unnecessary-computation. Other subagents handle those.
2777
+ Do NOT report findings about test files (tests/*.ts) — only about src/ files.
2778
+
2779
+ ## Your workflow (follow this EXACTLY)
2780
+
2781
+ 1. In your FIRST turn, do ALL of these in ONE batch:
2782
+ a. Run the workspace overview script:
2783
+ execute_command: node skills/profile-analysis/helpers/analyze-workspace.js
2784
+ b. Run the detailed listener analysis script:
2785
+ execute_command: node skills/profile-analysis/helpers/analyze-listeners.js
2786
+ c. Call read_file for EVERY src/ file listed in "FILES IN THIS WORKSPACE" above.
2787
+ Do NOT use ls or glob. Batch everything into ONE turn.
2788
+ 2. From the script outputs, identify:
2789
+ - exceedances (listenerCount > maxListeners threshold)
2790
+ - add/remove imbalances (addCount with zero removeCount = leak candidates)
2791
+ 3. In the source files you already read, find the .on() / .addEventListener() calls
2792
+ and check if corresponding removal exists.
2793
+ 4. For each issue found, provide before/after code.
2794
+
2795
+ ## Important: Report EACH pattern as a SEPARATE finding
2796
+
2797
+ - If a function adds a listener without removal → one finding about accumulation
2798
+ - If a subscribe function has no unsubscribe mechanism → a separate finding
2799
+ - If maxListeners is exceeded → a SEPARATE finding (cross-reference with the causal
2800
+ pattern above). This must be its own finding even if you already reported the
2801
+ listener accumulation that caused it. The developer needs to know BOTH that
2802
+ listeners accumulate AND that the threshold is exceeded.
2803
+
2804
+ ### Minimum expected findings
2805
+
2806
+ For a typical codebase with listener leaks, expect at least:
2807
+ 1. One finding per function that adds listeners without cleanup (Pattern A)
2808
+ 2. One finding per subscribe function without unsubscribe (Pattern B)
2809
+ 3. One finding per maxListeners exceedance from tracking data (Pattern C)
2810
+
2811
+ ${PARALLEL_TOOL_CALLS}
2812
+ ${VERIFICATION_RULES}
2813
+ ${SEVERITY_RULES}
2814
+ ${FINDING_CATEGORIES}
2815
+ ${OUTPUT_FORMAT}
2816
+ ${STRUCTURED_OUTPUT_FIELDS}
2817
+ ${WRITE_FINDINGS_REQUIREMENT}`;
2818
+ // ../utils/src/profiling/prompts/memory-closure.ts
2819
+ var MEMORY_CLOSURE_PROMPT = `You are a specialist in detecting memory leaks caused by closures, unbounded data structures, and missing cleanup/eviction in JavaScript/TypeScript code.
2820
+
2821
+ You have access to a workspace with V8 CPU profiling data from a Vitest test run.
2822
+
2823
+ ## Your SOLE focus: Closure & Memory Leak Patterns
2824
+
2825
+ You look for code where objects, closures, or data structures retain references
2826
+ longer than necessary, preventing garbage collection and causing continuous
2827
+ memory growth.
2828
+
2829
+ ### Pattern A — Closures capturing outer-scope data
2830
+
2831
+ Closures stored in long-lived data structures that capture variables from
2832
+ the enclosing scope — even after the captured data is conceptually stale.
2833
+
2834
+ \`\`\`typescript
2835
+ // BAD: closure captures 'value' and 'ctx' from enclosing scope
2836
+ set(key, value, ctx) {
2837
+ this.cache.set(key, {
2838
+ data: value,
2839
+ refresher: () => {
2840
+ // This closure captures 'value' and 'ctx' — they can
2841
+ // never be garbage collected while the cache entry exists
2842
+ return fetchFresh(key, ctx);
2843
+ }
2844
+ });
2845
+ }
2846
+ \`\`\`
2847
+
2848
+ ### Pattern B — Unbounded data structures (no eviction)
2849
+
2850
+ Arrays, Maps, or Sets that only grow — elements are added but never removed,
2851
+ cleared, or evicted. Over time, memory grows monotonically.
2852
+
2853
+ \`\`\`typescript
2854
+ // BAD: log grows without bound
2855
+ process(item) {
2856
+ this.log.push({
2857
+ item,
2858
+ timestamp: Date.now(),
2859
+ context: this.currentContext // retains reference forever
2860
+ });
2861
+ }
2862
+ \`\`\`
2863
+
2864
+ ### Pattern C — Closures capturing request/response or transient objects
2865
+
2866
+ Code that stores closures capturing objects meant to be short-lived (e.g.
2867
+ request bodies, response objects, connection handles), preventing them from
2868
+ being freed after their lifecycle ends.
2869
+
2870
+ \`\`\`typescript
2871
+ // BAD: closure captures the full transient object forever
2872
+ record(obj) {
2873
+ this.entries.push({
2874
+ id: obj.id,
2875
+ timestamp: Date.now(),
2876
+ getDetails: () => ({
2877
+ payload: obj.payload, // captures obj.payload forever
2878
+ metadata: obj.metadata // captures obj.metadata forever
2879
+ })
2880
+ });
2881
+ }
2882
+ \`\`\`
2883
+
2884
+ ## Your scope — categories YOU own
2885
+
2886
+ You are one of four parallel subagents. Use ONLY this category:
2887
+ - **gc-pressure** — for closures capturing outer-scope data, unbounded data
2888
+ structures (Maps, arrays) without eviction, and closures retaining transient objects
2889
+
2890
+ Do NOT report findings with categories: blocking-io, allocation, algorithm,
2891
+ serialization, listener-leak, event-handling, unnecessary-computation. Other
2892
+ subagents handle those. Specifically:
2893
+ - Do NOT report event listener leaks (the listener-leak agent handles those)
2894
+ - Do NOT report blocking I/O or CPU loops (the cpu-hotspot agent handles those)
2895
+ - Do NOT report algorithmic inefficiencies (the code-pattern agent handles those)
2896
+ Do NOT report findings about test files (tests/*.ts) — only about src/ files.
2897
+
2898
+ ## Your workflow (follow this EXACTLY)
2899
+
2900
+ 1. In your FIRST turn, do ALL of these in ONE batch:
2901
+ a. Run the workspace overview script:
2902
+ execute_command: node skills/profile-analysis/helpers/analyze-workspace.js
2903
+ b. Run the leak finder script:
2904
+ execute_command: node skills/profile-analysis/helpers/find-leaks.js
2905
+ c. Call read_file for EVERY src/ file listed in "FILES IN THIS WORKSPACE" above.
2906
+ Do NOT use ls or glob. Batch everything into ONE turn.
2907
+ 2. From the script outputs, identify potential leak patterns and allocation hotspots.
2908
+ 3. For each source file you read, look for:
2909
+ - Module-level or class-level Maps, Sets, Arrays used as stores
2910
+ - Whether a corresponding removal mechanism exists
2911
+ - Closures stored as values that capture outer-scope variables
2912
+ 4. For each issue found, provide before/after code with proper cleanup.
2913
+
2914
+ ### CRITICAL: Report EVERY distinct issue, even in the same class
2915
+
2916
+ A single class or module can have multiple closure/memory issues. Report
2917
+ each as a SEPARATE finding. For example, a CacheService class might have:
2918
+ 1. A \`set()\` method with a closure that captures outer-scope data
2919
+ 2. An unbounded access log in \`get()\` that grows without eviction
2920
+ 3. A Map that stores entries without any TTL or maxSize
2921
+ These are THREE separate findings, not one.
2922
+
2923
+ ${PARALLEL_TOOL_CALLS}
2924
+ ${VERIFICATION_RULES}
2925
+ ${SEVERITY_RULES}
2926
+ ${FINDING_CATEGORIES}
2927
+ ${OUTPUT_FORMAT}
2928
+ ${STRUCTURED_OUTPUT_FIELDS}
2929
+ ${WRITE_FINDINGS_REQUIREMENT}`;
2930
+ // ../utils/src/profiling/prompts/code-pattern.ts
2931
+ var CODE_PATTERN_PROMPT = `You are a specialist in detecting algorithmic inefficiencies, unnecessary computation, and serialization overhead in JavaScript/TypeScript code.
2932
+
2933
+ You have access to a workspace with V8 CPU profiling data from a Vitest test run.
2934
+
2935
+ ## Your focus areas
2936
+
2937
+ ### 1. Quadratic or Worse Algorithms (HIGHEST PRIORITY)
2938
+
2939
+ Look for O(n²) or worse complexity patterns:
2940
+
2941
+ **Pattern A — Nested iteration over same collection:**
2942
+ \`\`\`typescript
2943
+ // BAD: O(n²) — filter inside a loop
2944
+ for (const item of items) {
2945
+ const dupes = items.filter(other => other.id === item.id);
2946
+ }
2947
+ \`\`\`
2948
+
2949
+ **Pattern B — Pairwise comparison:**
2950
+ \`\`\`typescript
2951
+ // BAD: O(n²) or worse — nested loops over the same or related collections
2952
+ for (const a of items) {
2953
+ for (const b of items) {
2954
+ // comparison or accumulation logic
2955
+ }
2956
+ }
2957
+ \`\`\`
2958
+
2959
+ **Pattern C — O(n²) duplicate detection:**
2960
+ \`\`\`typescript
2961
+ // BAD: filter().length for each element = O(n²)
2962
+ items.forEach(item => {
2963
+ if (items.filter(x => x === item).length > 1) { /* duplicate */ }
2964
+ });
2965
+ // FIX: Use a Set or Map for O(n)
2966
+ \`\`\`
2967
+
2968
+ ### 2. Unnecessary Serialization (SECONDARY)
2969
+
2970
+ \`\`\`typescript
2971
+ // BAD: deep clone via JSON roundtrip on every call
2972
+ return JSON.parse(JSON.stringify(data));
2973
+ // FIX: structuredClone(data) or spread operator for shallow copies
2974
+ \`\`\`
2975
+
2976
+ ### 3. Regex Recompilation
2977
+
2978
+ \`\`\`typescript
2979
+ // BAD: compiles regex on every call
2980
+ function validate(input) {
2981
+ const pattern = new RegExp('^[a-z]+$'); // recompiled every call!
2982
+ return pattern.test(input);
2983
+ }
2984
+ // FIX: const PATTERN = /^[a-z]+$/; at module level
2985
+ \`\`\`
2986
+
2987
+ ### 4. Expensive Sort Comparators
2988
+
2989
+ \`\`\`typescript
2990
+ // BAD: creates objects inside sort comparator (called O(n log n) times)
2991
+ items.sort((a, b) => {
2992
+ const dateA = new Date(a.createdAt); // new object per comparison!
2993
+ return dateA.getTime() - new Date(b.createdAt).getTime();
2994
+ });
2995
+ // FIX: pre-compute timestamps before sorting
2996
+ \`\`\`
2997
+
2998
+ Also check for **functions called FROM sort comparators**. If \`items.sort((a, b) => computeWeight(a) - computeWeight(b))\` calls a function that does expensive work (Date parsing, string operations, object creation), that function runs O(n log n) times per sort — report it as a separate finding.
2999
+
3000
+ ### 5. Pairwise Correlation / Tag Comparison (O(n² × m²))
3001
+
3002
+ Look for functions that compare every pair of items AND every pair of their sub-elements:
3003
+ \`\`\`typescript
3004
+ // BAD: O(n²×m²) — for each pair of tasks, compare all pairs of their tags
3005
+ for (const taskA of tasks) {
3006
+ for (const taskB of tasks) {
3007
+ for (const tagA of taskA.tags) {
3008
+ for (const tagB of taskB.tags) { /* ... */ }
3009
+ }
3010
+ }
3011
+ }
3012
+ \`\`\`
3013
+ Functions named like \`computeCorrelations\`, \`computeTagCorrelations\`, \`findPairs\`, etc. are prime suspects. Also look for \`.sort()\` and \`.join()\` inside inner loops.
3014
+
3015
+ ## How to detect
3016
+
3017
+ 1. Read hot-functions/application.json to identify which functions are CPU-hot
3018
+ 2. Read EVERY application source file — not just the hot ones
3019
+ 3. Go through EVERY FUNCTION in every file and check for the patterns above
3020
+ 4. Pay special attention to:
3021
+ - Functions that operate on arrays or collections
3022
+ - Any function containing nested loops or chained .filter/.map/.reduce calls
3023
+ - Functions that call JSON.parse, JSON.stringify, or new RegExp inside a loop or on every invocation
3024
+ - Sort comparators that create objects (new Date(), etc.) — the comparator runs O(n log n) times
3025
+ - Functions called from sort comparators (they inherit O(n log n) invocations)
3026
+ - Functions that do pairwise comparison of collection elements (O(n²) or O(n²×m²))
3027
+ - Duplicate detection using .filter() instead of Set (O(n²) vs O(n))
3028
+
3029
+ ## Your scope — categories YOU own
3030
+
3031
+ You are one of four parallel subagents. Use ONLY these categories:
3032
+ - **algorithm** — for O(n²) loops, brute-force, pairwise comparison, expensive sort comparators
3033
+ - **serialization** — for unnecessary JSON.parse/JSON.stringify roundtrips
3034
+ - **unnecessary-computation** — for regex recompilation with constant patterns
3035
+
3036
+ Do NOT report findings with categories: blocking-io, allocation, gc-pressure,
3037
+ listener-leak, event-handling. Other subagents handle those. Specifically:
3038
+ - Do NOT report per-call object instantiation (new TextEncoder, etc.) — the cpu-hotspot agent handles those
3039
+ - Do NOT report event listener leaks — the listener-leak agent handles those
3040
+ - Do NOT report closure/memory leaks — the memory-closure agent handles those
3041
+ Do NOT report findings about test files (tests/*.ts) — only about src/ files.
3042
+
3043
+ ## Your workflow
3044
+
3045
+ 1. In your FIRST turn, do ALL of these in ONE batch:
3046
+ a. Run the workspace overview script:
3047
+ execute_command: node skills/profile-analysis/helpers/analyze-workspace.js
3048
+ b. Call read_file for ALL of these in ONE batch:
3049
+ - scripts/application.json
3050
+ - EVERY src/ file listed in "FILES IN THIS WORKSPACE" above
3051
+ Do NOT use ls or glob.
3052
+ 2. From the script output, identify which functions are CPU-hot.
3053
+ 3. For EVERY function in EVERY source file, check for the patterns above.
3054
+ 4. Report each distinct pattern as a separate finding.
3055
+
3056
+ ${PARALLEL_TOOL_CALLS}
3057
+ ${VERIFICATION_RULES}
3058
+ ${SEVERITY_RULES}
3059
+ ${FINDING_CATEGORIES}
3060
+ ${OUTPUT_FORMAT}
3061
+ ${STRUCTURED_OUTPUT_FIELDS}
3062
+ ${WRITE_FINDINGS_REQUIREMENT}`;
3063
+
3064
+ // ../utils/src/profiling/prompts/index.ts
3065
+ var TEST_ORCHESTRATOR_SYSTEM_PROMPT = `You are a performance analysis orchestrator.
3066
+
3067
+ ## Instructions
3068
+
3069
+ 1. Read the user message — it contains exactly 4 task descriptions.
3070
+ 2. In your FIRST response, call the \`task\` tool exactly 4 times.
3071
+ For each, set subagent_type and description EXACTLY as written in the
3072
+ user message. Copy the FULL multi-line description verbatim, including
3073
+ every file path listed.
3074
+ 3. After all 4 subagents return, respond with: "All subagents complete."
3075
+
3076
+ ## CRITICAL rules
3077
+
3078
+ - Do NOT consolidate, re-read, or re-serialize findings. Subagents write
3079
+ their findings to /findings/*.json files directly.
3080
+ - Do NOT add your own findings — all analysis is done by subagents.
3081
+ - Do NOT call read_file, grep, ls, or glob.
3082
+ - Your response should be SHORT — just confirm completion.`;
3083
+
3084
+ // ../utils/src/profiling/agent.ts
3085
+ function buildFileListSection(ctx) {
3086
+ const { sourceFiles, testFiles, hasListenerTracking, hasHeapProfiles } = ctx;
3087
+ const dataFiles = [
3088
+ {
3089
+ path: "/src/index.json",
3090
+ description: "(source file -> hot function mapping — read this first)"
3091
+ },
3092
+ {
3093
+ path: "/hot-functions/application.json",
3094
+ description: "(hot functions with selfTime, selfPercent, sourceSnippet)"
3095
+ },
3096
+ { path: "/scripts/application.json", description: "(per-script time breakdown)" },
3097
+ { path: "/profiles/index.json", description: "(manifest of CPU profiles)" }
3098
+ ];
3099
+ if (hasListenerTracking) {
3100
+ dataFiles.push({
3101
+ path: "/listener-tracking.json",
3102
+ description: "(event listener add/remove counts and exceedances)"
3103
+ });
3104
+ }
3105
+ if (hasHeapProfiles) {
3106
+ dataFiles.push({ path: "/heap-profiles/index.json" });
3107
+ }
3108
+ dataFiles.push({ path: "/summary.json", description: "(overall test run stats)" }, { path: "/metrics/current.json" });
3109
+ return buildFileListPromptSection({ dataFiles, sourceFiles, testFiles });
3110
+ }
3111
+ function buildUserMessage(ctx) {
3112
+ const { metrics, sourceFiles = [], hasListenerTracking } = ctx;
3113
+ const srcFiles = sourceFiles.map((f) => ` ${f}`).join(`
3114
+ `);
3115
+ const dataFiles = [
3116
+ " /src/index.json",
3117
+ " /hot-functions/application.json",
3118
+ " /scripts/application.json",
3119
+ hasListenerTracking ? " /listener-tracking.json" : ""
3120
+ ].filter(Boolean).join(`
3121
+ `);
3122
+ const allFiles = `${dataFiles}
3123
+ ${srcFiles}`;
3124
+ return `Dispatch all 4 subagent tasks NOW in a single response.
3125
+ Use these EXACT descriptions (copy them verbatim):
3126
+
3127
+ TASK 1 — subagent_type: "cpu-hotspot"
3128
+ description: "Find blocking/event-loop-blocking operations and excessive object instantiation.
3129
+ In your FIRST response, call read_file for ALL of these files (do NOT use ls or glob):
3130
+ ${allFiles}
3131
+ Read EVERY file above in ONE batch. Then analyze for: synchronous CPU-bound loops, compound blockers (A calls blocking B — report BOTH), and per-call object creation (new TextEncoder, new RegExp, new Date in sort comparators). Report each distinct issue as a separate finding with beforeCode and afterCode."
3132
+
3133
+ TASK 2 — subagent_type: "listener-leak"
3134
+ description: "Find event listener leaks, add/remove imbalances, and maxListeners exceedances.
3135
+ In your FIRST response, call read_file for ALL of these files (do NOT use ls or glob):
3136
+ ${allFiles}
3137
+ Read EVERY file above in ONE batch. Then analyze for: listeners added without removal, missing unsubscribe mechanisms, maxListeners threshold exceedances. Report each pattern as a separate finding with beforeCode and afterCode."
3138
+
3139
+ TASK 3 — subagent_type: "memory-closure"
3140
+ description: "Find closure-based memory leaks, unbounded data structures, and missing cleanup/eviction.
3141
+ In your FIRST response, call read_file for ALL of these files (do NOT use ls or glob):
3142
+ ${allFiles}
3143
+ Read EVERY file above in ONE batch. Then analyze for: closures capturing outer-scope data, unbounded arrays/Maps/Sets with no eviction, closures capturing transient objects. A single class can have 3+ separate issues — report each one. Include beforeCode and afterCode for every finding."
3144
+
3145
+ TASK 4 — subagent_type: "code-pattern"
3146
+ description: "Find O(n²) algorithms, unnecessary JSON serialization, regex recompilation, and expensive sort comparators.
3147
+ In your FIRST response, call read_file for ALL of these files (do NOT use ls or glob):
3148
+ ${allFiles}
3149
+ Read EVERY file above in ONE batch. Then check EVERY function for: nested loops/filter-inside-loop, JSON.parse(JSON.stringify(...)) cloning, new RegExp with constant patterns, sort comparators that create objects. Report each pattern as a separate finding with beforeCode and afterCode."
3150
+
3151
+ Test suite: ${metrics.suite.totalTests} tests, ${metrics.suite.totalDuration}ms
3152
+ CPU: app ${metrics.cpu.applicationPercent}%, deps ${metrics.cpu.dependencyPercent}%, GC ${metrics.cpu.gcPercentage}%`;
3153
+ }
3154
+ function buildSubagents(ctx) {
3155
+ const fileSection = ctx ? buildFileListSection(ctx) : "";
3156
+ const inject = (prompt) => insertFileListIntoPrompt(prompt, fileSection);
3157
+ const skills = ["skills/data-scripting/", "skills/profile-analysis/"];
3158
+ return [
3159
+ {
3160
+ name: "cpu-hotspot",
3161
+ description: "Analyzes CPU profiling data to find blocking/event-loop-blocking operations and excessive object instantiation.",
3162
+ systemPrompt: inject(CPU_HOTSPOT_PROMPT),
3163
+ skills
3164
+ },
3165
+ {
3166
+ name: "listener-leak",
3167
+ description: "Detects event listener leaks, add/remove imbalances, and maxListeners exceedances.",
3168
+ systemPrompt: inject(LISTENER_LEAK_PROMPT),
3169
+ skills
3170
+ },
3171
+ {
3172
+ name: "memory-closure",
3173
+ description: "Finds closure-based memory leaks, unbounded data structures, and missing cleanup/eviction.",
3174
+ systemPrompt: inject(MEMORY_CLOSURE_PROMPT),
3175
+ skills
3176
+ },
3177
+ {
3178
+ name: "code-pattern",
3179
+ description: "Detects algorithmic inefficiencies (O(n²)), unnecessary serialization, regex recompilation, and expensive sort comparators.",
3180
+ systemPrompt: inject(CODE_PATTERN_PROMPT),
3181
+ skills
3182
+ }
3183
+ ];
3184
+ }
3185
+ async function analyzeTestPerformance(model, backend, spinner, context, { animateProgress = true } = {}) {
3186
+ const subagents = buildSubagents(context);
3187
+ const agent = createDeepAgent({
3188
+ model,
3189
+ systemPrompt: TEST_ORCHESTRATOR_SYSTEM_PROMPT,
3190
+ backend,
3191
+ subagents,
3192
+ skills: ["skills/"]
3193
+ });
3194
+ const userMessage = context ? buildUserMessage(context) : [
3195
+ "Analyze the performance of the APPLICATION CODE being tested in this workspace.",
3196
+ "",
3197
+ "Start with hot-functions/application.json, then explore source files to verify",
3198
+ "root causes and provide code-level fixes."
3199
+ ].join(`
3200
+ `);
3201
+ await invokeWithTodoStreaming(agent, userMessage, spinner, { animateProgress });
3202
+ const findings = await mergeFindings(backend);
3203
+ if (findings.length === 0) {
3204
+ throw new Error("Subagents did not write any findings to /findings/*.json");
3205
+ }
3206
+ const deduped = deduplicateFindings(findings);
3207
+ return rankFindings(deduped);
3208
+ }
3209
+ // ../utils/src/output/terminal.ts
3210
+ import pc2 from "picocolors";
3211
+ import ora from "ora";
3212
+ function getTerminalWidth() {
3213
+ const cols = process.stdout.columns || 80;
3214
+ return Math.max(cols - 2, 40);
3215
+ }
3216
+ var SEVERITY_ICONS = {
3217
+ critical: pc2.red("\uD83D\uDD34 CRITICAL"),
3218
+ warning: pc2.yellow("\uD83D\uDFE1 WARNING"),
3219
+ info: pc2.green("\uD83D\uDFE2 INFO")
3220
+ };
3221
+ var SEVERITY_LABELS = {
3222
+ critical: pc2.red("CRITICAL"),
3223
+ warning: pc2.yellow("WARNING"),
3224
+ info: pc2.green("INFO")
3225
+ };
3226
+ var CATEGORY_LABELS = {
3227
+ "memory-leak": "Memory Leak",
3228
+ "large-retained-object": "Large Retained Object",
3229
+ "detached-dom": "Detached DOM",
3230
+ "render-blocking": "Render-Blocking",
3231
+ "long-task": "Long Task",
3232
+ "unused-code": "Unused Code",
3233
+ "waterfall-bottleneck": "Waterfall Bottleneck",
3234
+ "large-asset": "Large Asset",
3235
+ "frame-blocking-function": "Frame-Blocking Function",
3236
+ "listener-leak": "Listener Leak",
3237
+ "gc-pressure": "GC Pressure",
3238
+ "slow-test": "Slow Test",
3239
+ "expensive-setup": "Expensive Setup",
3240
+ "hot-function": "Hot Function",
3241
+ "unnecessary-computation": "Unnecessary Computation",
3242
+ "import-overhead": "Import Overhead",
3243
+ "dependency-bottleneck": "Dependency Bottleneck",
3244
+ algorithm: "Inefficient Algorithm",
3245
+ serialization: "Serialization Overhead",
3246
+ allocation: "Excessive Allocation",
3247
+ "event-handling": "Event Handling",
3248
+ "blocking-io": "Blocking I/O",
3249
+ other: "Other"
3250
+ };
3251
+ function wrapText(text, maxWidth) {
3252
+ const lines = [];
3253
+ for (const rawLine of text.split(`
3254
+ `)) {
3255
+ const words = rawLine.split(/\s+/).filter(Boolean);
3256
+ if (words.length === 0) {
3257
+ lines.push("");
3258
+ continue;
3259
+ }
3260
+ let current = "";
3261
+ for (const word of words) {
3262
+ const next = current ? `${current} ${word}` : word;
3263
+ if (next.length > maxWidth && current) {
3264
+ lines.push(current);
3265
+ current = word;
3266
+ } else {
3267
+ current = next;
3268
+ }
3269
+ }
3270
+ if (current)
3271
+ lines.push(current);
3272
+ }
3273
+ return lines;
3274
+ }
3275
+ function printFindingsVitest(findings) {
3276
+ const tw = getTerminalWidth();
3277
+ const indent = " ";
3278
+ const subIndent = indent + " ";
3279
+ const wrapWidth = tw - subIndent.length;
3280
+ if (findings.length === 0) {
3281
+ console.log(`${indent}${pc2.green("✔")} No significant performance issues found.`);
3282
+ return;
3283
+ }
3284
+ for (const finding of findings) {
3285
+ const severity = SEVERITY_LABELS[finding.severity];
3286
+ const categoryLabel = CATEGORY_LABELS[finding.category] ?? finding.category;
3287
+ console.log(`${indent}${severity} [${categoryLabel}]: ${pc2.bold(finding.title)}`);
3288
+ if (finding.testFile)
3289
+ console.log(pc2.dim(`${subIndent}Test file: ${finding.testFile}`));
3290
+ if (finding.impactMs != null)
3291
+ console.log(pc2.dim(`${subIndent}Impact: ${finding.impactMs.toFixed(0)}ms`));
3292
+ if (finding.resourceUrl) {
3293
+ for (const rl of wrapText(`Resource: ${finding.resourceUrl}`, wrapWidth)) {
3294
+ console.log(pc2.dim(`${subIndent}${rl}`));
3295
+ }
3296
+ }
3297
+ if (finding.hotFunction) {
3298
+ const hf = finding.hotFunction;
3299
+ const fnText = `Function: ${hf.name} at ${hf.scriptUrl}:${hf.lineNumber} ` + `(selfTime: ${hf.selfTime.toFixed(0)}ms, ${hf.selfPercent.toFixed(1)}%)`;
3300
+ for (const fl of wrapText(fnText, wrapWidth)) {
3301
+ console.log(pc2.dim(`${subIndent}${fl}`));
3302
+ }
3303
+ }
3304
+ for (const line of wrapText(finding.description, wrapWidth)) {
3305
+ console.log(`${subIndent}${line}`);
3306
+ }
3307
+ if (finding.suggestedFix) {
3308
+ console.log(pc2.dim(`${subIndent}Suggested fix:`));
3309
+ for (const rawLine of finding.suggestedFix.split(`
3310
+ `)) {
3311
+ for (const wl of wrapText(rawLine, wrapWidth - 2)) {
3312
+ console.log(`${subIndent} ${wl}`);
3313
+ }
3314
+ }
3315
+ }
3316
+ console.log();
3317
+ }
3318
+ const counts = {
3319
+ critical: findings.filter((f) => f.severity === "critical").length,
3320
+ warning: findings.filter((f) => f.severity === "warning").length,
3321
+ info: findings.filter((f) => f.severity === "info").length
3322
+ };
3323
+ console.log(`${indent}${pc2.dim("Summary:")} ${pc2.red(`${counts.critical} critical`)}, ${pc2.yellow(`${counts.warning} warning`)}, ${pc2.green(`${counts.info} info`)}`);
3324
+ }
3325
+ function printMetricsSummary(metrics) {
3326
+ const indent = " ";
3327
+ const s = metrics.suite;
3328
+ const c = metrics.cpu;
3329
+ console.log(`${indent}${pc2.bold("Suite")}`);
3330
+ console.log(`${indent} Total: ${formatMs(s.totalDuration)} · ` + `${s.totalTests} tests (${s.passCount} pass, ${s.failCount} fail) · ` + `Setup: ${formatMs(s.totalSetupTime)}`);
3331
+ console.log(`${indent} Avg: ${formatMs(s.averageTestDuration)} · ` + `Median: ${formatMs(s.medianTestDuration)} · ` + `P95: ${formatMs(s.p95TestDuration)} · ` + `Slowest: ${formatMs(s.slowestTestDuration)}`);
3332
+ if (s.slowestFile) {
3333
+ console.log(`${indent} Slowest file: ${s.slowestFile} (${formatMs(s.slowestFileDuration)})`);
3334
+ }
3335
+ if (c.gcTime > 0 || c.applicationTime > 0) {
3336
+ console.log("");
3337
+ console.log(`${indent}${pc2.bold("CPU Breakdown")}`);
3338
+ console.log(`${indent} Application: ${formatMs(c.applicationTime)} (${c.applicationPercent}%) · ` + `Dependencies: ${formatMs(c.dependencyTime)} (${c.dependencyPercent}%) · ` + `Test/Framework: ${formatMs(c.testFrameworkTime)} (${c.testFrameworkPercent}%)`);
3339
+ console.log(`${indent} GC: ${formatMs(c.gcTime)} (${c.gcPercentage}%) · ` + `Idle: ${formatMs(c.idleTime)} (${c.idlePercentage}%)`);
3340
+ }
3341
+ if (metrics.hotFunctions.length > 0) {
3342
+ console.log("");
3343
+ console.log(`${indent}${pc2.bold("Top Hot Functions")}`);
3344
+ const top5 = metrics.hotFunctions.slice(0, 5);
3345
+ for (const fn of top5) {
3346
+ const category = fn.sourceCategory !== "unknown" ? pc2.dim(` [${fn.sourceCategory}]`) : "";
3347
+ console.log(`${indent} ${formatMs(fn.selfTime)} (${fn.selfPercent}%) ${fn.functionName}${category}`);
3348
+ if (fn.scriptUrl) {
3349
+ console.log(pc2.dim(`${indent} ${fn.scriptUrl}:${fn.lineNumber}`));
3350
+ }
3351
+ }
3352
+ }
3353
+ if (metrics.heap) {
3354
+ console.log("");
3355
+ console.log(`${indent}${pc2.bold("Heap")}: ${formatBytes(metrics.heap.totalAllocatedBytes)} allocated`);
3356
+ }
3357
+ if (metrics.listenerTracking) {
3358
+ const lt = metrics.listenerTracking;
3359
+ console.log("");
3360
+ console.log(`${indent}${pc2.bold("Event Listener Tracking")}`);
3361
+ if (lt.exceedances.length > 0) {
3362
+ for (const exc of lt.exceedances) {
3363
+ console.log(`${indent} ${pc2.red("⚠")} ${pc2.red(`${exc.targetType}.${exc.eventType}`)}: ` + `${exc.listenerCount} listeners (max: ${exc.threshold})`);
3364
+ if (exc.stack) {
3365
+ for (const line of exc.stack.split(`
3366
+ `).slice(0, 2)) {
3367
+ console.log(pc2.dim(`${indent} ${line}`));
3368
+ }
3369
+ }
3370
+ }
3371
+ }
3372
+ const allImbalances = getListenerImbalances(lt);
3373
+ if (allImbalances.length > 0) {
3374
+ for (const entry of allImbalances.slice(0, 5)) {
3375
+ const leaked = entry.addCount - entry.removeCount;
3376
+ console.log(`${indent} ${pc2.yellow("⚠")} ${entry.api} "${entry.type}": ` + `${entry.addCount} adds, ${entry.removeCount} removes ` + pc2.yellow(`(${leaked} not cleaned up)`));
3377
+ }
3378
+ }
3379
+ }
3380
+ console.log("");
3381
+ }
3382
+ function formatMs(ms) {
3383
+ if (ms < 1)
3384
+ return `${(ms * 1000).toFixed(0)}µs`;
3385
+ if (ms < 1000)
3386
+ return `${ms.toFixed(0)}ms`;
3387
+ return `${(ms / 1000).toFixed(2)}s`;
3388
+ }
3389
+ function formatBytes(bytes) {
3390
+ if (bytes < 1024)
3391
+ return `${bytes} B`;
3392
+ if (bytes < 1024 * 1024)
3393
+ return `${(bytes / 1024).toFixed(1)} KB`;
3394
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
3395
+ }
3396
+ // ../utils/src/output/report.ts
3397
+ import { writeFileSync } from "node:fs";
3398
+ var SEVERITY_EMOJI = {
3399
+ critical: "\uD83D\uDD34",
3400
+ warning: "\uD83D\uDFE1",
3401
+ info: "ℹ️"
3402
+ };
3403
+ var CATEGORY_LABELS2 = {
3404
+ "memory-leak": "Memory Leak",
3405
+ "large-retained-object": "Large Retained Object",
3406
+ "detached-dom": "Detached DOM",
3407
+ "render-blocking": "Render-Blocking",
3408
+ "long-task": "Long Task",
3409
+ "unused-code": "Unused Code",
3410
+ "waterfall-bottleneck": "Waterfall Bottleneck",
3411
+ "large-asset": "Large Asset",
3412
+ "frame-blocking-function": "Frame-Blocking Function",
3413
+ "listener-leak": "Listener Leak",
3414
+ "gc-pressure": "GC Pressure",
3415
+ "slow-test": "Slow Test",
3416
+ "expensive-setup": "Expensive Setup",
3417
+ "hot-function": "Hot Function",
3418
+ "unnecessary-computation": "Unnecessary Computation",
3419
+ "import-overhead": "Import Overhead",
3420
+ "dependency-bottleneck": "Dependency Bottleneck",
3421
+ algorithm: "Inefficient Algorithm",
3422
+ serialization: "Serialization Overhead",
3423
+ allocation: "Excessive Allocation",
3424
+ "event-handling": "Event Handling",
3425
+ "blocking-io": "Blocking I/O",
3426
+ other: "Other"
3427
+ };
3428
+ function writeTestReport(outputPath, options) {
3429
+ const md = generateTestMarkdown(options);
3430
+ writeFileSync(outputPath, md, "utf-8");
3431
+ return outputPath;
3432
+ }
3433
+ function generateTestMarkdown(options) {
3434
+ const { version, findings, testTiming, profiles, metrics } = options;
3435
+ const now = new Date;
3436
+ const sections = [];
3437
+ sections.push(`# Vitest Performance Report`);
3438
+ sections.push("");
3439
+ sections.push(`> Analyzed ${now.toISOString().replace("T", " ").slice(0, 16)} UTC by zeitzeuge v${version}`);
3440
+ sections.push("");
3441
+ const totalTests = testTiming.reduce((s, t) => s + t.testCount, 0);
3442
+ const totalFiles = testTiming.length;
3443
+ const totalDuration = testTiming.reduce((s, t) => s + t.duration, 0);
3444
+ const slowest = testTiming.length > 0 ? testTiming.reduce((a, b) => a.duration > b.duration ? a : b) : null;
3445
+ const totalGcTime = profiles.reduce((s, p) => s + p.summary.duration * p.summary.gcPercentage / 100, 0);
3446
+ const gcPercentage = totalDuration > 0 ? (totalGcTime / totalDuration * 100).toFixed(2) : "0";
3447
+ sections.push(`**Test run** ${totalTests} tests across ${totalFiles} files · ` + `**Total duration** ${(totalDuration / 1000).toFixed(2)}s · ` + `**Slowest file** ${slowest ? `${slowest.file} (${(slowest.duration / 1000).toFixed(2)}s)` : "—"} · ` + `**GC overhead** ${gcPercentage}% (${totalGcTime.toFixed(0)}ms)`);
3448
+ sections.push("");
3449
+ if (metrics) {
3450
+ sections.push(`## Performance Metrics`);
3451
+ sections.push("");
3452
+ sections.push(`| Metric | Value |`);
3453
+ sections.push(`|--------|-------|`);
3454
+ sections.push(`| Total Duration | ${fmtMs(metrics.suite.totalDuration)} |`);
3455
+ sections.push(`| Tests | ${metrics.suite.totalTests} (${metrics.suite.passCount} pass, ${metrics.suite.failCount} fail) |`);
3456
+ sections.push(`| Setup Time | ${fmtMs(metrics.suite.totalSetupTime)} |`);
3457
+ sections.push(`| Avg Test Duration | ${fmtMs(metrics.suite.averageTestDuration)} |`);
3458
+ sections.push(`| Median Test Duration | ${fmtMs(metrics.suite.medianTestDuration)} |`);
3459
+ sections.push(`| P95 Test Duration | ${fmtMs(metrics.suite.p95TestDuration)} |`);
3460
+ sections.push(`| Slowest Test | ${fmtMs(metrics.suite.slowestTestDuration)} (\`${metrics.suite.slowestTestName}\`) |`);
3461
+ sections.push("");
3462
+ if (metrics.cpu.applicationTime > 0 || metrics.cpu.gcTime > 0) {
3463
+ sections.push(`### CPU Time Breakdown`);
3464
+ sections.push("");
3465
+ sections.push(`| Category | Time | % |`);
3466
+ sections.push(`|----------|------|---|`);
3467
+ sections.push(`| Application Code | ${fmtMs(metrics.cpu.applicationTime)} | ${metrics.cpu.applicationPercent}% |`);
3468
+ sections.push(`| Dependencies | ${fmtMs(metrics.cpu.dependencyTime)} | ${metrics.cpu.dependencyPercent}% |`);
3469
+ sections.push(`| Test/Framework | ${fmtMs(metrics.cpu.testFrameworkTime)} | ${metrics.cpu.testFrameworkPercent}% |`);
3470
+ sections.push(`| GC | ${fmtMs(metrics.cpu.gcTime)} | ${metrics.cpu.gcPercentage}% |`);
3471
+ sections.push(`| Idle | ${fmtMs(metrics.cpu.idleTime)} | ${metrics.cpu.idlePercentage}% |`);
3472
+ sections.push("");
3473
+ }
3474
+ if (metrics.hotFunctions.length > 0) {
3475
+ sections.push(`### Top Hot Functions`);
3476
+ sections.push("");
3477
+ sections.push(`| Function | Self Time | % | Category |`);
3478
+ sections.push(`|----------|-----------|---|----------|`);
3479
+ for (const fn of metrics.hotFunctions.slice(0, 10)) {
3480
+ sections.push(`| \`${fn.functionName}\` | ${fmtMs(fn.selfTime)} | ${fn.selfPercent}% | ${fn.sourceCategory} |`);
3481
+ }
3482
+ sections.push("");
3483
+ }
3484
+ if (metrics.listenerTracking) {
3485
+ const lt = metrics.listenerTracking;
3486
+ const hasExceedances = lt.exceedances.length > 0;
3487
+ const allImbalances = getListenerImbalances(lt);
3488
+ if (hasExceedances || allImbalances.length > 0) {
3489
+ sections.push(`### Event Listener Tracking`);
3490
+ sections.push("");
3491
+ if (hasExceedances) {
3492
+ sections.push(`**Listener exceedances detected** — one or more EventTarget/EventEmitter ` + `instances accumulated more listeners than their \`maxListeners\` threshold. ` + `This is a strong signal of a listener leak that can cause memory growth.`);
3493
+ sections.push("");
3494
+ sections.push(`| Target | Event | Listeners | Threshold |`);
3495
+ sections.push(`|--------|-------|-----------|-----------|`);
3496
+ for (const exc of lt.exceedances) {
3497
+ sections.push(`| \`${exc.targetType}\` | \`${exc.eventType}\` | ${exc.listenerCount} | ${exc.threshold} |`);
3498
+ }
3499
+ sections.push("");
3500
+ }
3501
+ if (allImbalances.length > 0) {
3502
+ sections.push(`**Listener imbalances:**`);
3503
+ sections.push("");
3504
+ sections.push(`| API | Event | Adds | Removes | Not Cleaned Up |`);
3505
+ sections.push(`|-----|-------|------|---------|----------------|`);
3506
+ for (const entry of allImbalances) {
3507
+ const leaked = entry.addCount - entry.removeCount;
3508
+ sections.push(`| ${entry.api} | \`${entry.type}\` | ${entry.addCount} | ${entry.removeCount} | ${leaked} |`);
3509
+ }
3510
+ sections.push("");
3511
+ }
3512
+ }
3513
+ }
3514
+ }
3515
+ const counts = {
3516
+ critical: findings.filter((f) => f.severity === "critical").length,
3517
+ warning: findings.filter((f) => f.severity === "warning").length,
3518
+ info: findings.filter((f) => f.severity === "info").length
3519
+ };
3520
+ if (findings.length === 0) {
3521
+ sections.push(`## ✅ No issues found`);
3522
+ sections.push("");
3523
+ sections.push(`No significant performance problems were detected. ` + `Tests complete in ${(totalDuration / 1000).toFixed(2)}s — looking healthy.`);
3524
+ sections.push("");
3525
+ } else {
3526
+ sections.push(`**${findings.length} issues found** — ` + `${counts.critical} critical, ${counts.warning} warning, ${counts.info} info`);
3527
+ sections.push("");
3528
+ for (const f of findings) {
3529
+ const emoji = SEVERITY_EMOJI[f.severity];
3530
+ const categoryLabel = CATEGORY_LABELS2[f.category] ?? f.category;
3531
+ sections.push(`---`);
3532
+ sections.push("");
3533
+ sections.push(`## ${emoji} ${f.title}`);
3534
+ sections.push("");
3535
+ const context = [`**${categoryLabel}**`];
3536
+ if (f.confidence)
3537
+ context.push(`confidence: ${f.confidence}`);
3538
+ if (f.impactMs != null)
3539
+ context.push(`${f.impactMs.toFixed(0)}ms impact`);
3540
+ if (f.estimatedSavingsMs != null)
3541
+ context.push(`~${f.estimatedSavingsMs.toFixed(0)}ms savings`);
3542
+ if (f.testFile)
3543
+ context.push(`\`${f.testFile}\``);
3544
+ if (f.hotFunction) {
3545
+ context.push(`\`${f.hotFunction.name}\` (${f.hotFunction.selfTime.toFixed(0)}ms, ${f.hotFunction.selfPercent.toFixed(1)}%)`);
3546
+ }
3547
+ if (f.sourceFile)
3548
+ context.push(`\`${f.sourceFile}${f.lineNumber != null ? `:${f.lineNumber}` : ""}\``);
3549
+ if (f.resourceUrl)
3550
+ context.push(`\`${f.resourceUrl}\``);
3551
+ sections.push(context.join(" · "));
3552
+ sections.push("");
3553
+ if (f.affectedTests && f.affectedTests.length > 0) {
3554
+ sections.push(`**Affected tests:** ${f.affectedTests.map((t) => `\`${t}\``).join(", ")}`);
3555
+ sections.push("");
3556
+ }
3557
+ sections.push(f.description);
3558
+ sections.push("");
3559
+ if (f.beforeCode || f.afterCode) {
3560
+ if (f.beforeCode) {
3561
+ sections.push(`### Before`);
3562
+ sections.push("");
3563
+ sections.push("```ts");
3564
+ sections.push(f.beforeCode);
3565
+ sections.push("```");
3566
+ sections.push("");
3567
+ }
3568
+ if (f.afterCode) {
3569
+ sections.push(`### After`);
3570
+ sections.push("");
3571
+ sections.push("```ts");
3572
+ sections.push(f.afterCode);
3573
+ sections.push("```");
3574
+ sections.push("");
3575
+ }
3576
+ }
3577
+ if (f.suggestedFix) {
3578
+ sections.push(`### How to fix`);
3579
+ sections.push("");
3580
+ const alreadyFenced = f.suggestedFix.includes("```");
3581
+ const looksLikeCode = !alreadyFenced && (f.suggestedFix.includes("{") || f.suggestedFix.includes(";") || f.suggestedFix.includes("=>") || f.suggestedFix.includes("import ") || f.suggestedFix.includes("function "));
3582
+ if (looksLikeCode) {
3583
+ sections.push("```ts");
3584
+ sections.push(f.suggestedFix);
3585
+ sections.push("```");
3586
+ } else {
3587
+ sections.push(f.suggestedFix);
3588
+ }
3589
+ sections.push("");
3590
+ }
3591
+ }
3592
+ }
3593
+ sections.push(`---`);
3594
+ sections.push("");
3595
+ sections.push(`*Generated by zeitzeuge v${version}*`);
3596
+ sections.push("");
3597
+ return sections.join(`
3598
+ `);
3599
+ }
3600
+ function fmtMs(ms) {
3601
+ if (ms < 1)
3602
+ return `${(ms * 1000).toFixed(0)}µs`;
3603
+ if (ms < 1000)
3604
+ return `${ms.toFixed(0)}ms`;
3605
+ return `${(ms / 1000).toFixed(2)}s`;
3606
+ }
3607
+ // src/reporter.ts
3608
+ async function* zeitZeugeReporter(source) {
3609
+ const profileDir = process.env.ZEITZEUGE_PROFILE_DIR || ".zeitzeuge-profiles";
3610
+ const output = process.env.ZEITZEUGE_OUTPUT || "zeitzeuge-report.md";
3611
+ const projectRoot = process.env.ZEITZEUGE_PROJECT_ROOT || process.cwd();
3612
+ const verbose = process.env.ZEITZEUGE_VERBOSE === "true";
3613
+ const analyzeOnFinish = process.env.ZEITZEUGE_ANALYZE !== "false";
3614
+ const fileTimings = new Map;
3615
+ for await (const event of source) {
3616
+ switch (event.type) {
3617
+ case "test:pass":
3618
+ case "test:fail": {
3619
+ const file = event.data.file ?? "";
3620
+ const name = event.data.name ?? "";
3621
+ const duration = event.data.details?.duration_ms ?? 0;
3622
+ const nesting = event.data.nesting ?? 0;
3623
+ if (nesting === 0 && file) {
3624
+ let acc = fileTimings.get(file);
3625
+ if (!acc) {
3626
+ acc = { file, tests: [], totalDuration: 0 };
3627
+ fileTimings.set(file, acc);
3628
+ }
3629
+ if (event.data.details?.type === "suite") {
3630
+ acc.totalDuration = duration;
3631
+ } else {
3632
+ acc.tests.push({
3633
+ name,
3634
+ duration,
3635
+ status: event.type === "test:pass" ? "pass" : "fail"
3636
+ });
3637
+ }
3638
+ } else if (nesting > 0 && file) {
3639
+ let acc = fileTimings.get(file);
3640
+ if (!acc) {
3641
+ acc = { file, tests: [], totalDuration: 0 };
3642
+ fileTimings.set(file, acc);
3643
+ }
3644
+ acc.tests.push({
3645
+ name,
3646
+ duration,
3647
+ status: event.type === "test:pass" ? "pass" : "fail"
3648
+ });
3649
+ }
3650
+ const icon = event.type === "test:pass" ? "✓" : "✗";
3651
+ yield `${icon} ${name} (${duration.toFixed(1)}ms)
3652
+ `;
3653
+ break;
3654
+ }
3655
+ case "test:diagnostic": {
3656
+ if (verbose) {
3657
+ yield `# ${event.data.message}
3658
+ `;
3659
+ }
3660
+ break;
3661
+ }
3662
+ case "test:summary": {
3663
+ const counts = event.data.counts;
3664
+ yield `
3665
+ # Summary: ${counts?.passed ?? 0} passed, ${counts?.failed ?? 0} failed
3666
+ `;
3667
+ if (!analyzeOnFinish)
3668
+ break;
3669
+ const testTiming = buildTestTiming(fileTimings);
3670
+ if (testTiming.length === 0) {
3671
+ yield `# zeitzeuge: No test timing data collected
3672
+ `;
3673
+ break;
3674
+ }
3675
+ try {
3676
+ const analysisOutput = await runAnalysis({
3677
+ testTiming,
3678
+ profileDir: resolve2(profileDir),
3679
+ output: resolve2(output),
3680
+ projectRoot: resolve2(projectRoot),
3681
+ verbose
3682
+ });
3683
+ yield analysisOutput;
3684
+ } catch (err) {
3685
+ const msg = err instanceof Error ? err.message : String(err);
3686
+ yield `# zeitzeuge: Analysis failed — ${msg}
3687
+ `;
3688
+ } finally {
3689
+ try {
3690
+ if (existsSync(resolve2(profileDir))) {
3691
+ rmSync(resolve2(profileDir), { recursive: true, force: true });
3692
+ }
3693
+ } catch {}
3694
+ }
3695
+ break;
3696
+ }
3697
+ }
3698
+ }
3699
+ }
3700
+ function buildTestTiming(fileTimings) {
3701
+ const results = [];
3702
+ for (const acc of fileTimings.values()) {
3703
+ let passCount = 0;
3704
+ let failCount = 0;
3705
+ for (const t of acc.tests) {
3706
+ if (t.status === "pass")
3707
+ passCount++;
3708
+ else if (t.status === "fail")
3709
+ failCount++;
3710
+ }
3711
+ const duration = acc.totalDuration || acc.tests.reduce((s, t) => s + t.duration, 0);
3712
+ results.push({
3713
+ file: acc.file,
3714
+ duration,
3715
+ testCount: acc.tests.length,
3716
+ passCount,
3717
+ failCount,
3718
+ setupTime: 0,
3719
+ tests: acc.tests
3720
+ });
3721
+ }
3722
+ return results;
3723
+ }
3724
+ async function runAnalysis(opts) {
3725
+ const { testTiming, profileDir, output, projectRoot, verbose } = opts;
3726
+ const lines = [];
3727
+ if (!existsSync(profileDir)) {
3728
+ return `# zeitzeuge: No profile directory found. Run with --cpu-prof --cpu-prof-dir=.zeitzeuge-profiles
3729
+ `;
3730
+ }
3731
+ const allFiles = readdirSync(profileDir);
3732
+ const profileFiles = allFiles.filter((f) => f.endsWith(".cpuprofile")).map((f) => {
3733
+ const fullPath = join(profileDir, f);
3734
+ try {
3735
+ const stat = statSync(fullPath);
3736
+ return { name: f, path: fullPath, lastModified: stat.mtimeMs, size: stat.size };
3737
+ } catch {
3738
+ return { name: f, path: fullPath, lastModified: 0, size: 0 };
3739
+ }
3740
+ });
3741
+ if (profileFiles.length === 0) {
3742
+ return `# zeitzeuge: No .cpuprofile files found. Ensure --cpu-prof is passed to test processes.
3743
+ `;
3744
+ }
3745
+ lines.push(`
3746
+ # zeitzeuge: ${profileFiles.length} CPU profile(s) collected
3747
+ `);
3748
+ const byMtime = [...profileFiles].sort((a, b) => a.lastModified - b.lastModified);
3749
+ const orderedTestFiles = testTiming.map((t) => t.file);
3750
+ const PROFILE_ANALYSIS_CAP = 10;
3751
+ const PROFILE_PARSE_BUDGET = Math.min(byMtime.length, PROFILE_ANALYSIS_CAP + 5);
3752
+ const toParse = byMtime.length <= PROFILE_PARSE_BUDGET ? byMtime.map((pf, i) => ({ ...pf, testFile: orderedTestFiles[i] ?? `unknown-${i}` })) : [...byMtime].map((pf, i) => ({ ...pf, testFile: orderedTestFiles[i] ?? `unknown-${i}` })).sort((a, b) => b.size - a.size).slice(0, PROFILE_PARSE_BUDGET);
3753
+ const profiles = [];
3754
+ const testFileSet = new Set(testTiming.map((t) => resolve2(t.file)));
3755
+ for (const pf of toParse) {
3756
+ try {
3757
+ const content = readFileSync(pf.path, "utf-8");
3758
+ const rawProfile = JSON.parse(content);
3759
+ const summary = parseCpuProfile(rawProfile, pf.path);
3760
+ for (const fn of summary.hotFunctions) {
3761
+ fn.sourceCategory = classifyScript(fn.scriptUrl, projectRoot, testFileSet);
3762
+ }
3763
+ for (const script of summary.scriptBreakdown) {
3764
+ script.sourceCategory = classifyScript(script.scriptUrl, projectRoot, testFileSet);
3765
+ }
3766
+ profiles.push({ testFile: pf.testFile, profilePath: pf.path, summary });
3767
+ } catch (err) {
3768
+ if (verbose) {
3769
+ lines.push(`# zeitzeuge: Failed to parse ${pf.name}: ${err instanceof Error ? err.message : err}
3770
+ `);
3771
+ }
3772
+ }
3773
+ }
3774
+ profiles.sort((a, b) => b.summary.duration - a.summary.duration);
3775
+ const topProfiles = profiles.slice(0, PROFILE_ANALYSIS_CAP);
3776
+ if (topProfiles.length === 0) {
3777
+ return lines.join("") + `# zeitzeuge: No profiles could be parsed
3778
+ `;
3779
+ }
3780
+ const metrics = computeMetrics(testTiming, topProfiles, [], projectRoot);
3781
+ printMetricsSummary(metrics);
3782
+ const testSources = readTestSources(testTiming);
3783
+ const sourcePaths = readHotFunctionSources(topProfiles, projectRoot);
3784
+ const workspace = await createTestWorkspace({
3785
+ testTiming,
3786
+ profiles: topProfiles,
3787
+ testSources,
3788
+ sourcePaths,
3789
+ projectRoot,
3790
+ metrics
3791
+ });
3792
+ try {
3793
+ const model = await initModel();
3794
+ const spinner = ora2({ text: "zeitzeuge: Analyzing...", isEnabled: false }).start();
3795
+ const findings = await analyzeTestPerformance(model, workspace.backend, spinner, {
3796
+ metrics,
3797
+ hasHeapProfiles: false,
3798
+ hasListenerTracking: false,
3799
+ sourceFiles: workspace.sourceFiles,
3800
+ testFiles: workspace.testFiles
3801
+ }, { animateProgress: false });
3802
+ spinner.stop();
3803
+ printFindingsVitest(findings);
3804
+ const reportPath = writeTestReport(output, {
3805
+ version: "0.1.0",
3806
+ findings,
3807
+ testTiming,
3808
+ profiles: topProfiles,
3809
+ metrics
3810
+ });
3811
+ lines.push(`
3812
+ # zeitzeuge: Report written to ${reportPath}
3813
+ `);
3814
+ } catch (err) {
3815
+ const msg = err instanceof Error ? err.message : String(err);
3816
+ if (msg.includes("API key")) {
3817
+ lines.push(`# zeitzeuge: No LLM API key found. Set OPENAI_API_KEY or ANTHROPIC_API_KEY.
3818
+ `);
3819
+ } else {
3820
+ lines.push(`# zeitzeuge: Analysis failed — ${msg}
3821
+ `);
3822
+ }
3823
+ } finally {
3824
+ workspace.cleanup();
3825
+ }
3826
+ return lines.join("");
3827
+ }
3828
+ function readTestSources(testTiming) {
3829
+ const sources = new Map;
3830
+ for (const timing of testTiming) {
3831
+ try {
3832
+ const resolvedPath = resolve2(timing.file);
3833
+ if (existsSync(resolvedPath)) {
3834
+ sources.set(timing.file, readFileSync(resolvedPath, "utf-8"));
3835
+ }
3836
+ } catch {}
3837
+ }
3838
+ return sources;
3839
+ }
3840
+ function readHotFunctionSources(profiles, _projectRoot) {
3841
+ const sources = new Map;
3842
+ const seen = new Set;
3843
+ for (const profile of profiles) {
3844
+ for (const fn of profile.summary.hotFunctions) {
3845
+ if (!fn.scriptUrl || seen.has(fn.scriptUrl))
3846
+ continue;
3847
+ const threshold = fn.sourceCategory === "application" ? 0.1 : 1;
3848
+ if (fn.selfPercent < threshold)
3849
+ continue;
3850
+ seen.add(fn.scriptUrl);
3851
+ try {
3852
+ let filePath = fn.scriptUrl;
3853
+ if (filePath.startsWith("file://")) {
3854
+ filePath = new URL(filePath).pathname;
3855
+ }
3856
+ if (existsSync(filePath)) {
3857
+ sources.set(fn.scriptUrl, readFileSync(filePath, "utf-8"));
3858
+ }
3859
+ } catch {}
3860
+ }
3861
+ }
3862
+ return sources;
3863
+ }
3864
+ export {
3865
+ zeitZeugeReporter,
3866
+ parseCpuProfile,
3867
+ mergeHotFunctions,
3868
+ createTestWorkspace as createNodeTestWorkspace,
3869
+ computeMetrics,
3870
+ classifyScript,
3871
+ analyzeTestPerformance,
3872
+ TEST_ORCHESTRATOR_SYSTEM_PROMPT as NODE_TEST_SYSTEM_PROMPT
3873
+ };