letfixit 0.1.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 (46) hide show
  1. package/.env.example +33 -0
  2. package/README.md +226 -0
  3. package/adapters/android/build.gradle +28 -0
  4. package/adapters/android/src/main/kotlin/dev/perfanalyzer/ChoreographerAdapter.kt +43 -0
  5. package/adapters/android/src/main/kotlin/dev/perfanalyzer/LogcatReader.kt +64 -0
  6. package/adapters/android/src/main/kotlin/dev/perfanalyzer/MemoryObserver.kt +46 -0
  7. package/adapters/android/src/main/kotlin/dev/perfanalyzer/OkHttpInterceptor.kt +53 -0
  8. package/adapters/android/src/main/kotlin/dev/perfanalyzer/PerfAnalyzer.kt +100 -0
  9. package/adapters/flutter/lib/perf_analyzer.dart +73 -0
  10. package/adapters/flutter/lib/src/frame_observer.dart +45 -0
  11. package/adapters/flutter/lib/src/network_observer.dart +72 -0
  12. package/adapters/flutter/lib/src/rebuild_observer.dart +57 -0
  13. package/adapters/flutter/lib/src/universal_event.dart +50 -0
  14. package/adapters/flutter/lib/src/vm_service_adapter.dart +81 -0
  15. package/adapters/flutter/pubspec.lock +184 -0
  16. package/adapters/flutter/pubspec.yaml +13 -0
  17. package/config/model.manifest.example.json +108 -0
  18. package/config/model.manifest.json +108 -0
  19. package/core/schema/universal_event.dart +63 -0
  20. package/dashboard/dist/assets/index-D5TCSsvB.js +107 -0
  21. package/dashboard/dist/index.html +118 -0
  22. package/package.json +62 -0
  23. package/scripts/encrypt-model.js +86 -0
  24. package/scripts/fill-manifest.js +67 -0
  25. package/scripts/load-env.js +20 -0
  26. package/scripts/model_crypto.js +14 -0
  27. package/scripts/setup-model.js +269 -0
  28. package/server/adb_bridge.js +201 -0
  29. package/server/adb_score.js +77 -0
  30. package/server/ai_proxy.js +373 -0
  31. package/server/analysis_engine.js +222 -0
  32. package/server/android_bridge.js +124 -0
  33. package/server/android_live.js +167 -0
  34. package/server/data/device_gpu_db.json +465 -0
  35. package/server/device_db.js +94 -0
  36. package/server/device_review.js +146 -0
  37. package/server/finding_generator.js +56 -0
  38. package/server/flutter_bridge.js +414 -0
  39. package/server/index.js +619 -0
  40. package/server/ios_bridge.js +181 -0
  41. package/server/model_config.js +54 -0
  42. package/server/report_template.js +169 -0
  43. package/server/session_manager.js +370 -0
  44. package/server/widget_gpu_classifier.js +77 -0
  45. package/server/widget_triage.js +78 -0
  46. package/setup.sh +160 -0
