memorydetective 1.11.0 → 1.13.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.
@@ -1,5 +1,7 @@
1
1
  import { z } from "zod";
2
+ import { runCommand } from "../runtime/exec.js";
2
3
  import { runLeaksAndParse } from "../runtime/leaks.js";
4
+ import { parseLeaksDebugStacks, } from "../parsers/leaksDebugStacks.js";
3
5
  import { suggestionGetDefinition } from "../runtime/suggestions.js";
4
6
  export const findRetainersSchema = z.object({
5
7
  path: z.string().min(1).describe("Absolute path to a `.memgraph` file."),
@@ -13,6 +15,10 @@ export const findRetainersSchema = z.object({
13
15
  .positive()
14
16
  .default(10)
15
17
  .describe("Cap on how many retain chains to return (default 10)."),
18
+ includeReferenceTree: z
19
+ .boolean()
20
+ .default(false)
21
+ .describe("v1.12+. When true, also run `leaks --debug=stacks --debug='<className>$'` to surface per-instance allocation stacks aggregated by call-stack fingerprint. Required on memgraphs where `leakCount: 0` and the class is reachable from KVO/NotificationCenter/caches (abandoned-memory shape). Each chain returns the allocation call stack + the unique retainer classes + a representative instance address. **Note:** `leaks --debug=stacks` only emits blocks for instances whose allocation stack was recorded, which requires the target was launched with `MallocStackLogging=1`. Xcode's Memory Graph Debugger export does NOT enable MSL by default, so memgraphs captured that way may surface fewer chains than the total instance count from `analyzeMemgraph.abandonedMemorySuspects[]`. Default false preserves v1.11 behavior."),
16
22
  });
17
23
  /**
18
24
  * Walk the cycle forest and collect every parent-path that ends in a node whose
@@ -46,19 +52,59 @@ export function findRetainersIn(report, needle, maxResults = 10) {
46
52
  visit(root, []);
47
53
  return { totalMatches: total, retainers: matches };
48
54
  }
55
+ /**
56
+ * Spawn `leaks --debug=stacks --debug='<ClassName>$'` and parse the stdout
57
+ * into per-stack-fingerprint aggregated chains. Failure is non-fatal:
58
+ * returns an empty array so the cycle-side path still completes.
59
+ *
60
+ * leaks(1) `--debug=` predicate rejects `^` (`cannot match the start of
61
+ * a class name`); only the `$` trailing anchor is supported. The
62
+ * resulting semantic is "ends with X", which matches AVPlayerItem
63
+ * exactly but also things like MyAVPlayerItem (rare; intentionally
64
+ * permissive over over-restrictive). Class-name regex metacharacters
65
+ * are escaped so substrings like "Player.Item" stay literal.
66
+ */
67
+ async function captureReferenceTreeChains(path, className, maxResults) {
68
+ // Escape regex metacharacters in the user-supplied class name so a
69
+ // substring like "AVPlayerItem" stays literal under leaks's regex
70
+ // predicate. `^` and `$` aren't escaped (leaks treats them specially).
71
+ const escaped = className.replace(/[.*+?{}()|[\]\\]/g, "\\$&");
72
+ const predicate = `${escaped}$`;
73
+ const result = await runCommand("leaks", ["--debug=stacks", `--debug=${predicate}`, path], { timeoutMs: 5 * 60_000 });
74
+ if (result.code !== 0 && result.code !== 1)
75
+ return [];
76
+ const all = parseLeaksDebugStacks(result.stdout);
77
+ return all.slice(0, maxResults);
78
+ }
49
79
  export async function findRetainers(input) {
50
- const { report, resolvedPath } = await runLeaksAndParse(input.path);
51
- const { totalMatches, retainers } = findRetainersIn(report, input.className, input.maxResults ?? 10);
52
- const suggestedNextCalls = totalMatches > 0
80
+ const wantReferenceTree = input.includeReferenceTree ?? false;
81
+ const maxResults = input.maxResults ?? 10;
82
+ const [{ report, resolvedPath }, referenceTreeChains,] = await Promise.all([
83
+ runLeaksAndParse(input.path),
84
+ wantReferenceTree
85
+ ? captureReferenceTreeChains(input.path, input.className, maxResults)
86
+ : Promise.resolve([]),
87
+ ]);
88
+ const { totalMatches, retainers } = findRetainersIn(report, input.className, maxResults);
89
+ // Update totalMatches to include reference-tree side if it found instances
90
+ // the cycle path missed. Instance counts aggregate across the per-stack
91
+ // chains; cycle matches count each path separately, so we don't double-add.
92
+ const referenceTreeInstanceTotal = referenceTreeChains.reduce((s, c) => s + c.instanceCount, 0);
93
+ const effectiveTotal = totalMatches > 0 ? totalMatches : referenceTreeInstanceTotal;
94
+ const suggestedNextCalls = effectiveTotal > 0
53
95
  ? [suggestionGetDefinition({ symbolName: input.className })]
54
96
  : [];
55
- return {
97
+ const result = {
56
98
  ok: true,
57
99
  path: resolvedPath,
58
100
  className: input.className,
59
- totalMatches,
101
+ totalMatches: effectiveTotal,
60
102
  retainers,
61
103
  ...(suggestedNextCalls.length > 0 ? { suggestedNextCalls } : {}),
62
104
  };
105
+ if (referenceTreeChains.length > 0) {
106
+ result.referenceTreeChains = referenceTreeChains;
107
+ }
108
+ return result;
63
109
  }
64
110
  //# sourceMappingURL=findRetainers.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"findRetainers.js","sourceRoot":"","sources":["../../src/tools/findRetainers.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"findRetainers.js","sourceRoot":"","sources":["../../src/tools/findRetainers.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,147 @@
1
+ /**
2
+ * `summarizeTrace`: the trace-to-summary-card-in-one-call feature.
3
+ *
4
+ * Today an agent handed a `.trace` chains inspectTrace + up to 5
5
+ * analyzers + reasons over tens of KB of JSON. That's 6 round-trips
6
+ * and ~$0.10-0.20 in tokens, most of which get thrown away after the
7
+ * agent identifies the one user-visible finding.
8
+ *
9
+ * summarizeTrace does it all in one call:
10
+ *
11
+ * 1. Inspects the TOC via inspectTrace (reuses the v1.11 path).
12
+ * 2. For each populated known schema, runs the matching analyzer in
13
+ * parallel with smart defaults tuned for "what would a user care
14
+ * about?" (Apple's 100ms hitch threshold, top-10 hangs, top-15
15
+ * time-profile symbols, etc).
16
+ * 3. Cross-correlates findings (v1.13 Phase 2): hangs overlapping
17
+ * with hitches, allocation spikes preceding hangs, etc.
18
+ * 4. Produces a structured result PLUS a pre-rendered compact
19
+ * markdown card (< 10 KB target) suitable for direct presentation
20
+ * to the user without further reasoning.
21
+ *
22
+ * Strategic positioning: this is memorydetective's "synthesis over
23
+ * raw-query" play vs trace-MCPs that go deep on single-schema access.
24
+ * See `~/Desktop/internal/v1.9-notelet-retro-market.md` §4.5 for the
25
+ * full framing.
26
+ */
27
+ import { z } from "zod";
28
+ import { type InspectTraceResult } from "./inspectTrace.js";
29
+ import { type AnalyzeHangsResult } from "./analyzeHangs.js";
30
+ import { type AnalyzeAnimationHitchesResult } from "./analyzeAnimationHitches.js";
31
+ import { type AnalyzeTimeProfileResult } from "./analyzeTimeProfile.js";
32
+ import { type AnalyzeAllocationsResult } from "./analyzeAllocations.js";
33
+ import { type AnalyzeAppLaunchResult } from "./analyzeAppLaunch.js";
34
+ export declare const summarizeTraceSchema: z.ZodObject<{
35
+ tracePath: z.ZodString;
36
+ focus: z.ZodDefault<z.ZodEnum<["hangs", "hitches", "allocations", "launch", "all"]>>;
37
+ verbose: z.ZodDefault<z.ZodBoolean>;
38
+ }, "strip", z.ZodTypeAny, {
39
+ tracePath: string;
40
+ focus: "all" | "allocations" | "hangs" | "hitches" | "launch";
41
+ verbose: boolean;
42
+ }, {
43
+ tracePath: string;
44
+ focus?: "all" | "allocations" | "hangs" | "hitches" | "launch" | undefined;
45
+ verbose?: boolean | undefined;
46
+ }>;
47
+ export type SummarizeTraceInput = z.infer<typeof summarizeTraceSchema>;
48
+ /**
49
+ * Per-analyzer entry on the structured result. `status` distinguishes
50
+ * "ran successfully", "schema absent in trace", and "ran but failed".
51
+ * Callers branching on the summary can decide whether to retry / refine.
52
+ */
53
+ export interface SummarizeAreaSummary<TResult> {
54
+ status: "ok" | "schema-absent" | "failed";
55
+ /** Why the status is what it is (one sentence). Surfaces SIGSEGV / missing-schema / parser-error reasons. */
56
+ diagnosis: string;
57
+ /** Full analyzer result when status === "ok". Useful when a caller wants to drill in without re-running the analyzer. */
58
+ result?: TResult;
59
+ }
60
+ /**
61
+ * v1.13 Phase 2: cross-area correlation. Each entry is a finding
62
+ * tying TWO areas together via timestamp overlap. The narrative
63
+ * field is the human-scannable string that goes into the markdown
64
+ * card; the structured fields (`kind`, `confidence`, evidence ids)
65
+ * are what callers can branch on programmatically.
66
+ */
67
+ export interface Correlation {
68
+ /** Which two areas this correlation ties together. Currently only `hangs+hitches` is supported; `hangs+allocations` and `hitches+allocations` are deferred to v1.14+ because the analyzer doesn't currently expose per-timestamp allocation rows. */
69
+ kind: "hangs+hitches";
70
+ /** `high` when the overlap is substantial (both events > 100ms and the windows overlap significantly); `medium` when one event is short; `low` when timestamps are only adjacent. */
71
+ confidence: "high" | "medium" | "low";
72
+ /** Pre-formatted human-scannable narrative. Goes into the markdown card. */
73
+ narrative: string;
74
+ /** Start time in seconds (for the hang event). Used to rank correlations by user-relevance (earliest first). */
75
+ atSec: number;
76
+ }
77
+ export interface SummarizeTraceResult {
78
+ ok: boolean;
79
+ tracePath: string;
80
+ /** TOC + suggestedNextCalls from inspectTrace. Always present. */
81
+ inspection: InspectTraceResult;
82
+ /** Per-area summaries. Each section is independent; absence of one doesn't fail the call. */
83
+ areas: {
84
+ hangs: SummarizeAreaSummary<AnalyzeHangsResult>;
85
+ hitches: SummarizeAreaSummary<AnalyzeAnimationHitchesResult>;
86
+ timeProfile: SummarizeAreaSummary<AnalyzeTimeProfileResult>;
87
+ allocations: SummarizeAreaSummary<AnalyzeAllocationsResult>;
88
+ appLaunch: SummarizeAreaSummary<AnalyzeAppLaunchResult>;
89
+ };
90
+ /** Cross-area correlations (v1.13 Phase 2). Empty when areas don't have enough data to correlate. */
91
+ correlations: Correlation[];
92
+ /** Headline string: 1-2 sentences naming the biggest user-impact finding across all areas. */
93
+ headline: string;
94
+ /** Pre-rendered markdown summary card. Target < 10 KB at default `verbose: false`. */
95
+ markdown: string;
96
+ }
97
+ /**
98
+ * Pure: detect hangs whose window overlaps with animation hitches.
99
+ * When a user sees a hang AND a hitch in the same time window,
100
+ * they almost certainly perceived the impact (the main-thread block
101
+ * delayed render commits, dropping frames).
102
+ *
103
+ * The overlap check is symmetric: a hitch can fall within a hang's
104
+ * window OR a hang can fall within a hitch's window. Both directions
105
+ * are treated equally.
106
+ *
107
+ * Confidence:
108
+ *
109
+ * - `high`: both events >= 250ms AND the overlap span >= 100ms.
110
+ * - `medium`: at least one event >= 250ms.
111
+ * - `low`: neither event >= 250ms but the windows touch.
112
+ *
113
+ * Results are sorted by `atSec` ascending so the markdown card lists
114
+ * correlations in trace order.
115
+ */
116
+ export declare function correlateHangsAndHitches(hangs: Array<{
117
+ startNs: number;
118
+ durationNs: number;
119
+ durationMs: number;
120
+ }>, hitches: Array<{
121
+ startNs: number;
122
+ durationNs: number;
123
+ durationMs: number;
124
+ hitchType?: string;
125
+ }>): Correlation[];
126
+ /**
127
+ * Pure: build all cross-area correlations from per-area summaries.
128
+ * Currently only `hangs+hitches` produces entries; allocation-based
129
+ * correlations need per-timestamp allocation data the existing
130
+ * analyzeAllocations doesn't expose (v1.14+ candidate).
131
+ */
132
+ export declare function buildCorrelations(areas: SummarizeTraceResult["areas"]): Correlation[];
133
+ /**
134
+ * Pure: produce the one-or-two-sentence headline that goes at the top
135
+ * of the markdown card. Picks the most user-visible finding across
136
+ * all areas. Order of priority: longest hang above 250ms > worst
137
+ * launch phase > worst hitch > largest allocation spike.
138
+ */
139
+ export declare function buildHeadline(areas: SummarizeTraceResult["areas"]): string;
140
+ /**
141
+ * Pure: assemble the compact markdown summary card. Designed to be
142
+ * <10 KB at default settings. The structured `areas` field carries
143
+ * the full data for callers who need it; this is the human view.
144
+ */
145
+ export declare function buildMarkdownCard(result: Omit<SummarizeTraceResult, "markdown">, verbose: boolean): string;
146
+ export declare function summarizeTrace(input: SummarizeTraceInput): Promise<SummarizeTraceResult>;
147
+ export declare const SUMMARIZE_AREA_KEYS: readonly ["hangs", "hitches", "timeProfile", "allocations", "appLaunch"];
@@ -0,0 +1,424 @@
1
+ /**
2
+ * `summarizeTrace`: the trace-to-summary-card-in-one-call feature.
3
+ *
4
+ * Today an agent handed a `.trace` chains inspectTrace + up to 5
5
+ * analyzers + reasons over tens of KB of JSON. That's 6 round-trips
6
+ * and ~$0.10-0.20 in tokens, most of which get thrown away after the
7
+ * agent identifies the one user-visible finding.
8
+ *
9
+ * summarizeTrace does it all in one call:
10
+ *
11
+ * 1. Inspects the TOC via inspectTrace (reuses the v1.11 path).
12
+ * 2. For each populated known schema, runs the matching analyzer in
13
+ * parallel with smart defaults tuned for "what would a user care
14
+ * about?" (Apple's 100ms hitch threshold, top-10 hangs, top-15
15
+ * time-profile symbols, etc).
16
+ * 3. Cross-correlates findings (v1.13 Phase 2): hangs overlapping
17
+ * with hitches, allocation spikes preceding hangs, etc.
18
+ * 4. Produces a structured result PLUS a pre-rendered compact
19
+ * markdown card (< 10 KB target) suitable for direct presentation
20
+ * to the user without further reasoning.
21
+ *
22
+ * Strategic positioning: this is memorydetective's "synthesis over
23
+ * raw-query" play vs trace-MCPs that go deep on single-schema access.
24
+ * See `~/Desktop/internal/v1.9-notelet-retro-market.md` §4.5 for the
25
+ * full framing.
26
+ */
27
+ import { z } from "zod";
28
+ import { existsSync } from "node:fs";
29
+ import { resolve as resolvePath, basename } from "node:path";
30
+ import { inspectTrace } from "./inspectTrace.js";
31
+ import { analyzeHangs, } from "./analyzeHangs.js";
32
+ import { analyzeAnimationHitches, } from "./analyzeAnimationHitches.js";
33
+ import { analyzeTimeProfile, } from "./analyzeTimeProfile.js";
34
+ import { analyzeAllocations, } from "./analyzeAllocations.js";
35
+ import { analyzeAppLaunch, } from "./analyzeAppLaunch.js";
36
+ export const summarizeTraceSchema = z.object({
37
+ tracePath: z
38
+ .string()
39
+ .min(1)
40
+ .describe("Absolute path to a `.trace` bundle (output of `xcrun xctrace record` or Instruments)."),
41
+ focus: z
42
+ .enum(["hangs", "hitches", "allocations", "launch", "all"])
43
+ .default("all")
44
+ .describe("When set to a specific area, the summary card emphasizes that area and downplays others. Useful for piping into more focused agent loops. Default `all`."),
45
+ verbose: z
46
+ .boolean()
47
+ .default(false)
48
+ .describe("When true, the markdown card includes the full top-N per area (15+ rows per section) instead of the default 5. Trade-off: card grows from <10 KB to potentially 30+ KB."),
49
+ });
50
+ const DEFAULT_HITCH_THRESHOLD_MS = 100; // Apple's user-perceptible threshold.
51
+ const DEFAULT_HANG_MIN_MS = 100;
52
+ const DEFAULT_TOP_N_HANGS = 10;
53
+ const DEFAULT_TOP_N_HITCHES = 10;
54
+ const DEFAULT_TOP_N_TIME_PROFILE = 15;
55
+ const DEFAULT_TOP_N_ALLOCATIONS = 10;
56
+ /**
57
+ * Build a per-area summary by running an analyzer with smart defaults
58
+ * and wrapping the outcome (success / schema-absent / failed) into a
59
+ * status-tagged struct. The schema-absent branch reads the inspectTrace
60
+ * `rowCounts` so we don't spawn xctrace for empty schemas.
61
+ */
62
+ async function buildAreaSummary(schemaName, inspection, runner, schemaAbsentDiagnosis) {
63
+ const rowCount = inspection.rowCounts[schemaName] ?? 0;
64
+ if (rowCount === 0) {
65
+ return {
66
+ status: "schema-absent",
67
+ diagnosis: schemaAbsentDiagnosis,
68
+ };
69
+ }
70
+ try {
71
+ const result = await runner();
72
+ return {
73
+ status: "ok",
74
+ diagnosis: `${rowCount.toLocaleString()} rows analyzed.`,
75
+ result,
76
+ };
77
+ }
78
+ catch (err) {
79
+ return {
80
+ status: "failed",
81
+ diagnosis: `Analyzer failed: ${err instanceof Error ? err.message : String(err)}`,
82
+ };
83
+ }
84
+ }
85
+ /**
86
+ * Pure: detect hangs whose window overlaps with animation hitches.
87
+ * When a user sees a hang AND a hitch in the same time window,
88
+ * they almost certainly perceived the impact (the main-thread block
89
+ * delayed render commits, dropping frames).
90
+ *
91
+ * The overlap check is symmetric: a hitch can fall within a hang's
92
+ * window OR a hang can fall within a hitch's window. Both directions
93
+ * are treated equally.
94
+ *
95
+ * Confidence:
96
+ *
97
+ * - `high`: both events >= 250ms AND the overlap span >= 100ms.
98
+ * - `medium`: at least one event >= 250ms.
99
+ * - `low`: neither event >= 250ms but the windows touch.
100
+ *
101
+ * Results are sorted by `atSec` ascending so the markdown card lists
102
+ * correlations in trace order.
103
+ */
104
+ export function correlateHangsAndHitches(hangs, hitches) {
105
+ const results = [];
106
+ for (const hang of hangs) {
107
+ const hangEnd = hang.startNs + hang.durationNs;
108
+ for (const hitch of hitches) {
109
+ const hitchEnd = hitch.startNs + hitch.durationNs;
110
+ // Half-open interval overlap: max(starts) < min(ends).
111
+ const overlapStart = Math.max(hang.startNs, hitch.startNs);
112
+ const overlapEnd = Math.min(hangEnd, hitchEnd);
113
+ if (overlapEnd <= overlapStart)
114
+ continue;
115
+ const overlapMs = (overlapEnd - overlapStart) / 1e6;
116
+ const atSec = Math.min(hang.startNs, hitch.startNs) / 1e9;
117
+ let confidence;
118
+ if (hang.durationMs >= 250 &&
119
+ hitch.durationMs >= 250 &&
120
+ overlapMs >= 100) {
121
+ confidence = "high";
122
+ }
123
+ else if (hang.durationMs >= 250 || hitch.durationMs >= 250) {
124
+ confidence = "medium";
125
+ }
126
+ else {
127
+ confidence = "low";
128
+ }
129
+ const hitchKind = hitch.hitchType ? `${hitch.hitchType} ` : "";
130
+ const narrative = `Hang at t=${(hang.startNs / 1e9).toFixed(2)}s (${hang.durationMs.toFixed(0)}ms) overlaps with ${hitchKind}hitch at t=${(hitch.startNs / 1e9).toFixed(2)}s (${hitch.durationMs.toFixed(0)}ms). Main-thread block likely caused the dropped frames.`;
131
+ results.push({ kind: "hangs+hitches", confidence, narrative, atSec });
132
+ }
133
+ }
134
+ results.sort((a, b) => a.atSec - b.atSec);
135
+ return results;
136
+ }
137
+ /**
138
+ * Pure: build all cross-area correlations from per-area summaries.
139
+ * Currently only `hangs+hitches` produces entries; allocation-based
140
+ * correlations need per-timestamp allocation data the existing
141
+ * analyzeAllocations doesn't expose (v1.14+ candidate).
142
+ */
143
+ export function buildCorrelations(areas) {
144
+ const hangs = areas.hangs.result?.top ?? [];
145
+ const hitches = areas.hitches.result?.top ?? [];
146
+ if (hangs.length === 0 || hitches.length === 0)
147
+ return [];
148
+ return correlateHangsAndHitches(hangs, hitches);
149
+ }
150
+ /**
151
+ * Pure: produce the one-or-two-sentence headline that goes at the top
152
+ * of the markdown card. Picks the most user-visible finding across
153
+ * all areas. Order of priority: longest hang above 250ms > worst
154
+ * launch phase > worst hitch > largest allocation spike.
155
+ */
156
+ export function buildHeadline(areas) {
157
+ const hang = areas.hangs.result?.top?.[0];
158
+ if (hang && hang.durationMs >= 250) {
159
+ const violation = hang.mainThreadViolations?.[0];
160
+ const causedBy = violation
161
+ ? ` (caused by \`${violation.topFrame}\` -> ${violation.kind})`
162
+ : "";
163
+ return `${hang.durationMs.toFixed(0)}ms hang at t=${(hang.startNs / 1e9).toFixed(2)}s${causedBy}. Likely user-visible freeze.`;
164
+ }
165
+ const launch = areas.appLaunch.result;
166
+ if (launch && launch.totalLaunchMs > 1000) {
167
+ return `Launch took ${launch.totalLaunchMs.toFixed(0)}ms (${launch.launchType} launch). Above the 1s user-visible threshold.`;
168
+ }
169
+ const hitchesPerceptible = areas.hitches.result?.totals?.perceptible ?? 0;
170
+ if (hitchesPerceptible > 0) {
171
+ return `${hitchesPerceptible} animation hitch${hitchesPerceptible === 1 ? "" : "es"} above the 100ms user-perceptible threshold. Investigate render-server commits and main-thread work during scroll.`;
172
+ }
173
+ if (hang) {
174
+ return `${hang.durationMs.toFixed(0)}ms hang at t=${(hang.startNs / 1e9).toFixed(2)}s. Below the 250ms user-visible threshold but still worth investigating.`;
175
+ }
176
+ return "No user-perceptible perf events detected in the analyzed schemas.";
177
+ }
178
+ /**
179
+ * Pure: assemble the compact markdown summary card. Designed to be
180
+ * <10 KB at default settings. The structured `areas` field carries
181
+ * the full data for callers who need it; this is the human view.
182
+ */
183
+ export function buildMarkdownCard(result, verbose) {
184
+ const sections = [];
185
+ const inspection = result.inspection;
186
+ const traceName = basename(result.tracePath);
187
+ sections.push(`# Trace summary: ${traceName}`);
188
+ sections.push("");
189
+ const meta = [];
190
+ if (inspection.deviceModel)
191
+ meta.push(inspection.deviceModel);
192
+ if (inspection.osVersion)
193
+ meta.push(inspection.osVersion);
194
+ if (inspection.templateName)
195
+ meta.push(`Template: \`${inspection.templateName}\``);
196
+ if (meta.length > 0)
197
+ sections.push(`**${meta.join(" · ")}**`);
198
+ sections.push("");
199
+ sections.push(`> **Headline:** ${result.headline}`);
200
+ sections.push("");
201
+ const topNHangs = verbose ? DEFAULT_TOP_N_HANGS : 5;
202
+ const topNHitches = verbose ? DEFAULT_TOP_N_HITCHES : 5;
203
+ const topNAllocations = verbose ? DEFAULT_TOP_N_ALLOCATIONS : 5;
204
+ const topNTimeProfile = verbose ? DEFAULT_TOP_N_TIME_PROFILE : 5;
205
+ // Hangs section.
206
+ if (result.areas.hangs.status === "ok" && result.areas.hangs.result) {
207
+ const h = result.areas.hangs.result;
208
+ const totalHangs = h.totals?.hangs ?? 0;
209
+ const totalMicrohangs = h.totals?.microhangs ?? 0;
210
+ const userVisible = (h.top ?? []).filter((e) => e.durationMs >= 250).length;
211
+ sections.push(`## Hangs (${totalHangs}, ${userVisible} user-visible, ${totalMicrohangs} microhang${totalMicrohangs === 1 ? "" : "s"})`);
212
+ sections.push("");
213
+ if ((h.top ?? []).length > 0) {
214
+ for (const entry of (h.top ?? []).slice(0, topNHangs)) {
215
+ const at = (entry.startNs / 1e9).toFixed(2);
216
+ const violation = entry.mainThreadViolations?.[0];
217
+ const classification = violation
218
+ ? ` → ${violation.kind} (\`${violation.topFrame}\`)`
219
+ : "";
220
+ sections.push(`- ${entry.durationMs.toFixed(0)}ms at t=${at}s${classification}`);
221
+ }
222
+ }
223
+ else {
224
+ sections.push("_No hang events above the minimum threshold._");
225
+ }
226
+ sections.push("");
227
+ }
228
+ else if (result.areas.hangs.status === "schema-absent") {
229
+ // Suppressed when no hangs data; reduces card clutter.
230
+ }
231
+ else {
232
+ sections.push(`## Hangs`);
233
+ sections.push("");
234
+ sections.push(`_${result.areas.hangs.diagnosis}_`);
235
+ sections.push("");
236
+ }
237
+ // Animation hitches section.
238
+ if (result.areas.hitches.status === "ok" && result.areas.hitches.result) {
239
+ const h = result.areas.hitches.result;
240
+ const totalHitches = h.totals?.rows ?? 0;
241
+ const perceptible = h.totals?.perceptible ?? 0;
242
+ sections.push(`## Animation hitches (${totalHitches}, ${perceptible} above 100ms)`);
243
+ sections.push("");
244
+ if ((h.top ?? []).length > 0) {
245
+ sections.push("| At | Duration | Type |");
246
+ sections.push("|---|---:|---|");
247
+ for (const entry of (h.top ?? []).slice(0, topNHitches)) {
248
+ const at = `t=${(entry.startNs / 1e9).toFixed(2)}s`;
249
+ sections.push(`| ${at} | ${entry.durationMs.toFixed(0)}ms | ${entry.hitchType || "—"} |`);
250
+ }
251
+ }
252
+ sections.push("");
253
+ }
254
+ else if (result.areas.hitches.status === "failed") {
255
+ sections.push(`## Animation hitches`);
256
+ sections.push("");
257
+ sections.push(`_${result.areas.hitches.diagnosis}_`);
258
+ sections.push("");
259
+ }
260
+ // Time profile section. analyzeTimeProfile may surface a workaround
261
+ // notice (xctrace SIGSEGV) via `notice`; we surface it inline so the
262
+ // summary card flags the partial-data situation.
263
+ if (result.areas.timeProfile.status === "ok" &&
264
+ result.areas.timeProfile.result) {
265
+ const tp = result.areas.timeProfile.result;
266
+ sections.push(`## Time profile (${tp.totalSamples.toLocaleString()} samples, top ${topNTimeProfile} symbols)`);
267
+ sections.push("");
268
+ if (tp.notice) {
269
+ sections.push(`> _${tp.notice}_`);
270
+ sections.push("");
271
+ }
272
+ const symbols = tp.topSymbols ?? [];
273
+ if (symbols.length > 0) {
274
+ for (const s of symbols.slice(0, topNTimeProfile)) {
275
+ const pct = tp.totalSamples > 0
276
+ ? `${((s.samples / tp.totalSamples) * 100).toFixed(1)}%`
277
+ : `${s.samples} samples`;
278
+ sections.push(`- ${pct} \`${s.symbol || "???"}\``);
279
+ }
280
+ }
281
+ else {
282
+ sections.push("_No symbols above the noise threshold._");
283
+ }
284
+ sections.push("");
285
+ }
286
+ else if (result.areas.timeProfile.status === "failed") {
287
+ sections.push(`## Time profile`);
288
+ sections.push("");
289
+ sections.push(`_${result.areas.timeProfile.diagnosis}_`);
290
+ sections.push("");
291
+ }
292
+ // Allocations section.
293
+ if (result.areas.allocations.status === "ok" &&
294
+ result.areas.allocations.result) {
295
+ const a = result.areas.allocations.result;
296
+ const cumulativeBytes = a.totals.cumulativeBytes;
297
+ const persistentBytes = a.totals.persistentBytes;
298
+ sections.push(`## Allocations (${(cumulativeBytes / 1024 / 1024).toFixed(1)} MB cumulative, ${(persistentBytes / 1024 / 1024).toFixed(1)} MB persistent)`);
299
+ sections.push("");
300
+ const top = a.topByBytes ?? [];
301
+ if (top.length > 0) {
302
+ sections.push("| Category | Lifecycle | Bytes (cumulative) | Count |");
303
+ sections.push("|---|---|---:|---:|");
304
+ for (const entry of top.slice(0, topNAllocations)) {
305
+ const mb = (entry.cumulativeBytes / 1024 / 1024).toFixed(2);
306
+ sections.push(`| \`${entry.category}\` | ${entry.lifecycle} | ${mb} MB | ${entry.cumulativeCount.toLocaleString()} |`);
307
+ }
308
+ }
309
+ sections.push("");
310
+ }
311
+ // App launch section.
312
+ if (result.areas.appLaunch.status === "ok" &&
313
+ result.areas.appLaunch.result) {
314
+ const al = result.areas.appLaunch.result;
315
+ sections.push(`## App launch (${al.launchType}, ${al.totalLaunchMs.toFixed(0)}ms total)`);
316
+ sections.push("");
317
+ if ((al.phases ?? []).length > 0) {
318
+ sections.push("| Phase | Duration | % of total |");
319
+ sections.push("|---|---:|---:|");
320
+ for (const p of al.phases ?? []) {
321
+ sections.push(`| ${p.label || p.phase} | ${p.durationMs.toFixed(0)}ms | ${p.percentOfTotal.toFixed(1)}% |`);
322
+ }
323
+ }
324
+ sections.push("");
325
+ }
326
+ // Cross-correlations (v1.13 Phase 2). High and medium go straight
327
+ // into the card; low-confidence entries collapsed into a single
328
+ // "plus N more" line to keep the card compact.
329
+ const correlations = result.correlations ?? [];
330
+ if (correlations.length > 0) {
331
+ const highMedium = correlations.filter((c) => c.confidence === "high" || c.confidence === "medium");
332
+ const low = correlations.filter((c) => c.confidence === "low");
333
+ if (highMedium.length > 0 || verbose) {
334
+ sections.push("## Cross-correlations");
335
+ sections.push("");
336
+ const visible = verbose ? correlations : highMedium;
337
+ for (const c of visible) {
338
+ const confidenceBadge = c.confidence === "high"
339
+ ? "**HIGH**"
340
+ : c.confidence === "medium"
341
+ ? "MEDIUM"
342
+ : "low";
343
+ sections.push(`- (${confidenceBadge}) ${c.narrative}`);
344
+ }
345
+ if (!verbose && low.length > 0) {
346
+ sections.push(`- _${low.length} low-confidence overlap${low.length === 1 ? "" : "s"} omitted; pass \`verbose: true\` to see them._`);
347
+ }
348
+ sections.push("");
349
+ }
350
+ }
351
+ // Suggested next calls from inspectTrace (carries them already).
352
+ if (inspection.suggestedNextCalls.length > 0) {
353
+ sections.push("## Suggested next calls");
354
+ sections.push("");
355
+ for (const call of inspection.suggestedNextCalls.slice(0, 5)) {
356
+ sections.push(`- \`${call.tool}\` — ${call.why}`);
357
+ }
358
+ sections.push("");
359
+ }
360
+ return sections.join("\n").trim();
361
+ }
362
+ export async function summarizeTrace(input) {
363
+ const tracePath = resolvePath(input.tracePath);
364
+ if (!existsSync(tracePath)) {
365
+ throw new Error(`Trace bundle not found: ${tracePath}`);
366
+ }
367
+ const verbose = input.verbose ?? false;
368
+ // Step 1: TOC.
369
+ const inspection = await inspectTrace({ tracePath });
370
+ // Step 2: chain analyzers in parallel. Each branch is fault-tolerant
371
+ // via buildAreaSummary so one failure doesn't tank the whole summary.
372
+ const [hangs, hitches, timeProfile, allocations, appLaunch] = await Promise.all([
373
+ buildAreaSummary("potential-hangs", inspection, () => analyzeHangs({
374
+ tracePath,
375
+ topN: DEFAULT_TOP_N_HANGS,
376
+ minDurationMs: DEFAULT_HANG_MIN_MS,
377
+ includeStackClassification: true,
378
+ }), "potential-hangs schema absent from this trace."),
379
+ buildAreaSummary("animation-hitches", inspection, () => analyzeAnimationHitches({
380
+ tracePath,
381
+ topN: DEFAULT_TOP_N_HITCHES,
382
+ minDurationMs: DEFAULT_HITCH_THRESHOLD_MS,
383
+ }), "animation-hitches schema absent from this trace."),
384
+ buildAreaSummary("time-profile", inspection, () => analyzeTimeProfile({
385
+ tracePath,
386
+ topN: DEFAULT_TOP_N_TIME_PROFILE,
387
+ }), "time-profile schema absent from this trace."),
388
+ buildAreaSummary("allocations", inspection, () => analyzeAllocations({
389
+ tracePath,
390
+ topN: DEFAULT_TOP_N_ALLOCATIONS,
391
+ minBytes: 0,
392
+ }), "allocations schema absent from this trace."),
393
+ buildAreaSummary("app-launch", inspection, () => analyzeAppLaunch({ tracePath }), "app-launch schema absent from this trace."),
394
+ ]);
395
+ const areas = {
396
+ hangs,
397
+ hitches,
398
+ timeProfile,
399
+ allocations,
400
+ appLaunch,
401
+ };
402
+ const correlations = buildCorrelations(areas);
403
+ const headline = buildHeadline(areas);
404
+ const base = {
405
+ ok: true,
406
+ tracePath,
407
+ inspection,
408
+ areas,
409
+ correlations,
410
+ headline,
411
+ };
412
+ const markdown = buildMarkdownCard(base, verbose);
413
+ return { ...base, markdown };
414
+ }
415
+ // Helper for tests: ensure the keys in `areas` stay aligned with the
416
+ // implementation. Imported by the test file.
417
+ export const SUMMARIZE_AREA_KEYS = [
418
+ "hangs",
419
+ "hitches",
420
+ "timeProfile",
421
+ "allocations",
422
+ "appLaunch",
423
+ ];
424
+ //# sourceMappingURL=summarizeTrace.js.map