@barefootjs/jsx 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/dist/compiler.d.ts.map +1 -1
  2. package/dist/debug-profile.d.ts +115 -0
  3. package/dist/debug-profile.d.ts.map +1 -0
  4. package/dist/debug.d.ts +4 -3
  5. package/dist/debug.d.ts.map +1 -1
  6. package/dist/expression-parser.d.ts +31 -0
  7. package/dist/expression-parser.d.ts.map +1 -1
  8. package/dist/index.d.ts +8 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +1905 -207
  11. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts +6 -0
  12. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/control-flow/plan/build-component-loop.d.ts +1 -1
  16. package/dist/ir-to-client-js/control-flow/plan/build-component-loop.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/control-flow/plan/build-composite-loop.d.ts +2 -2
  18. package/dist/ir-to-client-js/control-flow/plan/build-composite-loop.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.d.ts +3 -3
  20. package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/plan/build-insert.d.ts +2 -0
  23. package/dist/ir-to-client-js/control-flow/plan/build-insert.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts +2 -0
  25. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
  26. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts +4 -2
  27. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  28. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts +3 -1
  29. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
  30. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts +7 -0
  31. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts.map +1 -1
  32. package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts +7 -0
  33. package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts.map +1 -1
  34. package/dist/ir-to-client-js/control-flow/plan/insert.d.ts +8 -0
  35. package/dist/ir-to-client-js/control-flow/plan/insert.d.ts.map +1 -1
  36. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts +8 -0
  37. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts.map +1 -1
  38. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +28 -0
  39. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  40. package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts +7 -0
  41. package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts.map +1 -1
  42. package/dist/ir-to-client-js/control-flow/stringify/component-loop.d.ts.map +1 -1
  43. package/dist/ir-to-client-js/control-flow/stringify/composite-loop.d.ts.map +1 -1
  44. package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts.map +1 -1
  45. package/dist/ir-to-client-js/control-flow/stringify/event-listener.d.ts +1 -1
  46. package/dist/ir-to-client-js/control-flow/stringify/event-listener.d.ts.map +1 -1
  47. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts +1 -1
  48. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts.map +1 -1
  49. package/dist/ir-to-client-js/control-flow/stringify/insert.d.ts.map +1 -1
  50. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts +2 -2
  51. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  52. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  53. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
  54. package/dist/ir-to-client-js/control-flow.d.ts.map +1 -1
  55. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  56. package/dist/ir-to-client-js/imports.d.ts +2 -2
  57. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  58. package/dist/ir-to-client-js/index.d.ts +2 -2
  59. package/dist/ir-to-client-js/index.d.ts.map +1 -1
  60. package/dist/ir-to-client-js/phases/effects-and-on-mounts.d.ts.map +1 -1
  61. package/dist/ir-to-client-js/phases/event-handlers.d.ts.map +1 -1
  62. package/dist/ir-to-client-js/plan/build-declaration-emit.d.ts.map +1 -1
  63. package/dist/ir-to-client-js/plan/declaration-emit.d.ts +6 -0
  64. package/dist/ir-to-client-js/plan/declaration-emit.d.ts.map +1 -1
  65. package/dist/ir-to-client-js/types.d.ts +5 -0
  66. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  67. package/dist/ir-to-client-js/utils.d.ts +29 -0
  68. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  69. package/dist/loop-destructure.d.ts +26 -0
  70. package/dist/loop-destructure.d.ts.map +1 -0
  71. package/dist/profiler.d.ts +492 -0
  72. package/dist/profiler.d.ts.map +1 -0
  73. package/dist/ssr-defaults.d.ts.map +1 -1
  74. package/dist/types.d.ts +8 -0
  75. package/dist/types.d.ts.map +1 -1
  76. package/package.json +2 -2
  77. package/src/__tests__/debug-profile.test.ts +405 -0
  78. package/src/__tests__/expression-parser.test.ts +44 -1
  79. package/src/__tests__/profile-bfid-emission.test.ts +63 -0
  80. package/src/__tests__/profile-binding-ids.test.ts +123 -0
  81. package/src/__tests__/profile-cond-binding-ids.test.ts +80 -0
  82. package/src/__tests__/profile-loop-binding-ids.test.ts +106 -0
  83. package/src/__tests__/profile-nested-binding-ids.test.ts +153 -0
  84. package/src/__tests__/profile-turn-markers-branch.test.ts +83 -0
  85. package/src/__tests__/profile-turn-markers-delegation.test.ts +63 -0
  86. package/src/__tests__/profile-turn-markers.test.ts +54 -0
  87. package/src/__tests__/profiler-batch-advisor.test.ts +198 -0
  88. package/src/__tests__/profiler-coverage-conformance.test.ts +360 -0
  89. package/src/__tests__/profiler-e2e.test.ts +104 -0
  90. package/src/__tests__/profiler-hot-subscribers.test.ts +263 -0
  91. package/src/__tests__/profiler-wasted-re-runs.test.ts +147 -0
  92. package/src/__tests__/profiler.test.ts +408 -0
  93. package/src/__tests__/ssr-defaults.test.ts +24 -0
  94. package/src/compiler.ts +3 -0
  95. package/src/debug-profile.ts +543 -0
  96. package/src/debug.ts +192 -28
  97. package/src/expression-parser.ts +53 -0
  98. package/src/index.ts +72 -1
  99. package/src/ir-to-client-js/control-flow/plan/branch-loop.ts +6 -0
  100. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +5 -3
  101. package/src/ir-to-client-js/control-flow/plan/build-component-loop.ts +3 -1
  102. package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +8 -2
  103. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +19 -3
  104. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +2 -0
  105. package/src/ir-to-client-js/control-flow/plan/build-insert.ts +9 -2
  106. package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +9 -1
  107. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +12 -8
  108. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +10 -4
  109. package/src/ir-to-client-js/control-flow/plan/event-delegation.ts +7 -0
  110. package/src/ir-to-client-js/control-flow/plan/inner-loop.ts +7 -0
  111. package/src/ir-to-client-js/control-flow/plan/insert.ts +8 -0
  112. package/src/ir-to-client-js/control-flow/plan/loop-child-arm.ts +8 -0
  113. package/src/ir-to-client-js/control-flow/plan/loop.ts +28 -0
  114. package/src/ir-to-client-js/control-flow/plan/reactive-effects.ts +7 -0
  115. package/src/ir-to-client-js/control-flow/stringify/branch-loop.ts +5 -3
  116. package/src/ir-to-client-js/control-flow/stringify/component-loop.ts +4 -2
  117. package/src/ir-to-client-js/control-flow/stringify/composite-loop.ts +6 -3
  118. package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +14 -2
  119. package/src/ir-to-client-js/control-flow/stringify/event-listener.ts +5 -2
  120. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +13 -11
  121. package/src/ir-to-client-js/control-flow/stringify/insert.ts +19 -7
  122. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +18 -13
  123. package/src/ir-to-client-js/control-flow/stringify/loop.ts +9 -7
  124. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +18 -14
  125. package/src/ir-to-client-js/control-flow.ts +12 -6
  126. package/src/ir-to-client-js/emit-reactive.ts +18 -5
  127. package/src/ir-to-client-js/imports.ts +2 -0
  128. package/src/ir-to-client-js/index.ts +6 -1
  129. package/src/ir-to-client-js/phases/effects-and-on-mounts.ts +10 -4
  130. package/src/ir-to-client-js/phases/event-handlers.ts +6 -2
  131. package/src/ir-to-client-js/plan/build-declaration-emit.ts +7 -1
  132. package/src/ir-to-client-js/plan/declaration-emit.ts +6 -0
  133. package/src/ir-to-client-js/stringify/declaration-emit.ts +12 -6
  134. package/src/ir-to-client-js/types.ts +5 -0
  135. package/src/ir-to-client-js/utils.ts +37 -0
  136. package/src/jsx-to-ir.ts +2 -2
  137. package/src/loop-destructure.ts +170 -0
  138. package/src/profiler.ts +1488 -0
  139. package/src/ssr-defaults.ts +65 -0
  140. package/src/types.ts +8 -0
