what-devtools-mcp 0.6.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.
@@ -0,0 +1,818 @@
1
+ /**
2
+ * Agent-first MCP tools for what-devtools-mcp.
3
+ * 5 new tools: lint, scaffold, validate, perf, fix.
4
+ * Plus enhancements to existing tools (errors, snapshot).
5
+ *
6
+ * These tools make WhatFW THE framework designed for AI coding agents.
7
+ */
8
+
9
+ import { z } from 'zod';
10
+
11
+ // --- Error code database (mirrors packages/core/src/errors.js) ---
12
+ const ERROR_DATABASE = {
13
+ ERR_INFINITE_EFFECT: {
14
+ code: 'ERR_INFINITE_EFFECT',
15
+ severity: 'error',
16
+ diagnosis: 'An effect reads and writes the same signal, creating an infinite update cycle. The effect triggers itself on every run.',
17
+ suggestedFix: 'Use untrack() to read the signal without subscribing, or restructure so the read and write are in separate effects.',
18
+ codeExample: `// Bad — reads and writes count, creating a cycle:
19
+ effect(() => { count(count() + 1); });
20
+
21
+ // Fix — use untrack() so the read doesn't subscribe:
22
+ effect(() => { count(untrack(count) + 1); });
23
+
24
+ // Better — split into separate logic:
25
+ const doubled = computed(() => count() * 2);`,
26
+ },
27
+ ERR_MISSING_SIGNAL_READ: {
28
+ code: 'ERR_MISSING_SIGNAL_READ',
29
+ severity: 'warning',
30
+ diagnosis: 'A signal function reference is used where its value was intended. Signals are functions — they must be called with () to read their value.',
31
+ suggestedFix: 'Add () after the signal name. In JSX: {count()} not {count}. In template literals: `${count()}` not `${count}`.',
32
+ codeExample: `// Bad:
33
+ <span>{count}</span> // renders "[Function]"
34
+
35
+ // Good:
36
+ <span>{count()}</span> // renders the actual value`,
37
+ },
38
+ ERR_HYDRATION_MISMATCH: {
39
+ code: 'ERR_HYDRATION_MISMATCH',
40
+ severity: 'error',
41
+ diagnosis: 'Server-rendered HTML does not match what the client expects. This causes the DOM to be rebuilt, losing any server-rendered content benefits.',
42
+ suggestedFix: 'Avoid reading browser-only APIs (window, localStorage, navigator) during the initial render. Use onMount() for client-only logic.',
43
+ codeExample: `// Bad — different on server vs client:
44
+ function App() {
45
+ return <p>{window.innerWidth}</p>;
46
+ }
47
+
48
+ // Good — use onMount for client-only values:
49
+ function App() {
50
+ const width = signal(0);
51
+ onMount(() => width(window.innerWidth));
52
+ return <p>{width()}</p>;
53
+ }`,
54
+ },
55
+ ERR_ORPHAN_EFFECT: {
56
+ code: 'ERR_ORPHAN_EFFECT',
57
+ severity: 'warning',
58
+ diagnosis: 'An effect was created outside any reactive root or component. It will never be automatically cleaned up, causing a memory leak.',
59
+ suggestedFix: 'Create effects inside component functions (where they are auto-tracked) or wrap in createRoot().',
60
+ codeExample: `// Bad — orphaned, leaks memory:
61
+ effect(() => console.log(count()));
62
+
63
+ // Good — inside a root with cleanup:
64
+ createRoot(dispose => {
65
+ effect(() => console.log(count()));
66
+ // later: dispose() cleans up
67
+ });`,
68
+ },
69
+ ERR_SIGNAL_WRITE_IN_RENDER: {
70
+ code: 'ERR_SIGNAL_WRITE_IN_RENDER',
71
+ severity: 'error',
72
+ diagnosis: 'A signal is being written during the component render phase (the component function body). This triggers immediate re-execution and can cause infinite loops.',
73
+ suggestedFix: 'Move signal writes into event handlers, effects, or onMount(). The component body should only read signals.',
74
+ codeExample: `// Bad — write during render:
75
+ function Counter() {
76
+ count(count() + 1); // infinite loop!
77
+ return <span>{count()}</span>;
78
+ }
79
+
80
+ // Good — write in event handler:
81
+ function Counter() {
82
+ return <button onclick={() => count(c => c + 1)}>{count()}</button>;
83
+ }`,
84
+ },
85
+ ERR_MISSING_CLEANUP: {
86
+ code: 'ERR_MISSING_CLEANUP',
87
+ severity: 'warning',
88
+ diagnosis: 'An effect sets up a resource (event listener, timer, connection) but does not return a cleanup function. This causes memory leaks when the component unmounts.',
89
+ suggestedFix: 'Return a cleanup function from the effect that removes the resource.',
90
+ codeExample: `// Bad — no cleanup:
91
+ effect(() => {
92
+ window.addEventListener('resize', handler);
93
+ });
94
+
95
+ // Good — return cleanup:
96
+ effect(() => {
97
+ window.addEventListener('resize', handler);
98
+ return () => window.removeEventListener('resize', handler);
99
+ });`,
100
+ },
101
+ ERR_UNSAFE_INNERHTML: {
102
+ code: 'ERR_UNSAFE_INNERHTML',
103
+ severity: 'warning',
104
+ diagnosis: 'innerHTML is set directly on an element without the __html safety marker. This is an XSS risk if the content comes from user input.',
105
+ suggestedFix: 'Use { __html: content } to explicitly mark innerHTML as intentional, or use the html tagged template literal.',
106
+ codeExample: `// Bad — raw innerHTML (XSS risk):
107
+ <div innerHTML={userInput} />
108
+
109
+ // Good — explicit opt-in:
110
+ <div innerHTML={{ __html: sanitizedContent }} />`,
111
+ },
112
+ ERR_MISSING_KEY: {
113
+ code: 'ERR_MISSING_KEY',
114
+ severity: 'warning',
115
+ diagnosis: 'A list is rendered without key props. Without keys, the framework cannot efficiently track item identity during reordering, leading to incorrect UI updates.',
116
+ suggestedFix: 'Add a unique key prop to each list item using a stable identifier (like a database ID), not the array index.',
117
+ codeExample: `// Bad — no key:
118
+ <For each={items()}>{item => <li>{item.name}</li>}</For>
119
+
120
+ // Good — stable key:
121
+ <For each={items()}>{item => <li key={item.id}>{item.name}</li>}</For>`,
122
+ },
123
+ };
124
+
125
+ // --- Lint Patterns ---
126
+ // Static analysis rules applied to code snippets.
127
+
128
+ const LINT_RULES = [
129
+ {
130
+ id: 'missing-signal-read',
131
+ code: 'ERR_MISSING_SIGNAL_READ',
132
+ severity: 'error',
133
+ test(code) {
134
+ const issues = [];
135
+ // Find signal declarations
136
+ const signalDecls = [...code.matchAll(/(?:const|let)\s+(\w+)\s*=\s*(?:signal|useSignal)\s*\(/g)];
137
+ const signalNames = signalDecls.map(m => m[1]);
138
+
139
+ for (const name of signalNames) {
140
+ // Look for JSX usage without () — e.g., {count} but not {count()}
141
+ const jsxPattern = new RegExp(`\\{\\s*${name}\\s*\\}`, 'g');
142
+ let match;
143
+ while ((match = jsxPattern.exec(code)) !== null) {
144
+ // Make sure it's not {count()} — check the char before }
145
+ const snippet = code.slice(match.index, match.index + match[0].length);
146
+ if (!snippet.includes(`${name}(`)) {
147
+ const line = code.slice(0, match.index).split('\n').length;
148
+ issues.push({
149
+ severity: 'error',
150
+ code: 'ERR_MISSING_SIGNAL_READ',
151
+ message: `Signal "${name}" used in JSX without calling () — will render as "[Function]". Use {${name}()} instead.`,
152
+ line,
153
+ suggestedFix: `Change {${name}} to {${name}()}`,
154
+ });
155
+ }
156
+ }
157
+
158
+ // Template literal without ()
159
+ const templatePattern = new RegExp(`\\\$\\{\\s*${name}\\s*\\}`, 'g');
160
+ while ((match = templatePattern.exec(code)) !== null) {
161
+ if (!match[0].includes(`${name}(`)) {
162
+ const line = code.slice(0, match.index).split('\n').length;
163
+ issues.push({
164
+ severity: 'error',
165
+ code: 'ERR_MISSING_SIGNAL_READ',
166
+ message: `Signal "${name}" in template literal without () — will stringify as "[Function]". Use \${${name}()} instead.`,
167
+ line,
168
+ suggestedFix: `Change \${${name}} to \${${name}()}`,
169
+ });
170
+ }
171
+ }
172
+ }
173
+ return issues;
174
+ },
175
+ },
176
+ {
177
+ id: 'innerhtml-without-html',
178
+ code: 'ERR_UNSAFE_INNERHTML',
179
+ severity: 'warning',
180
+ test(code) {
181
+ const issues = [];
182
+ const pattern = /innerHTML\s*=\s*\{(?!.*__html)/g;
183
+ let match;
184
+ while ((match = pattern.exec(code)) !== null) {
185
+ const line = code.slice(0, match.index).split('\n').length;
186
+ issues.push({
187
+ severity: 'warning',
188
+ code: 'ERR_UNSAFE_INNERHTML',
189
+ message: 'innerHTML set without __html safety marker — potential XSS risk.',
190
+ line,
191
+ suggestedFix: 'Use innerHTML={{ __html: content }} to explicitly mark as intentional.',
192
+ });
193
+ }
194
+ return issues;
195
+ },
196
+ },
197
+ {
198
+ id: 'effect-writes-read-signal',
199
+ code: 'ERR_INFINITE_EFFECT',
200
+ severity: 'error',
201
+ test(code) {
202
+ const issues = [];
203
+ // Find effect blocks and check for read+write of same signal
204
+ const effectPattern = /effect\s*\(\s*\(\s*\)\s*=>\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/g;
205
+ let match;
206
+ while ((match = effectPattern.exec(code)) !== null) {
207
+ const body = match[1];
208
+ // Find signal reads: name()
209
+ const reads = [...body.matchAll(/(\w+)\(\)/g)].map(m => m[1]);
210
+ // Find signal writes: name(value) where value is not empty
211
+ const writes = [...body.matchAll(/(\w+)\([^)]+\)/g)].map(m => m[1]);
212
+
213
+ for (const name of reads) {
214
+ if (writes.includes(name) && name !== 'console' && name !== 'Math' && name !== 'JSON' && name !== 'Date') {
215
+ const line = code.slice(0, match.index).split('\n').length;
216
+ issues.push({
217
+ severity: 'error',
218
+ code: 'ERR_INFINITE_EFFECT',
219
+ message: `Effect reads and writes signal "${name}" — will cause infinite loop. Use untrack(${name}) for the read.`,
220
+ line,
221
+ suggestedFix: `Replace ${name}() reads with untrack(${name}) inside this effect.`,
222
+ });
223
+ }
224
+ }
225
+ }
226
+ return issues;
227
+ },
228
+ },
229
+ {
230
+ id: 'missing-cleanup',
231
+ code: 'ERR_MISSING_CLEANUP',
232
+ severity: 'warning',
233
+ test(code) {
234
+ const issues = [];
235
+ // Check effects that add listeners but don't return cleanup
236
+ const effectPattern = /effect\s*\(\s*\(\s*\)\s*=>\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/g;
237
+ let match;
238
+ while ((match = effectPattern.exec(code)) !== null) {
239
+ const body = match[1];
240
+ const hasListener = body.includes('addEventListener') || body.includes('setInterval') || body.includes('setTimeout');
241
+ const hasReturn = body.includes('return');
242
+
243
+ if (hasListener && !hasReturn) {
244
+ const line = code.slice(0, match.index).split('\n').length;
245
+ const resource = body.includes('addEventListener') ? 'event listener'
246
+ : body.includes('setInterval') ? 'interval'
247
+ : 'timeout';
248
+ issues.push({
249
+ severity: 'warning',
250
+ code: 'ERR_MISSING_CLEANUP',
251
+ message: `Effect sets up ${resource} but does not return a cleanup function — memory leak risk.`,
252
+ line,
253
+ suggestedFix: `Return a cleanup function: return () => remove${resource === 'event listener' ? 'EventListener(...)' : resource === 'interval' ? 'clearInterval(id)' : 'clearTimeout(id)'}`,
254
+ });
255
+ }
256
+ }
257
+ return issues;
258
+ },
259
+ },
260
+ ];
261
+
262
+ // --- Scaffold Templates ---
263
+
264
+ const SCAFFOLD_TEMPLATES = {
265
+ component: ({ name, props = [], signals = [] }) => {
266
+ const propsParam = props.length > 0
267
+ ? `{ ${props.join(', ')} }`
268
+ : '';
269
+ const signalDecls = signals.map(s => ` const ${s} = signal(${typeof s === 'string' && s.includes('is') ? 'false' : '""'}, '${s}');`).join('\n');
270
+
271
+ return `import { signal, effect } from 'what-framework';
272
+
273
+ function ${name}(${propsParam}) {
274
+ ${signalDecls || ' // Add signals here'}
275
+
276
+ return (
277
+ <div>
278
+ <h2>${name}</h2>
279
+ {/* Add your JSX here */}
280
+ </div>
281
+ );
282
+ }
283
+
284
+ export default ${name};
285
+ `;
286
+ },
287
+
288
+ page: ({ name, props = [], signals = [] }) => {
289
+ const signalDecls = signals.map(s => ` const ${s} = signal(null, '${s}');`).join('\n');
290
+
291
+ return `import { signal, effect, onMount } from 'what-framework';
292
+ import { Head } from 'what-framework';
293
+
294
+ function ${name}() {
295
+ ${signalDecls || ' const loading = signal(true, \'loading\');'}
296
+
297
+ onMount(() => {
298
+ // Fetch data or initialize page
299
+ loading(false);
300
+ });
301
+
302
+ return (
303
+ <>
304
+ <Head><title>${name}</title></Head>
305
+ <main>
306
+ <h1>${name}</h1>
307
+ {/* Page content */}
308
+ </main>
309
+ </>
310
+ );
311
+ }
312
+
313
+ export default ${name};
314
+ `;
315
+ },
316
+
317
+ form: ({ name, props = [], signals = [] }) => {
318
+ const fields = signals.length > 0 ? signals : ['email', 'password'];
319
+ const fieldDecls = fields.map(f => ` ${f}: { initial: '', rules: [rules.required('${f} is required')] },`).join('\n');
320
+
321
+ return `import { signal } from 'what-framework';
322
+ import { useForm, Input, ErrorMessage, rules } from 'what-framework';
323
+
324
+ function ${name}() {
325
+ const { fields, handleSubmit, isSubmitting, errors } = useForm({
326
+ fields: {
327
+ ${fieldDecls}
328
+ },
329
+ onSubmit: async (values) => {
330
+ console.log('Form submitted:', values);
331
+ },
332
+ });
333
+
334
+ return (
335
+ <form onsubmit={handleSubmit}>
336
+ ${fields.map(f => ` <div>
337
+ <label for="${f}">${f.charAt(0).toUpperCase() + f.slice(1)}</label>
338
+ <Input field={fields.${f}} id="${f}" type="text" />
339
+ <ErrorMessage field={fields.${f}} />
340
+ </div>`).join('\n')}
341
+ <button type="submit" disabled={isSubmitting()}>
342
+ {isSubmitting() ? 'Submitting...' : 'Submit'}
343
+ </button>
344
+ </form>
345
+ );
346
+ }
347
+
348
+ export default ${name};
349
+ `;
350
+ },
351
+
352
+ store: ({ name, signals = [] }) => {
353
+ const stateFields = signals.length > 0
354
+ ? signals.map(s => ` ${s}: null,`).join('\n')
355
+ : ' items: [],\n loading: false,\n error: null,';
356
+
357
+ return `import { createStore, derived } from 'what-framework';
358
+
359
+ const ${name.charAt(0).toLowerCase() + name.slice(1)} = createStore({
360
+ ${stateFields}
361
+ });
362
+
363
+ // Derived values
364
+ // const itemCount = derived(state => state.items.length);
365
+
366
+ // Actions
367
+ export function addItem(item) {
368
+ ${name.charAt(0).toLowerCase() + name.slice(1)}.set(state => ({
369
+ ...state,
370
+ items: [...state.items, item],
371
+ }));
372
+ }
373
+
374
+ export function setLoading(value) {
375
+ ${name.charAt(0).toLowerCase() + name.slice(1)}.set(state => ({ ...state, loading: value }));
376
+ }
377
+
378
+ export default ${name.charAt(0).toLowerCase() + name.slice(1)};
379
+ `;
380
+ },
381
+
382
+ island: ({ name, props = [], signals = [] }) => {
383
+ const signalDecls = signals.map(s => ` const ${s} = signal(null, '${s}');`).join('\n');
384
+
385
+ return `import { signal, effect, onMount } from 'what-framework';
386
+
387
+ // Island component — hydrates independently on the client.
388
+ // Server renders the static shell; this code runs only in the browser.
389
+ function ${name}(${props.length > 0 ? `{ ${props.join(', ')} }` : ''}) {
390
+ ${signalDecls || ' const active = signal(false, \'active\');'}
391
+
392
+ onMount(() => {
393
+ // Client-only initialization
394
+ console.log('${name} island hydrated');
395
+ });
396
+
397
+ return (
398
+ <div data-island="${name.toLowerCase()}">
399
+ {/* Interactive island content */}
400
+ </div>
401
+ );
402
+ }
403
+
404
+ // Mark as island for the compiler
405
+ ${name}.island = true;
406
+
407
+ export default ${name};
408
+ `;
409
+ },
410
+ };
411
+
412
+ // --- Register Agent Tools ---
413
+
414
+ export function registerAgentTools(server, bridge) {
415
+
416
+ // Helper responses
417
+ function errorResponse(message, nextSteps) {
418
+ return {
419
+ content: [{
420
+ type: 'text',
421
+ text: JSON.stringify({
422
+ error: message,
423
+ summary: message,
424
+ nextSteps: nextSteps || ['Check the arguments and try again.'],
425
+ }, null, 2),
426
+ }],
427
+ isError: true,
428
+ };
429
+ }
430
+
431
+ function ok(data) {
432
+ return {
433
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
434
+ };
435
+ }
436
+
437
+ function noConnection(tool) {
438
+ return {
439
+ content: [{
440
+ type: 'text',
441
+ text: JSON.stringify({
442
+ error: 'No browser connected',
443
+ summary: `Cannot reach browser for ${tool}.`,
444
+ nextSteps: [
445
+ 'Ensure your What Framework app is running with the devtools-mcp Vite plugin enabled.',
446
+ 'Check that the bridge server is started (default port 9229).',
447
+ ],
448
+ }, null, 2),
449
+ }],
450
+ isError: true,
451
+ };
452
+ }
453
+
454
+ // -----------------------------------------------------------------------
455
+ // Tool 1 — what_lint
456
+ // -----------------------------------------------------------------------
457
+
458
+ server.tool(
459
+ 'what_lint',
460
+ 'Static analysis for What Framework code. Pass a code snippet, get back structured issues with fix suggestions. Works offline — no browser connection needed.',
461
+ {
462
+ code: z.string().describe('The What Framework code snippet to analyze'),
463
+ rules: z.array(z.string()).optional().describe('Specific rule IDs to run (default: all). Options: missing-signal-read, innerhtml-without-html, effect-writes-read-signal, missing-cleanup'),
464
+ },
465
+ async ({ code, rules: ruleFilter }) => {
466
+ let rulesToRun = LINT_RULES;
467
+ if (ruleFilter && ruleFilter.length > 0) {
468
+ rulesToRun = LINT_RULES.filter(r => ruleFilter.includes(r.id));
469
+ if (rulesToRun.length === 0) {
470
+ return errorResponse(
471
+ `No matching rules found. Available: ${LINT_RULES.map(r => r.id).join(', ')}`,
472
+ ['Use one of the available rule IDs.']
473
+ );
474
+ }
475
+ }
476
+
477
+ const issues = [];
478
+ for (const rule of rulesToRun) {
479
+ try {
480
+ const ruleIssues = rule.test(code);
481
+ issues.push(...ruleIssues);
482
+ } catch (e) {
483
+ // Rule crashed — skip silently
484
+ }
485
+ }
486
+
487
+ // Sort by line number
488
+ issues.sort((a, b) => (a.line || 0) - (b.line || 0));
489
+
490
+ const errorCount = issues.filter(i => i.severity === 'error').length;
491
+ const warningCount = issues.filter(i => i.severity === 'warning').length;
492
+ const summary = issues.length === 0
493
+ ? 'No issues found. Code looks good.'
494
+ : `${issues.length} issue${issues.length !== 1 ? 's' : ''}: ${errorCount} error${errorCount !== 1 ? 's' : ''}, ${warningCount} warning${warningCount !== 1 ? 's' : ''}.`;
495
+
496
+ return ok({
497
+ summary,
498
+ issueCount: issues.length,
499
+ errorCount,
500
+ warningCount,
501
+ issues,
502
+ rulesChecked: rulesToRun.map(r => r.id),
503
+ });
504
+ }
505
+ );
506
+
507
+ // -----------------------------------------------------------------------
508
+ // Tool 2 — what_scaffold
509
+ // -----------------------------------------------------------------------
510
+
511
+ server.tool(
512
+ 'what_scaffold',
513
+ 'Generate idiomatic What Framework boilerplate. Works offline — no browser connection needed.',
514
+ {
515
+ type: z.enum(['component', 'page', 'form', 'store', 'island']).describe('Type of code to generate'),
516
+ name: z.string().describe('Name for the generated component/store (PascalCase recommended)'),
517
+ props: z.array(z.string()).optional().describe('Prop names the component accepts'),
518
+ signals: z.array(z.string()).optional().describe('Signal names to declare in the component'),
519
+ },
520
+ async ({ type, name, props, signals }) => {
521
+ // Validate name is PascalCase
522
+ if (!/^[A-Z]/.test(name) && type !== 'store') {
523
+ return errorResponse(
524
+ `Component name "${name}" should be PascalCase (e.g., "${name.charAt(0).toUpperCase() + name.slice(1)}").`,
525
+ ['Use PascalCase for component names — this is required by the What Framework compiler.']
526
+ );
527
+ }
528
+
529
+ const template = SCAFFOLD_TEMPLATES[type];
530
+ if (!template) {
531
+ return errorResponse(
532
+ `Unknown scaffold type: ${type}`,
533
+ [`Available types: ${Object.keys(SCAFFOLD_TEMPLATES).join(', ')}`]
534
+ );
535
+ }
536
+
537
+ const code = template({ name, props: props || [], signals: signals || [] });
538
+
539
+ return ok({
540
+ summary: `Generated ${type} "${name}" with ${(signals || []).length} signals and ${(props || []).length} props.`,
541
+ type,
542
+ name,
543
+ code,
544
+ instructions: [
545
+ `Save this as src/${type === 'page' ? 'pages/' : type === 'store' ? 'stores/' : 'components/'}${name}.jsx`,
546
+ type === 'island' ? 'The island will hydrate independently on the client.' : null,
547
+ type === 'store' ? 'Import the store in components that need shared state.' : null,
548
+ type === 'form' ? 'Install zod if using zodResolver for validation.' : null,
549
+ ].filter(Boolean),
550
+ });
551
+ }
552
+ );
553
+
554
+ // -----------------------------------------------------------------------
555
+ // Tool 3 — what_validate
556
+ // -----------------------------------------------------------------------
557
+
558
+ server.tool(
559
+ 'what_validate',
560
+ 'Validate What Framework component code by running it through the compiler pipeline in the browser. Returns parse errors and compiled output.',
561
+ {
562
+ code: z.string().describe('Component code to validate'),
563
+ format: z.enum(['esm', 'cjs']).optional().default('esm').describe('Output format (default: esm)'),
564
+ },
565
+ async ({ code, format }) => {
566
+ if (!bridge.isConnected()) return noConnection('what_validate');
567
+
568
+ try {
569
+ const result = await bridge.sendCommand('validate-code', { code, format }, 10000);
570
+
571
+ if (result.error) {
572
+ return ok({
573
+ valid: false,
574
+ summary: `Validation failed: ${result.error}`,
575
+ errors: result.errors || [{ message: result.error, line: result.line }],
576
+ suggestions: result.suggestions || [
577
+ 'Check for syntax errors in the JSX.',
578
+ 'Ensure all imports are valid what-framework exports.',
579
+ ],
580
+ });
581
+ }
582
+
583
+ return ok({
584
+ valid: true,
585
+ summary: `Code is valid. Compiled to ${(result.output || '').length} chars of ${format}.`,
586
+ output: result.output,
587
+ warnings: result.warnings || [],
588
+ stats: {
589
+ signalCount: (result.output || '').match(/signal\s*\(/g)?.length || 0,
590
+ effectCount: (result.output || '').match(/effect\s*\(/g)?.length || 0,
591
+ componentCount: (result.output || '').match(/function\s+[A-Z]\w*\s*\(/g)?.length || 0,
592
+ },
593
+ });
594
+ } catch (e) {
595
+ // Fallback: do basic validation without browser
596
+ const issues = [];
597
+
598
+ // Check for basic syntax issues
599
+ const openParens = (code.match(/\(/g) || []).length;
600
+ const closeParens = (code.match(/\)/g) || []).length;
601
+ if (openParens !== closeParens) {
602
+ issues.push({ message: `Mismatched parentheses: ${openParens} open, ${closeParens} close`, severity: 'error' });
603
+ }
604
+
605
+ const openBraces = (code.match(/\{/g) || []).length;
606
+ const closeBraces = (code.match(/\}/g) || []).length;
607
+ if (openBraces !== closeBraces) {
608
+ issues.push({ message: `Mismatched braces: ${openBraces} open, ${closeBraces} close`, severity: 'error' });
609
+ }
610
+
611
+ // Run lint rules as fallback
612
+ for (const rule of LINT_RULES) {
613
+ try {
614
+ issues.push(...rule.test(code));
615
+ } catch { /* skip */ }
616
+ }
617
+
618
+ return ok({
619
+ valid: issues.filter(i => i.severity === 'error').length === 0,
620
+ summary: issues.length > 0
621
+ ? `Found ${issues.length} issues (browser validation unavailable, used static analysis).`
622
+ : 'No issues found via static analysis (browser validation unavailable).',
623
+ errors: issues,
624
+ note: 'Browser-based compiler validation was unavailable. Results are from static analysis only.',
625
+ });
626
+ }
627
+ }
628
+ );
629
+
630
+ // -----------------------------------------------------------------------
631
+ // Tool 4 — what_perf
632
+ // -----------------------------------------------------------------------
633
+
634
+ server.tool(
635
+ 'what_perf',
636
+ 'Performance snapshot of the running What Framework app. Signal count, effect count, hot effects, largest subscriber counts, memory estimate.',
637
+ {
638
+ threshold: z.number().optional().default(10).describe('Flag effects that ran more than this many times per second (default: 10)'),
639
+ },
640
+ async ({ threshold }) => {
641
+ if (!bridge.isConnected()) return noConnection('what_perf');
642
+
643
+ let snapshot;
644
+ try {
645
+ snapshot = bridge.getOrRefreshSnapshot
646
+ ? await bridge.getOrRefreshSnapshot()
647
+ : await bridge.refreshSnapshot();
648
+ } catch {
649
+ snapshot = bridge.getSnapshot();
650
+ }
651
+
652
+ if (!snapshot) {
653
+ return errorResponse('No snapshot available.', [
654
+ 'Refresh the browser page to trigger a snapshot.',
655
+ ]);
656
+ }
657
+
658
+ const signals = snapshot.signals || [];
659
+ const effects = snapshot.effects || [];
660
+ const components = snapshot.components || [];
661
+ const recentEvents = bridge.getEvents(Date.now() - 1000); // last 1s
662
+
663
+ // Hot effects: ran more than threshold times
664
+ const hotEffects = effects
665
+ .filter(e => (e.runCount || 0) > threshold)
666
+ .sort((a, b) => (b.runCount || 0) - (a.runCount || 0))
667
+ .slice(0, 20)
668
+ .map(e => ({
669
+ id: e.id,
670
+ name: e.name || `effect_${e.id}`,
671
+ runCount: e.runCount,
672
+ depCount: (e.depSignalIds || e.deps || []).length,
673
+ componentId: e.componentId,
674
+ }));
675
+
676
+ // Largest subscriber counts (signals with most effects depending on them)
677
+ const subCounts = {};
678
+ for (const eff of effects) {
679
+ for (const sid of (eff.depSignalIds || eff.deps || [])) {
680
+ subCounts[sid] = (subCounts[sid] || 0) + 1;
681
+ }
682
+ }
683
+ const largestSubscribers = Object.entries(subCounts)
684
+ .map(([id, count]) => {
685
+ const sig = signals.find(s => s.id === Number(id));
686
+ return { id: Number(id), name: sig?.name || `signal_${id}`, subscriberCount: count };
687
+ })
688
+ .sort((a, b) => b.subscriberCount - a.subscriberCount)
689
+ .slice(0, 10);
690
+
691
+ // Event rate
692
+ const eventRate = recentEvents.length; // events per second
693
+
694
+ // Memory estimate (rough heuristic)
695
+ const signalMemory = signals.length * 200; // ~200 bytes per signal (value + subs set)
696
+ const effectMemory = effects.length * 300; // ~300 bytes per effect (fn + deps array)
697
+ const componentMemory = components.length * 500; // ~500 bytes per component
698
+ const totalEstimate = signalMemory + effectMemory + componentMemory;
699
+ const memoryStr = totalEstimate > 1048576
700
+ ? `${(totalEstimate / 1048576).toFixed(1)} MB`
701
+ : `${(totalEstimate / 1024).toFixed(1)} KB`;
702
+
703
+ const issues = [];
704
+ if (hotEffects.length > 0) {
705
+ issues.push(`${hotEffects.length} effects exceeded ${threshold} runs (potential performance issue)`);
706
+ }
707
+ if (eventRate > 100) {
708
+ issues.push(`High event rate: ${eventRate} events/sec`);
709
+ }
710
+ if (signals.length > 1000) {
711
+ issues.push(`High signal count (${signals.length}) — consider consolidating with stores`);
712
+ }
713
+
714
+ const summary = issues.length > 0
715
+ ? `Performance concerns: ${issues.join('; ')}.`
716
+ : `Healthy. ${signals.length} signals, ${effects.length} effects, ${components.length} components. ${memoryStr} estimated.`;
717
+
718
+ return ok({
719
+ summary,
720
+ counts: {
721
+ signals: signals.length,
722
+ effects: effects.length,
723
+ components: components.length,
724
+ },
725
+ hotEffects,
726
+ largestSubscribers,
727
+ eventRate,
728
+ memoryEstimate: memoryStr,
729
+ memoryBytes: totalEstimate,
730
+ issues,
731
+ nextSteps: issues.length > 0 ? [
732
+ 'Use what_dependency_graph to trace hot effect dependencies.',
733
+ 'Consider using batch() to group signal writes.',
734
+ 'Use computed() for derived values instead of effects.',
735
+ ] : [],
736
+ });
737
+ }
738
+ );
739
+
740
+ // -----------------------------------------------------------------------
741
+ // Tool 5 — what_fix
742
+ // -----------------------------------------------------------------------
743
+
744
+ server.tool(
745
+ 'what_fix',
746
+ 'Given a What Framework error code, get diagnosis, suggested fix, and code example. Works offline — no browser needed.',
747
+ {
748
+ error: z.string().describe('Error code (e.g., "ERR_INFINITE_EFFECT") or error message text'),
749
+ },
750
+ async ({ error: errorInput }) => {
751
+ // Try exact code match first
752
+ let entry = ERROR_DATABASE[errorInput];
753
+
754
+ // Try with ERR_ prefix
755
+ if (!entry) {
756
+ entry = ERROR_DATABASE[`ERR_${errorInput}`];
757
+ }
758
+
759
+ // Try fuzzy match on message text
760
+ if (!entry) {
761
+ const lower = errorInput.toLowerCase();
762
+ for (const [code, def] of Object.entries(ERROR_DATABASE)) {
763
+ if (lower.includes(code.toLowerCase().replace('err_', '').replace(/_/g, ' '))) {
764
+ entry = def;
765
+ break;
766
+ }
767
+ }
768
+ }
769
+
770
+ // Try keyword match
771
+ if (!entry) {
772
+ const lower = errorInput.toLowerCase();
773
+ const keywordMap = {
774
+ 'infinite': 'ERR_INFINITE_EFFECT',
775
+ 'loop': 'ERR_INFINITE_EFFECT',
776
+ 'cycle': 'ERR_INFINITE_EFFECT',
777
+ 'signal read': 'ERR_MISSING_SIGNAL_READ',
778
+ 'missing ()': 'ERR_MISSING_SIGNAL_READ',
779
+ 'function]': 'ERR_MISSING_SIGNAL_READ',
780
+ 'hydration': 'ERR_HYDRATION_MISMATCH',
781
+ 'mismatch': 'ERR_HYDRATION_MISMATCH',
782
+ 'orphan': 'ERR_ORPHAN_EFFECT',
783
+ 'cleanup': 'ERR_MISSING_CLEANUP',
784
+ 'leak': 'ERR_MISSING_CLEANUP',
785
+ 'render': 'ERR_SIGNAL_WRITE_IN_RENDER',
786
+ 'innerhtml': 'ERR_UNSAFE_INNERHTML',
787
+ 'xss': 'ERR_UNSAFE_INNERHTML',
788
+ 'key': 'ERR_MISSING_KEY',
789
+ };
790
+ for (const [keyword, code] of Object.entries(keywordMap)) {
791
+ if (lower.includes(keyword)) {
792
+ entry = ERROR_DATABASE[code];
793
+ break;
794
+ }
795
+ }
796
+ }
797
+
798
+ if (!entry) {
799
+ return ok({
800
+ found: false,
801
+ summary: `No matching error code found for "${errorInput}".`,
802
+ availableCodes: Object.keys(ERROR_DATABASE),
803
+ suggestion: 'Try one of the available error codes, or paste the exact error message.',
804
+ });
805
+ }
806
+
807
+ return ok({
808
+ found: true,
809
+ summary: `${entry.code}: ${entry.diagnosis.split('.')[0]}.`,
810
+ error: entry.code,
811
+ severity: entry.severity,
812
+ diagnosis: entry.diagnosis,
813
+ suggestedFix: entry.suggestedFix,
814
+ codeExample: entry.codeExample,
815
+ });
816
+ }
817
+ );
818
+ }