@@ -0,0 +1,370 @@
1
+ const { EventEmitter } = require('events');
2
+ const { v4: uuidv4 } = require('uuid');
3
+ const deviceDb = require('./device_db');
4
+ const { classifyWidget } = require('./widget_gpu_classifier');
5
+
6
+ const FRAME_BUDGET_MS = 16.6; // 60Hz
7
+ const REQUIRED_RUNS = 5;
8
+
9
+ // Rough cost multiplier for projecting measurements (usually taken on a strong
10
+ // simulator/device) onto weaker GPU tiers. Baseline is 'high'.
11
+ const TIER_FACTOR = { flagship: 0.85, high: 1.0, mid: 1.5, low: 2.3, entry: 3.2 };
12
+
13
+ // Extra penalty (ms) when a high-GPU-cost widget runs on a tier it's risky on.
14
+ const RISK_PENALTY_MS = 6;
15
+
16
+ function rate(projectedMs) {
17
+ if (projectedMs < 8) return 'best';
18
+ if (projectedMs < 12) return 'good';
19
+ if (projectedMs < FRAME_BUDGET_MS) return 'ok';
20
+ return 'bad';
21
+ }
22
+
23
+ function percentile(sorted, p) {
24
+ if (!sorted.length) return 0;
25
+ const idx = Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length));
26
+ return sorted[idx];
27
+ }
28
+
29
+ class SessionManager extends EventEmitter {
30
+ constructor() {
31
+ super();
32
+ this.session = null;
33
+ }
34
+
35
+ // ── Lifecycle ───────────────────────────────────────────────────────────────
36
+ start(module, targetDeviceIds = []) {
37
+ // No devices selected → evaluate against every unique device configuration
38
+ // by default (one representative per platform × GPU tier).
39
+ const targets = (Array.isArray(targetDeviceIds) && targetDeviceIds.length)
40
+ ? targetDeviceIds
41
+ : deviceDb.listUniqueConfigs().map((d) => d.id);
42
+
43
+ this.session = {
44
+ id: uuidv4(),
45
+ module: module || 'Unnamed module',
46
+ targetDeviceIds: targets,
47
+ requiredRuns: REQUIRED_RUNS,
48
+ status: 'recording',
49
+ runs: [],
50
+ current: this._newRun(0),
51
+ analysis: null,
52
+ startedAt: Date.now(),
53
+ };
54
+ this._emit();
55
+ return this.session;
56
+ }
57
+
58
+ /** Finalize the current run and begin the next (the "Restart / Next run" button). */
59
+ nextRun() {
60
+ if (!this.session || this.session.status !== 'recording') return this.session;
61
+ this._finalizeCurrent();
62
+ if (this.session.runs.length >= this.session.requiredRuns) {
63
+ this.session.status = 'ready'; // enough runs — analysis unlocked
64
+ }
65
+ this.session.current = this._newRun(this.session.runs.length);
66
+ this._emit();
67
+ return this.session;
68
+ }
69
+
70
+ /** Stop recording entirely (finalizes the in-progress run). */
71
+ stop() {
72
+ if (!this.session) return null;
73
+ if (this.session.current && this.session.current.frameCount > 0) {
74
+ this._finalizeCurrent();
75
+ } else {
76
+ this.session.current = null;
77
+ }
78
+ this.session.status = this.session.runs.length >= this.session.requiredRuns ? 'ready' : 'stopped';
79
+ this.session.current = null;
80
+ this._emit();
81
+ return this.session;
82
+ }
83
+
84
+ reset() {
85
+ this.session = null;
86
+ this._emit();
87
+ }
88
+
89
+ getState() {
90
+ return this.session;
91
+ }
92
+
93
+ // ── Event ingestion (called from index.js for every flutter event) ───────────
94
+ ingest(event) {
95
+ const s = this.session;
96
+ if (!s || s.status !== 'recording' || !s.current) return;
97
+ const run = s.current;
98
+
99
+ if (event.event === 'frame') {
100
+ const ms = event.metric.value;
101
+ run.frames.push(ms);
102
+ run.builds.push(event.metric.build || 0);
103
+ run.rasters.push(event.metric.raster || 0);
104
+ run.frameCount++;
105
+ } else if (event.event === 'rebuild' && event.widget) {
106
+ const w = run.widgets.get(event.widget) || { rebuilds: 0, totalBuildMs: 0, maxBuildMs: 0 };
107
+ w.rebuilds++;
108
+ const bms = event.metric.buildMs || 0;
109
+ w.totalBuildMs += bms;
110
+ if (bms > w.maxBuildMs) w.maxBuildMs = bms;
111
+ run.widgets.set(event.widget, w);
112
+ } else if (event.event === 'memory') {
113
+ run.memory.push(event.metric.value || 0);
114
+ } else if (event.event === 'api') {
115
+ const m = event.metric || {};
116
+ run.apis.push({
117
+ url: m.url || 'unknown', method: m.method || 'GET',
118
+ size: m.value || 0, durationMs: m.durationMs || 0, status: m.status || 0,
119
+ });
120
+ } else if (event.event === 'error') {
121
+ run.errors.push({ message: event.metric?.message || event.title || 'Error', widget: event.widget || null });
122
+ }
123
+ }
124
+
125
+ // ── Internal ─────────────────────────────────────────────────────────────────
126
+ _newRun(index) {
127
+ return { index, frames: [], builds: [], rasters: [], widgets: new Map(),
128
+ memory: [], apis: [], errors: [], frameCount: 0, startedAt: Date.now() };
129
+ }
130
+
131
+ _finalizeCurrent() {
132
+ const r = this.session.current;
133
+ if (!r) return;
134
+ const sortedFrames = [...r.frames].sort((a, b) => a - b);
135
+ const jank = r.frames.filter((ms) => ms > FRAME_BUDGET_MS).length;
136
+ const avg = (arr) => (arr.length ? arr.reduce((s, x) => s + x, 0) / arr.length : 0);
137
+
138
+ const widgets = [...r.widgets.entries()].map(([name, w]) => {
139
+ const info = classifyWidget(name);
140
+ return {
141
+ name,
142
+ rebuilds: w.rebuilds,
143
+ avgBuildMs: parseFloat((w.totalBuildMs / w.rebuilds).toFixed(3)),
144
+ maxBuildMs: parseFloat(w.maxBuildMs.toFixed(3)),
145
+ gpuCost: info.cost,
146
+ riskyTiers: info.risky,
147
+ };
148
+ }).sort((a, b) => (b.avgBuildMs * b.rebuilds) - (a.avgBuildMs * a.rebuilds));
149
+
150
+ const peakMemMb = r.memory.length ? Math.max(...r.memory) / (1024 * 1024) : 0;
151
+ const memGrowthMb = r.memory.length >= 2 ? (r.memory[r.memory.length - 1] - r.memory[0]) / (1024 * 1024) : 0;
152
+ const slowApis = r.apis.filter((a) => a.durationMs > 500).length;
153
+ const largeApis = r.apis.filter((a) => a.size > 512000).length;
154
+ const largestApiKb = r.apis.length ? Math.max(...r.apis.map((a) => a.size)) / 1024 : 0;
155
+
156
+ this.session.runs.push({
157
+ index: r.index,
158
+ durationMs: Date.now() - r.startedAt,
159
+ frameCount: r.frameCount,
160
+ jankFrames: jank,
161
+ jankPct: r.frameCount ? parseFloat(((jank / r.frameCount) * 100).toFixed(1)) : 0,
162
+ avgFrameMs: parseFloat(avg(r.frames).toFixed(2)),
163
+ p90FrameMs: parseFloat(percentile(sortedFrames, 90).toFixed(2)),
164
+ worstFrameMs: parseFloat((sortedFrames[sortedFrames.length - 1] || 0).toFixed(2)),
165
+ avgBuildMs: parseFloat(avg(r.builds).toFixed(2)),
166
+ avgRasterMs: parseFloat(avg(r.rasters).toFixed(2)),
167
+ widgets: widgets.slice(0, 20),
168
+ peakMemMb: parseFloat(peakMemMb.toFixed(1)),
169
+ memGrowthMb: parseFloat(memGrowthMb.toFixed(1)),
170
+ apiCount: r.apis.length,
171
+ slowApis, largeApis,
172
+ largestApiKb: parseFloat(largestApiKb.toFixed(1)),
173
+ errorCount: r.errors.length,
174
+ apis: r.apis.slice(0, 30),
175
+ errors: r.errors.slice(0, 20),
176
+ });
177
+ }
178
+
179
+ /**
180
+ * Aggregate all runs into a module-level summary and project onto each
181
+ * selected target device. Returns the aggregate WITHOUT calling AI —
182
+ * index.js triggers the single AI call separately.
183
+ */
184
+ aggregate() {
185
+ const s = this.session;
186
+ if (!s || s.runs.length === 0) return null;
187
+
188
+ const med = (arr) => {
189
+ const a = [...arr].sort((x, y) => x - y);
190
+ return a.length ? a[Math.floor(a.length / 2)] : 0;
191
+ };
192
+
193
+ const p90 = med(s.runs.map((r) => r.p90FrameMs));
194
+ const jankPct = med(s.runs.map((r) => r.jankPct));
195
+ const avgFrame = med(s.runs.map((r) => r.avgFrameMs));
196
+
197
+ // Merge widget stats across runs (avg the per-run averages, sum rebuilds).
198
+ const merged = new Map();
199
+ for (const run of s.runs) {
200
+ for (const w of run.widgets) {
201
+ const m = merged.get(w.name) || { name: w.name, rebuilds: 0, buildSum: 0, buildN: 0, maxBuildMs: 0, gpuCost: w.gpuCost, riskyTiers: w.riskyTiers };
202
+ m.rebuilds += w.rebuilds;
203
+ m.buildSum += w.avgBuildMs;
204
+ m.buildN += 1;
205
+ m.maxBuildMs = Math.max(m.maxBuildMs, w.maxBuildMs);
206
+ merged.set(w.name, m);
207
+ }
208
+ }
209
+ const widgets = [...merged.values()].map((m) => ({
210
+ name: m.name,
211
+ rebuilds: m.rebuilds,
212
+ avgBuildMs: parseFloat((m.buildSum / m.buildN).toFixed(3)),
213
+ maxBuildMs: parseFloat(m.maxBuildMs.toFixed(3)),
214
+ gpuCost: m.gpuCost,
215
+ riskyTiers: m.riskyTiers,
216
+ })).sort((a, b) => (b.avgBuildMs * Math.max(1, b.rebuilds)) - (a.avgBuildMs * Math.max(1, a.rebuilds)));
217
+
218
+ // Per-device projection
219
+ const devices = (s.targetDeviceIds.length ? s.targetDeviceIds : [])
220
+ .map((id) => deviceDb.getDevice(id))
221
+ .filter(Boolean)
222
+ .map((dev) => {
223
+ const factor = TIER_FACTOR[dev.gpu_tier] ?? 1.5;
224
+ const projectedFrameMs = parseFloat((avgFrame * factor).toFixed(2));
225
+ const projectedP90 = parseFloat((p90 * factor).toFixed(2));
226
+ const overall = rate(projectedP90);
227
+ const widgetRatings = widgets.slice(0, 12).map((w) => {
228
+ let projMs = w.avgBuildMs * factor;
229
+ if (w.gpuCost === 'high' && w.riskyTiers.includes(dev.gpu_tier)) projMs += RISK_PENALTY_MS;
230
+ return { name: w.name, projectedMs: parseFloat(projMs.toFixed(2)), rating: rate(projMs), gpuCost: w.gpuCost };
231
+ });
232
+ return {
233
+ id: dev.id, name: dev.name, platform: dev.platform,
234
+ gpu: dev.gpu, gpu_tier: dev.gpu_tier, ram_gb: dev.ram_gb,
235
+ arch: dev.arch, api: dev.api ?? null, ios_min: dev.ios_min ?? null,
236
+ projectedFrameMs, projectedP90, overall,
237
+ widgetRatings,
238
+ };
239
+ });
240
+
241
+ const measured = { p90FrameMs: p90, avgFrameMs: avgFrame, jankPct };
242
+ const report = this._buildReport(s.runs, widgets, devices, measured);
243
+
244
+ return {
245
+ module: s.module,
246
+ runsCompleted: s.runs.length,
247
+ measured,
248
+ widgets: widgets.slice(0, 20),
249
+ devices,
250
+ report,
251
+ };
252
+ }
253
+
254
+ /**
255
+ * Roll the runs + projections into a per-parameter report with a traffic-light
256
+ * status for each: performance, memory, API, and UI. Computed entirely in code
257
+ * (no LLM) — the AI advice is layered on separately by the analysis step.
258
+ */
259
+ _buildReport(runs, widgets, devices, measured) {
260
+ const med = (arr) => { const a = [...arr].sort((x, y) => x - y); return a.length ? a[Math.floor(a.length / 2)] : 0; };
261
+ const worst = (arr) => (arr.length ? Math.max(...arr) : 0);
262
+
263
+ // ── Performance ────────────────────────────────────────────────────────
264
+ const badDevices = devices.filter((d) => d.overall === 'bad');
265
+ const perfStatus = measured.p90FrameMs > FRAME_BUDGET_MS || badDevices.length
266
+ ? 'bad' : measured.p90FrameMs > 12 || measured.jankPct > 5 ? 'warn' : 'good';
267
+ const performance = {
268
+ status: perfStatus,
269
+ metrics: {
270
+ p90FrameMs: measured.p90FrameMs,
271
+ avgFrameMs: measured.avgFrameMs,
272
+ jankPct: measured.jankPct,
273
+ budgetMs: FRAME_BUDGET_MS,
274
+ },
275
+ failingDevices: badDevices.map((d) => `${d.name} (${d.projectedP90}ms)`),
276
+ note: perfStatus === 'good'
277
+ ? 'Frame times stay within the 16.6ms budget across target devices.'
278
+ : perfStatus === 'warn'
279
+ ? 'Frame times are close to budget — some jank on weaker tiers.'
280
+ : `p90 frame exceeds budget${badDevices.length ? ` on ${badDevices.length} device tier(s)` : ''}.`,
281
+ };
282
+
283
+ // ── Memory ─────────────────────────────────────────────────────────────
284
+ const peakMemMb = worst(runs.map((r) => r.peakMemMb || 0));
285
+ const growthMb = med(runs.map((r) => r.memGrowthMb || 0));
286
+ const memStatus = growthMb > 5 ? 'bad' : growthMb > 2 ? 'warn' : 'good';
287
+ const memory = {
288
+ status: memStatus,
289
+ metrics: { peakMb: parseFloat(peakMemMb.toFixed(1)), growthPerRunMb: parseFloat(growthMb.toFixed(1)) },
290
+ note: memStatus === 'good'
291
+ ? 'Heap is stable across runs — no growth trend detected.'
292
+ : memStatus === 'warn'
293
+ ? `Heap grows ~${growthMb.toFixed(1)}MB/run — watch for retained objects.`
294
+ : `Heap grows ~${growthMb.toFixed(1)}MB/run — likely a leak (unclosed streams, image cache, or Context retention).`,
295
+ };
296
+
297
+ // ── API ────────────────────────────────────────────────────────────────
298
+ const totalApis = runs.reduce((s, r) => s + (r.apiCount || 0), 0);
299
+ const totalSlow = runs.reduce((s, r) => s + (r.slowApis || 0), 0);
300
+ const totalLarge = runs.reduce((s, r) => s + (r.largeApis || 0), 0);
301
+ const largestKb = worst(runs.map((r) => r.largestApiKb || 0));
302
+ const apiStatus = totalLarge > 0 ? 'bad' : totalSlow > 0 ? 'warn' : 'good';
303
+ const api = {
304
+ status: totalApis === 0 ? 'good' : apiStatus,
305
+ metrics: { total: totalApis, slow: totalSlow, large: totalLarge, largestKb: parseFloat(largestKb.toFixed(1)) },
306
+ note: totalApis === 0
307
+ ? 'No API calls captured during the test (route calls through PerfHttpClient to track them).'
308
+ : apiStatus === 'good'
309
+ ? 'All responses are within the 500KB / 500ms budget.'
310
+ : apiStatus === 'warn'
311
+ ? `${totalSlow} slow call(s) (>500ms) — consider pagination or caching.`
312
+ : `${totalLarge} oversized payload(s) (>500KB) block parsing on the main thread.`,
313
+ };
314
+
315
+ // ── UI issues (high/medium GPU widgets landing on risky target tiers) ────
316
+ const targetTiers = new Set(devices.map((d) => d.gpu_tier));
317
+ const uiIssues = widgets
318
+ .filter((w) => w.gpuCost === 'high' || w.gpuCost === 'medium')
319
+ .map((w) => {
320
+ const riskyOnTarget = (w.riskyTiers || []).filter((t) => targetTiers.has(t));
321
+ return {
322
+ widget: w.name,
323
+ gpuCost: w.gpuCost,
324
+ rebuilds: w.rebuilds,
325
+ avgBuildMs: w.avgBuildMs,
326
+ riskyOnTarget,
327
+ note: classifyWidget(w.name).note,
328
+ };
329
+ })
330
+ .filter((w) => w.gpuCost === 'high' || w.riskyOnTarget.length || w.rebuilds > 30)
331
+ .slice(0, 12);
332
+ const highRisk = uiIssues.filter((w) => w.gpuCost === 'high' && w.riskyOnTarget.length);
333
+ const uiStatus = highRisk.length ? 'bad' : uiIssues.length ? 'warn' : 'good';
334
+ const ui = {
335
+ status: uiStatus,
336
+ issues: uiIssues,
337
+ note: uiStatus === 'good'
338
+ ? 'No GPU-expensive widgets on risky device tiers.'
339
+ : uiStatus === 'warn'
340
+ ? `${uiIssues.length} widget(s) worth reviewing on weaker tiers.`
341
+ : `${highRisk.length} high-GPU widget(s) on device tiers they're risky on.`,
342
+ };
343
+
344
+ // Overall status = worst of the four.
345
+ const rank = { good: 0, warn: 1, bad: 2 };
346
+ const overall = [performance, memory, api, ui].reduce(
347
+ (acc, sec) => (rank[sec.status] > rank[acc] ? sec.status : acc), 'good');
348
+
349
+ return { overall, performance, memory, api, ui };
350
+ }
351
+
352
+ /** Serializable snapshot (Maps stripped) for the dashboard / REST responses. */
353
+ snapshot() {
354
+ const s = this.session;
355
+ if (!s) return null;
356
+ return {
357
+ id: s.id, module: s.module, targetDeviceIds: s.targetDeviceIds,
358
+ requiredRuns: s.requiredRuns, status: s.status,
359
+ runs: s.runs, analysis: s.analysis,
360
+ currentRunFrames: s.current ? s.current.frameCount : 0,
361
+ runsCompleted: s.runs.length,
362
+ };
363
+ }
364
+
365
+ _emit() {
366
+ this.emit('update', this.snapshot());
367
+ }
368
+ }
369
+
370
+ module.exports = new SessionManager();
@@ -0,0 +1,77 @@
1
+ // Static GPU cost classification for Flutter widgets.
2
+ // Cost is based on whether the widget forces an offscreen compositing pass
3
+ // (saveLayer) or executes custom GPU shader work per frame.
4
+ const GPU_COSTS = {
5
+ // ── High GPU cost (offscreen compositing / custom shaders) ──────────────
6
+ BackdropFilter: { cost: 'high', risky: ['entry', 'low'], note: 'Full offscreen compositing pass — avoid in scrollable lists' },
7
+ ShaderMask: { cost: 'high', risky: ['entry', 'low'], note: 'Custom shader evaluation per pixel — use pre-rendered assets on low-end devices' },
8
+ ImageFiltered: { cost: 'high', risky: ['entry', 'low', 'mid'], note: 'Per-frame image processing — cache with RepaintBoundary' },
9
+ ColorFiltered: { cost: 'high', risky: ['entry', 'low'], note: 'Fragment shader per frame — consider pre-composited assets' },
10
+ PhysicalModel: { cost: 'high', risky: ['entry', 'low'], note: 'Box shadow triggers saveLayer — use pre-baked shadows on low-end devices' },
11
+
12
+ // ── Medium GPU cost (conditional saveLayer / unbounded paint) ────────────
13
+ Opacity: { cost: 'medium', risky: ['entry'], note: 'Values 0<x<1 force saveLayer. Prefer AnimatedOpacity or FadeTransition' },
14
+ AnimatedOpacity: { cost: 'medium', risky: ['entry'], note: 'Avoid driving with non-0/1 values on entry-tier devices' },
15
+ FadeTransition: { cost: 'medium', risky: ['entry'], note: 'Avoid stacking multiple FadeTransitions on entry-tier GPUs' },
16
+ CustomPaint: { cost: 'medium', risky: ['entry', 'low'], note: 'Unbounded GPU cost — profile shouldRepaint() and use RepaintBoundary' },
17
+ ClipPath: { cost: 'medium', risky: ['entry', 'low'], note: 'Anti-aliased clip is GPU-expensive — prefer ClipRRect with simple radii' },
18
+ ClipOval: { cost: 'medium', risky: ['entry'], note: 'Rounded clip — acceptable on low/mid, avoid animating on entry-tier' },
19
+ DecoratedBox: { cost: 'medium', risky: ['entry'], note: 'boxShadow triggers saveLayer on ARM Cortex-A53 class GPUs' },
20
+ Card: { cost: 'medium', risky: ['entry'], note: 'Shadow compositing — raise elevation only when needed' },
21
+ Material: { cost: 'medium', risky: ['entry'], note: 'Ink splash layer compositing — avoid inside tight list items on entry-tier' },
22
+ BoxShadow: { cost: 'medium', risky: ['entry', 'low'], note: 'Shadow compositing forces saveLayer — pre-bake on low-end devices' },
23
+ InkWell: { cost: 'medium', risky: ['entry'], note: 'Ink ripple compositing — consider InkResponse or custom hit-test shapes' },
24
+ Ink: { cost: 'medium', risky: ['entry'], note: 'Material ink compositing — watch rebuild count on entry-tier' },
25
+ AnimatedContainer:{ cost: 'medium', risky: ['entry'], note: 'Per-frame layout + paint during animation — use AnimationController manually' },
26
+ AnimatedCrossFade:{ cost: 'medium', risky: ['entry', 'low'], note: 'Dual subtree + CrossFadeState compositing — avoid inside ListView' },
27
+ Hero: { cost: 'medium', risky: ['entry', 'low'], note: 'Overlay layer during transition — heavy when source widget is complex' },
28
+ Draggable: { cost: 'medium', risky: ['entry'], note: 'Drag overlay compositing — keep dragged widget simple on entry-tier' },
29
+
30
+ // ── Low GPU cost (standard layout/paint — safe on all tiers) ────────────
31
+ Container: { cost: 'low', risky: [], note: 'Safe. Add RepaintBoundary around frequently-rebuilding subtrees.' },
32
+ Row: { cost: 'low', risky: [], note: 'Layout only — safe on all tiers' },
33
+ Column: { cost: 'low', risky: [], note: 'Layout only — safe on all tiers' },
34
+ Stack: { cost: 'low', risky: [], note: 'Adds compositing if children overflow — keep depth ≤ 8 on entry-tier' },
35
+ Text: { cost: 'low', risky: [], note: 'Safe. Watch font fallback chains on entry-tier (slow glyph load)' },
36
+ RichText: { cost: 'low', risky: [], note: 'Safe. Complex InlineSpan trees may slow text layout' },
37
+ Padding: { cost: 'low', risky: [], note: 'Layout pass only — safe on all tiers' },
38
+ Align: { cost: 'low', risky: [], note: 'Layout pass only — safe on all tiers' },
39
+ Center: { cost: 'low', risky: [], note: 'Alias of Align — safe on all tiers' },
40
+ Expanded: { cost: 'low', risky: [], note: 'Flex child — safe on all tiers' },
41
+ Flexible: { cost: 'low', risky: [], note: 'Flex child — safe on all tiers' },
42
+ SizedBox: { cost: 'low', risky: [], note: 'Constraint box — safe on all tiers' },
43
+ ConstrainedBox: { cost: 'low', risky: [], note: 'Constraint box — safe on all tiers' },
44
+ ListView: { cost: 'low', risky: [], note: 'Safe when using ListView.builder. Avoid ListView with non-lazy children.' },
45
+ GridView: { cost: 'low', risky: [], note: 'Use GridView.builder. Avoid fixed child arrays >20 items.' },
46
+ SingleChildScrollView:{ cost: 'low', risky: [], note: 'Safe but entire subtree paints at once — prefer ListView.builder for long content' },
47
+ CustomScrollView: { cost: 'low', risky: [], note: 'Sliver-based — safe. Avoid SliverFillRemaining with complex children.' },
48
+ Image: { cost: 'low', risky: [], note: 'Decoded image upload is GPU-cheap. Watch resolution scaling on entry-tier.' },
49
+ Icon: { cost: 'low', risky: [], note: 'Safe on all tiers' },
50
+ RepaintBoundary: { cost: 'low', risky: [], note: 'Isolates repaints — always beneficial around frequently-updating subtrees' },
51
+ GestureDetector: { cost: 'low', risky: [], note: 'Hit-test only — safe on all tiers' },
52
+ Navigator: { cost: 'low', risky: [], note: 'Overlay management — safe' },
53
+ Scaffold: { cost: 'low', risky: [], note: 'Safe on all tiers' },
54
+ AppBar: { cost: 'low', risky: [], note: 'Safe. Avoid elevation > 0 in scrollable app bars on entry-tier.' },
55
+ BottomNavigationBar:{ cost: 'low', risky: [], note: 'Safe. Avoid AnimatedIcons on entry-tier.' },
56
+ TextField: { cost: 'low', risky: [], note: 'Safe. Watch InputDecoration with boxShadow on entry-tier.' },
57
+ };
58
+
59
+ const UNKNOWN = { cost: 'unknown', risky: [], note: 'No data — profile on target device' };
60
+
61
+ function classifyWidget(widgetName) {
62
+ if (!widgetName) return UNKNOWN;
63
+ // Direct match
64
+ if (GPU_COSTS[widgetName]) return GPU_COSTS[widgetName];
65
+ // Prefix match for generated widget names like _MyHomePageState → State rebuild
66
+ for (const [key, val] of Object.entries(GPU_COSTS)) {
67
+ if (widgetName.startsWith(key)) return val;
68
+ }
69
+ return UNKNOWN;
70
+ }
71
+
72
+ function isRiskyOn(widgetName, gpuTier) {
73
+ const info = classifyWidget(widgetName);
74
+ return info.risky.includes(gpuTier);
75
+ }
76
+
77
+ module.exports = { classifyWidget, isRiskyOn };
@@ -0,0 +1,78 @@
1
+ // ── Code-first widget triage ─────────────────────────────────────────────────
2
+ // The local LLM is expensive on CPU, so we do NOT ask it about every widget.
3
+ // Instead we score each widget deterministically in JavaScript and only escalate
4
+ // the genuinely expensive / ambiguous ones to the model. Cheap, clearly-safe
5
+ // widgets are "auto-cleared" here with a code-generated note — the LLM never sees
6
+ // them. This keeps prompts short (fewer tokens = far less CPU) and often skips the
7
+ // model call entirely when a module is clean.
8
+
9
+ const FRAME_BUDGET_MS = 16.6;
10
+
11
+ // A widget is worth the LLM's time only if code can't already explain it.
12
+ // Score components (higher = more worth escalating):
13
+ // • build cost — avgBuildMs weighted by how often it rebuilds
14
+ // • budget pressure — how far its worst projected frame pushes past 16.6ms
15
+ // • gpu risk — high/medium GPU cost widget landing on a risky target tier
16
+ function scoreWidget(widget, devices) {
17
+ const rebuilds = Math.max(1, widget.rebuilds || 0);
18
+ const buildCost = (widget.avgBuildMs || 0) * Math.min(rebuilds, 60); // cap rebuild weight
19
+
20
+ // Worst projected ms for this widget across all selected target devices.
21
+ let worstProjected = widget.avgBuildMs || 0;
22
+ let worstDevice = null;
23
+ let riskyHit = false;
24
+ for (const d of devices) {
25
+ const wr = (d.widgetRatings || []).find((w) => w.name === widget.name);
26
+ if (!wr) continue;
27
+ if (wr.projectedMs > worstProjected) { worstProjected = wr.projectedMs; worstDevice = d; }
28
+ if ((wr.rating === 'bad' || wr.rating === 'ok') && wr.gpuCost === 'high') riskyHit = true;
29
+ }
30
+
31
+ const budgetPressure = Math.max(0, worstProjected - FRAME_BUDGET_MS);
32
+ const gpuRisk = (widget.gpuCost === 'high' ? 8 : widget.gpuCost === 'medium' ? 3 : 0)
33
+ * (widget.riskyTiers?.length ? 1 : 0.4);
34
+
35
+ const score = buildCost * 1.5 + budgetPressure * 4 + gpuRisk + (riskyHit ? 6 : 0);
36
+ return { score, worstProjected: parseFloat(worstProjected.toFixed(2)), worstDevice, riskyHit };
37
+ }
38
+
39
+ // A deterministic, no-LLM note for widgets that don't clear the escalation bar.
40
+ function autoNote(widget, meta) {
41
+ if (widget.gpuCost === 'high') {
42
+ return `High GPU-cost widget but projected within budget (worst ~${meta.worstProjected}ms). Keep it out of long scrollable lists and wrap in RepaintBoundary.`;
43
+ }
44
+ if (meta.worstProjected >= FRAME_BUDGET_MS * 0.75) {
45
+ return `Approaching frame budget (worst ~${meta.worstProjected}ms). Watch rebuild count; add const constructors where possible.`;
46
+ }
47
+ return `Cheap on all target tiers (worst ~${meta.worstProjected}ms, ${widget.rebuilds} rebuilds). No action needed.`;
48
+ }
49
+
50
+ /**
51
+ * Split widgets into { escalate, cleared } using code-computed scores.
52
+ * @param agg aggregate from session_manager.aggregate()
53
+ * @param maxEscalate hard cap on how many widgets we let reach the LLM
54
+ * @param minScore escalation threshold
55
+ */
56
+ function triage(agg, { maxEscalate = 5, minScore = 6 } = {}) {
57
+ const devices = agg.devices || [];
58
+ const scored = (agg.widgets || []).map((w) => ({ widget: w, meta: scoreWidget(w, devices) }));
59
+ scored.sort((a, b) => b.meta.score - a.meta.score);
60
+
61
+ const escalate = [];
62
+ const cleared = [];
63
+ for (const s of scored) {
64
+ if (escalate.length < maxEscalate && s.meta.score >= minScore) {
65
+ escalate.push(s);
66
+ } else {
67
+ cleared.push({
68
+ widget: s.widget.name,
69
+ gpuCost: s.widget.gpuCost,
70
+ worstProjectedMs: s.meta.worstProjected,
71
+ note: autoNote(s.widget, s.meta),
72
+ });
73
+ }
74
+ }
75
+ return { escalate, cleared };
76
+ }
77
+
78
+ module.exports = { triage, scoreWidget, FRAME_BUDGET_MS };