@@ -0,0 +1,1488 @@
1
+ /**
2
+ * Reactive performance profiler — static half (SR5 / SR6).
3
+ *
4
+ * Background: issue #1690. User-facing docs live in `bf debug profile --help`
5
+ * (the design doc that used to live in `spec/profiler.md` was removed to avoid
6
+ * drift — the CLI help is the single source of truth). This module implements
7
+ * the **run-free** parts of `bf debug profile`: the static reactivity budget
8
+ * (SR5) and the compile-diff regression (SR6). Both are pure functions of
9
+ * the IR — no instrumented run is required — so they reuse the static
10
+ * analysis already shipped in `debug.ts`:
11
+ *
12
+ * - `buildComponentAnalysis` → `{ graph, ir }`
13
+ * - `buildComponentSummary` → node counts
14
+ * - `traceUpdatePath` → transitive dependents (fan-out + chain depth)
15
+ *
16
+ * The dynamic half (SR1–SR4: instrumented runtime, turn markers, IR join,
17
+ * Hot-subscribers / Wasted-re-runs / Batch-advisor analyses) is assembled by
18
+ * `buildProfileReport` from a recorded SR2 event stream — see those analyses
19
+ * below.
20
+ */
21
+
22
+ import ts from 'typescript'
23
+ import {
24
+ buildComponentAnalysis,
25
+ buildComponentSummary,
26
+ buildEventSummary,
27
+ traceUpdatePath,
28
+ type ComponentGraph,
29
+ type EventBinding,
30
+ type UpdatePathEntry,
31
+ } from './debug.ts'
32
+ import { listComponentFunctions, createProgramForFile } from './analyzer.ts'
33
+ import type { ProfilerEvent } from '@barefootjs/shared'
34
+
35
+ // -- Static budget (SR5) ------------------------------------------------------
36
+
37
+ export interface FanOutEntry {
38
+ /** Signal name (the reactive source whose change fans out). */
39
+ signal: string
40
+ /** Distinct transitive subscribers (memos + effects + DOM bindings). */
41
+ subscribers: number
42
+ /** True when `subscribers` exceeds the configured threshold. */
43
+ hot: boolean
44
+ loc: { file: string; line: number }
45
+ }
46
+
47
+ export interface StaticBudget {
48
+ componentName: string
49
+ sourceFile: string
50
+ kind: 'static-budget'
51
+ signals: number
52
+ memos: number
53
+ effects: number
54
+ loops: number
55
+ /** Σ over signals of `consumers.length` — total reactive subscriptions. */
56
+ subscriptions: number
57
+ /** Longest memo→memo dependency path length (0 = no memos). */
58
+ memoChainDepth: number
59
+ /** The memo names forming `memoChainDepth`, head-first. */
60
+ memoChainLongest: string[]
61
+ /** Per-signal fan-out, descending, hottest first. */
62
+ fanOut: FanOutEntry[]
63
+ /**
64
+ * True when the component declares reactive state (signals/memos) but nothing
65
+ * in *this* component subscribes to it — every consumer is in a composed child
66
+ * (a compound component like `Select`/`Combobox`), or the state is only read
67
+ * from event handlers. The single-component static budget can't see across the
68
+ * composition boundary, so its `subscriptions`/fan-out read 0 and would
69
+ * otherwise look misleadingly "free". `--scenario` measures across components.
70
+ */
71
+ crossComponentOnly: boolean
72
+ }
73
+
74
+ export interface StaticBudgetOptions {
75
+ /** Fan-out threshold above which a signal is flagged `hot`. Default 8. */
76
+ fanOutThreshold?: number
77
+ }
78
+
79
+ const DEFAULT_FANOUT_THRESHOLD = 8
80
+
81
+ /**
82
+ * Build the static reactivity budget for one component (SR5).
83
+ *
84
+ * Predictive only — it names likely hot spots before any run. Pair with
85
+ * `--scenario` (the dynamic half) to confirm actual cost.
86
+ */
87
+ export function buildStaticBudget(
88
+ source: string,
89
+ filePath: string,
90
+ componentName?: string,
91
+ options: StaticBudgetOptions = {},
92
+ ): StaticBudget {
93
+ const threshold = options.fanOutThreshold ?? DEFAULT_FANOUT_THRESHOLD
94
+ // Build the TS program once and reuse it for both analyses (re-parsing the
95
+ // file twice is the bulk of the cost on a large source).
96
+ const program = createProgramForFile(source, filePath)?.program
97
+ const { graph } = buildComponentAnalysis(source, filePath, componentName, program)
98
+ // `graph` is the authority for reactive-node counts; the summary is consulted
99
+ // only for `loops`, which needs the IR tree walk (`countNodeType`) that the
100
+ // graph doesn't expose.
101
+ const summary = buildComponentSummary(source, filePath, componentName, program)
102
+
103
+ const subscriptions = graph.signals.reduce(
104
+ (n, s) => n + s.consumers.filter(c => !isEventHandlerConsumer(c)).length,
105
+ 0,
106
+ )
107
+
108
+ const fanOut: FanOutEntry[] = graph.signals
109
+ .map(s => {
110
+ const subscribers = transitiveSubscriberCount(graph, s.name)
111
+ return { signal: s.name, subscribers, hot: subscribers >= threshold, loc: s.loc }
112
+ })
113
+ .sort((a, b) => b.subscribers - a.subscribers)
114
+
115
+ const { depth, chain } = longestMemoChain(graph)
116
+
117
+ // Reactive state exists, yet nothing in *this* component observes it. The
118
+ // check spans signals AND memos: a memo with an in-component consumer (e.g.
119
+ // memo → DOM binding, with the memo reading a prop rather than a signal) is a
120
+ // real subscriber even though `subscriptions`/signal-`fanOut` — both
121
+ // signal-derived — read 0. So a node is "observed in-component" iff it has a
122
+ // non-empty transitive dependent tree; if none of the signals or memos do, the
123
+ // consumers live across a composition boundary (compound component) — flag it
124
+ // so the 0s don't read as "free".
125
+ const hasReactiveState = graph.signals.length > 0 || graph.memos.length > 0
126
+ const observedInComponent =
127
+ graph.signals.some(s => transitiveSubscriberCount(graph, s.name) > 0) ||
128
+ graph.memos.some(m => transitiveSubscriberCount(graph, m.name) > 0)
129
+ const crossComponentOnly = hasReactiveState && !observedInComponent
130
+
131
+ return {
132
+ componentName: summary.componentName,
133
+ sourceFile: summary.sourceFile,
134
+ kind: 'static-budget',
135
+ signals: graph.signals.length,
136
+ memos: graph.memos.length,
137
+ effects: graph.effects.length,
138
+ loops: summary.loops,
139
+ subscriptions,
140
+ memoChainDepth: depth,
141
+ memoChainLongest: chain,
142
+ fanOut,
143
+ crossComponentOnly,
144
+ }
145
+ }
146
+
147
+ /**
148
+ * An event handler *reads* a signal (e.g. `setCount(count() + 1)`) but runs
149
+ * outside any reactive scope, so it does not re-run when the signal changes —
150
+ * it is not a reactive subscriber and must be excluded from fan-out /
151
+ * subscription counts (cross-checked against the dynamic run, #1690 §4.2.4).
152
+ */
153
+ function isEventHandlerEntry(kind: string, name: string): boolean {
154
+ return kind === 'dom' && /^\w+ handler "/.test(name)
155
+ }
156
+
157
+ /** As above, for the `kind:name` consumer strings on `SignalNode.consumers`. */
158
+ function isEventHandlerConsumer(consumer: string): boolean {
159
+ const i = consumer.indexOf(':')
160
+ return i > 0 && isEventHandlerEntry(consumer.slice(0, i), consumer.slice(i + 1))
161
+ }
162
+
163
+ /**
164
+ * Distinct transitive subscribers of a signal/memo. Walks the same tagged
165
+ * `consumers` tree `traceUpdatePath` builds (`debug.ts`), deduplicating across
166
+ * branches so a diamond dependency counts each subscriber once. Event handlers
167
+ * are excluded — they read but don't react.
168
+ */
169
+ function transitiveSubscriberCount(graph: ComponentGraph, name: string): number {
170
+ const path = traceUpdatePath(graph, name)
171
+ if (!path) return 0
172
+ const seen = new Set<string>()
173
+ const walk = (entries: UpdatePathEntry[]): void => {
174
+ for (const e of entries) {
175
+ if (!isEventHandlerEntry(e.kind, e.name)) seen.add(`${e.kind}:${e.name}`)
176
+ walk(e.children)
177
+ }
178
+ }
179
+ walk(path.dependents)
180
+ return seen.size
181
+ }
182
+
183
+ /**
184
+ * Longest chain of memo→memo dependencies across the whole component. Starting
185
+ * from every memo (so a head memo whose deps are signals seeds the full chain)
186
+ * and taking the max captures the true longest path.
187
+ */
188
+ function longestMemoChain(graph: ComponentGraph): { depth: number; chain: string[] } {
189
+ const memoChainFrom = (entry: UpdatePathEntry): string[] => {
190
+ if (entry.kind !== 'memo') return []
191
+ let best: string[] = []
192
+ for (const child of entry.children) {
193
+ const c = memoChainFrom(child)
194
+ if (c.length > best.length) best = c
195
+ }
196
+ return [entry.name, ...best]
197
+ }
198
+
199
+ let best: string[] = []
200
+ for (const memo of graph.memos) {
201
+ const path = traceUpdatePath(graph, memo.name)
202
+ let downstream: string[] = []
203
+ for (const dep of path?.dependents ?? []) {
204
+ const c = memoChainFrom(dep)
205
+ if (c.length > downstream.length) downstream = c
206
+ }
207
+ const chain = [memo.name, ...downstream]
208
+ if (chain.length > best.length) best = chain
209
+ }
210
+
211
+ return { depth: best.length, chain: best }
212
+ }
213
+
214
+ export function formatStaticBudget(b: StaticBudget): string {
215
+ const lines: string[] = []
216
+ lines.push(`${b.componentName} — static reactivity budget`)
217
+ lines.push(` signals: ${b.signals} memos: ${b.memos} effects: ${b.effects} loops: ${b.loops}`)
218
+ lines.push(` subscriptions: ${b.subscriptions}`)
219
+ if (b.memoChainDepth > 0) {
220
+ lines.push(` memo-chain depth: ${b.memoChainDepth} (${b.memoChainLongest.join(' → ')})`)
221
+ }
222
+ const shown = b.fanOut.filter(f => f.subscribers > 0).slice(0, 5)
223
+ if (shown.length > 0) {
224
+ lines.push(' fan-out (top):')
225
+ for (const f of shown) {
226
+ lines.push(` ${f.signal.padEnd(12)} → ${f.subscribers} subscribers${f.hot ? ' ⚠ high' : ''}`)
227
+ }
228
+ }
229
+ if (b.crossComponentOnly) {
230
+ lines.push(
231
+ ` ⓘ compound: ${b.signals} signal(s) / ${b.memos} memo(s) but 0 in-component subscriptions —`,
232
+ )
233
+ lines.push(' consumers are likely in composed child components (or it is read only from handlers);')
234
+ lines.push(' run with --scenario to measure across the composition boundary.')
235
+ }
236
+ lines.push(' note: run with --scenario to measure actual cost; static budget is predictive only.')
237
+ return lines.join('\n')
238
+ }
239
+
240
+ // -- Compile-diff regression (SR6) --------------------------------------------
241
+
242
+ export interface FanOutChange {
243
+ signal: string
244
+ before: number
245
+ after: number
246
+ }
247
+
248
+ export interface BudgetDiff {
249
+ /**
250
+ * Discriminator so a JSON consumer can tell the three `bf debug profile`
251
+ * modes apart (`static-budget` / `profile` / `diff`). Without it an all-zero
252
+ * diff (no structural change) was indistinguishable from a pure-static
253
+ * component with no reactive state (#1849 B2).
254
+ */
255
+ kind: 'diff'
256
+ componentName: string
257
+ signals: number
258
+ memos: number
259
+ effects: number
260
+ loops: number
261
+ subscriptions: number
262
+ memoChainDepth: number
263
+ /** Signals whose fan-out changed (added/removed signals included as 0↔n). */
264
+ fanOut: FanOutChange[]
265
+ /** True when any tracked metric regressed (grew) past zero. */
266
+ regressed: boolean
267
+ }
268
+
269
+ /**
270
+ * Structural reactivity delta between two compiles of the same component (SR6).
271
+ * Each numeric field is `after − before`; positive = grew. CI can fail when
272
+ * `regressed` is true (or gate on a specific metric threshold).
273
+ */
274
+ export function diffStaticBudget(base: StaticBudget, head: StaticBudget): BudgetDiff {
275
+ const baseFan = new Map(base.fanOut.map(f => [f.signal, f.subscribers]))
276
+ const headFan = new Map(head.fanOut.map(f => [f.signal, f.subscribers]))
277
+ const signals = new Set([...baseFan.keys(), ...headFan.keys()])
278
+
279
+ const fanOut: FanOutChange[] = []
280
+ for (const sig of signals) {
281
+ const before = baseFan.get(sig) ?? 0
282
+ const after = headFan.get(sig) ?? 0
283
+ if (before !== after) fanOut.push({ signal: sig, before, after })
284
+ }
285
+ fanOut.sort((a, b) => (b.after - b.before) - (a.after - a.before))
286
+
287
+ const d: Omit<BudgetDiff, 'regressed'> = {
288
+ kind: 'diff',
289
+ componentName: head.componentName,
290
+ signals: head.signals - base.signals,
291
+ memos: head.memos - base.memos,
292
+ effects: head.effects - base.effects,
293
+ loops: head.loops - base.loops,
294
+ subscriptions: head.subscriptions - base.subscriptions,
295
+ memoChainDepth: head.memoChainDepth - base.memoChainDepth,
296
+ fanOut,
297
+ }
298
+
299
+ const regressed =
300
+ d.signals > 0 || d.memos > 0 || d.effects > 0 || d.subscriptions > 0 ||
301
+ d.memoChainDepth > 0 || fanOut.some(f => f.after > f.before)
302
+
303
+ return { ...d, regressed }
304
+ }
305
+
306
+ export function formatBudgetDiff(d: BudgetDiff): string {
307
+ const lines: string[] = []
308
+ lines.push(`${d.componentName} — reactivity diff`)
309
+ const metric = (label: string, v: number) => {
310
+ if (v !== 0) lines.push(` ${v > 0 ? '+' : ''}${v} ${label}`)
311
+ }
312
+ metric('signals', d.signals)
313
+ metric('memos', d.memos)
314
+ metric('effects', d.effects)
315
+ metric('loops', d.loops)
316
+ metric('subscriptions', d.subscriptions)
317
+ if (d.memoChainDepth !== 0) {
318
+ lines.push(` memo chain ${d.memoChainDepth > 0 ? 'deepened' : 'shortened'} by ${Math.abs(d.memoChainDepth)}`)
319
+ }
320
+ for (const f of d.fanOut) {
321
+ lines.push(` signal \`${f.signal}\` fan-out ${f.before}→${f.after}`)
322
+ }
323
+ if (lines.length === 1) lines.push(' no structural reactivity change')
324
+ else lines.push(d.regressed ? ' ⚠ reactivity regressed' : ' ✓ no regression')
325
+ return lines.join('\n')
326
+ }
327
+
328
+ // -- SR4 IR join --------------------------------------------------------------
329
+
330
+ /** An IR node a compiler-assigned profiler id resolves to. */
331
+ export interface ResolvedNode {
332
+ kind: 'signal' | 'memo' | 'effect' | 'handler'
333
+ /** Display name/label of the resolved IR node. */
334
+ name: string
335
+ loc: { file: string; line: number }
336
+ }
337
+
338
+ /** id (`<Component>#<kind>:<rest>`) → resolved IR node. */
339
+ export type IdIndex = Map<string, ResolvedNode>
340
+
341
+ export interface ParsedProfilerId {
342
+ component: string
343
+ kind: string
344
+ /** Everything after `<kind>:` — a name, a line, or `controlled:<setter>`. */
345
+ rest: string
346
+ }
347
+
348
+ /**
349
+ * Parse a compiler-assigned profiler id `<Component>#<kind>:<rest>` (SR1/SR3).
350
+ * Returns `null` for anything that isn't shaped like one, so a stray id in the
351
+ * event stream is surfaced as a coverage gap rather than mis-parsed.
352
+ */
353
+ export function parseProfilerId(id: string): ParsedProfilerId | null {
354
+ const hash = id.indexOf('#')
355
+ if (hash < 0) return null
356
+ const colon = id.indexOf(':', hash)
357
+ if (colon < 0) return null
358
+ const component = id.slice(0, hash)
359
+ const kind = id.slice(hash + 1, colon)
360
+ const rest = id.slice(colon + 1)
361
+ if (!component || !kind || !rest) return null
362
+ return { component, kind, rest }
363
+ }
364
+
365
+ /**
366
+ * Build the id→IR-node index that the SR4 join keys on. Every graph node
367
+ * already carries `loc`, so this turns a raw runtime id into a source-mapped
368
+ * node. Effects are registered under both their source line and their label
369
+ * (the two shapes `effects-and-on-mounts.ts` emits), and controlled-signal
370
+ * sync effects under `effect:controlled:<setter>`, mirroring the compiler's
371
+ * id namespace (`build-declaration-emit.ts`).
372
+ */
373
+ export function buildIdIndex(graph: ComponentGraph): IdIndex {
374
+ const comp = graph.componentName
375
+ const index: IdIndex = new Map()
376
+
377
+ for (const s of graph.signals) {
378
+ index.set(`${comp}#signal:${s.name}`, { kind: 'signal', name: s.name, loc: s.loc })
379
+ if (s.setter) {
380
+ index.set(`${comp}#effect:controlled:${s.setter}`, {
381
+ kind: 'effect',
382
+ name: `controlled:${s.setter}`,
383
+ loc: s.loc,
384
+ })
385
+ }
386
+ }
387
+ for (const m of graph.memos) {
388
+ index.set(`${comp}#memo:${m.name}`, { kind: 'memo', name: m.name, loc: m.loc })
389
+ }
390
+ for (const e of graph.effects) {
391
+ const node: ResolvedNode = { kind: 'effect', name: e.label, loc: e.loc }
392
+ index.set(`${comp}#effect:${e.loc.line}`, node)
393
+ index.set(`${comp}#effect:${e.label}`, node)
394
+ }
395
+ // DOM-binding effects (#1690, SR3/SR4): text/attribute/conditional/loop
396
+ // updates emit `createEffect(…, "<Component>#binding:<slotId>")`. Resolve
397
+ // each from its `domBinding` (slotId + loc). Event bindings are handlers, not
398
+ // re-running effects — skip them.
399
+ for (const b of graph.domBindings) {
400
+ if (!b.loc) continue
401
+ const loc = { file: b.loc.file, line: b.loc.start.line }
402
+ if (b.type === 'event') {
403
+ // Handler turn ids (`<Component>#handler:<slotId>:<eventName>`, SR3). The
404
+ // event name is embedded in the label (`click handler "s1"`).
405
+ const eventName = b.label.match(/^(\w+)\s+handler/)?.[1]
406
+ if (eventName) {
407
+ index.set(`${comp}#handler:${b.slotId}:${eventName}`, {
408
+ kind: 'handler',
409
+ name: `${eventName}@${b.slotId}`,
410
+ loc,
411
+ })
412
+ }
413
+ continue
414
+ }
415
+ index.set(`${comp}#binding:${b.slotId}`, { kind: 'effect', name: `${b.slotId} (${b.type})`, loc })
416
+ }
417
+ return index
418
+ }
419
+
420
+ /** A profiler id that no IR node could be found for (SR4 coverage gap). */
421
+ export interface UnattributedId {
422
+ id: string
423
+ /** Distinct events that referenced this id. */
424
+ count: number
425
+ }
426
+
427
+ export interface JoinedEvent {
428
+ event: ProfilerEvent
429
+ /** Resolved node for `event.subscriber` (effect/memo), if any. */
430
+ subscriber?: ResolvedNode
431
+ /** Resolved node for `event.signal`, if any. */
432
+ signal?: ResolvedNode
433
+ }
434
+
435
+ export interface JoinResult {
436
+ joined: JoinedEvent[]
437
+ /**
438
+ * Actionable coverage gaps: ids shaped like a compiler profiler id
439
+ * (`<Component>#<kind>:<rest>`) that the IR did not resolve — surfaced, never
440
+ * dropped (SR4 invariant). A non-empty list is the honest coverage caveat the
441
+ * analyses print: something the IR *should* have explained but didn't.
442
+ */
443
+ unattributed: UnattributedId[]
444
+ /**
445
+ * Non-actionable runtime bookkeeping ids (`s9`, `e3`, `m7`, `r10`) — anonymous
446
+ * reactive nodes the compiler never named (see `isRuntimeBookkeepingId`).
447
+ * Kept separate from `unattributed` so the coverage report focuses on real,
448
+ * fixable gaps instead of internal slot/ref noise, but still surfaced (never
449
+ * dropped) so the SR4 "account for every id" invariant holds.
450
+ */
451
+ diagnostics: UnattributedId[]
452
+ }
453
+
454
+ /**
455
+ * True for the reactive runtime's *fallback* ids — the synthetic `s<n>`
456
+ * (signal), `e<n>` (effect), `m<n>` (memo), and `r<n>` (root) sequences
457
+ * `reactive.ts` assigns when a node carries no compiler `__bfId`. These name
458
+ * anonymous internal machinery, never a source location, so they can never be
459
+ * attributed to an IR node. Treating them as coverage gaps makes a healthy
460
+ * report look broken (#1840); they belong in the non-actionable diagnostics
461
+ * bucket instead. Compiler ids always carry a `#`, so they never match.
462
+ */
463
+ function isRuntimeBookkeepingId(id: string): boolean {
464
+ return /^[serm]\d+$/.test(id)
465
+ }
466
+
467
+ /**
468
+ * A runtime *fallback effect* id (`e1`, `e2`, …) — the subset of bookkeeping
469
+ * ids the runtime assigns to a `createEffect` the compiler never named (one in
470
+ * a ref callback / helper, not top-level reactive init). Narrower than
471
+ * {@link isRuntimeBookkeepingId} on purpose: only these surface as
472
+ * `uninstrumented` hot rows with candidate sites (#1849 B6), so the hot-table
473
+ * label and the candidate-scan gate share this one definition rather than each
474
+ * re-spelling `^e\d+$`.
475
+ */
476
+ function isFallbackEffectId(id: string): boolean {
477
+ return /^e\d+$/.test(id)
478
+ }
479
+
480
+ /**
481
+ * Join a recorded event stream (SR2) to a component's IR (SR4). Each event's
482
+ * `subscriber` / `signal` id is resolved to its source-mapped node; ids with no
483
+ * match are collected as coverage gaps. This is the seam that turns a raw
484
+ * measurement into an explained, fixable finding.
485
+ */
486
+ export function joinProfilerEvents(events: readonly ProfilerEvent[], index: IdIndex): JoinResult {
487
+ const joined: JoinedEvent[] = []
488
+ const gaps = new Map<string, number>()
489
+
490
+ const resolve = (id: string | undefined): ResolvedNode | undefined => {
491
+ if (id === undefined) return undefined
492
+ const node = index.get(id)
493
+ if (!node) gaps.set(id, (gaps.get(id) ?? 0) + 1)
494
+ return node
495
+ }
496
+
497
+ for (const event of events) {
498
+ joined.push({
499
+ event,
500
+ subscriber: resolve(event.subscriber),
501
+ signal: resolve(event.signal),
502
+ })
503
+ }
504
+
505
+ const unattributed: UnattributedId[] = []
506
+ const diagnostics: UnattributedId[] = []
507
+ for (const [id, count] of gaps.entries()) {
508
+ ;(isRuntimeBookkeepingId(id) ? diagnostics : unattributed).push({ id, count })
509
+ }
510
+ const byCount = (a: UnattributedId, b: UnattributedId): number => b.count - a.count
511
+ unattributed.sort(byCount)
512
+ diagnostics.sort(byCount)
513
+
514
+ return { joined, unattributed, diagnostics }
515
+ }
516
+
517
+ // -- Analysis: hot subscribers (v1, §4.2.1) -----------------------------------
518
+
519
+ /** A likely source `createEffect(...)` call site for an uninstrumented id. */
520
+ export interface EffectCandidate {
521
+ file: string
522
+ line: number
523
+ }
524
+
525
+ /**
526
+ * Static scan for `createEffect(...)` call sites in `source` whose line is NOT
527
+ * in `instrumentedLines` — i.e. effects the compiler did not assign a `__bfId`
528
+ * (a `createEffect` nested in a ref callback / helper rather than at the
529
+ * component's top-level reactive init). These are the likely sources behind an
530
+ * anonymous runtime `e<n>` id the IR join can't resolve (#1849 B6). A specific
531
+ * `e<n>` can't be mapped to a specific call site, so these are surfaced as
532
+ * *candidates* ("possible sources"), not definitive attribution.
533
+ */
534
+ export function findUninstrumentedEffects(
535
+ source: string,
536
+ filePath: string,
537
+ instrumentedLines: ReadonlySet<number>,
538
+ ): EffectCandidate[] {
539
+ const sf = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
540
+ const out: EffectCandidate[] = []
541
+ const visit = (node: ts.Node): void => {
542
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'createEffect') {
543
+ // Use the call node's start (not the callee identifier's) to match the
544
+ // compiler's `EffectInfo.loc`, which is `getSourceLocation(node)` over the
545
+ // CallExpression — so an instrumented effect's line never mismatches here.
546
+ const line = sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1
547
+ if (!instrumentedLines.has(line)) out.push({ file: filePath, line })
548
+ }
549
+ ts.forEachChild(node, visit)
550
+ }
551
+ visit(sf)
552
+ out.sort((a, b) => a.line - b.line)
553
+ return out
554
+ }
555
+
556
+ export interface HotSubscriber {
557
+ /** The compiler-assigned subscriber id (effect/memo). */
558
+ subscriber: string
559
+ /** Source-mapped node from the SR4 join; absent ⇒ a coverage gap. */
560
+ loc?: { file: string; line: number }
561
+ name?: string
562
+ kind?: ResolvedNode['kind']
563
+ /** Total times this subscriber ran (`effectEnter` count), mount included. */
564
+ runs: number
565
+ /** Runs during initial mount (outside any turn) — the unavoidable baseline. */
566
+ mountRuns: number
567
+ /** Total run time in ms (Σ `effectExit.dur`). */
568
+ totalMs: number
569
+ /** Distinct *interaction* turns it ran in (mount excluded). */
570
+ turns: number
571
+ /**
572
+ * Interaction runs per active turn — `(runs − mountRuns) / turns`, the
573
+ * re-run-pressure signal. Mount is excluded so a click that re-runs an effect
574
+ * 5× reads as `5.0`, not diluted by the one-time mount run.
575
+ */
576
+ runsPerTurn: number
577
+ /** True when `runsPerTurn` meets the configured threshold. */
578
+ hot: boolean
579
+ /**
580
+ * Why an `e<n>`-style runtime id has no `loc`: the compiler never assigned it
581
+ * a `__bfId` (a `createEffect` outside the top-level reactive init — e.g.
582
+ * inside a ref callback). Set only for those ids so the reader understands the
583
+ * missing location is expected, not a broken profiler (#1849 B6). The cost is
584
+ * still surfaced — the row is kept, not hidden.
585
+ */
586
+ resolution?: 'uninstrumented'
587
+ /** Human note paired with `resolution`. */
588
+ resolutionNote?: string
589
+ /**
590
+ * Likely source `createEffect` call sites for an `uninstrumented` id, from a
591
+ * static scan — possible sources, not a definitive attribution.
592
+ */
593
+ candidates?: EffectCandidate[]
594
+ }
595
+
596
+ export interface HotSubscribersResult {
597
+ kind: 'hot-subscribers'
598
+ /** Ranked by `totalMs` descending, then `runs` descending. */
599
+ subscribers: HotSubscriber[]
600
+ /** SR4 coverage gaps — subscriber ids the IR could not resolve. */
601
+ unattributed: UnattributedId[]
602
+ }
603
+
604
+ export interface HotSubscribersOptions {
605
+ /** `runsPerTurn` at/above which a subscriber is flagged `hot`. Default 2. */
606
+ hotRunsPerTurn?: number
607
+ /** Keep only the top-N by `totalMs` (after ranking). Default: all. */
608
+ topN?: number
609
+ /**
610
+ * Drop subscribers whose `totalMs` is below this threshold (after ranking,
611
+ * before `topN`). Noise filter for `--hot-ms`: a component with a long tail of
612
+ * sub-millisecond effects (e.g. a calendar grid) collapses to the few that
613
+ * actually cost. Default: keep all.
614
+ */
615
+ minMs?: number
616
+ /**
617
+ * Candidate `createEffect` call sites for uninstrumented `e<n>` ids (#1849 B6).
618
+ * Attached to each such subscriber so the reader can jump to the likely source
619
+ * without opening the file. Computed by the caller (it needs the component
620
+ * source); the analysis is otherwise source-free.
621
+ */
622
+ uninstrumentedCandidates?: readonly EffectCandidate[]
623
+ }
624
+
625
+ const DEFAULT_HOT_RUNS_PER_TURN = 2
626
+
627
+ /**
628
+ * Hot subscribers (§4.2.1): which effects/memos ran most and cost most, joined
629
+ * to IR source loc. Pure over the SR2 stream + SR4 index — same scenario ⇒ same
630
+ * ranking (timings vary, ranks/structure do not).
631
+ *
632
+ * `runsPerTurn` is the re-run-pressure signal: an effect that runs many times
633
+ * within a single turn is a batch / over-subscription candidate (links to the
634
+ * batch advisor and wasted-re-runs analyses).
635
+ */
636
+ export function analyzeHotSubscribers(
637
+ events: readonly ProfilerEvent[],
638
+ index: IdIndex,
639
+ options: HotSubscribersOptions = {},
640
+ ): HotSubscribersResult {
641
+ const threshold = options.hotRunsPerTurn ?? DEFAULT_HOT_RUNS_PER_TURN
642
+
643
+ interface Acc {
644
+ runs: number
645
+ mountRuns: number
646
+ totalMs: number
647
+ /** Distinct turn *invocations* (by `turnSeq`, falling back to id) it ran in. */
648
+ turns: Set<string>
649
+ }
650
+ const byId = new Map<string, Acc>()
651
+ const acc = (id: string): Acc => {
652
+ let a = byId.get(id)
653
+ if (!a) {
654
+ a = { runs: 0, mountRuns: 0, totalMs: 0, turns: new Set() }
655
+ byId.set(id, a)
656
+ }
657
+ return a
658
+ }
659
+
660
+ for (const e of events) {
661
+ if (e.subscriber === undefined) continue
662
+ if (e.type === 'effectEnter') {
663
+ const a = acc(e.subscriber)
664
+ a.runs++
665
+ // Runs outside any turn are the one-time mount/setup baseline — kept
666
+ // separate so they don't dilute the per-interaction `runsPerTurn`.
667
+ if (e.turn === null) a.mountRuns++
668
+ else a.turns.add(String(e.turnSeq ?? e.turn))
669
+ } else if (e.type === 'effectExit' && e.dur !== undefined) {
670
+ acc(e.subscriber).totalMs += e.dur
671
+ }
672
+ }
673
+
674
+ const { joined, unattributed } = joinProfilerEvents(events, index)
675
+ const nodeFor = new Map<string, JoinedEvent['subscriber']>()
676
+ for (const j of joined) {
677
+ if (j.event.subscriber !== undefined && j.subscriber) nodeFor.set(j.event.subscriber, j.subscriber)
678
+ }
679
+
680
+ let subscribers: HotSubscriber[] = [...byId.entries()].map(([subscriber, a]) => {
681
+ const node = nodeFor.get(subscriber)
682
+ const turns = a.turns.size
683
+ const interactionRuns = a.runs - a.mountRuns
684
+ const runsPerTurn = turns > 0 ? interactionRuns / turns : 0
685
+ // An anonymous runtime `e<n>` id with no resolved node is a `createEffect`
686
+ // the compiler never assigned a `__bfId` (one nested in a ref callback /
687
+ // helper). Keep it in the table — its cost is real — but label *why* it has
688
+ // no location and surface candidate source lines so the reader needn't hunt
689
+ // for it (#1849 B6). Other bookkeeping ids (`s*`/`m*`/`r*`) stay plain.
690
+ const uninstrumented = !node && isFallbackEffectId(subscriber)
691
+ return {
692
+ subscriber,
693
+ loc: node?.loc,
694
+ name: node?.name,
695
+ kind: node?.kind,
696
+ runs: a.runs,
697
+ mountRuns: a.mountRuns,
698
+ totalMs: a.totalMs,
699
+ turns,
700
+ runsPerTurn,
701
+ hot: runsPerTurn >= threshold,
702
+ ...(uninstrumented
703
+ ? {
704
+ resolution: 'uninstrumented' as const,
705
+ resolutionNote: 'createEffect in non-JSX function scope (not attributed by compiler)',
706
+ candidates: [...(options.uninstrumentedCandidates ?? [])],
707
+ }
708
+ : {}),
709
+ }
710
+ })
711
+
712
+ // Rank by cost — `totalMs` is what the user fixes ("where's the time?"). To
713
+ // stay reproducible (SR7) despite wall-clock noise, compare at the displayed
714
+ // 0.1ms precision so same-cost subscribers form a stable cohort, then break
715
+ // ties by `runs` (the structural leading indicator) and finally the id.
716
+ const roundMs = (m: number): number => Math.round(m * 10) / 10
717
+ subscribers.sort(
718
+ (x, y) =>
719
+ roundMs(y.totalMs) - roundMs(x.totalMs) ||
720
+ y.runs - x.runs ||
721
+ (x.subscriber < y.subscriber ? -1 : x.subscriber > y.subscriber ? 1 : 0),
722
+ )
723
+ // `minMs` is a noise floor applied at the displayed 0.1ms precision so a
724
+ // subscriber on the line is filtered consistently with how it would render.
725
+ if (options.minMs !== undefined) subscribers = subscribers.filter(s => roundMs(s.totalMs) >= options.minMs!)
726
+ if (options.topN !== undefined) subscribers = subscribers.slice(0, options.topN)
727
+
728
+ // Only subscriber ids matter for this analysis — filter the join's gaps to
729
+ // ids that actually appeared as a subscriber.
730
+ const subscriberIds = new Set(byId.keys())
731
+ const gaps = unattributed.filter(u => subscriberIds.has(u.id))
732
+
733
+ return { kind: 'hot-subscribers', subscribers, unattributed: gaps }
734
+ }
735
+
736
+ const BAR_EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
737
+
738
+ /** Pad or ellipsize `s` to exactly `width` cells so columns stay aligned. */
739
+ function fitLabel(s: string, width: number): string {
740
+ return s.length > width ? `${s.slice(0, width - 1)}…` : s.padEnd(width)
741
+ }
742
+
743
+ /**
744
+ * A proportional horizontal bar for `value/max`, in eighth-block precision
745
+ * (mitata-style), left-padded to `width` cells. Empty when value/max ≤ 0.
746
+ */
747
+ function bar(value: number, max: number, width: number): string {
748
+ if (max <= 0 || value <= 0) return ''.padEnd(width)
749
+ const units = Math.min(value / max, 1) * width
750
+ let full = Math.floor(units)
751
+ let rem = Math.round((units - full) * 8)
752
+ if (rem === 8) {
753
+ full++ // a fractional part that rounds to 8/8 is a whole extra cell
754
+ rem = 0
755
+ }
756
+ return ('█'.repeat(full) + (rem > 0 ? BAR_EIGHTHS[rem] : '')).padEnd(width)
757
+ }
758
+
759
+ /**
760
+ * Render candidate `createEffect` sites grouped per file, e.g.
761
+ * `collapsible/index.tsx:82, :126, :184` (multiple files joined by `; `).
762
+ */
763
+ function formatEffectCandidates(candidates: readonly EffectCandidate[]): string {
764
+ const byFile = new Map<string, number[]>()
765
+ for (const c of candidates) {
766
+ const arr = byFile.get(c.file) ?? []
767
+ arr.push(c.line)
768
+ byFile.set(c.file, arr)
769
+ }
770
+ const groups: string[] = []
771
+ for (const [file, ls] of byFile) {
772
+ const sorted = [...new Set(ls)].sort((a, b) => a - b)
773
+ groups.push(`${file}:${sorted[0]}${sorted.slice(1).map(l => `, :${l}`).join('')}`)
774
+ }
775
+ return groups.join('; ')
776
+ }
777
+
778
+ export function formatHotSubscribers(r: HotSubscribersResult, limit = 12): string {
779
+ const lines: string[] = []
780
+ lines.push('hot subscribers — most run / most time')
781
+ if (r.subscribers.length === 0) {
782
+ lines.push(' (no effect/memo runs recorded)')
783
+ }
784
+ // Resolved subscribers carry a source line and are the actionable ones; show
785
+ // them first, then a capped tail of unresolved noise (loop/binding effects
786
+ // the analyzer can't yet place), so a big list (e.g. a calendar grid) stays
787
+ // readable instead of dumping a thousand rows.
788
+ const shown = r.subscribers.slice(0, limit)
789
+ // Bars are proportional to `totalMs` — the cost, i.e. where to spend a fix.
790
+ // `runs` (the leading indicator of that cost) rides alongside as a number.
791
+ const maxMs = shown.reduce((m, s) => Math.max(m, s.totalMs), 0)
792
+ for (const s of shown) {
793
+ // An uninstrumented `e<n>` id has no loc by design (the compiler never named
794
+ // it); say so explicitly instead of the bare `unresolved` that reads like
795
+ // a broken profiler (#1849 B6). These strings carry no parens of their own —
796
+ // the row formatter wraps `where` in a single `(…)` below.
797
+ const where = s.loc
798
+ ? `${s.loc.file}:${s.loc.line}`
799
+ : s.resolution === 'uninstrumented'
800
+ ? 'uninstrumented — createEffect in non-JSX scope'
801
+ : 'unresolved'
802
+ const base = s.name ?? s.subscriber
803
+ // Binding names already carry their type (`s1 (attribute)`); don't double up.
804
+ const label = s.kind && !base.endsWith(')') ? `${base} (${s.kind})` : base
805
+ const note = s.hot ? ` ⚠ ${s.runsPerTurn.toFixed(1)}/turn` : ''
806
+ lines.push(
807
+ ` ${fitLabel(label, 24)} ${bar(s.totalMs, maxMs, 14)} ${s.totalMs.toFixed(1)}ms ${String(s.runs).padStart(3)}× (${where})${note}`,
808
+ )
809
+ // Candidate source lines for an uninstrumented id — the reader jumps there
810
+ // without opening the file. Possible sources, not a definitive mapping.
811
+ if (s.candidates && s.candidates.length > 0) {
812
+ lines.push(`${' '.repeat(26)}candidates: ${formatEffectCandidates(s.candidates)}`)
813
+ }
814
+ }
815
+ if (r.subscribers.length > shown.length) {
816
+ lines.push(` … and ${r.subscribers.length - shown.length} more`)
817
+ }
818
+ if (r.unattributed.length > 0) {
819
+ lines.push(` ⚠ coverage: ${r.unattributed.length} unresolved subscriber id(s)`)
820
+ }
821
+ return lines.join('\n')
822
+ }
823
+
824
+ // -- Analysis: wasted re-runs (v1, §4.2.2) ------------------------------------
825
+
826
+ export interface WastedSubscriber {
827
+ /** The compiler-assigned subscriber id (effect/memo/binding). */
828
+ subscriber: string
829
+ /** Source-mapped node from the SR4 join; absent ⇒ a coverage gap. */
830
+ loc?: { file: string; line: number }
831
+ name?: string
832
+ kind?: ResolvedNode['kind']
833
+ /** Runs that emitted an output fingerprint (`effectOutput` count). */
834
+ totalRuns: number
835
+ /** Fingerprinted runs whose output was identical to the previous run. */
836
+ wastedRuns: number
837
+ /** `wastedRuns / totalRuns` — the share of recompute that produced no change. */
838
+ wastedRatio: number
839
+ /** True when `wastedRatio` meets the configured threshold. */
840
+ wasted: boolean
841
+ }
842
+
843
+ export interface WastedReRunsResult {
844
+ kind: 'wasted-re-runs'
845
+ /** Subscribers with ≥1 wasted run, ranked by `wastedRuns` then `wastedRatio`. */
846
+ subscribers: WastedSubscriber[]
847
+ /** SR4 coverage gaps — fingerprinted subscriber ids the IR could not resolve. */
848
+ unattributed: UnattributedId[]
849
+ }
850
+
851
+ export interface WastedReRunsOptions {
852
+ /**
853
+ * `wastedRatio` at/above which a subscriber is flagged `wasted`. A fraction in
854
+ * `[0,1]` (e.g. `0.5` = half its runs produced identical output). Default 0.5.
855
+ */
856
+ wastedRatio?: number
857
+ /** Keep only the top-N by `wastedRuns` (after ranking). Default: all. */
858
+ topN?: number
859
+ }
860
+
861
+ const DEFAULT_WASTED_RATIO = 0.5
862
+
863
+ /**
864
+ * Wasted re-runs (§4.2.2): effects/memos that re-ran but produced output
865
+ * identical to their previous run — the computation happened, the DOM/value did
866
+ * not change, so the re-run was removable. The complement to hot subscribers:
867
+ * hot says *where the cost is*, wasted says *how much of it is removable*.
868
+ *
869
+ * Pure over the SR2 stream's `effectOutput` fingerprints (memo-value `Object.is`
870
+ * + instrumented DOM writes) joined to IR source loc — same scenario ⇒ same
871
+ * ranking. A high ratio means the subscriber reads at a coarser grain than its
872
+ * output needs; the fix is a finer signal/memo split.
873
+ */
874
+ export function analyzeWastedReReruns(
875
+ events: readonly ProfilerEvent[],
876
+ index: IdIndex,
877
+ options: WastedReRunsOptions = {},
878
+ ): WastedReRunsResult {
879
+ const threshold = options.wastedRatio ?? DEFAULT_WASTED_RATIO
880
+
881
+ interface Acc {
882
+ total: number
883
+ wasted: number
884
+ }
885
+ const byId = new Map<string, Acc>()
886
+
887
+ for (const e of events) {
888
+ if (e.type !== 'effectOutput' || e.subscriber === undefined || e.changed === undefined) continue
889
+ let a = byId.get(e.subscriber)
890
+ if (!a) {
891
+ a = { total: 0, wasted: 0 }
892
+ byId.set(e.subscriber, a)
893
+ }
894
+ a.total++
895
+ if (!e.changed) a.wasted++
896
+ }
897
+
898
+ const { unattributed } = joinProfilerEvents(events, index)
899
+ const nodeFor = new Map<string, ResolvedNode>()
900
+ for (const [id] of byId) {
901
+ const node = index.get(id)
902
+ if (node) nodeFor.set(id, node)
903
+ }
904
+
905
+ let subscribers: WastedSubscriber[] = [...byId.entries()]
906
+ .map(([subscriber, a]) => {
907
+ const node = nodeFor.get(subscriber)
908
+ const wastedRatio = a.total > 0 ? a.wasted / a.total : 0
909
+ return {
910
+ subscriber,
911
+ loc: node?.loc,
912
+ name: node?.name,
913
+ kind: node?.kind,
914
+ totalRuns: a.total,
915
+ wastedRuns: a.wasted,
916
+ wastedRatio,
917
+ wasted: wastedRatio >= threshold,
918
+ }
919
+ })
920
+ // Only subscribers that actually wasted work are findings.
921
+ .filter(s => s.wastedRuns > 0)
922
+
923
+ // Rank by *removable* cost first — absolute wasted runs is what a finer split
924
+ // would eliminate; ratio (the severity) and id break ties, keeping the order
925
+ // deterministic across runs (SR7).
926
+ subscribers.sort(
927
+ (x, y) =>
928
+ y.wastedRuns - x.wastedRuns ||
929
+ y.wastedRatio - x.wastedRatio ||
930
+ (x.subscriber < y.subscriber ? -1 : x.subscriber > y.subscriber ? 1 : 0),
931
+ )
932
+ if (options.topN !== undefined) subscribers = subscribers.slice(0, options.topN)
933
+
934
+ // Only fingerprinted subscriber ids matter — filter the join's gaps to ids
935
+ // that actually appeared as a fingerprinted subscriber.
936
+ const subscriberIds = new Set(byId.keys())
937
+ const gaps = unattributed.filter(u => subscriberIds.has(u.id))
938
+
939
+ return { kind: 'wasted-re-runs', subscribers, unattributed: gaps }
940
+ }
941
+
942
+ /** The noun for a subscriber's identical output, keyed by kind (memo vs DOM). */
943
+ function wastedOutputNoun(kind?: ResolvedNode['kind']): string {
944
+ return kind === 'memo' ? 'identical value' : 'identical DOM'
945
+ }
946
+
947
+ export function formatWastedReReruns(r: WastedReRunsResult, limit = 12): string {
948
+ const lines: string[] = []
949
+ lines.push('wasted re-runs — re-ran but produced identical output')
950
+ if (r.subscribers.length === 0) {
951
+ lines.push(' (no wasted re-runs recorded)')
952
+ }
953
+ const shown = r.subscribers.slice(0, limit)
954
+ const maxWasted = shown.reduce((m, s) => Math.max(m, s.wastedRuns), 0)
955
+ for (const s of shown) {
956
+ const where = s.loc ? `${s.loc.file}:${s.loc.line}` : '(unresolved)'
957
+ const base = s.name ?? s.subscriber
958
+ const label = s.kind && !base.endsWith(')') ? `${base} (${s.kind})` : base
959
+ const pct = Math.round(s.wastedRatio * 100)
960
+ const note = s.wasted ? ' ⚠ split so it doesn’t re-run on unrelated changes' : ''
961
+ lines.push(
962
+ ` ${fitLabel(label, 24)} ${bar(s.wastedRuns, maxWasted, 14)} wasted: ${s.wastedRuns}/${s.totalRuns} produced ${wastedOutputNoun(s.kind)} (${pct}%) (${where})${note}`,
963
+ )
964
+ }
965
+ if (r.subscribers.length > shown.length) {
966
+ lines.push(` … and ${r.subscribers.length - shown.length} more`)
967
+ }
968
+ if (r.unattributed.length > 0) {
969
+ lines.push(` ⚠ coverage: ${r.unattributed.length} unresolved subscriber id(s)`)
970
+ }
971
+ return lines.join('\n')
972
+ }
973
+
974
+ // -- Analysis: batch advisor (v1, §4.2.3) -------------------------------------
975
+
976
+ export type BatchSafety = 'safe' | 'unsafe' | 'unverified'
977
+
978
+ export interface BatchCandidate {
979
+ /** The turn's handler id (`<Component>#handler:<slot>:<event>`). */
980
+ turn: string
981
+ /** Handler source location, when the id index resolves it. */
982
+ loc?: { file: string; line: number }
983
+ /** Friendly handler name (`click@s1`), when resolved. */
984
+ handler?: string
985
+ /** Total effect runs in the turn. */
986
+ totalRuns: number
987
+ /** Distinct effects that ran (the floor a batched turn would collapse to). */
988
+ distinctSubscribers: number
989
+ /**
990
+ * Signal writes (`set()` calls) the turn performed. A `batch()` wrap can only
991
+ * collapse re-runs when there are *several* writes notifying the same effect,
992
+ * so this is always ≥ 2 for a candidate — a single-write turn benefits not at
993
+ * all and is never surfaced (see {@link analyzeBatchAdvisor}).
994
+ */
995
+ writes: number
996
+ /**
997
+ * Upper bound on the runs a `batch()` wrap would remove (`totalRuns −
998
+ * distinctSubscribers`). It is an upper bound, not an exact count: distinct
999
+ * effects are counted by *id*, so a loop binding whose single id maps to many
1000
+ * per-item DOM instances reads as one "distinct subscriber" even though each
1001
+ * instance must still run once under a batch. Treat it as "up to N".
1002
+ */
1003
+ savings: number
1004
+ /**
1005
+ * Whether wrapping the handler in `batch()` is provably behavior-preserving.
1006
+ * `'unverified'` until the static post-write-derived-read oracle (SR4) runs —
1007
+ * a savings opportunity is surfaced, but never advised as `'safe'` without
1008
+ * proof (§4.2.3).
1009
+ */
1010
+ safety: BatchSafety
1011
+ }
1012
+
1013
+ export interface BatchAdvisorResult {
1014
+ kind: 'batch-advisor'
1015
+ /** Turns with `savings > 0`, ranked by `savings` descending. */
1016
+ candidates: BatchCandidate[]
1017
+ }
1018
+
1019
+ /**
1020
+ * Batch advisor (§4.2.3). BarefootJS uses **explicit** `batch()` — `set()`
1021
+ * notifies synchronously — so a turn that writes several signals re-runs shared
1022
+ * effects once per write. Per turn this measures `totalRuns` (effect runs) vs
1023
+ * `distinctSubscribers` (unique effects); `savings = totalRuns −
1024
+ * distinctSubscribers` is an upper bound on what a `batch()` wrap would collapse.
1025
+ *
1026
+ * A candidate must be a genuine **multi-write** turn (`writes ≥ 2`). `batch()`
1027
+ * only fuses the notifications of *several* writes; it does nothing to a single
1028
+ * write, even one that fans out widely. A loop binding re-running once per item
1029
+ * from one `set()` (e.g. a 42-cell calendar grid: one id, 42 runs) is *not* a
1030
+ * batch opportunity — without the write gate its 42 runs collapse to "saves 41"
1031
+ * against a single distinct id, a confident false positive.
1032
+ *
1033
+ * `writes` counts only `signalSet`s made *directly in the handler body* —
1034
+ * `effectEnter`/`effectExit` track effect-nesting depth, and a write is a
1035
+ * genuine handler write only at depth 0. This excludes the writes a memo makes
1036
+ * to its own private signal when it recomputes (a memo is an effect that writes
1037
+ * a signal sharing its id), and any other downstream cascade write, since those
1038
+ * fire *inside* the effects the direct writes triggered and are not something a
1039
+ * `batch()` around the handler could fuse.
1040
+ *
1041
+ * Measured half only: every candidate is reported `safety: 'unverified'`. The
1042
+ * static safety oracle that upgrades a candidate to `'safe'`/`'unsafe'` lands
1043
+ * in a follow-up — an advisory that could change behavior must not be labeled
1044
+ * safe (§4.2.3).
1045
+ */
1046
+ export function analyzeBatchAdvisor(
1047
+ events: readonly ProfilerEvent[],
1048
+ index?: IdIndex,
1049
+ ): BatchAdvisorResult {
1050
+ // Group by turn *invocation* (`turnSeq`), so several clicks of the same
1051
+ // handler are evaluated separately rather than summed into one inflated turn.
1052
+ interface TurnAcc {
1053
+ handlerId: string
1054
+ totalRuns: number
1055
+ writes: number
1056
+ /** Effect-nesting depth in this turn: a `signalSet` is a direct handler
1057
+ * write only at depth 0; deeper sets are memo/cascade recomputes. */
1058
+ depth: number
1059
+ subscribers: Set<string>
1060
+ }
1061
+ const byInvocation = new Map<string, TurnAcc>()
1062
+
1063
+ const get = (e: ProfilerEvent): TurnAcc | undefined => {
1064
+ if (e.turn === null) return undefined
1065
+ const key = String(e.turnSeq ?? e.turn)
1066
+ let acc = byInvocation.get(key)
1067
+ if (!acc) {
1068
+ acc = { handlerId: e.turn, totalRuns: 0, writes: 0, depth: 0, subscribers: new Set() }
1069
+ byInvocation.set(key, acc)
1070
+ }
1071
+ return acc
1072
+ }
1073
+
1074
+ for (const e of events) {
1075
+ // `signalSet` at depth 0 is a write the handler made itself — the ground
1076
+ // truth for whether batching can fuse anything. `effectEnter`/`effectExit`
1077
+ // bracket the runs those writes (plus loop fan-out and memo recomputes)
1078
+ // produced; a set fired while one of those runs is on the stack (depth > 0)
1079
+ // is a downstream cascade write, not a handler write.
1080
+ if (e.type === 'signalSet') {
1081
+ const acc = get(e)
1082
+ if (acc && acc.depth === 0) acc.writes++
1083
+ } else if (e.type === 'effectEnter') {
1084
+ const acc = get(e)
1085
+ if (!acc) continue
1086
+ acc.totalRuns++
1087
+ acc.depth++
1088
+ if (e.subscriber !== undefined) acc.subscribers.add(e.subscriber)
1089
+ } else if (e.type === 'effectExit') {
1090
+ const acc = get(e)
1091
+ if (acc && acc.depth > 0) acc.depth--
1092
+ }
1093
+ }
1094
+
1095
+ // Collapse invocations to one candidate per handler — the worst (max-savings)
1096
+ // invocation represents the handler's batch opportunity.
1097
+ const byHandler = new Map<string, BatchCandidate>()
1098
+ for (const acc of byInvocation.values()) {
1099
+ // A turn that writes at most one signal cannot benefit from `batch()`, no
1100
+ // matter how many effect runs that single write fanned out to (#1690).
1101
+ if (acc.writes < 2) continue
1102
+ const distinctSubscribers = acc.subscribers.size
1103
+ const savings = acc.totalRuns - distinctSubscribers
1104
+ if (savings <= 0) continue
1105
+ const prev = byHandler.get(acc.handlerId)
1106
+ if (prev && prev.savings >= savings) continue
1107
+ const node = index?.get(acc.handlerId)
1108
+ byHandler.set(acc.handlerId, {
1109
+ turn: acc.handlerId,
1110
+ loc: node?.loc,
1111
+ handler: node?.name,
1112
+ totalRuns: acc.totalRuns,
1113
+ distinctSubscribers,
1114
+ writes: acc.writes,
1115
+ savings,
1116
+ safety: 'unverified',
1117
+ })
1118
+ }
1119
+
1120
+ const candidates = [...byHandler.values()]
1121
+ candidates.sort((a, b) => b.savings - a.savings || b.totalRuns - a.totalRuns)
1122
+
1123
+ return { kind: 'batch-advisor', candidates }
1124
+ }
1125
+
1126
+ export function formatBatchAdvisor(r: BatchAdvisorResult): string {
1127
+ const lines: string[] = []
1128
+ lines.push('batch advisor — unbatched multi-write turns')
1129
+ if (r.candidates.length === 0) {
1130
+ lines.push(' (no turn would benefit from batching)')
1131
+ }
1132
+ const maxSavings = r.candidates.reduce((m, c) => Math.max(m, c.savings), 0)
1133
+ for (const c of r.candidates) {
1134
+ const safe = c.safety === 'safe' ? ', safe' : c.safety === 'unsafe' ? ', UNSAFE' : ', safety unverified'
1135
+ const where = c.loc ? ` (${c.loc.file}:${c.loc.line})` : ''
1136
+ const label = fitLabel(c.handler ?? c.turn, 24)
1137
+ // Bar proportional to savings (deterministic) — the bigger the bar, the more
1138
+ // effect re-runs a `batch()` would collapse.
1139
+ lines.push(
1140
+ ` ${label} ${bar(c.savings, maxSavings, 14)} batch candidate ${c.totalRuns}→${c.distinctSubscribers} (saves ${c.savings}${safe})${where}`,
1141
+ )
1142
+ }
1143
+ return lines.join('\n')
1144
+ }
1145
+
1146
+ // -- Batch safety oracle (§4.2.3 / SR4) ---------------------------------------
1147
+
1148
+ /** Memos transitively dependent on any of `written` (so stale under batch). */
1149
+ function downstreamMemos(graph: ComponentGraph, written: Set<string>): Set<string> {
1150
+ const byName = new Map(graph.memos.map(m => [m.name, m]))
1151
+ const result = new Set<string>()
1152
+ const dependsOnWritten = (memoName: string, seen: Set<string>): boolean => {
1153
+ if (seen.has(memoName)) return false
1154
+ seen.add(memoName)
1155
+ const m = byName.get(memoName)
1156
+ if (!m) return false
1157
+ for (const dep of m.deps) {
1158
+ if (written.has(dep)) return true
1159
+ if (byName.has(dep) && dependsOnWritten(dep, seen)) return true
1160
+ }
1161
+ return false
1162
+ }
1163
+ for (const m of graph.memos) if (dependsOnWritten(m.name, new Set())) result.add(m.name)
1164
+ return result
1165
+ }
1166
+
1167
+ /**
1168
+ * The **post-write-derived-read** oracle (§4.2.3). Wrapping a handler in
1169
+ * `batch()` defers effect flush; since a memo is a push-effect that writes a
1170
+ * private signal (`reactive.ts`), a memo read *after* a write to one of its
1171
+ * dependencies returns a **stale** value under batch. So a wrap is safe iff no
1172
+ * such read happens.
1173
+ *
1174
+ * Conservative by construction — only `'safe'` when provably so:
1175
+ * - indirect setters (`via` a helper) ⇒ `'unverified'` (helper body unseen);
1176
+ * - a downstream-memo getter called after the first write ⇒ `'unsafe'`;
1177
+ * - a bare unknown-function call after a write ⇒ `'unverified'` (it could read a
1178
+ * memo we can't see). Signal reads are fine — `set()` updates the value
1179
+ * synchronously; only memo recomputation is deferred.
1180
+ *
1181
+ * Known gap: a memo read reached through an object-method call's closure is not
1182
+ * traced (only lexically-visible `memo()` getters and bare helper calls are).
1183
+ */
1184
+ export function assessBatchSafety(args: {
1185
+ handler: string
1186
+ setterNames: readonly string[]
1187
+ hasIndirectSetters: boolean
1188
+ writtenSignals: readonly string[]
1189
+ graph: ComponentGraph
1190
+ }): BatchSafety {
1191
+ if (args.hasIndirectSetters || args.setterNames.length === 0) return 'unverified'
1192
+
1193
+ const D = downstreamMemos(args.graph, new Set(args.writtenSignals))
1194
+ const setters = new Set(args.setterNames)
1195
+ const memoNames = new Set(args.graph.memos.map(m => m.name))
1196
+ const signalGetters = new Set(args.graph.signals.map(s => s.name))
1197
+
1198
+ let sf: ts.SourceFile
1199
+ try {
1200
+ sf = ts.createSourceFile('__h.ts', `const __h = ${args.handler}`, ts.ScriptTarget.Latest, true)
1201
+ } catch {
1202
+ return 'unverified'
1203
+ }
1204
+
1205
+ type Call = { pos: number; kind: 'write' | 'memoRead' | 'risky' }
1206
+ const calls: Call[] = []
1207
+ const visit = (node: ts.Node): void => {
1208
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
1209
+ const name = node.expression.text
1210
+ if (setters.has(name)) calls.push({ pos: node.getStart(sf), kind: 'write' })
1211
+ else if (D.has(name) && node.arguments.length === 0) calls.push({ pos: node.getStart(sf), kind: 'memoRead' })
1212
+ else if (!signalGetters.has(name) && !memoNames.has(name)) calls.push({ pos: node.getStart(sf), kind: 'risky' })
1213
+ }
1214
+ ts.forEachChild(node, visit)
1215
+ }
1216
+ visit(sf)
1217
+ calls.sort((a, b) => a.pos - b.pos)
1218
+
1219
+ let seenWrite = false
1220
+ let risky = false
1221
+ for (const c of calls) {
1222
+ if (c.kind === 'write') seenWrite = true
1223
+ else if (seenWrite && c.kind === 'memoRead') return 'unsafe'
1224
+ else if (seenWrite && c.kind === 'risky') risky = true
1225
+ }
1226
+ if (!seenWrite) return 'unverified'
1227
+ return risky ? 'unverified' : 'safe'
1228
+ }
1229
+
1230
+ /**
1231
+ * Pair each turn handler id (`<Component>#handler:<slotId>:<event>`) with its
1232
+ * `EventBinding`, matching the event `domBinding`'s slotId to the binding by
1233
+ * source line (the binding itself has no slotId). Used to feed the safety
1234
+ * oracle a candidate's handler body + setter calls.
1235
+ */
1236
+ function turnToEventBinding(graph: ComponentGraph, events: EventBinding[]): Map<string, EventBinding> {
1237
+ const byLine = new Map<number, EventBinding>()
1238
+ for (const e of events) if (e.loc) byLine.set(e.loc.start.line, e)
1239
+ const out = new Map<string, EventBinding>()
1240
+ for (const b of graph.domBindings) {
1241
+ if (b.type !== 'event' || !b.loc) continue
1242
+ const eventName = b.label.match(/^(\w+)\s+handler/)?.[1]
1243
+ const binding = byLine.get(b.loc.start.line)
1244
+ if (eventName && binding) out.set(`${graph.componentName}#handler:${b.slotId}:${eventName}`, binding)
1245
+ }
1246
+ return out
1247
+ }
1248
+
1249
+ // -- Dynamic report (SR1–SR4 + analyses, SR7) ---------------------------------
1250
+
1251
+ /**
1252
+ * Compact rollup of the non-actionable runtime bookkeeping ids — a count plus a
1253
+ * small sample. The full per-id list could run to hundreds of entries for a
1254
+ * loop-heavy component (a calendar's 6-week grid emits ~90 binding ids × mount
1255
+ * + interaction ≈ 180), drowning a JSON consumer in noise it can't act on
1256
+ * (#1849 B7). The text report only ever printed the count, so the array carried
1257
+ * no information the summary doesn't.
1258
+ */
1259
+ export interface DiagnosticsSummary {
1260
+ /** Total distinct anonymous runtime bookkeeping ids encountered. */
1261
+ count: number
1262
+ /** A few example ids (hottest first) for a sanity check — not exhaustive. */
1263
+ sample: string[]
1264
+ }
1265
+
1266
+ export interface ProfileCoverage {
1267
+ /** Distinct handlers exercised (turns observed). */
1268
+ handlersFired: number
1269
+ /** Handlers the IR knows about (`buildEventSummary`). */
1270
+ handlersTotal: number
1271
+ /** SR4 ids the IR could not resolve — the honest, actionable gap. */
1272
+ unattributed: UnattributedId[]
1273
+ /**
1274
+ * Anonymous runtime bookkeeping ids (`s*`/`e*`/`m*`/`r*`) that have no source
1275
+ * node — non-actionable noise, kept out of `unattributed` so the gap list
1276
+ * stays meaningful, but summarized here so nothing is silently dropped.
1277
+ */
1278
+ diagnostics: DiagnosticsSummary
1279
+ }
1280
+
1281
+ export interface ProfileReport {
1282
+ kind: 'profile'
1283
+ componentName: string
1284
+ sourceFile: string
1285
+ /** Scenario label (e.g. `'auto'` or a scenario file name). */
1286
+ scenario: string
1287
+ /** Total recorded events. */
1288
+ events: number
1289
+ /** Distinct interaction turns. */
1290
+ turns: number
1291
+ hotSubscribers: HotSubscribersResult
1292
+ wastedReReruns: WastedReRunsResult
1293
+ batchAdvisor: BatchAdvisorResult
1294
+ coverage: ProfileCoverage
1295
+ }
1296
+
1297
+ export interface ProfileReportInput {
1298
+ source: string
1299
+ filePath: string
1300
+ componentName?: string
1301
+ scenario: string
1302
+ /** The recorded event log from driving the instrumented component (SR2). */
1303
+ events: readonly ProfilerEvent[]
1304
+ /**
1305
+ * Other sources compiled for the same run — the composed sub-components of a
1306
+ * scenario-file story. Their graphs are merged into the id index so events
1307
+ * from those components resolve too.
1308
+ */
1309
+ extraSources?: readonly { source: string; filePath: string }[]
1310
+ /** Keep only the top-N hot subscribers by `totalMs` (`--top`). Default: all. */
1311
+ topN?: number
1312
+ /** Drop hot subscribers below this `totalMs` floor (`--hot-ms`). Default: all. */
1313
+ minMs?: number
1314
+ /**
1315
+ * `wastedRatio` threshold (`--wasted-pct`) for the wasted-re-runs analysis, a
1316
+ * fraction in `[0,1]`. Default 0.5.
1317
+ */
1318
+ wastedRatio?: number
1319
+ }
1320
+
1321
+ /**
1322
+ * Assemble a dynamic profile (SR1–SR4 + analyses, SR7) from a recorded event
1323
+ * stream. Pure: the DOM run that *produces* `events` lives in the driver (the
1324
+ * CLI's scenario harness); this joins them to the IR and ranks findings.
1325
+ * Deterministic — same stream + same source ⇒ same report.
1326
+ */
1327
+ export function buildProfileReport(input: ProfileReportInput): ProfileReport {
1328
+ const { source, filePath, componentName, scenario, events } = input
1329
+
1330
+ const primary = buildComponentAnalysis(source, filePath, componentName).graph
1331
+ // All sources contributing to the merged index (primary first).
1332
+ const allSources = [{ source, filePath }, ...(input.extraSources ?? [])]
1333
+
1334
+ const index: IdIndex = new Map()
1335
+ // turn id → the handler binding + the component graph that owns it (so the
1336
+ // safety oracle reasons over the right component's memos).
1337
+ const turnBindings = new Map<string, { binding: EventBinding; graph: ComponentGraph }>()
1338
+ let handlersTotal = 0
1339
+ // Per-file lines of the `createEffect` calls the compiler instrumented (top
1340
+ // level, carry a `__bfId`). Subtracted from a source scan to find the
1341
+ // uninstrumented ones behind anonymous `e<n>` ids (#1849 B6).
1342
+ const instrumentedEffectLines = new Map<string, Set<number>>()
1343
+
1344
+ for (const s of allSources) {
1345
+ // A source file may declare several components (e.g. a headless set:
1346
+ // Collapsible + CollapsibleTrigger + CollapsibleContent). Build the TS
1347
+ // program once and reuse it for every component — re-parsing per component
1348
+ // is the dominant cost (a 25-component file like `chart` is ~30s otherwise).
1349
+ const program = createProgramForFile(s.source, s.filePath)?.program
1350
+ let componentNames: string[]
1351
+ try {
1352
+ componentNames = listComponentFunctions(s.source, s.filePath)
1353
+ } catch {
1354
+ componentNames = []
1355
+ }
1356
+ if (componentNames.length === 0) componentNames = [undefined as unknown as string]
1357
+ for (const name of componentNames) {
1358
+ let graph: ComponentGraph
1359
+ try {
1360
+ graph = buildComponentAnalysis(s.source, s.filePath, name, program).graph
1361
+ } catch {
1362
+ continue
1363
+ }
1364
+ for (const [k, v] of buildIdIndex(graph)) index.set(k, v)
1365
+ for (const e of graph.effects) {
1366
+ const set = instrumentedEffectLines.get(e.loc.file) ?? new Set<number>()
1367
+ set.add(e.loc.line)
1368
+ instrumentedEffectLines.set(e.loc.file, set)
1369
+ }
1370
+ try {
1371
+ const summary = buildEventSummary(s.source, s.filePath, name, program)
1372
+ handlersTotal += summary.events.length
1373
+ for (const [turn, binding] of turnToEventBinding(graph, summary.events)) {
1374
+ turnBindings.set(turn, { binding, graph })
1375
+ }
1376
+ } catch {
1377
+ /* a component the analyzer can't summarize contributes no handlers */
1378
+ }
1379
+ }
1380
+ }
1381
+
1382
+ // Candidate sites for uninstrumented `createEffect` ids, across every source
1383
+ // in the run (a compound scenario spans several files), so an `e<n>` row can
1384
+ // cite where to look (#1849 B6). The per-file AST scan is only ever consumed
1385
+ // when a runtime fallback `e<n>` id is in the stream, so skip it entirely in
1386
+ // the common case (every effect compiler-instrumented) where it would be
1387
+ // wasted work.
1388
+ const hasFallbackEffectId = events.some(
1389
+ e => e.subscriber !== undefined && isFallbackEffectId(e.subscriber),
1390
+ )
1391
+ const uninstrumentedCandidates: EffectCandidate[] = []
1392
+ if (hasFallbackEffectId) {
1393
+ for (const s of allSources) {
1394
+ uninstrumentedCandidates.push(
1395
+ ...findUninstrumentedEffects(s.source, s.filePath, instrumentedEffectLines.get(s.filePath) ?? new Set()),
1396
+ )
1397
+ }
1398
+ }
1399
+
1400
+ const hotSubscribers = analyzeHotSubscribers(events, index, {
1401
+ topN: input.topN,
1402
+ minMs: input.minMs,
1403
+ uninstrumentedCandidates,
1404
+ })
1405
+ const wastedReReruns = analyzeWastedReReruns(events, index, { wastedRatio: input.wastedRatio })
1406
+ const batchAdvisor = analyzeBatchAdvisor(events, index)
1407
+ const { unattributed, diagnostics } = joinProfilerEvents(events, index)
1408
+
1409
+ // Safety oracle (§4.2.3): upgrade each candidate from 'unverified'.
1410
+ for (const c of batchAdvisor.candidates) {
1411
+ const hit = turnBindings.get(c.turn)
1412
+ if (!hit) continue
1413
+ c.safety = assessBatchSafety({
1414
+ handler: hit.binding.handler,
1415
+ setterNames: hit.binding.setterCalls.map(s => s.setter),
1416
+ hasIndirectSetters: hit.binding.setterCalls.some(s => s.via && s.via.length > 0),
1417
+ writtenSignals: hit.binding.setterCalls.map(s => s.signal).filter((s): s is string => s !== null),
1418
+ graph: hit.graph,
1419
+ })
1420
+ }
1421
+
1422
+ // Count distinct turn *invocations* (turnSeq), not handler ids — N clicks of
1423
+ // one handler are N turns. Handlers exercised (coverage) counts distinct ids.
1424
+ const turnSeqs = new Set<string>()
1425
+ const handlerIds = new Set<string>()
1426
+ for (const e of events) {
1427
+ if (e.turn !== null) {
1428
+ turnSeqs.add(String(e.turnSeq ?? e.turn))
1429
+ handlerIds.add(e.turn)
1430
+ }
1431
+ }
1432
+
1433
+ return {
1434
+ kind: 'profile',
1435
+ componentName: primary.componentName,
1436
+ sourceFile: primary.sourceFile,
1437
+ scenario,
1438
+ events: events.length,
1439
+ turns: turnSeqs.size,
1440
+ hotSubscribers,
1441
+ wastedReReruns,
1442
+ batchAdvisor,
1443
+ coverage: {
1444
+ handlersFired: handlerIds.size,
1445
+ handlersTotal,
1446
+ unattributed,
1447
+ // Roll the (potentially hundreds of) bookkeeping ids up to a count + a
1448
+ // small sample so JSON consumers aren't flooded (#1849 B7). `diagnostics`
1449
+ // is already sorted hottest-first by `joinProfilerEvents`.
1450
+ diagnostics: { count: diagnostics.length, sample: diagnostics.slice(0, 3).map(d => d.id) },
1451
+ },
1452
+ }
1453
+ }
1454
+
1455
+ export function formatProfileReport(r: ProfileReport): string {
1456
+ const lines: string[] = []
1457
+ lines.push(`${r.componentName} — profile (scenario: ${r.scenario})`)
1458
+ lines.push(` ${r.events} events across ${r.turns} turn(s)`)
1459
+ // No interactions measured: either the component has no handlers (use the
1460
+ // static budget) or its handlers live in composed children the auto scenario
1461
+ // couldn't reach (use a --scenario file). Say so plainly rather than leaving
1462
+ // the user with mount-only, mostly-unresolved noise.
1463
+ if (r.turns === 0) {
1464
+ lines.push(
1465
+ r.coverage.handlersTotal === 0
1466
+ ? ' note: no event handlers — run `bf debug profile <component>` for the static budget.'
1467
+ : ' note: no interactions measured (handlers are likely in composed children) — try `--scenario <story.tsx>`.',
1468
+ )
1469
+ }
1470
+ lines.push('')
1471
+ lines.push(formatHotSubscribers(r.hotSubscribers))
1472
+ lines.push('')
1473
+ lines.push(formatWastedReReruns(r.wastedReReruns))
1474
+ lines.push('')
1475
+ lines.push(formatBatchAdvisor(r.batchAdvisor))
1476
+ lines.push('')
1477
+ const c = r.coverage
1478
+ lines.push(`coverage: ${c.handlersFired}/${c.handlersTotal} handlers exercised`)
1479
+ if (c.unattributed.length > 0) {
1480
+ lines.push(` ⚠ ${c.unattributed.length} unattributed id(s): ${c.unattributed.slice(0, 3).map(u => u.id).join(', ')}`)
1481
+ }
1482
+ // Anonymous runtime bookkeeping ids (s*/e*/m*/r*) are noted but flagged
1483
+ // non-actionable, so the report is honest about coverage without crying wolf.
1484
+ if (c.diagnostics.count > 0) {
1485
+ lines.push(` · ${c.diagnostics.count} anonymous runtime id(s) (non-actionable bookkeeping)`)
1486
+ }
1487
+ return lines.join('\n')
1488
+ }