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