@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/README.md +106 -0
- package/dist/agent.d.ts +5 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/classify.d.ts +5 -0
- package/dist/classify.d.ts.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3873 -0
- package/dist/metrics.d.ts +5 -0
- package/dist/metrics.d.ts.map +1 -0
- package/dist/profile-parser.d.ts +5 -0
- package/dist/profile-parser.d.ts.map +1 -0
- package/dist/prompts.d.ts +5 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/reporter.d.ts +57 -0
- package/dist/reporter.d.ts.map +1 -0
- package/dist/reporter.js +3866 -0
- package/dist/types.d.ts +68 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/workspace.d.ts +5 -0
- package/dist/workspace.d.ts.map +1 -0
- package/package.json +73 -0
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
|
+
};
|