what-devtools-mcp 0.8.3 → 0.10.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.
- package/package.json +13 -3
- package/src/bridge.js +32 -4
- package/src/client-commands.js +53 -28
- package/src/client.js +18 -1
- package/src/tools-agent.js +290 -5
- package/src/tools-extended.js +74 -19
- package/src/tools.js +164 -8
- package/src/vite-plugin.js +45 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "what-devtools-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "MCP server bridging AI agents to live What Framework app state via WebSocket",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"exports": {
|
|
10
10
|
".": "./src/index.js",
|
|
11
11
|
"./client": "./src/client.js",
|
|
12
|
+
"./vite": "./src/vite-plugin.js",
|
|
12
13
|
"./vite-plugin": "./src/vite-plugin.js"
|
|
13
14
|
},
|
|
14
15
|
"files": [
|
|
@@ -16,7 +17,7 @@
|
|
|
16
17
|
],
|
|
17
18
|
"dependencies": {
|
|
18
19
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
19
|
-
"ws": "^8.
|
|
20
|
+
"ws": "^8.18.0",
|
|
20
21
|
"zod": "^3.25.0"
|
|
21
22
|
},
|
|
22
23
|
"peerDependencies": {
|
|
@@ -29,5 +30,14 @@
|
|
|
29
30
|
"model-context-protocol",
|
|
30
31
|
"ai-debugging"
|
|
31
32
|
],
|
|
32
|
-
"
|
|
33
|
+
"author": "ZVN DEV (https://zvndev.com)",
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "https://github.com/CelsianJs/what-framework"
|
|
38
|
+
},
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/CelsianJs/what-framework/issues"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://whatfw.com"
|
|
33
43
|
}
|
package/src/bridge.js
CHANGED
|
@@ -33,9 +33,26 @@ export function createBridge({ port = 9229, host = '127.0.0.1' } = {}) {
|
|
|
33
33
|
// Set WHAT_MCP_TOKEN to share the same token between bridge and Vite plugin.
|
|
34
34
|
const authToken = process.env.WHAT_MCP_TOKEN || randomBytes(24).toString('hex');
|
|
35
35
|
|
|
36
|
+
// Only browser pages served from a loopback origin may talk to the bridge.
|
|
37
|
+
// This is the key defense against the "confused deputy" attack where a page
|
|
38
|
+
// on evil.com (open in the same browser as `what dev`) steals the token and
|
|
39
|
+
// drives set-signal/navigate/eval against the live app. Requests with no
|
|
40
|
+
// Origin header come from non-browser local processes (the MCP server's own
|
|
41
|
+
// client, curl) and are gated by the token instead. (AUDIT-2026-06-06 C6)
|
|
42
|
+
function isAllowedOrigin(origin) {
|
|
43
|
+
if (!origin) return true; // non-browser client; token is the gate
|
|
44
|
+
try {
|
|
45
|
+
const { hostname } = new URL(origin);
|
|
46
|
+
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]';
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
36
52
|
const wss = new WebSocketServer({ host, port, verifyClient: ({ req }) => {
|
|
37
|
-
// Require a
|
|
53
|
+
// Require BOTH a loopback origin (defense in depth) AND a valid token.
|
|
38
54
|
try {
|
|
55
|
+
if (!isAllowedOrigin(req.headers.origin)) return false;
|
|
39
56
|
const url = new URL(req.url, `http://${host}:${port}`);
|
|
40
57
|
return url.searchParams.get('token') === authToken;
|
|
41
58
|
} catch {
|
|
@@ -54,8 +71,19 @@ export function createBridge({ port = 9229, host = '127.0.0.1' } = {}) {
|
|
|
54
71
|
|
|
55
72
|
const discoveryPort = port + 1;
|
|
56
73
|
const httpServer = createServer((req, res) => {
|
|
57
|
-
|
|
58
|
-
|
|
74
|
+
const origin = req.headers.origin;
|
|
75
|
+
// NEVER use a wildcard ACAO here — it would let any website read the token
|
|
76
|
+
// and then connect to the bridge. Echo the origin back only for loopback
|
|
77
|
+
// origins; reject token reads from any other origin. (AUDIT-2026-06-06 C6)
|
|
78
|
+
if (!isAllowedOrigin(origin)) {
|
|
79
|
+
res.writeHead(403);
|
|
80
|
+
res.end('Forbidden origin');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (origin) {
|
|
84
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
85
|
+
res.setHeader('Vary', 'Origin');
|
|
86
|
+
}
|
|
59
87
|
res.setHeader('Access-Control-Allow-Methods', 'GET');
|
|
60
88
|
res.setHeader('Cache-Control', 'no-store');
|
|
61
89
|
|
|
@@ -86,7 +114,7 @@ export function createBridge({ port = 9229, host = '127.0.0.1' } = {}) {
|
|
|
86
114
|
}
|
|
87
115
|
|
|
88
116
|
console.error(`[what-devtools-mcp] Bridge listening on ws://${host}:${port}`);
|
|
89
|
-
console.error(`[what-devtools-mcp]
|
|
117
|
+
console.error(`[what-devtools-mcp] Bridge started on port ${port} (auth enabled)`);
|
|
90
118
|
|
|
91
119
|
wss.on('connection', (ws) => {
|
|
92
120
|
browserSocket = ws;
|
package/src/client-commands.js
CHANGED
|
@@ -133,12 +133,14 @@ export async function handleExtendedCommand(command, args, devtools) {
|
|
|
133
133
|
|
|
134
134
|
// Allow safe read-only expressions without the unsafe flag
|
|
135
135
|
const code = (args.code || '').trim();
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
136
|
+
// Strict safe-read: only allow dotted property access on known safe globals.
|
|
137
|
+
const SAFE_GLOBALS = new Set(['document', 'window', 'navigator', 'location', 'screen', 'performance', 'console']);
|
|
138
|
+
const segments = code.split('.');
|
|
139
|
+
const isSimpleIdent = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
|
|
140
|
+
const PROTO_DENYLIST = new Set(['constructor', 'prototype', '__proto__']);
|
|
141
|
+
const isSafeRead = segments.length >= 2 &&
|
|
142
|
+
SAFE_GLOBALS.has(segments[0]) &&
|
|
143
|
+
segments.every(s => isSimpleIdent.test(s) && !PROTO_DENYLIST.has(s));
|
|
142
144
|
|
|
143
145
|
if (!evalEnabled && !isSafeRead) {
|
|
144
146
|
return {
|
|
@@ -148,9 +150,11 @@ export async function handleExtendedCommand(command, args, devtools) {
|
|
|
148
150
|
|
|
149
151
|
const start = performance.now();
|
|
150
152
|
try {
|
|
151
|
-
//
|
|
153
|
+
// Execute the SAME validated string that passed the safe-read check
|
|
154
|
+
// above (not the untrimmed args.code) to avoid a validate-one/run-another
|
|
155
|
+
// mismatch. (AUDIT-2026-06-06 m10)
|
|
152
156
|
// eslint-disable-next-line no-new-func
|
|
153
|
-
const fn = new Function(
|
|
157
|
+
const fn = new Function(code);
|
|
154
158
|
const raw = fn();
|
|
155
159
|
const elapsed = performance.now() - start;
|
|
156
160
|
return {
|
|
@@ -293,6 +297,14 @@ export async function handleExtendedCommand(command, args, devtools) {
|
|
|
293
297
|
return { error: 'No path provided' };
|
|
294
298
|
}
|
|
295
299
|
|
|
300
|
+
// Validate URL — reject dangerous protocols (server-side + browser-side defense)
|
|
301
|
+
const trimmedPath = String(path).trim();
|
|
302
|
+
const normalizedUrl = trimmedPath.replace(/[\s\x00-\x1f]/g, '').toLowerCase();
|
|
303
|
+
const isRelativePath = /^[/.#?]/.test(trimmedPath) || !trimmedPath.includes(':');
|
|
304
|
+
if (!isRelativePath && !/^https?:/.test(normalizedUrl)) {
|
|
305
|
+
return { error: `Blocked navigation to unsafe URL: "${path}"` };
|
|
306
|
+
}
|
|
307
|
+
|
|
296
308
|
try {
|
|
297
309
|
// Prefer What Router's navigate() if available
|
|
298
310
|
const core = window.__WHAT_CORE__;
|
|
@@ -539,14 +551,26 @@ export async function handleExtendedCommand(command, args, devtools) {
|
|
|
539
551
|
// page-map — Structured map of the entire visible page
|
|
540
552
|
// -------------------------------------------------------------------------
|
|
541
553
|
case 'page-map': {
|
|
554
|
+
// Each category gets its own budget so a page with many landmarks
|
|
555
|
+
// doesn't starve out headings or components. Previous behaviour shared
|
|
556
|
+
// one `count` across all sections — a page with >maxElements landmarks
|
|
557
|
+
// returned headings: [] and components: [], which agents read as
|
|
558
|
+
// "the page has no headings / no components".
|
|
542
559
|
const maxElements = args?.maxElements || 200;
|
|
543
|
-
|
|
560
|
+
const perCategory = Math.max(50, Math.floor(maxElements / 2));
|
|
561
|
+
|
|
562
|
+
function rectOf(el) {
|
|
563
|
+
// Comment nodes / detached nodes have no rect; element walk handles that.
|
|
564
|
+
if (!el || el.nodeType !== 1) return { x: 0, y: 0, w: 0, h: 0 };
|
|
565
|
+
const r = el.getBoundingClientRect();
|
|
566
|
+
return { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) };
|
|
567
|
+
}
|
|
544
568
|
|
|
545
569
|
// Landmarks
|
|
546
570
|
const landmarks = [];
|
|
547
571
|
const landmarkEls = document.querySelectorAll('[role], header, footer, nav, main, aside, section, article');
|
|
548
572
|
for (const el of landmarkEls) {
|
|
549
|
-
if (
|
|
573
|
+
if (landmarks.length >= perCategory) break;
|
|
550
574
|
const rect = el.getBoundingClientRect();
|
|
551
575
|
if (rect.width === 0 && rect.height === 0) continue;
|
|
552
576
|
landmarks.push({
|
|
@@ -554,16 +578,15 @@ export async function handleExtendedCommand(command, args, devtools) {
|
|
|
554
578
|
role: el.getAttribute('role') || undefined,
|
|
555
579
|
id: el.id || undefined,
|
|
556
580
|
text: (el.textContent || '').trim().substring(0, 50),
|
|
557
|
-
rect:
|
|
581
|
+
rect: rectOf(el),
|
|
558
582
|
});
|
|
559
|
-
count++;
|
|
560
583
|
}
|
|
561
584
|
|
|
562
585
|
// Interactive elements
|
|
563
586
|
const interactives = [];
|
|
564
587
|
const interactiveEls = document.querySelectorAll('button, a[href], input, select, textarea, [role=button], [role=link], [contenteditable]');
|
|
565
588
|
for (const el of interactiveEls) {
|
|
566
|
-
if (
|
|
589
|
+
if (interactives.length >= perCategory) break;
|
|
567
590
|
const rect = el.getBoundingClientRect();
|
|
568
591
|
if (rect.width === 0 && rect.height === 0) continue;
|
|
569
592
|
const label = el.getAttribute('aria-label') || el.textContent?.trim().substring(0, 40) || el.getAttribute('placeholder') || el.getAttribute('name') || '';
|
|
@@ -572,39 +595,41 @@ export async function handleExtendedCommand(command, args, devtools) {
|
|
|
572
595
|
type: el.getAttribute('type') || undefined,
|
|
573
596
|
label: label || '(unlabeled)',
|
|
574
597
|
disabled: el.disabled || undefined,
|
|
575
|
-
rect:
|
|
598
|
+
rect: rectOf(el),
|
|
576
599
|
});
|
|
577
|
-
count++;
|
|
578
600
|
}
|
|
579
601
|
|
|
580
|
-
// Headings
|
|
602
|
+
// Headings — heading lists are nearly always small so we don't gate
|
|
603
|
+
// on visibility (some agents use display:none headings for SR-only nav).
|
|
581
604
|
const headings = [];
|
|
582
605
|
const headingEls = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
|
583
606
|
for (const el of headingEls) {
|
|
584
|
-
if (
|
|
607
|
+
if (headings.length >= perCategory) break;
|
|
608
|
+
const text = (el.textContent || '').trim().substring(0, 80);
|
|
609
|
+
if (!text) continue; // skip truly empty headings
|
|
585
610
|
headings.push({
|
|
586
|
-
level: parseInt(el.tagName[1]),
|
|
587
|
-
text
|
|
611
|
+
level: parseInt(el.tagName[1], 10),
|
|
612
|
+
text,
|
|
613
|
+
id: el.id || undefined,
|
|
588
614
|
});
|
|
589
|
-
count++;
|
|
590
615
|
}
|
|
591
616
|
|
|
592
|
-
// WhatFW component boundaries
|
|
617
|
+
// WhatFW component boundaries. Comment-node boundaries have no
|
|
618
|
+
// dimensions, so a strict zero-rect check was filtering everything
|
|
619
|
+
// out. Walk the registry once and emit every component, using the
|
|
620
|
+
// resolved element's rect when available.
|
|
593
621
|
const components = [];
|
|
594
622
|
const registries = devtools?._registries;
|
|
595
623
|
if (registries?.components) {
|
|
596
624
|
for (const [id, entry] of registries.components) {
|
|
597
|
-
if (
|
|
625
|
+
if (components.length >= perCategory) break;
|
|
598
626
|
const compEl = getComponentElement(entry);
|
|
599
|
-
if (!compEl) continue;
|
|
600
|
-
const rect = compEl.getBoundingClientRect();
|
|
601
|
-
if (rect.width === 0 && rect.height === 0) continue;
|
|
602
627
|
components.push({
|
|
603
628
|
id,
|
|
604
629
|
name: entry.name,
|
|
605
|
-
rect: { x:
|
|
630
|
+
rect: compEl ? rectOf(compEl) : { x: 0, y: 0, w: 0, h: 0 },
|
|
631
|
+
hasElement: !!compEl,
|
|
606
632
|
});
|
|
607
|
-
count++;
|
|
608
633
|
}
|
|
609
634
|
}
|
|
610
635
|
|
|
@@ -614,7 +639,7 @@ export async function handleExtendedCommand(command, args, devtools) {
|
|
|
614
639
|
interactives,
|
|
615
640
|
headings,
|
|
616
641
|
components,
|
|
617
|
-
totalElements:
|
|
642
|
+
totalElements: landmarks.length + interactives.length + headings.length + components.length,
|
|
618
643
|
};
|
|
619
644
|
}
|
|
620
645
|
|
package/src/client.js
CHANGED
|
@@ -207,7 +207,24 @@ export function connectDevToolsMCP({ port = 9229, token = '' } = {}) {
|
|
|
207
207
|
break;
|
|
208
208
|
}
|
|
209
209
|
case 'set-signal': {
|
|
210
|
-
const { signalId, value } = args || {};
|
|
210
|
+
const { signalId, value: rawValue } = args || {};
|
|
211
|
+
// Defense-in-depth: also coerce stringified-JSON here in case an
|
|
212
|
+
// older MCP server forwards the raw string. See tools.js coerceJsonValue.
|
|
213
|
+
let value = rawValue;
|
|
214
|
+
if (typeof rawValue === 'string') {
|
|
215
|
+
const t = rawValue.trim();
|
|
216
|
+
const first = t[0];
|
|
217
|
+
if (t.length > 0 && (
|
|
218
|
+
first === '{' || first === '[' ||
|
|
219
|
+
first === 't' || first === 'f' || first === 'n' ||
|
|
220
|
+
first === '-' || (first >= '0' && first <= '9')
|
|
221
|
+
)) {
|
|
222
|
+
try {
|
|
223
|
+
const parsed = JSON.parse(t);
|
|
224
|
+
if (typeof parsed !== 'string') value = parsed;
|
|
225
|
+
} catch {}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
211
228
|
const registries = devtools?._registries;
|
|
212
229
|
if (registries?.signals) {
|
|
213
230
|
const entry = registries.signals.get(signalId);
|
package/src/tools-agent.js
CHANGED
|
@@ -262,7 +262,7 @@ const LINT_RULES = [
|
|
|
262
262
|
code: 'ERR_MISSING_CLEANUP',
|
|
263
263
|
message: `Effect sets up ${resource} but does not return a cleanup function — memory leak risk.`,
|
|
264
264
|
line,
|
|
265
|
-
suggestedFix: `Return a cleanup function: return () =>
|
|
265
|
+
suggestedFix: `Return a cleanup function: return () => ${resource === 'event listener' ? 'removeEventListener(...)' : resource === 'interval' ? 'clearInterval(id)' : 'clearTimeout(id)'}`,
|
|
266
266
|
});
|
|
267
267
|
}
|
|
268
268
|
}
|
|
@@ -431,6 +431,136 @@ const LINT_RULES = [
|
|
|
431
431
|
return issues;
|
|
432
432
|
},
|
|
433
433
|
},
|
|
434
|
+
{
|
|
435
|
+
// -----------------------------------------------------------------------
|
|
436
|
+
// destructured-props-lose-reactivity
|
|
437
|
+
//
|
|
438
|
+
// What Framework components run ONCE — the function body is not re-run on
|
|
439
|
+
// prop change. The reactive props proxy auto-tracks reads via `props.foo`,
|
|
440
|
+
// but `const { foo } = props` snapshots the current value and detaches it
|
|
441
|
+
// from the proxy. Subsequent updates from the parent are invisible.
|
|
442
|
+
// -----------------------------------------------------------------------
|
|
443
|
+
id: 'destructured-props-lose-reactivity',
|
|
444
|
+
code: 'ERR_DESTRUCTURED_PROPS',
|
|
445
|
+
severity: 'warning',
|
|
446
|
+
test(code) {
|
|
447
|
+
const issues = [];
|
|
448
|
+
// Find component functions whose first param is `props` (any binding).
|
|
449
|
+
// We support both `function Foo(props) {` and `const Foo = (props) =>`.
|
|
450
|
+
const componentPatterns = [
|
|
451
|
+
/function\s+([A-Z]\w*)\s*\(\s*(\w+)\s*\)\s*\{/g,
|
|
452
|
+
/(?:const|let)\s+([A-Z]\w*)\s*=\s*\(\s*(\w+)\s*\)\s*=>/g,
|
|
453
|
+
];
|
|
454
|
+
for (const pattern of componentPatterns) {
|
|
455
|
+
let compMatch;
|
|
456
|
+
while ((compMatch = pattern.exec(code)) !== null) {
|
|
457
|
+
const propsBinding = compMatch[2];
|
|
458
|
+
// Skip if the param is already destructured at the signature
|
|
459
|
+
// (that's a separate, more legible smell; we focus on body-level
|
|
460
|
+
// destructuring inside the component).
|
|
461
|
+
if (propsBinding === 'props' || /^[a-z]/.test(propsBinding)) {
|
|
462
|
+
// Walk forward and find the component body extent (braces).
|
|
463
|
+
const startIdx = compMatch.index + compMatch[0].length;
|
|
464
|
+
let braceDepth = 1;
|
|
465
|
+
let bodyEnd = startIdx;
|
|
466
|
+
for (let i = startIdx; i < code.length && braceDepth > 0; i++) {
|
|
467
|
+
if (code[i] === '{') braceDepth++;
|
|
468
|
+
if (code[i] === '}') braceDepth--;
|
|
469
|
+
bodyEnd = i;
|
|
470
|
+
}
|
|
471
|
+
const body = code.slice(startIdx, bodyEnd);
|
|
472
|
+
// Find: const|let { ... } = props (the actual binding name).
|
|
473
|
+
const destructPattern = new RegExp(
|
|
474
|
+
`(?:const|let)\\s*\\{([^}]+)\\}\\s*=\\s*${propsBinding}\\b`,
|
|
475
|
+
'g'
|
|
476
|
+
);
|
|
477
|
+
let dm;
|
|
478
|
+
while ((dm = destructPattern.exec(body)) !== null) {
|
|
479
|
+
const fields = dm[1].split(',').map(s => s.trim().split(/[:=]/)[0].trim()).filter(Boolean);
|
|
480
|
+
const line = code.slice(0, startIdx + dm.index).split('\n').length;
|
|
481
|
+
issues.push({
|
|
482
|
+
severity: 'warning',
|
|
483
|
+
code: 'ERR_DESTRUCTURED_PROPS',
|
|
484
|
+
message: `Destructuring '${propsBinding}' in the component body snapshots props and loses reactivity. Components run ONCE — '${propsBinding}.${fields[0] || 'foo'}' tracks via the props proxy, but '{ ${fields.join(', ')} } = ${propsBinding}' does not.`,
|
|
485
|
+
line,
|
|
486
|
+
suggestedFix: `Read props directly inside JSX or effects: \`${propsBinding}.${fields[0] || 'foo'}\` — or wrap each in an accessor: const ${fields[0] || 'foo'} = () => ${propsBinding}.${fields[0] || 'foo'}.`,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return issues;
|
|
493
|
+
},
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
// -----------------------------------------------------------------------
|
|
497
|
+
// module-scope-signal-missing-name
|
|
498
|
+
//
|
|
499
|
+
// Module-scope signals are global state. Without a debug name (second arg
|
|
500
|
+
// to signal()), they appear as `signal_42` in devtools and what_signals,
|
|
501
|
+
// making cross-tool debugging much harder. This is a hint, not an error.
|
|
502
|
+
// -----------------------------------------------------------------------
|
|
503
|
+
id: 'module-scope-signal-missing-name',
|
|
504
|
+
code: 'HINT_SIGNAL_MISSING_NAME',
|
|
505
|
+
severity: 'info',
|
|
506
|
+
test(code) {
|
|
507
|
+
const issues = [];
|
|
508
|
+
// Look for signal/computed declarations at the top level — i.e., not
|
|
509
|
+
// indented (or only minimally) and not inside a function. A simple but
|
|
510
|
+
// reliable heuristic: lines that match `^(?:export\s+)?(?:const|let)
|
|
511
|
+
// \s+\w+\s*=\s*(signal|computed)\s*\(` AND the opening call has only
|
|
512
|
+
// one argument (no comma at the same paren depth before the close).
|
|
513
|
+
const lines = code.split('\n');
|
|
514
|
+
const declRe = /^(?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*(signal|computed)\s*\(/;
|
|
515
|
+
// Track simple brace depth so we skip signals declared inside a function.
|
|
516
|
+
let depth = 0;
|
|
517
|
+
for (let i = 0; i < lines.length; i++) {
|
|
518
|
+
const line = lines[i];
|
|
519
|
+
// Pre-line depth determines whether we're at module scope.
|
|
520
|
+
const wasModuleScope = depth === 0;
|
|
521
|
+
// Update depth after — we want to evaluate the line at its starting scope.
|
|
522
|
+
for (let j = 0; j < line.length; j++) {
|
|
523
|
+
if (line[j] === '{') depth++;
|
|
524
|
+
else if (line[j] === '}') depth = Math.max(0, depth - 1);
|
|
525
|
+
}
|
|
526
|
+
if (!wasModuleScope) continue;
|
|
527
|
+
const m = line.match(declRe);
|
|
528
|
+
if (!m) continue;
|
|
529
|
+
const sigName = m[1];
|
|
530
|
+
const kind = m[2];
|
|
531
|
+
// Reconstruct the full call args by walking parens forward.
|
|
532
|
+
const callStart = m.index + m[0].length; // position right after the opening '('
|
|
533
|
+
// The match is on `line` only; rebuild full call across lines.
|
|
534
|
+
let scan = code.indexOf(line, 0);
|
|
535
|
+
// Find absolute position of the opening paren of signal/computed(...)
|
|
536
|
+
const lineStart = code.split('\n').slice(0, i).join('\n').length + (i > 0 ? 1 : 0);
|
|
537
|
+
const openIdx = lineStart + callStart - 1; // points at the '('
|
|
538
|
+
// Walk to matching close.
|
|
539
|
+
let d = 1, j = openIdx + 1, commasAtDepth1 = 0;
|
|
540
|
+
for (; j < code.length && d > 0; j++) {
|
|
541
|
+
const ch = code[j];
|
|
542
|
+
if (ch === '(') d++;
|
|
543
|
+
else if (ch === ')') d--;
|
|
544
|
+
else if (ch === ',' && d === 1) commasAtDepth1++;
|
|
545
|
+
// Treat string literals naively — bail out of this check if we see a
|
|
546
|
+
// backtick, since template literals make this regex unreliable.
|
|
547
|
+
}
|
|
548
|
+
// For signal(), only one arg means no debug name.
|
|
549
|
+
// For computed(), arity is always 1 — skip; debug name is via .debugName?
|
|
550
|
+
if (kind === 'signal' && commasAtDepth1 === 0) {
|
|
551
|
+
issues.push({
|
|
552
|
+
severity: 'info',
|
|
553
|
+
code: 'HINT_SIGNAL_MISSING_NAME',
|
|
554
|
+
message: `Module-scope signal '${sigName}' has no debug name. It will appear as 'signal_N' in devtools and what_signals — agents and humans both find it harder to trace.`,
|
|
555
|
+
line: i + 1,
|
|
556
|
+
suggestedFix: `Add a debug name as the second argument: signal(<initial>, '${sigName}').`,
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
void scan;
|
|
560
|
+
}
|
|
561
|
+
return issues;
|
|
562
|
+
},
|
|
563
|
+
},
|
|
434
564
|
];
|
|
435
565
|
|
|
436
566
|
// --- Scaffold Templates ---
|
|
@@ -641,7 +771,7 @@ export function registerAgentTools(server, bridge) {
|
|
|
641
771
|
'Static analysis for What Framework code. Pass a code snippet, get back structured issues with fix suggestions. Works offline — no browser connection needed.',
|
|
642
772
|
{
|
|
643
773
|
code: z.string().describe('The What Framework code snippet to analyze'),
|
|
644
|
-
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, signal-write-in-render, missing-key-in-for, prefer-computed-over-effect'),
|
|
774
|
+
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, signal-write-in-render, missing-key-in-for, prefer-computed-over-effect, destructured-props-lose-reactivity, module-scope-signal-missing-name'),
|
|
645
775
|
},
|
|
646
776
|
async ({ code, rules: ruleFilter }) => {
|
|
647
777
|
let rulesToRun = LINT_RULES;
|
|
@@ -896,7 +1026,13 @@ export function registerAgentTools(server, bridge) {
|
|
|
896
1026
|
? `Performance concerns: ${issues.join('; ')}.`
|
|
897
1027
|
: `Healthy. ${signals.length} signals, ${effects.length} effects, ${components.length} components. ${memoryStr} estimated.`;
|
|
898
1028
|
|
|
899
|
-
|
|
1029
|
+
// Suppress noisy "every signal has 1 subscriber" output — agents would
|
|
1030
|
+
// chase it as a signal. Only emit largestSubscribers when at least one
|
|
1031
|
+
// signal has 2+ subscribers worth investigating.
|
|
1032
|
+
const maxSubs = largestSubscribers.length > 0
|
|
1033
|
+
? largestSubscribers[0].subscriberCount
|
|
1034
|
+
: 0;
|
|
1035
|
+
const result = {
|
|
900
1036
|
summary,
|
|
901
1037
|
counts: {
|
|
902
1038
|
signals: signals.length,
|
|
@@ -904,7 +1040,6 @@ export function registerAgentTools(server, bridge) {
|
|
|
904
1040
|
components: components.length,
|
|
905
1041
|
},
|
|
906
1042
|
hotEffects,
|
|
907
|
-
largestSubscribers,
|
|
908
1043
|
eventRate,
|
|
909
1044
|
memoryEstimate: memoryStr,
|
|
910
1045
|
memoryBytes: totalEstimate,
|
|
@@ -914,7 +1049,9 @@ export function registerAgentTools(server, bridge) {
|
|
|
914
1049
|
'Consider using batch() to group signal writes.',
|
|
915
1050
|
'Use computed() for derived values instead of effects.',
|
|
916
1051
|
] : [],
|
|
917
|
-
}
|
|
1052
|
+
};
|
|
1053
|
+
if (maxSubs >= 2) result.largestSubscribers = largestSubscribers;
|
|
1054
|
+
return ok(result);
|
|
918
1055
|
}
|
|
919
1056
|
);
|
|
920
1057
|
|
|
@@ -996,4 +1133,152 @@ export function registerAgentTools(server, bridge) {
|
|
|
996
1133
|
});
|
|
997
1134
|
}
|
|
998
1135
|
);
|
|
1136
|
+
|
|
1137
|
+
// -----------------------------------------------------------------------
|
|
1138
|
+
// Tool 6 — what_record_window
|
|
1139
|
+
//
|
|
1140
|
+
// Opens a sampling window (default 1s, max 30s), captures which effects
|
|
1141
|
+
// re-ran during that window, and returns a ranked list — most-fired first.
|
|
1142
|
+
//
|
|
1143
|
+
// Differs from `what_perf` (cumulative runCount since boot) and from
|
|
1144
|
+
// `what_watch` (raw event log). This one is the one-call "which effects
|
|
1145
|
+
// re-ran during this action?" answer, which is what you want when
|
|
1146
|
+
// profiling a single user interaction (drag-drop, button click, route
|
|
1147
|
+
// change). Pair with `what_set_signal` or a user-driven action to scope
|
|
1148
|
+
// the recording.
|
|
1149
|
+
// -----------------------------------------------------------------------
|
|
1150
|
+
|
|
1151
|
+
server.tool(
|
|
1152
|
+
'what_record_window',
|
|
1153
|
+
'Sample which effects re-ran during a time window. Captures runCount before and after a configurable duration, then returns a ranked delta. Use this to identify which effects fire during a specific action (e.g., a drag, a click, or a route change). Default 1000ms.',
|
|
1154
|
+
{
|
|
1155
|
+
duration: z.number().optional().default(1000).describe('Sampling window in ms (default: 1000, max: 30000, min: 50)'),
|
|
1156
|
+
topN: z.number().optional().default(20).describe('Maximum number of effects to return (default: 20)'),
|
|
1157
|
+
includeZero: z.boolean().optional().default(false).describe('Include effects that did not re-run (default: false — only changed effects)'),
|
|
1158
|
+
},
|
|
1159
|
+
async ({ duration, topN, includeZero }) => {
|
|
1160
|
+
if (!bridge.isConnected()) return noConnection('what_record_window');
|
|
1161
|
+
|
|
1162
|
+
const ms = Math.min(Math.max(duration ?? 1000, 50), 30000);
|
|
1163
|
+
const limit = Math.min(Math.max(topN ?? 20, 1), 200);
|
|
1164
|
+
|
|
1165
|
+
// ---- Take baseline ----
|
|
1166
|
+
let baseline;
|
|
1167
|
+
try {
|
|
1168
|
+
baseline = await (bridge.refreshSnapshot ? bridge.refreshSnapshot() : bridge.getSnapshot());
|
|
1169
|
+
} catch {
|
|
1170
|
+
baseline = bridge.getSnapshot();
|
|
1171
|
+
}
|
|
1172
|
+
if (!baseline) {
|
|
1173
|
+
return errorResponse('No snapshot available for baseline.', [
|
|
1174
|
+
'Refresh the browser page so the devtools client registers its state.',
|
|
1175
|
+
]);
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
const baselineRunCounts = new Map();
|
|
1179
|
+
const baselineEffectMeta = new Map();
|
|
1180
|
+
for (const e of baseline.effects || []) {
|
|
1181
|
+
baselineRunCounts.set(e.id, e.runCount || 0);
|
|
1182
|
+
baselineEffectMeta.set(e.id, {
|
|
1183
|
+
id: e.id,
|
|
1184
|
+
name: e.name || `effect_${e.id}`,
|
|
1185
|
+
componentId: e.componentId,
|
|
1186
|
+
depCount: (e.depSignalIds || e.deps || []).length,
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
const baselineEventCount = bridge.getEvents
|
|
1190
|
+
? bridge.getEvents(Date.now() - 1).length
|
|
1191
|
+
: 0;
|
|
1192
|
+
const startTs = Date.now();
|
|
1193
|
+
|
|
1194
|
+
// ---- Wait for the window ----
|
|
1195
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
1196
|
+
|
|
1197
|
+
// ---- Take post-window snapshot ----
|
|
1198
|
+
let after;
|
|
1199
|
+
try {
|
|
1200
|
+
after = await (bridge.refreshSnapshot ? bridge.refreshSnapshot() : bridge.getSnapshot());
|
|
1201
|
+
} catch {
|
|
1202
|
+
after = bridge.getSnapshot();
|
|
1203
|
+
}
|
|
1204
|
+
if (!after) {
|
|
1205
|
+
return errorResponse('No snapshot available after the recording window.', [
|
|
1206
|
+
'The browser may have lost connection mid-recording.',
|
|
1207
|
+
]);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// ---- Compute delta ----
|
|
1211
|
+
const ranked = [];
|
|
1212
|
+
const newEffects = [];
|
|
1213
|
+
const seen = new Set();
|
|
1214
|
+
for (const e of after.effects || []) {
|
|
1215
|
+
seen.add(e.id);
|
|
1216
|
+
const before = baselineRunCounts.get(e.id);
|
|
1217
|
+
const nowCount = e.runCount || 0;
|
|
1218
|
+
if (before === undefined) {
|
|
1219
|
+
// Effect was created during the window
|
|
1220
|
+
newEffects.push({
|
|
1221
|
+
id: e.id,
|
|
1222
|
+
name: e.name || `effect_${e.id}`,
|
|
1223
|
+
componentId: e.componentId,
|
|
1224
|
+
runCount: nowCount,
|
|
1225
|
+
depCount: (e.depSignalIds || e.deps || []).length,
|
|
1226
|
+
});
|
|
1227
|
+
continue;
|
|
1228
|
+
}
|
|
1229
|
+
const delta = nowCount - before;
|
|
1230
|
+
if (delta > 0 || includeZero) {
|
|
1231
|
+
ranked.push({
|
|
1232
|
+
id: e.id,
|
|
1233
|
+
name: e.name || baselineEffectMeta.get(e.id)?.name || `effect_${e.id}`,
|
|
1234
|
+
componentId: e.componentId ?? baselineEffectMeta.get(e.id)?.componentId,
|
|
1235
|
+
runs: delta,
|
|
1236
|
+
totalRuns: nowCount,
|
|
1237
|
+
depCount: (e.depSignalIds || e.deps || []).length,
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
const disposedDuring = [];
|
|
1242
|
+
for (const [id, meta] of baselineEffectMeta) {
|
|
1243
|
+
if (!seen.has(id)) disposedDuring.push(meta);
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
ranked.sort((a, b) => b.runs - a.runs);
|
|
1247
|
+
const top = ranked.slice(0, limit);
|
|
1248
|
+
|
|
1249
|
+
const totalRuns = ranked.reduce((sum, e) => sum + e.runs, 0);
|
|
1250
|
+
const distinctEffects = ranked.length;
|
|
1251
|
+
const eventsDuring = bridge.getEvents
|
|
1252
|
+
? bridge.getEvents(startTs).length
|
|
1253
|
+
: null;
|
|
1254
|
+
|
|
1255
|
+
const summary = totalRuns === 0
|
|
1256
|
+
? `No effects re-ran during the ${ms}ms window. App is idle (or no reactive state changed).`
|
|
1257
|
+
: `${totalRuns} effect run${totalRuns !== 1 ? 's' : ''} across ${distinctEffects} distinct effect${distinctEffects !== 1 ? 's' : ''} in ${ms}ms.`;
|
|
1258
|
+
|
|
1259
|
+
const nextSteps = [];
|
|
1260
|
+
if (top.length > 0 && top[0].runs >= 10) {
|
|
1261
|
+
nextSteps.push(`Hot effect "${top[0].name}" ran ${top[0].runs} times — inspect with what_dependency_graph({effectId: ${top[0].id}, direction: "upstream"}).`);
|
|
1262
|
+
}
|
|
1263
|
+
if (newEffects.length > 5) {
|
|
1264
|
+
nextSteps.push(`${newEffects.length} effects were created during the window — likely a re-mount cycle. Check what_diff_snapshot for component churn.`);
|
|
1265
|
+
}
|
|
1266
|
+
if (disposedDuring.length > 0 && newEffects.length > 0) {
|
|
1267
|
+
nextSteps.push(`${disposedDuring.length} effects were disposed and ${newEffects.length} were created — component tree is being torn down and rebuilt.`);
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
return ok({
|
|
1271
|
+
summary,
|
|
1272
|
+
windowMs: ms,
|
|
1273
|
+
totalRuns,
|
|
1274
|
+
distinctEffects,
|
|
1275
|
+
topEffects: top,
|
|
1276
|
+
newEffectsCount: newEffects.length,
|
|
1277
|
+
newEffects: newEffects.slice(0, limit),
|
|
1278
|
+
disposedCount: disposedDuring.length,
|
|
1279
|
+
eventsDuring,
|
|
1280
|
+
nextSteps,
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
);
|
|
999
1284
|
}
|
package/src/tools-extended.js
CHANGED
|
@@ -70,6 +70,37 @@ export function registerExtendedTools(server, bridge) {
|
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Helper: framework-wide signal value preview policy.
|
|
75
|
+
//
|
|
76
|
+
// Goal: agents see the SAME shape across every tool that surfaces a signal
|
|
77
|
+
// value (what_signals, what_dependency_graph, what_signal_trace, …).
|
|
78
|
+
//
|
|
79
|
+
// Policy:
|
|
80
|
+
// - Primitives (number/boolean/null/undefined): return as-is.
|
|
81
|
+
// - Strings: full when small, truncated with ellipsis when long.
|
|
82
|
+
// - Arrays/objects: full structure when its JSON stringification fits
|
|
83
|
+
// under PREVIEW_LIMIT chars; otherwise return a truncated JSON string.
|
|
84
|
+
//
|
|
85
|
+
// Threshold chosen to fit "small list of small items" while protecting
|
|
86
|
+
// dep-graph topology output from token blow-ups on huge structures.
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
const PREVIEW_LIMIT = 100;
|
|
89
|
+
function previewSignalValue(raw) {
|
|
90
|
+
if (raw == null) return raw;
|
|
91
|
+
const t = typeof raw;
|
|
92
|
+
if (t === 'number' || t === 'boolean') return raw;
|
|
93
|
+
if (t === 'string') return raw.length > PREVIEW_LIMIT ? raw.slice(0, PREVIEW_LIMIT) + '…' : raw;
|
|
94
|
+
if (t === 'object') {
|
|
95
|
+
let json;
|
|
96
|
+
try { json = JSON.stringify(raw); } catch { return String(raw); }
|
|
97
|
+
if (json == null) return String(raw);
|
|
98
|
+
if (json.length <= PREVIEW_LIMIT) return raw;
|
|
99
|
+
return json.slice(0, PREVIEW_LIMIT) + '…';
|
|
100
|
+
}
|
|
101
|
+
return String(raw);
|
|
102
|
+
}
|
|
103
|
+
|
|
73
104
|
// ---------------------------------------------------------------------------
|
|
74
105
|
// Helper: get a fresh or cached snapshot
|
|
75
106
|
// ---------------------------------------------------------------------------
|
|
@@ -353,16 +384,10 @@ export function registerExtendedTools(server, bridge) {
|
|
|
353
384
|
const id = Number(idStr);
|
|
354
385
|
if (type === 'signal') {
|
|
355
386
|
const s = signalMap.get(id);
|
|
356
|
-
//
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
else if (typeof raw === 'string') value = raw.length > 80 ? raw.slice(0, 80) + '…' : raw;
|
|
361
|
-
else if (Array.isArray(raw)) value = `Array(${raw.length})`;
|
|
362
|
-
else if (typeof raw === 'object') {
|
|
363
|
-
const json = JSON.stringify(raw);
|
|
364
|
-
value = json.length > 80 ? json.slice(0, 80) + '…' : raw;
|
|
365
|
-
} else value = raw;
|
|
387
|
+
// Use the framework-wide value-preview policy — full values when
|
|
388
|
+
// small, truncated JSON when large. Keeps output consistent with
|
|
389
|
+
// what_signals so agents see the same shape across tools.
|
|
390
|
+
const value = previewSignalValue(s?.value);
|
|
366
391
|
nodes.push({ type: 'signal', id, name: s?.name || `signal_${id}`, value });
|
|
367
392
|
} else {
|
|
368
393
|
const e = effectMap.get(id);
|
|
@@ -403,12 +428,15 @@ export function registerExtendedTools(server, bridge) {
|
|
|
403
428
|
async ({ code, timeout }) => {
|
|
404
429
|
// Allow safe read-only property access without the unsafe flag
|
|
405
430
|
const trimmed = (code || '').trim();
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
431
|
+
// Strict safe-read: only allow dotted property access on known safe globals.
|
|
432
|
+
// Each segment must be a simple identifier (no brackets, quotes, or calls).
|
|
433
|
+
const SAFE_GLOBALS = new Set(['document', 'window', 'navigator', 'location', 'screen', 'performance', 'console']);
|
|
434
|
+
const segments = trimmed.split('.');
|
|
435
|
+
const isSimpleIdent = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
|
|
436
|
+
const PROTO_DENYLIST = new Set(['constructor', 'prototype', '__proto__']);
|
|
437
|
+
const isSafeRead = segments.length >= 2 &&
|
|
438
|
+
SAFE_GLOBALS.has(segments[0]) &&
|
|
439
|
+
segments.every(s => isSimpleIdent.test(s) && !PROTO_DENYLIST.has(s));
|
|
412
440
|
|
|
413
441
|
if (!unsafeEvalEnabled && !isSafeRead) {
|
|
414
442
|
return errorResponse(
|
|
@@ -833,8 +861,19 @@ export function registerExtendedTools(server, bridge) {
|
|
|
833
861
|
async ({ path, replace }) => {
|
|
834
862
|
if (!bridge.isConnected()) return noConnection('what_navigate');
|
|
835
863
|
|
|
864
|
+
// Validate URL — reject dangerous protocols
|
|
865
|
+
const trimmedPath = path.trim();
|
|
866
|
+
const normalized = trimmedPath.replace(/[\s\x00-\x1f]/g, '').toLowerCase();
|
|
867
|
+
const isRelative = /^[/.#?]/.test(trimmedPath) || !trimmedPath.includes(':');
|
|
868
|
+
if (!isRelative && !/^https?:/.test(normalized)) {
|
|
869
|
+
return errorResponse(`Blocked navigation to unsafe URL: "${path}"`, [
|
|
870
|
+
'Only relative paths (/foo, ./bar, #hash, ?query) and http(s) URLs are allowed.',
|
|
871
|
+
'javascript:, data:, and vbscript: URLs are rejected for security.',
|
|
872
|
+
]);
|
|
873
|
+
}
|
|
874
|
+
|
|
836
875
|
try {
|
|
837
|
-
const result = await bridge.sendCommand('navigate', { path, replace });
|
|
876
|
+
const result = await bridge.sendCommand('navigate', { path: trimmedPath, replace });
|
|
838
877
|
if (result.error) {
|
|
839
878
|
return errorResponse(result.error, [
|
|
840
879
|
'Check that the path is valid.',
|
|
@@ -860,7 +899,7 @@ export function registerExtendedTools(server, bridge) {
|
|
|
860
899
|
|
|
861
900
|
server.tool(
|
|
862
901
|
'what_explain',
|
|
863
|
-
'Get a complete picture of a component: its signals with values, effects with deps and run counts, rendered DOM, and any errors. The "tell me everything about this component" tool.',
|
|
902
|
+
'Get a complete picture of a component: its signals with values, effects with deps and run counts, rendered DOM, and any errors. The "tell me everything about this component" tool. Component IDs are ephemeral — they change on mount/unmount, so re-query what_components after any signal write that may have remounted the tree.',
|
|
864
903
|
{
|
|
865
904
|
componentId: z.number().describe('Component ID to explain (from what_components)'),
|
|
866
905
|
includeDOM: z.boolean().optional().default(true).describe('Include rendered DOM output (default: true)'),
|
|
@@ -937,8 +976,9 @@ export function registerExtendedTools(server, bridge) {
|
|
|
937
976
|
{
|
|
938
977
|
signalId: z.number().describe('Signal ID to trace (from what_signals)'),
|
|
939
978
|
depth: z.number().optional().default(2).describe('Causal chain depth — how many levels of effect->signal->effect to trace (default: 2)'),
|
|
979
|
+
auto_watch_ms: z.number().optional().default(500).describe('If no recent writes were captured, briefly listen for events for this many ms before returning (default: 500, set to 0 to disable).'),
|
|
940
980
|
},
|
|
941
|
-
async ({ signalId, depth }) => {
|
|
981
|
+
async ({ signalId, depth, auto_watch_ms }) => {
|
|
942
982
|
const { snapshot, err } = await freshSnapshot('what_signal_trace');
|
|
943
983
|
if (err) return err;
|
|
944
984
|
|
|
@@ -959,6 +999,21 @@ export function registerExtendedTools(server, bridge) {
|
|
|
959
999
|
if (writers.error) writers = { recentWrites: [], totalWrites: 0, note: writers.error };
|
|
960
1000
|
} catch {}
|
|
961
1001
|
|
|
1002
|
+
// Auto-arm what_watch when no writes have been captured yet. The
|
|
1003
|
+
// signal-writer ring buffer is populated by initEventTracking, which
|
|
1004
|
+
// runs on first extended command. If the caller hits what_signal_trace
|
|
1005
|
+
// first, the buffer is empty even though the browser is producing
|
|
1006
|
+
// events. Listen briefly so the user gets a result on the first try
|
|
1007
|
+
// instead of having to manually chain what_watch -> what_signal_trace.
|
|
1008
|
+
const watchMs = Math.min(Math.max(Number(auto_watch_ms) || 0, 0), 5000);
|
|
1009
|
+
if (watchMs > 0 && (!writers.recentWrites || writers.recentWrites.length === 0)) {
|
|
1010
|
+
await new Promise(r => setTimeout(r, watchMs));
|
|
1011
|
+
try {
|
|
1012
|
+
const refetched = await bridge.sendCommand('get-signal-writers', { signalId }, 5000);
|
|
1013
|
+
if (refetched && !refetched.error) writers = refetched;
|
|
1014
|
+
} catch {}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
962
1017
|
// Build causal chain
|
|
963
1018
|
// For each writer effect, find what signals it depends on
|
|
964
1019
|
const chain = [];
|
package/src/tools.js
CHANGED
|
@@ -126,6 +126,7 @@ export function registerTools(server, bridge) {
|
|
|
126
126
|
{ name: 'what_signal_trace', desc: 'Why did a signal change? Causal chain.' },
|
|
127
127
|
{ name: 'what_dependency_graph', desc: 'Reactive dependency graph' },
|
|
128
128
|
{ name: 'what_watch', desc: 'Observe events over a time window' },
|
|
129
|
+
{ name: 'what_record_window', desc: 'Rank effects that re-ran during a recording window — what fired for this action?' },
|
|
129
130
|
{ name: 'what_set_signal', desc: 'Change a signal value in the live app' },
|
|
130
131
|
{ name: 'what_lint', desc: 'Static analysis for code (no browser needed)' },
|
|
131
132
|
{ name: 'what_scaffold', desc: 'Generate boilerplate (no browser needed)' },
|
|
@@ -285,7 +286,7 @@ export function registerTools(server, bridge) {
|
|
|
285
286
|
|
|
286
287
|
server.tool(
|
|
287
288
|
'what_components',
|
|
288
|
-
'List all mounted What Framework components',
|
|
289
|
+
'List all mounted What Framework components. Component IDs are ephemeral — they change on mount/unmount (view switches, conditional rendering, filters). Re-query this tool after any operation that may have remounted before using returned IDs.',
|
|
289
290
|
{
|
|
290
291
|
filter: z.string().optional().describe('Regex pattern to filter component names'),
|
|
291
292
|
},
|
|
@@ -476,19 +477,28 @@ export function registerTools(server, bridge) {
|
|
|
476
477
|
if (!bridge.isConnected()) return noConnection('what_errors');
|
|
477
478
|
let errors = bridge.getErrors(since);
|
|
478
479
|
|
|
480
|
+
// Build a name->component lookup so we can attribute errors to a
|
|
481
|
+
// mounted component when the stack references one. Re-fetched each call
|
|
482
|
+
// because component IDs are ephemeral (mount/unmount cycles).
|
|
483
|
+
const knownComponents = (bridge.getSnapshot()?.components || [])
|
|
484
|
+
.map(c => ({ id: c.id, name: c.name }))
|
|
485
|
+
.filter(c => c.name && /^[A-Z]/.test(c.name));
|
|
486
|
+
|
|
479
487
|
// Classify each error with structured codes and suggestions
|
|
480
488
|
const classified = errors.map((err, idx) => {
|
|
481
489
|
const msg = err.message || err.error || '';
|
|
490
|
+
const parsed = parseStack(err.stack, knownComponents);
|
|
482
491
|
let classification = {
|
|
483
492
|
id: `err_${idx}`,
|
|
484
493
|
severity: 'error',
|
|
485
494
|
code: 'ERR_RUNTIME',
|
|
486
495
|
message: msg,
|
|
487
496
|
timestamp: err.timestamp,
|
|
488
|
-
file: err.file || null,
|
|
489
|
-
line: err.line || null,
|
|
490
|
-
|
|
491
|
-
|
|
497
|
+
file: err.file || parsed.file || null,
|
|
498
|
+
line: err.line || parsed.line || null,
|
|
499
|
+
column: parsed.column || null,
|
|
500
|
+
component: err.component || parsed.component || null,
|
|
501
|
+
suggestion: inferSuggestion(msg),
|
|
492
502
|
codeExample: null,
|
|
493
503
|
};
|
|
494
504
|
|
|
@@ -612,12 +622,14 @@ export function registerTools(server, bridge) {
|
|
|
612
622
|
'Set a signal value in the running app. Returns previous and new values.',
|
|
613
623
|
{
|
|
614
624
|
signalId: z.number().describe('The signal ID to update (from what_signals)'),
|
|
615
|
-
value: z.any().describe('The new value to set (JSON-compatible)'),
|
|
625
|
+
value: z.any().describe('The new value to set (JSON-compatible). Note: string values that look like numbers/booleans/JSON are auto-parsed (e.g. "42" becomes 42). Set rawString: true to keep the literal string.'),
|
|
626
|
+
rawString: z.boolean().optional().describe('When true, skip auto-coercion and write the value as-is (useful for string values like "42" that would otherwise be parsed as numbers)'),
|
|
616
627
|
},
|
|
617
|
-
async ({ signalId, value }) => {
|
|
628
|
+
async ({ signalId, value, rawString }) => {
|
|
618
629
|
if (!bridge.isConnected()) return noConnection('what_set_signal');
|
|
619
630
|
try {
|
|
620
|
-
const
|
|
631
|
+
const parsedValue = rawString ? value : coerceJsonValue(value);
|
|
632
|
+
const result = await bridge.sendCommand('set-signal', { signalId, value: parsedValue });
|
|
621
633
|
if (result.error) return error(result.error);
|
|
622
634
|
|
|
623
635
|
const summary = `Signal ${signalId} updated. Previous: ${JSON.stringify(result.previousValue)}, New: ${JSON.stringify(result.newValue ?? value)}`;
|
|
@@ -757,3 +769,147 @@ function error(message) {
|
|
|
757
769
|
isError: true,
|
|
758
770
|
};
|
|
759
771
|
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Parse a JS error stack trace and extract:
|
|
775
|
+
* - file/line/column of the topmost USER frame (skipping framework internals)
|
|
776
|
+
* - component name matched against the live components registry
|
|
777
|
+
*
|
|
778
|
+
* Stack formats vary by engine. We handle V8/Chrome (the only target since
|
|
779
|
+
* the app runs in a browser):
|
|
780
|
+
* " at FunctionName (file.js:12:34)"
|
|
781
|
+
* " at file.js:12:34"
|
|
782
|
+
*
|
|
783
|
+
* Frames are skipped if they reference framework internals (what-framework,
|
|
784
|
+
* what-core, what-devtools, node_modules, vite/, /@id/, internal anonymous).
|
|
785
|
+
*/
|
|
786
|
+
function parseStack(stack, knownComponents = []) {
|
|
787
|
+
const out = { file: null, line: null, column: null, component: null };
|
|
788
|
+
if (!stack || typeof stack !== 'string') return out;
|
|
789
|
+
|
|
790
|
+
const lines = stack.split('\n');
|
|
791
|
+
const skipPatterns = [
|
|
792
|
+
/what-framework/,
|
|
793
|
+
/what-core/,
|
|
794
|
+
/what-devtools/,
|
|
795
|
+
/node_modules/,
|
|
796
|
+
/\/vite\//,
|
|
797
|
+
/\/@id\//,
|
|
798
|
+
/<anonymous>/,
|
|
799
|
+
/^Error[: ]/,
|
|
800
|
+
];
|
|
801
|
+
|
|
802
|
+
const knownNames = new Set(knownComponents.map(c => c.name));
|
|
803
|
+
|
|
804
|
+
for (const rawLine of lines) {
|
|
805
|
+
const line = rawLine.trim();
|
|
806
|
+
if (!line.startsWith('at ')) continue;
|
|
807
|
+
|
|
808
|
+
// Try to match: at FunctionName (path:line:col) OR at path:line:col
|
|
809
|
+
const withFn = line.match(/^at\s+(.+?)\s+\((.+):(\d+):(\d+)\)$/);
|
|
810
|
+
const noFn = line.match(/^at\s+(.+):(\d+):(\d+)$/);
|
|
811
|
+
|
|
812
|
+
const fnName = withFn ? withFn[1] : null;
|
|
813
|
+
const path = withFn ? withFn[2] : noFn ? noFn[1] : null;
|
|
814
|
+
const ln = withFn ? Number(withFn[3]) : noFn ? Number(noFn[2]) : null;
|
|
815
|
+
const col = withFn ? Number(withFn[4]) : noFn ? Number(noFn[3]) : null;
|
|
816
|
+
|
|
817
|
+
if (!path) continue;
|
|
818
|
+
|
|
819
|
+
// Match component name from function frame against known components.
|
|
820
|
+
// Pulls out just the bare identifier, e.g. "TaskList" from "Object.TaskList"
|
|
821
|
+
// or "TaskList.handleClick".
|
|
822
|
+
if (!out.component && fnName) {
|
|
823
|
+
const tokens = fnName.split(/[.\s]/);
|
|
824
|
+
for (const tok of tokens) {
|
|
825
|
+
if (knownNames.has(tok)) { out.component = tok; break; }
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// First non-skipped frame wins for file/line/column.
|
|
830
|
+
if (out.file == null) {
|
|
831
|
+
const skip = skipPatterns.some(re => re.test(path));
|
|
832
|
+
if (!skip) {
|
|
833
|
+
out.file = path;
|
|
834
|
+
out.line = ln;
|
|
835
|
+
out.column = col;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
return out;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Infer a more specific suggestion from a runtime error message before any
|
|
844
|
+
* pattern-specific overrides apply. Catches common JS mistakes that the
|
|
845
|
+
* What-Framework-specific pattern matchers below don't cover.
|
|
846
|
+
*/
|
|
847
|
+
function inferSuggestion(msg) {
|
|
848
|
+
if (!msg) return 'Check the stack trace and component context for more details.';
|
|
849
|
+
// ReferenceError
|
|
850
|
+
if (/is not defined$/.test(msg)) {
|
|
851
|
+
const m = msg.match(/^(\w+) is not defined/);
|
|
852
|
+
const name = m?.[1] || 'identifier';
|
|
853
|
+
return `'${name}' is referenced before it is declared/imported. Check for a missing import or a typo. If it's a hook value, ensure the binding is in scope.`;
|
|
854
|
+
}
|
|
855
|
+
// TypeError: ... is not a function
|
|
856
|
+
if (/is not a function$/.test(msg)) {
|
|
857
|
+
const m = msg.match(/^(.+?) is not a function/);
|
|
858
|
+
const name = m?.[1] || 'value';
|
|
859
|
+
return `'${name}' is not callable. Common causes: a signal-returning value used like a non-signal, an undefined import, or a typo in the name. If '${name}' is a signal, you must call it with () to read its value.`;
|
|
860
|
+
}
|
|
861
|
+
// Cannot read properties of undefined/null
|
|
862
|
+
if (/Cannot read propert(?:y|ies) of (undefined|null)/.test(msg)) {
|
|
863
|
+
const m = msg.match(/Cannot read propert(?:y|ies) of (undefined|null) \(reading '(.+?)'\)/);
|
|
864
|
+
if (m) {
|
|
865
|
+
return `Tried to read '.${m[2]}' on ${m[1]}. Guard the access (e.g. \`value?.${m[2]}\`), or ensure the signal/prop has been initialised before this code runs.`;
|
|
866
|
+
}
|
|
867
|
+
return 'Tried to access a property on undefined/null. Add an optional-chain (`?.`) or null-check before the access.';
|
|
868
|
+
}
|
|
869
|
+
// Maximum call stack
|
|
870
|
+
if (/Maximum call stack/.test(msg)) {
|
|
871
|
+
return 'Infinite recursion detected. Usually an effect writes to a signal it reads — wrap the read in untrack(), or move the write into a different effect.';
|
|
872
|
+
}
|
|
873
|
+
// Assignment to constant variable
|
|
874
|
+
if (/Assignment to constant variable/.test(msg)) {
|
|
875
|
+
return 'Tried to reassign a `const`. Signals are constants — to update them, call them as functions: `mySignal(newValue)`, not `mySignal = newValue`.';
|
|
876
|
+
}
|
|
877
|
+
return 'Check the stack trace and component context for more details.';
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Coerce a possibly-stringified JSON value into its native form.
|
|
882
|
+
*
|
|
883
|
+
* Agents and some MCP clients pass complex values as JSON-encoded strings
|
|
884
|
+
* (e.g. `'[{"id":1}]'` instead of `[{id: 1}]`). Without this, the receiver
|
|
885
|
+
* would store the literal string and downstream iteration/rendering breaks
|
|
886
|
+
* (mapArray iterates the string's chars, signals hold "42" instead of 42).
|
|
887
|
+
*
|
|
888
|
+
* Heuristic: only parse strings whose first non-whitespace char is one of
|
|
889
|
+
* `{ [ " t f n -` or 0-9. We intentionally do NOT parse arbitrary string
|
|
890
|
+
* content like "hello" because users may legitimately want to set a string
|
|
891
|
+
* value. Quoted JSON strings like `'"hello"'` and `'true'` etc are detected
|
|
892
|
+
* by JSON.parse succeeding on them; if the parse changes the type
|
|
893
|
+
* meaningfully (object/array/boolean/number/null), we accept it. For plain
|
|
894
|
+
* string content (`hello`), JSON.parse fails and we keep the original.
|
|
895
|
+
*/
|
|
896
|
+
function coerceJsonValue(value) {
|
|
897
|
+
if (typeof value !== 'string') return value;
|
|
898
|
+
const trimmed = value.trim();
|
|
899
|
+
if (trimmed.length === 0) return value;
|
|
900
|
+
const first = trimmed[0];
|
|
901
|
+
const looksLikeJson =
|
|
902
|
+
first === '{' || first === '[' || first === '"' ||
|
|
903
|
+
first === 't' || first === 'f' || first === 'n' ||
|
|
904
|
+
first === '-' || (first >= '0' && first <= '9');
|
|
905
|
+
if (!looksLikeJson) return value;
|
|
906
|
+
try {
|
|
907
|
+
const parsed = JSON.parse(trimmed);
|
|
908
|
+
// Only accept the parse if it changes the type to a non-string — keeps
|
|
909
|
+
// user-supplied strings intact while catching double-stringified payloads.
|
|
910
|
+
if (typeof parsed !== 'string') return parsed;
|
|
911
|
+
return value;
|
|
912
|
+
} catch {
|
|
913
|
+
return value;
|
|
914
|
+
}
|
|
915
|
+
}
|
package/src/vite-plugin.js
CHANGED
|
@@ -32,23 +32,56 @@ function resolveToken(explicitToken) {
|
|
|
32
32
|
return '';
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// Virtual module id served via Vite's `\0` convention.
|
|
36
|
+
// Resolved IDs starting with `\0` are hidden from the file system and recognised
|
|
37
|
+
// by Vite as plugin-owned modules. We expose them to the browser via the
|
|
38
|
+
// `/@id/<resolved-id>` URL convention so `<script src>` can request them.
|
|
39
|
+
const VIRTUAL_BOOTSTRAP_ID = 'virtual:what-devtools-mcp/bootstrap';
|
|
40
|
+
const RESOLVED_BOOTSTRAP_ID = '\0' + VIRTUAL_BOOTSTRAP_ID;
|
|
41
|
+
// Vite encodes `\0` as `__x00__` in `/@id/` URLs — stable since Vite 2 (2021).
|
|
42
|
+
const BROWSER_BOOTSTRAP_URL = '/@id/__x00__' + VIRTUAL_BOOTSTRAP_ID;
|
|
43
|
+
|
|
35
44
|
export default function whatDevToolsMCP({ port = 9229, token = '' } = {}) {
|
|
36
45
|
return {
|
|
37
46
|
name: 'what-devtools-mcp',
|
|
38
47
|
apply: 'serve',
|
|
39
|
-
|
|
48
|
+
|
|
49
|
+
// Resolve the virtual module so Vite knows we own it.
|
|
50
|
+
resolveId(id) {
|
|
51
|
+
if (id === VIRTUAL_BOOTSTRAP_ID || id === RESOLVED_BOOTSTRAP_ID) {
|
|
52
|
+
return RESOLVED_BOOTSTRAP_ID;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
// Load the bootstrap source. Because this is a real JS module that goes
|
|
58
|
+
// through Vite's transform pipeline, bare specifiers like `what-core` get
|
|
59
|
+
// properly rewritten to dev-server URLs — unlike inline <script type=module>
|
|
60
|
+
// tags injected via transformIndexHtml, which Vite does not transform.
|
|
61
|
+
load(id) {
|
|
62
|
+
if (id !== RESOLVED_BOOTSTRAP_ID) return null;
|
|
40
63
|
const tokenValue = resolveToken(token);
|
|
41
|
-
return
|
|
42
|
-
'
|
|
43
|
-
|
|
44
|
-
import
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
64
|
+
return [
|
|
65
|
+
`import * as core from 'what-core';`,
|
|
66
|
+
`import { installDevTools } from 'what-devtools';`,
|
|
67
|
+
`import { connectDevToolsMCP } from 'what-devtools-mcp/client';`,
|
|
68
|
+
`installDevTools(core);`,
|
|
69
|
+
`connectDevToolsMCP({ port: ${port}, token: ${JSON.stringify(tokenValue)} });`,
|
|
70
|
+
].join('\n');
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
transformIndexHtml() {
|
|
74
|
+
// Inject a <script src> that points at the virtual module. The browser
|
|
75
|
+
// fetches `/@id/__x00__virtual:what-devtools-mcp/bootstrap`, Vite serves
|
|
76
|
+
// the transformed bootstrap (bare specifiers resolved), and everything
|
|
77
|
+
// loads correctly without "Failed to resolve module specifier" errors.
|
|
78
|
+
return [
|
|
79
|
+
{
|
|
80
|
+
tag: 'script',
|
|
81
|
+
attrs: { type: 'module', src: BROWSER_BOOTSTRAP_URL },
|
|
82
|
+
injectTo: 'body',
|
|
83
|
+
},
|
|
84
|
+
];
|
|
52
85
|
},
|
|
53
86
|
};
|
|
54
87
|
}
|