@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,543 @@
1
+ /**
2
+ * Static reactive profile analysis for `bf debug profile` (#1690, v1).
3
+ *
4
+ * Implements SR5 (static reactivity budget): per-component profile computed
5
+ * entirely from the IR — signal/memo/binding counts, fan-out, memo chain depth,
6
+ * and batch candidates — without running any code.
7
+ *
8
+ * v1 analyses implemented:
9
+ * - High fan-out signals (hot-subscriber precursor)
10
+ * - Deep memo chains
11
+ * - Batch candidates (event sets ≥2 distinct signals)
12
+ * - Fallback-heavy components (compiler couldn't prove reactivity)
13
+ *
14
+ * SR6 (compile-diff regression) is also included: `diffProfiles` compares two
15
+ * `ComponentProfileMetrics` instances and surfaces structural regressions.
16
+ *
17
+ * Runtime analyses (hot subscribers, wasted re-runs) require SR1-SR4
18
+ * (instrumentation hooks + compiler-emitted turn markers) and are out of
19
+ * scope for v1.
20
+ */
21
+
22
+ import type { ComponentGraph, EventSummary } from './debug.ts'
23
+ import { buildComponentGraph, buildEventSummary } from './debug.ts'
24
+
25
+ // =============================================================================
26
+ // Types
27
+ // =============================================================================
28
+
29
+ /** Per-component reactive budget metrics derived from the static IR (SR5). */
30
+ export interface ComponentProfileMetrics {
31
+ componentName: string
32
+ sourceFile: string
33
+ /** Component uses `"use client"` / has reactive state. */
34
+ hydrated: boolean
35
+ // Raw counts
36
+ signals: number
37
+ memos: number
38
+ effects: number
39
+ loops: number
40
+ eventHandlers: number
41
+ /** Total reactive DOM bindings (reactive + fallback). */
42
+ dynamicBindings: number
43
+ /** Bindings the compiler could not statically prove reactive (fallback-wrapped). */
44
+ fallbacks: number
45
+ conditionals: number
46
+ // Derived budget metrics
47
+ /** Consumers of the most-subscribed signal (fan-out). */
48
+ maxSignalFanOut: number
49
+ /** Name of the signal with maximum fan-out; null when no signals. */
50
+ hotSignal: string | null
51
+ /** Longest memo→memo chain depth (1 = standalone memo, N = N-level chain). */
52
+ maxMemoChainDepth: number
53
+ /** Sum of dependency counts across all memos, effects, and DOM bindings. */
54
+ totalSubscriptions: number
55
+ /** Event handlers that set ≥2 distinct signals — batch() candidates. */
56
+ batchCandidateCount: number
57
+ }
58
+
59
+ /** A single actionable insight produced by static analysis. */
60
+ export interface ProfileFinding {
61
+ kind: 'high-fan-out' | 'deep-memo-chain' | 'batch-candidate' | 'fallback-heavy'
62
+ severity: 'info' | 'warning'
63
+ message: string
64
+ suggestion: string
65
+ /** Affected signal name (high-fan-out). */
66
+ signal?: string
67
+ /** Chain depth (deep-memo-chain). */
68
+ depth?: number
69
+ /** Signals involved (batch-candidate). */
70
+ signals?: string[]
71
+ loc?: { file: string; line: number }
72
+ }
73
+
74
+ /** Full profile for one component: metrics + findings. */
75
+ export interface ComponentProfile {
76
+ metrics: ComponentProfileMetrics
77
+ findings: ProfileFinding[]
78
+ }
79
+
80
+ // SR6: structural diff between two compile snapshots (no run required).
81
+ export interface ProfileDiff {
82
+ componentName: string
83
+ before: ComponentProfileMetrics
84
+ after: ComponentProfileMetrics
85
+ regressions: ProfileDiffEntry[]
86
+ improvements: ProfileDiffEntry[]
87
+ neutral: ProfileDiffEntry[]
88
+ }
89
+
90
+ export interface ProfileDiffEntry {
91
+ metric: string
92
+ before: number | string | null
93
+ after: number | string | null
94
+ delta: number | null
95
+ }
96
+
97
+ // =============================================================================
98
+ // Thresholds (tunable, kept conservative for v1)
99
+ // =============================================================================
100
+
101
+ const THRESHOLDS = {
102
+ /** Warn when a signal has more than this many consumers. */
103
+ highFanOut: 3,
104
+ /** Warn when any memo chain is deeper than this. */
105
+ deepMemoChain: 3,
106
+ /** Minimum fallback count before triggering fallback-heavy. */
107
+ fallbackHeavyMin: 3,
108
+ /** Fallback ratio (fallbacks/total bindings) above which to warn. */
109
+ fallbackHeavyRatio: 0.5,
110
+ /** Suggest batch() when a handler sets this many or more distinct signals. */
111
+ batchMinSignals: 2,
112
+ } as const
113
+
114
+ // =============================================================================
115
+ // Public API
116
+ // =============================================================================
117
+
118
+ /**
119
+ * Build a reactive profile from raw source text.
120
+ * Runs the full analyzer → IR → graph + event pipeline.
121
+ */
122
+ export function buildReactiveProfile(
123
+ source: string,
124
+ filePath: string,
125
+ componentName?: string,
126
+ ): ComponentProfile {
127
+ const graph = buildComponentGraph(source, filePath, componentName)
128
+ const eventSummary = buildEventSummary(source, filePath, componentName)
129
+ const hydrated = graph.signals.length > 0 || graph.memos.length > 0 || graph.effects.length > 0
130
+ return buildProfileFromGraph(graph, eventSummary, hydrated)
131
+ }
132
+
133
+ /**
134
+ * Build a reactive profile from a pre-built graph + event summary.
135
+ * Useful when the caller already has the graph (avoids double analysis).
136
+ */
137
+ export function buildProfileFromGraph(
138
+ graph: ComponentGraph,
139
+ eventSummary: EventSummary,
140
+ hydrated: boolean,
141
+ ): ComponentProfile {
142
+ const metrics = computeMetrics(graph, eventSummary, hydrated)
143
+ const findings = computeFindings(metrics, graph, eventSummary)
144
+ return { metrics, findings }
145
+ }
146
+
147
+ // =============================================================================
148
+ // Metrics computation
149
+ // =============================================================================
150
+
151
+ function computeMetrics(
152
+ graph: ComponentGraph,
153
+ eventSummary: EventSummary,
154
+ hydrated: boolean,
155
+ ): ComponentProfileMetrics {
156
+ // Fan-out: which signal has the most consumers?
157
+ let maxFanOut = 0
158
+ let hotSignal: string | null = null
159
+ for (const signal of graph.signals) {
160
+ if (signal.consumers.length > maxFanOut) {
161
+ maxFanOut = signal.consumers.length
162
+ hotSignal = signal.name
163
+ }
164
+ }
165
+
166
+ const maxMemoChainDepth = computeMaxMemoChainDepth(graph)
167
+
168
+ const dynamicBindings = graph.domBindings.length
169
+ const fallbacks = graph.domBindings.filter(d => d.classification === 'fallback').length
170
+ const eventHandlers = graph.domBindings.filter(d => d.type === 'event').length
171
+ const conditionals = graph.domBindings.filter(d => d.type === 'conditional').length
172
+ const loops = graph.domBindings.filter(d => d.type === 'loop').length
173
+
174
+ // Total subscriptions = sum of all dep-counts across memos, effects, and DOM bindings.
175
+ const totalSubscriptions =
176
+ graph.memos.reduce((s, m) => s + m.deps.length, 0) +
177
+ graph.effects.reduce((s, e) => s + e.deps.length, 0) +
178
+ graph.domBindings.reduce((s, b) => s + b.deps.length, 0)
179
+
180
+ // Batch candidates: deduplicate by (eventName, loc, signals) to match the
181
+ // findings list — the same handler wired at N JSX sites (e.g. Calendar's
182
+ // dual-month layout) counts once, not N times. (Bug D)
183
+ const batchDedupeKeys = new Set<string>()
184
+ for (const event of eventSummary.events) {
185
+ const distinct = new Set<string>()
186
+ for (const sc of event.setterCalls) {
187
+ if (sc.signal) distinct.add(sc.signal)
188
+ }
189
+ if (distinct.size >= THRESHOLDS.batchMinSignals) {
190
+ const key = `${event.eventName}|${event.loc.file ?? ''}|${event.loc.start.line}|${[...distinct].sort().join(',')}`
191
+ batchDedupeKeys.add(key)
192
+ }
193
+ }
194
+ const batchCandidateCount = batchDedupeKeys.size
195
+
196
+ return {
197
+ componentName: graph.componentName,
198
+ sourceFile: graph.sourceFile,
199
+ hydrated,
200
+ signals: graph.signals.length,
201
+ memos: graph.memos.length,
202
+ effects: graph.effects.length,
203
+ loops,
204
+ eventHandlers,
205
+ dynamicBindings,
206
+ fallbacks,
207
+ conditionals,
208
+ maxSignalFanOut: maxFanOut,
209
+ hotSignal,
210
+ maxMemoChainDepth,
211
+ totalSubscriptions,
212
+ batchCandidateCount,
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Compute the longest memo→memo chain depth in the graph.
218
+ * Depth 1 = a memo with no memo dependants (leaf); depth N = N-level chain.
219
+ * Cycles are guarded via a visited set per traversal.
220
+ */
221
+ function computeMaxMemoChainDepth(graph: ComponentGraph): number {
222
+ if (graph.memos.length === 0) return 0
223
+
224
+ const memoSet = new Set(graph.memos.map(m => m.name))
225
+ // For each memo, which other memos does it depend on?
226
+ const memoDeps = new Map<string, string[]>()
227
+ for (const memo of graph.memos) {
228
+ memoDeps.set(memo.name, memo.deps.filter(d => memoSet.has(d)))
229
+ }
230
+
231
+ const cache = new Map<string, number>()
232
+
233
+ function depth(name: string, visited: Set<string>): number {
234
+ if (cache.has(name)) return cache.get(name)!
235
+ if (visited.has(name)) return 0 // cycle guard
236
+ const children = memoDeps.get(name) ?? []
237
+ if (children.length === 0) {
238
+ cache.set(name, 1)
239
+ return 1
240
+ }
241
+ visited.add(name)
242
+ const d = 1 + Math.max(...children.map(c => depth(c, new Set(visited))))
243
+ cache.set(name, d)
244
+ return d
245
+ }
246
+
247
+ let max = 0
248
+ for (const memo of graph.memos) {
249
+ const d = depth(memo.name, new Set())
250
+ if (d > max) max = d
251
+ }
252
+ return max
253
+ }
254
+
255
+ // =============================================================================
256
+ // Findings (static analyses)
257
+ // =============================================================================
258
+
259
+ function computeFindings(
260
+ metrics: ComponentProfileMetrics,
261
+ graph: ComponentGraph,
262
+ eventSummary: EventSummary,
263
+ ): ProfileFinding[] {
264
+ const findings: ProfileFinding[] = []
265
+
266
+ // 1. High fan-out signals
267
+ for (const signal of graph.signals) {
268
+ if (signal.consumers.length > THRESHOLDS.highFanOut) {
269
+ findings.push({
270
+ kind: 'high-fan-out',
271
+ severity: 'warning',
272
+ signal: signal.name,
273
+ message: `${signal.name} has ${signal.consumers.length} consumers (fan-out > ${THRESHOLDS.highFanOut})`,
274
+ suggestion: `Split ${signal.name} into finer-grained signals, or add a createMemo to shield downstream consumers from unrelated updates`,
275
+ loc: { file: signal.loc.file, line: signal.loc.line },
276
+ })
277
+ }
278
+ }
279
+
280
+ // 2. Deep memo chains
281
+ if (metrics.maxMemoChainDepth > THRESHOLDS.deepMemoChain) {
282
+ findings.push({
283
+ kind: 'deep-memo-chain',
284
+ severity: 'warning',
285
+ depth: metrics.maxMemoChainDepth,
286
+ message: `Memo chain depth ${metrics.maxMemoChainDepth} (threshold: ${THRESHOLDS.deepMemoChain}) — a single signal update cascades through ${metrics.maxMemoChainDepth} memo levels`,
287
+ suggestion: 'Flatten intermediate memos that do not cache expensive computations; deep chains increase propagation latency',
288
+ })
289
+ }
290
+
291
+ // 3. Batch candidates — deduplicate by (eventName, loc, signals) so that
292
+ // the same handler wired to multiple JSX elements (e.g. Calendar dual-month
293
+ // nav buttons) does not produce duplicate findings. (Bug C)
294
+ const batchSeen = new Set<string>()
295
+ for (const event of eventSummary.events) {
296
+ const distinct = new Set<string>()
297
+ const setterNames: string[] = []
298
+ for (const sc of event.setterCalls) {
299
+ if (sc.signal) {
300
+ distinct.add(sc.signal)
301
+ setterNames.push(sc.setter)
302
+ }
303
+ }
304
+ if (distinct.size >= THRESHOLDS.batchMinSignals) {
305
+ const dedupeKey = `${event.eventName}|${event.loc.file ?? ''}|${event.loc.start.line}|${[...distinct].sort().join(',')}`
306
+ if (batchSeen.has(dedupeKey)) continue
307
+ batchSeen.add(dedupeKey)
308
+ findings.push({
309
+ kind: 'batch-candidate',
310
+ severity: 'info',
311
+ signals: [...distinct],
312
+ message: `${event.eventName} on <${event.elementContext}> sets ${distinct.size} signals (${[...distinct].join(', ')}) — triggers ${distinct.size} separate update cycles (static; verify setters are not in separate if/else branches)`,
313
+ suggestion: `If all listed setters fire unconditionally in the same handler path, wrap in batch(() => { ${setterNames.join('; ')}; }) to collapse ${distinct.size} cycles into 1`,
314
+ loc: event.loc.file ? { file: event.loc.file, line: event.loc.start.line } : undefined,
315
+ })
316
+ }
317
+ }
318
+
319
+ // 4. Fallback-heavy
320
+ if (
321
+ metrics.dynamicBindings > 0 &&
322
+ metrics.fallbacks >= THRESHOLDS.fallbackHeavyMin &&
323
+ metrics.fallbacks / metrics.dynamicBindings > THRESHOLDS.fallbackHeavyRatio
324
+ ) {
325
+ findings.push({
326
+ kind: 'fallback-heavy',
327
+ severity: 'info',
328
+ message: `${metrics.fallbacks}/${metrics.dynamicBindings} bindings (${Math.round(metrics.fallbacks / metrics.dynamicBindings * 100)}%) are fallback-wrapped — reactivity not statically provable`,
329
+ suggestion: 'Run `bf debug fallbacks` to see each expression and fix them so the compiler can prove reactivity without the fallback wrapper',
330
+ })
331
+ }
332
+
333
+ return findings
334
+ }
335
+
336
+ // =============================================================================
337
+ // SR6: Compile-diff regression detection
338
+ // =============================================================================
339
+
340
+ /**
341
+ * Compare two profile metric snapshots.
342
+ * Returns regressions (metrics that got worse), improvements, and neutral changes.
343
+ *
344
+ * "Worse" metrics when they increase: fallbacks, maxSignalFanOut,
345
+ * maxMemoChainDepth, totalSubscriptions, batchCandidateCount.
346
+ * All other metric changes (signal/memo/binding counts) are neutral (structural).
347
+ */
348
+ export function diffProfiles(
349
+ before: ComponentProfileMetrics,
350
+ after: ComponentProfileMetrics,
351
+ ): ProfileDiff {
352
+ const worseWhenHigher = new Set<keyof ComponentProfileMetrics>([
353
+ 'fallbacks',
354
+ 'maxSignalFanOut',
355
+ 'maxMemoChainDepth',
356
+ 'totalSubscriptions',
357
+ 'batchCandidateCount',
358
+ ])
359
+
360
+ const numericKeys: Array<keyof ComponentProfileMetrics> = [
361
+ 'signals', 'memos', 'effects', 'loops', 'eventHandlers',
362
+ 'dynamicBindings', 'fallbacks', 'conditionals',
363
+ 'maxSignalFanOut', 'maxMemoChainDepth', 'totalSubscriptions', 'batchCandidateCount',
364
+ ]
365
+
366
+ const regressions: ProfileDiffEntry[] = []
367
+ const improvements: ProfileDiffEntry[] = []
368
+ const neutral: ProfileDiffEntry[] = []
369
+
370
+ for (const key of numericKeys) {
371
+ const b = before[key] as number
372
+ const a = after[key] as number
373
+ if (a === b) continue
374
+ const entry: ProfileDiffEntry = { metric: key, before: b, after: a, delta: a - b }
375
+ if (worseWhenHigher.has(key)) {
376
+ if (a > b) regressions.push(entry)
377
+ else improvements.push(entry)
378
+ } else {
379
+ neutral.push(entry)
380
+ }
381
+ }
382
+
383
+ return { componentName: after.componentName, before, after, regressions, improvements, neutral }
384
+ }
385
+
386
+ // =============================================================================
387
+ // Formatting: human-readable output
388
+ // =============================================================================
389
+
390
+ /** Format a single component profile for `bf debug profile <component>`. */
391
+ export function formatSingleProfile(profile: ComponentProfile): string {
392
+ const m = profile.metrics
393
+ const lines: string[] = []
394
+
395
+ lines.push(`${m.componentName} — reactive profile (static)`)
396
+ if (m.sourceFile) lines.push(` source: ${m.sourceFile}`)
397
+ lines.push(` hydrated: ${m.hydrated ? 'yes' : 'no'}`)
398
+
399
+ lines.push('')
400
+ lines.push(' Counts:')
401
+ lines.push(` signals: ${m.signals}`)
402
+ lines.push(` memos: ${m.memos}`)
403
+ if (m.effects > 0) lines.push(` effects: ${m.effects}`)
404
+ lines.push(` dynamic bindings: ${m.dynamicBindings}`)
405
+ if (m.fallbacks > 0) lines.push(` fallbacks: ${m.fallbacks}`)
406
+ if (m.loops > 0) lines.push(` loops: ${m.loops}`)
407
+ if (m.conditionals > 0) lines.push(` conditionals: ${m.conditionals}`)
408
+ if (m.eventHandlers > 0) lines.push(` event handlers: ${m.eventHandlers}`)
409
+
410
+ lines.push('')
411
+ lines.push(' Reactive budget (SR5):')
412
+ const fanOutSuffix = m.hotSignal ? ` (${m.hotSignal})` : ''
413
+ lines.push(` max signal fan-out: ${m.maxSignalFanOut}${fanOutSuffix}`)
414
+ lines.push(` max memo chain depth: ${m.maxMemoChainDepth}`)
415
+ lines.push(` total subscriptions: ${m.totalSubscriptions}`)
416
+ if (m.batchCandidateCount > 0) {
417
+ lines.push(` batch candidates: ${m.batchCandidateCount} handler(s) set ≥2 signals`)
418
+ }
419
+
420
+ if (profile.findings.length > 0) {
421
+ lines.push('')
422
+ lines.push(' Findings:')
423
+ for (const f of profile.findings) {
424
+ const icon = f.severity === 'warning' ? '⚠' : '→'
425
+ lines.push(` ${icon} [${f.kind}] ${f.message}`)
426
+ lines.push(` fix: ${f.suggestion}`)
427
+ if (f.loc) {
428
+ const file = f.loc.file.split('/').pop() ?? f.loc.file
429
+ lines.push(` at ${file}:${f.loc.line}`)
430
+ }
431
+ }
432
+ } else {
433
+ lines.push('')
434
+ lines.push(' No findings — component is within all thresholds.')
435
+ }
436
+
437
+ return lines.join('\n')
438
+ }
439
+
440
+ /**
441
+ * Format a multi-component profile table for `--scenario auto`.
442
+ * Sorted by totalSubscriptions descending (highest reactive cost first).
443
+ */
444
+ export function formatProfileTable(profiles: ComponentProfile[]): string {
445
+ if (profiles.length === 0) return 'No components found.'
446
+
447
+ const sorted = [...profiles].sort(
448
+ (a, b) => b.metrics.totalSubscriptions - a.metrics.totalSubscriptions,
449
+ )
450
+
451
+ const lines: string[] = []
452
+ lines.push('Component sig memo bind fall fanOut chain subs batch findings')
453
+ lines.push('─'.repeat(90))
454
+
455
+ for (const p of sorted) {
456
+ const m = p.metrics
457
+ const name = m.componentName.padEnd(23).slice(0, 23)
458
+ const findingStr = p.findings.length > 0
459
+ ? p.findings.map(f => f.kind.replace(/-/g, '_')).join(',')
460
+ : '—'
461
+ const row = [
462
+ name,
463
+ String(m.signals).padStart(3),
464
+ String(m.memos).padStart(5),
465
+ String(m.dynamicBindings).padStart(5),
466
+ String(m.fallbacks).padStart(5),
467
+ String(m.maxSignalFanOut).padStart(7),
468
+ String(m.maxMemoChainDepth).padStart(6),
469
+ String(m.totalSubscriptions).padStart(5),
470
+ String(m.batchCandidateCount).padStart(6),
471
+ ` ${findingStr}`,
472
+ ].join(' ')
473
+ lines.push(row)
474
+ }
475
+
476
+ // Findings detail section
477
+ const allFindings = sorted.flatMap(p =>
478
+ p.findings.map(f => ({ component: p.metrics.componentName, finding: f })),
479
+ )
480
+
481
+ if (allFindings.length > 0) {
482
+ lines.push('')
483
+ lines.push('Findings:')
484
+ for (const { component, finding } of allFindings) {
485
+ const icon = finding.severity === 'warning' ? '⚠' : '→'
486
+ lines.push(` ${icon} ${component}: ${finding.message}`)
487
+ lines.push(` fix: ${finding.suggestion}`)
488
+ if (finding.loc) {
489
+ const file = finding.loc.file.split('/').pop() ?? finding.loc.file
490
+ lines.push(` at ${file}:${finding.loc.line}`)
491
+ }
492
+ }
493
+ } else {
494
+ lines.push('')
495
+ lines.push('No findings across all components.')
496
+ }
497
+
498
+ return lines.join('\n')
499
+ }
500
+
501
+ /** Format a profile diff for `--diff`. */
502
+ export function formatProfileDiff(diff: ProfileDiff): string {
503
+ const lines: string[] = []
504
+ lines.push(`${diff.componentName} — reactive profile diff (before → after)`)
505
+ lines.push('')
506
+
507
+ if (diff.regressions.length === 0 && diff.improvements.length === 0 && diff.neutral.length === 0) {
508
+ lines.push(' No changes in reactive metrics.')
509
+ return lines.join('\n')
510
+ }
511
+
512
+ if (diff.regressions.length > 0) {
513
+ lines.push(' Regressions (reactive cost increased):')
514
+ for (const e of diff.regressions) {
515
+ lines.push(` ${e.metric}: ${e.before} → ${e.after} (+${e.delta})`)
516
+ }
517
+ }
518
+
519
+ if (diff.improvements.length > 0) {
520
+ lines.push(' Improvements (reactive cost decreased):')
521
+ for (const e of diff.improvements) {
522
+ lines.push(` ${e.metric}: ${e.before} → ${e.after} (${e.delta})`)
523
+ }
524
+ }
525
+
526
+ if (diff.neutral.length > 0) {
527
+ lines.push(' Structural changes (count changes, no clear direction):')
528
+ for (const e of diff.neutral) {
529
+ const sign = (e.delta ?? 0) > 0 ? '+' : ''
530
+ lines.push(` ${e.metric}: ${e.before} → ${e.after} (${sign}${e.delta})`)
531
+ }
532
+ }
533
+
534
+ return lines.join('\n')
535
+ }
536
+
537
+ /** Serialize a single profile to a JSON-safe object. */
538
+ export function profileToJSON(profile: ComponentProfile): object {
539
+ return {
540
+ metrics: profile.metrics,
541
+ findings: profile.findings,
542
+ }
543
+ }