1/**
2 * Attribute TypeScript check time and type creation to their source —
3 * "which types are making the checker slow, and whose fault are they?"
4 *
5 * The workspace compiler is tsgo (TS 7), which does NOT support
6 * `--generateTrace`, so traces are produced with Strada (TS 5.x) used purely
7 * as a measurement instrument against the same project:
8 *
9 * ```sh
10 * # 1. generate a trace (~2.5 min, ~12GB RAM for packages/alchemy)
11 * bunx --package typescript@5.9 tsc \
12 * -p packages/alchemy/tsconfig.json \
13 * --noEmit --incremental false --composite false \
14 * --generateTrace /tmp/tstrace
15 *
16 * # 2. attribute it
17 * bun scripts/analyze-type-trace.ts /tmp/tstrace
18 * ```
19 *
20 * Reports, from `trace.json` + a streaming pass over `types.json`:
21 * - check self-time per bucket (src/AWS, src/Cloudflare, core, distilled
22 * lib, effect, node_modules, TS libs) and the top files
23 * - top-level `structuredTypeRelatedTo` relation pairs by time — this is
24 * what exposed the 28.4s `Worker<NormalizedBindings<...>>` vs
25 * `WorkerBindingResource` constraint proof (45% of all check time)
26 * - locationless union/intersection types attributed to their first
27 * member's origin, and the top symbols by types created
28 *
29 * NOTE: `npx @typescript/analyze-trace` is complementary (per-file hot-spot
30 * drill-downs from trace.json) but crashes with "Invalid string length" when
31 * given an Effect-scale types.json — this script streams it instead.
32 * types.json can exceed 1GB; the stream pass takes ~1 min.
33 */
34import * as fs from "node:fs";
35import * as path from "node:path";
36import * as readline from "node:readline";
37
38const traceDir = process.argv[2];
39if (!traceDir) {
40 console.error("usage: bun scripts/analyze-type-trace.ts <traceDir>");
41 process.exit(1);
42}
43
44const BUCKETS = [
45 "(unknown)",
46 "packages/alchemy/src/AWS",
47 "packages/alchemy/src/Cloudflare",
48 "packages/alchemy/src (core)",
49 "submodules/distilled/*/lib",
50 "node_modules/effect",
51 "other node_modules",
52 "typescript libs",
53 "other",
54] as const;
55
56// Match by path segment rather than a repo-root prefix so the script works no
57// matter which checkout produced the trace. Strada lowercases paths on macOS.
58function bucketIdOf(p: string | undefined): number {
59 if (!p) return 0;
60 const lower = p.toLowerCase();
61 if (lower.includes("node_modules/effect/")) return 5;
62 if (lower.includes("node_modules/typescript/lib/")) return 7;
63 if (lower.includes("node_modules/")) return 6;
64 if (lower.includes("packages/alchemy/src/aws/")) return 1;
65 if (lower.includes("packages/alchemy/src/cloudflare/")) return 2;
66 if (lower.includes("packages/alchemy/src/")) return 3;
67 if (/distilled\/packages\/[^/]+\//.test(lower)) return 4;
68 return 8;
69}
70
71// Strip the absolute prefix before the first recognizable repo-relative
72// segment, purely for display.
73const rel = (p: string | undefined) => {
74 if (!p) return "?";
75 const m = p.match(
76 /(?:packages|submodules|distilled|node_modules|examples|scripts)\/.*$/,
77 );
78 return m ? m[0] : p;
79};
80
81// ---------- trace.json: check self-time + top-level relation pairs ----------
82interface TraceEvent {
83 name?: string;
84 ph?: string;
85 ts: number;
86 dur?: number;
87 args?: { path?: string; sourceId?: number; targetId?: number };
88}
89
90const trace: TraceEvent[] = JSON.parse(
91 fs.readFileSync(path.join(traceDir, "trace.json"), "utf8"),
92);
93
94const stack: { p: string | undefined; start: number; child: number }[] = [];
95const fileSelf = new Map<string, number>();
96let totalCheck = 0;
97const relEvents: TraceEvent[] = [];
98for (const ev of trace) {
99 if (!ev?.name) continue;
100 if (ev.name === "checkSourceFile") {
101 if (ev.ph === "B") {
102 stack.push({ p: ev.args?.path, start: ev.ts, child: 0 });
103 } else if (ev.ph === "E" && stack.length) {
104 const f = stack.pop()!;
105 const dur = ev.ts - f.start;
106 const self = dur - f.child;
107 if (stack.length) stack[stack.length - 1].child += dur;
108 fileSelf.set(f.p ?? "?", (fileSelf.get(f.p ?? "?") ?? 0) + self);
109 totalCheck += self;
110 }
111 } else if (ev.name === "structuredTypeRelatedTo" && ev.ph === "X") {
112 relEvents.push(ev);
113 }
114}
115
116// Keep only top-level relation intervals (not nested inside a prior one).
117relEvents.sort((a, b) => a.ts - b.ts);
118let coverEnd = -1;
119const pairTime = new Map<string, number>();
120let topLevelRelTotal = 0;
121const neededIds = new Set<number>();
122for (const ev of relEvents) {
123 if (ev.ts >= coverEnd) {
124 topLevelRelTotal += ev.dur ?? 0;
125 coverEnd = ev.ts + (ev.dur ?? 0);
126 const k = `${ev.args!.sourceId}->${ev.args!.targetId}`;
127 pairTime.set(k, (pairTime.get(k) ?? 0) + (ev.dur ?? 0));
128 neededIds.add(ev.args!.sourceId!);
129 neededIds.add(ev.args!.targetId!);
130 }
131}
132const topPairs = [...pairTime.entries()].sort((a, b) => b[1] - a[1]).slice(0, 25);
133
134const out: string[] = [];
135out.push(
136 `== checkSourceFile self-time: ${(totalCheck / 1e6).toFixed(1)}s over ${fileSelf.size} files ==`,
137 "",
138 "== Check self-time by bucket ==",
139);
140const bucketTime = new Map<string, number>();
141for (const [p, us] of fileSelf) {
142 const b = BUCKETS[bucketIdOf(p)];
143 bucketTime.set(b, (bucketTime.get(b) ?? 0) + us);
144}
145for (const [b, us] of [...bucketTime.entries()].sort((a, b) => b[1] - a[1])) {
146 out.push(
147 `${(us / 1000).toFixed(0).padStart(9)} ms ${((us / totalCheck) * 100).toFixed(1).padStart(5)}% ${b}`,
148 );
149}
150out.push("", "== Top 30 files by check self-time ==");
151for (const [p, us] of [...fileSelf.entries()].sort((a, b) => b[1] - a[1]).slice(0, 30)) {
152 out.push(`${(us / 1000).toFixed(0).padStart(9)} ms ${rel(p)}`);
153}
154out.push(
155 "",
156 `== Top-level structuredTypeRelatedTo total: ${(topLevelRelTotal / 1e6).toFixed(1)}s ==`,
157);
158
159// ---------- types.json: streamed attribution ----------
160interface TypeDescriptor {
161 id: number;
162 symbolName?: string;
163 firstDeclaration?: { path?: string; start?: { line: number } };
164 location?: { path?: string; line?: number };
165 intersectionTypes?: number[];
166 unionTypes?: number[];
167 flags?: string[];
168}
169
170const typesPath = path.join(traceDir, "types.json");
171const rl = readline.createInterface({
172 input: fs.createReadStream(typesPath, { encoding: "utf8", highWaterMark: 1 << 22 }),
173 crlfDelay: Infinity,
174});
175
176const MAX = 16_000_000;
177const bucketById = new Uint8Array(MAX);
178const idInfo = new Map<
179 number,
180 { sym?: string; path?: string; line?: number; flags?: string }
181>();
182const compositeByBucket = new Map<string, number>();
183const symCount = new Map<string, number>();
184const locatedByBucket = new Map<string, number>();
185let total = 0;
186
187for await (let line of rl) {
188 line = line.trim();
189 if (line === "[" || line === "]" || line === "") continue;
190 if (line.startsWith("[")) line = line.slice(1);
191 if (line.endsWith(",")) line = line.slice(0, -1);
192 if (line[0] !== "{") continue;
193 let t: TypeDescriptor;
194 try {
195 t = JSON.parse(line);
196 } catch {
197 continue;
198 }
199 total++;
200 const decl = t.firstDeclaration ?? t.location;
201 const p = decl && ("path" in decl ? decl.path : undefined);
202 const lineNo =
203 decl && ("start" in decl && decl.start ? decl.start.line : (decl as { line?: number }).line);
204 let b = 0;
205 if (p) {
206 b = bucketIdOf(p);
207 locatedByBucket.set(BUCKETS[b], (locatedByBucket.get(BUCKETS[b]) ?? 0) + 1);
208 if (t.symbolName) {
209 const key = `${t.symbolName} @ ${rel(p)}:${lineNo}`;
210 symCount.set(key, (symCount.get(key) ?? 0) + 1);
211 }
212 } else {
213 const members = t.intersectionTypes ?? t.unionTypes;
214 if (members) {
215 // Attribute a locationless composite to its first non-lib member.
216 for (const m of members) {
217 if (m > 0 && m < MAX && bucketById[m] !== 0 && bucketById[m] !== 7) {
218 b = bucketById[m];
219 break;
220 }
221 }
222 if (b === 0) {
223 for (const m of members) {
224 if (m > 0 && m < MAX && bucketById[m] !== 0) {
225 b = bucketById[m];
226 break;
227 }
228 }
229 }
230 const bn = BUCKETS[b] + (t.intersectionTypes ? " [intersection]" : " [union]");
231 compositeByBucket.set(bn, (compositeByBucket.get(bn) ?? 0) + 1);
232 }
233 }
234 if (t.id < MAX) bucketById[t.id] = b;
235 if (neededIds.has(t.id)) {
236 idInfo.set(t.id, {
237 sym: t.symbolName,
238 path: p,
239 line: lineNo,
240 flags: t.flags?.join("|"),
241 });
242 }
243 if (total % 1_000_000 === 0) process.stderr.write(` ...streamed ${total} types\n`);
244}
245
246out.push("", `== ${total} types total; located types by bucket ==`);
247for (const [b, c] of [...locatedByBucket.entries()].sort((a, b) => b[1] - a[1])) {
248 out.push(`${String(c).padStart(9)} ${b}`);
249}
250out.push("", "== Locationless union/intersection types by first member's bucket ==");
251for (const [b, c] of [...compositeByBucket.entries()].sort((a, b) => b[1] - a[1])) {
252 out.push(`${String(c).padStart(9)} ${b}`);
253}
254out.push("", "== Top 30 symbols by types created ==");
255for (const [k, c] of [...symCount.entries()].sort((a, b) => b[1] - a[1]).slice(0, 30)) {
256 out.push(`${String(c).padStart(9)} ${k}`);
257}
258out.push("", "== Top 25 top-level relation pairs by time ==");
259const fmt = (id: number) => {
260 const i = idInfo.get(id);
261 if (!i) return `#${id}`;
262 const loc = i.path ? `${rel(i.path)}:${i.line}` : i.flags;
263 return `${i.sym ?? "(anon)"} [${i.flags}] ${loc}`;
264};
265for (const [k, us] of topPairs) {
266 const [s, tg] = k.split("->").map(Number);
267 out.push(`${(us / 1000).toFixed(0).padStart(8)} ms ${fmt(s)}`, ` -> ${fmt(tg)}`);
268}
269
270const outPath = path.join(traceDir, "attribution.txt");
271fs.writeFileSync(outPath, `${out.join("\n")}\n`);
272console.log(out.join("\n"));
273console.error(`\nwritten to ${outPath}`);