what-devtools-mcp 0.6.0 → 0.6.3
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 +3 -3
- package/src/bridge.js +66 -2
- package/src/client-commands.js +1636 -7
- package/src/client.js +53 -12
- package/src/index.js +478 -0
- package/src/tools-agent.js +183 -2
- package/src/tools-extended.js +426 -10
- package/src/tools-interact.js +420 -0
- package/src/tools.js +110 -14
- package/src/vite-plugin.js +44 -6
package/src/tools-agent.js
CHANGED
|
@@ -120,6 +120,18 @@ effect(() => {
|
|
|
120
120
|
// Good — stable key:
|
|
121
121
|
<For each={items()}>{item => <li key={item.id}>{item.name}</li>}</For>`,
|
|
122
122
|
},
|
|
123
|
+
HINT_PREFER_COMPUTED: {
|
|
124
|
+
code: 'HINT_PREFER_COMPUTED',
|
|
125
|
+
severity: 'info',
|
|
126
|
+
diagnosis: 'An effect is only used to derive a value from other signals. This is better expressed as a computed() signal, which is lazy and more efficient.',
|
|
127
|
+
suggestedFix: 'Replace the effect + signal pair with a single computed() signal.',
|
|
128
|
+
codeExample: `// Before — effect + signal (runs eagerly, more memory):
|
|
129
|
+
const doubled = signal(0);
|
|
130
|
+
effect(() => { doubled(count() * 2); });
|
|
131
|
+
|
|
132
|
+
// After — computed (lazy, efficient):
|
|
133
|
+
const doubled = computed(() => count() * 2);`,
|
|
134
|
+
},
|
|
123
135
|
};
|
|
124
136
|
|
|
125
137
|
// --- Lint Patterns ---
|
|
@@ -257,6 +269,168 @@ const LINT_RULES = [
|
|
|
257
269
|
return issues;
|
|
258
270
|
},
|
|
259
271
|
},
|
|
272
|
+
{
|
|
273
|
+
id: 'signal-write-in-render',
|
|
274
|
+
code: 'ERR_SIGNAL_WRITE_IN_RENDER',
|
|
275
|
+
severity: 'error',
|
|
276
|
+
test(code) {
|
|
277
|
+
const issues = [];
|
|
278
|
+
// Find signal declarations
|
|
279
|
+
const signalDecls = [...code.matchAll(/(?:const|let)\s+(\w+)\s*=\s*(?:signal|useSignal)\s*\(/g)];
|
|
280
|
+
const signalNames = signalDecls.map(m => m[1]);
|
|
281
|
+
|
|
282
|
+
// Find component functions (PascalCase function declarations)
|
|
283
|
+
const componentPattern = /function\s+([A-Z]\w*)\s*\([^)]*\)\s*\{/g;
|
|
284
|
+
let compMatch;
|
|
285
|
+
while ((compMatch = componentPattern.exec(code)) !== null) {
|
|
286
|
+
// Get the component body (rough extraction)
|
|
287
|
+
const startIdx = compMatch.index + compMatch[0].length;
|
|
288
|
+
let braceDepth = 1;
|
|
289
|
+
let bodyEnd = startIdx;
|
|
290
|
+
for (let i = startIdx; i < code.length && braceDepth > 0; i++) {
|
|
291
|
+
if (code[i] === '{') braceDepth++;
|
|
292
|
+
if (code[i] === '}') braceDepth--;
|
|
293
|
+
bodyEnd = i;
|
|
294
|
+
}
|
|
295
|
+
const body = code.slice(startIdx, bodyEnd);
|
|
296
|
+
|
|
297
|
+
// For each signal, check if it's written at the TOP LEVEL of the component body
|
|
298
|
+
// (not inside effect(), onMount(), setTimeout, event handlers, arrow functions)
|
|
299
|
+
for (const name of signalNames) {
|
|
300
|
+
// Look for signal writes: name(someValue) where someValue is not empty
|
|
301
|
+
const writePattern = new RegExp(`(?<!\\w)${name}\\s*\\((?!\\s*\\))(?!.*=>)`, 'g');
|
|
302
|
+
let writeMatch;
|
|
303
|
+
while ((writeMatch = writePattern.exec(body)) !== null) {
|
|
304
|
+
// Check if this write is inside a nested function/callback
|
|
305
|
+
const beforeWrite = body.slice(0, writeMatch.index);
|
|
306
|
+
const nestedDepth = (beforeWrite.match(/(?:effect|onMount|setTimeout|setInterval|addEventListener|=>)\s*(?:\([^)]*\)\s*)?\{/g) || []).length;
|
|
307
|
+
const closingBraces = (beforeWrite.match(/\}/g) || []).length;
|
|
308
|
+
|
|
309
|
+
// If we're at top level of the component (not inside a nested callback)
|
|
310
|
+
if (nestedDepth <= closingBraces) {
|
|
311
|
+
// Check it's not a signal READ (no args or the call is just `name()`)
|
|
312
|
+
const fullCall = body.slice(writeMatch.index, writeMatch.index + writeMatch[0].length + 50);
|
|
313
|
+
if (!fullCall.match(new RegExp(`^${name}\\s*\\(\\s*\\)`))) {
|
|
314
|
+
const line = code.slice(0, compMatch.index + compMatch[0].length + writeMatch.index).split('\n').length;
|
|
315
|
+
issues.push({
|
|
316
|
+
severity: 'error',
|
|
317
|
+
code: 'ERR_SIGNAL_WRITE_IN_RENDER',
|
|
318
|
+
message: `Signal "${name}" written during render in ${compMatch[1]}. Move signal writes into event handlers, effects, or onMount().`,
|
|
319
|
+
line,
|
|
320
|
+
suggestedFix: `Move ${name}(...) into an effect() or event handler.`,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return issues;
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
id: 'missing-key-in-for',
|
|
332
|
+
code: 'ERR_MISSING_KEY',
|
|
333
|
+
severity: 'warning',
|
|
334
|
+
test(code) {
|
|
335
|
+
const issues = [];
|
|
336
|
+
// Match <For each={...}> without a key prop
|
|
337
|
+
const forPattern = /<For\s+each=\{[^}]+\}\s*>/g;
|
|
338
|
+
let match;
|
|
339
|
+
while ((match = forPattern.exec(code)) !== null) {
|
|
340
|
+
// Check if there's NO key= anywhere in the For tag
|
|
341
|
+
// Get the full tag (up to the closing >)
|
|
342
|
+
const tagStr = match[0];
|
|
343
|
+
if (!tagStr.includes('key=') && !tagStr.includes('key ')) {
|
|
344
|
+
const line = code.slice(0, match.index).split('\n').length;
|
|
345
|
+
issues.push({
|
|
346
|
+
severity: 'warning',
|
|
347
|
+
code: 'ERR_MISSING_KEY',
|
|
348
|
+
message: '<For> list rendered without a key prop. Add key={item => item.id} for efficient reordering.',
|
|
349
|
+
line,
|
|
350
|
+
suggestedFix: 'Add a key prop: <For each={items()} key={item => item.id}>',
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Also check mapArray without a key function (3rd arg)
|
|
356
|
+
const mapPattern = /mapArray\s*\(\s*[^,]+,\s*[^,)]+\s*\)/g;
|
|
357
|
+
while ((match = mapPattern.exec(code)) !== null) {
|
|
358
|
+
// mapArray(source, mapFn) — missing the 3rd arg (keyFn)
|
|
359
|
+
const line = code.slice(0, match.index).split('\n').length;
|
|
360
|
+
issues.push({
|
|
361
|
+
severity: 'warning',
|
|
362
|
+
code: 'ERR_MISSING_KEY',
|
|
363
|
+
message: 'mapArray() called without a key function (3rd argument). Add a key function for efficient list updates.',
|
|
364
|
+
line,
|
|
365
|
+
suggestedFix: 'Add a key function: mapArray(items, mapFn, item => item.id)',
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
return issues;
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
id: 'prefer-computed-over-effect',
|
|
373
|
+
code: 'HINT_PREFER_COMPUTED',
|
|
374
|
+
severity: 'info',
|
|
375
|
+
test(code) {
|
|
376
|
+
const issues = [];
|
|
377
|
+
// Find signal declarations
|
|
378
|
+
const signalDecls = [...code.matchAll(/(?:const|let)\s+(\w+)\s*=\s*(?:signal|useSignal)\s*\(/g)];
|
|
379
|
+
const signalNames = new Set(signalDecls.map(m => m[1]));
|
|
380
|
+
|
|
381
|
+
// Extract effect bodies using the same nested-brace approach as effect-writes-read-signal
|
|
382
|
+
const effectPattern = /effect\s*\(\s*\(\s*\)\s*=>\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/g;
|
|
383
|
+
let match;
|
|
384
|
+
while ((match = effectPattern.exec(code)) !== null) {
|
|
385
|
+
const body = match[1].trim();
|
|
386
|
+
// Check if body is a single signal write statement: signalName(expression);
|
|
387
|
+
const singleWrite = body.match(/^(\w+)\s*\([\s\S]+\)\s*;?\s*$/);
|
|
388
|
+
if (singleWrite) {
|
|
389
|
+
const writtenSignal = singleWrite[1];
|
|
390
|
+
if (writtenSignal && signalNames.has(writtenSignal)) {
|
|
391
|
+
const line = code.slice(0, match.index).split('\n').length;
|
|
392
|
+
issues.push({
|
|
393
|
+
severity: 'info',
|
|
394
|
+
code: 'HINT_PREFER_COMPUTED',
|
|
395
|
+
message: `Effect only writes to signal "${writtenSignal}". Consider using computed() instead for lazy evaluation.`,
|
|
396
|
+
line,
|
|
397
|
+
suggestedFix: `Replace with: const ${writtenSignal} = computed(() => /* your expression */);`,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Also match arrow-expression form: effect(() => signalName(expression))
|
|
404
|
+
// Use a manual scan to handle nested parens
|
|
405
|
+
const arrowPattern = /effect\s*\(\s*\(\s*\)\s*=>\s*(\w+)\s*\(/g;
|
|
406
|
+
let arrowMatch;
|
|
407
|
+
while ((arrowMatch = arrowPattern.exec(code)) !== null) {
|
|
408
|
+
const writtenSignal = arrowMatch[1];
|
|
409
|
+
if (!signalNames.has(writtenSignal)) continue;
|
|
410
|
+
// Find the matching closing paren for the signal call, then for the effect call
|
|
411
|
+
const signalCallStart = arrowMatch.index + arrowMatch[0].length - 1; // position of '('
|
|
412
|
+
let depth = 1;
|
|
413
|
+
let i = signalCallStart + 1;
|
|
414
|
+
for (; i < code.length && depth > 0; i++) {
|
|
415
|
+
if (code[i] === '(') depth++;
|
|
416
|
+
if (code[i] === ')') depth--;
|
|
417
|
+
}
|
|
418
|
+
// After signal call closing paren, next char should be ')' (closing effect)
|
|
419
|
+
const afterSignalCall = code.slice(i).match(/^\s*\)/);
|
|
420
|
+
if (afterSignalCall) {
|
|
421
|
+
const line = code.slice(0, arrowMatch.index).split('\n').length;
|
|
422
|
+
issues.push({
|
|
423
|
+
severity: 'info',
|
|
424
|
+
code: 'HINT_PREFER_COMPUTED',
|
|
425
|
+
message: `Effect only writes to signal "${writtenSignal}". Consider using computed() instead for lazy evaluation.`,
|
|
426
|
+
line,
|
|
427
|
+
suggestedFix: `Replace with: const ${writtenSignal} = computed(() => /* your expression */);`,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return issues;
|
|
432
|
+
},
|
|
433
|
+
},
|
|
260
434
|
];
|
|
261
435
|
|
|
262
436
|
// --- Scaffold Templates ---
|
|
@@ -318,6 +492,13 @@ export default ${name};
|
|
|
318
492
|
const fields = signals.length > 0 ? signals : ['email', 'password'];
|
|
319
493
|
const fieldDecls = fields.map(f => ` ${f}: { initial: '', rules: [rules.required('${f} is required')] },`).join('\n');
|
|
320
494
|
|
|
495
|
+
function inferInputType(fieldName) {
|
|
496
|
+
const lower = fieldName.toLowerCase();
|
|
497
|
+
if (lower === 'password' || lower === 'pass') return 'password';
|
|
498
|
+
if (lower === 'email') return 'email';
|
|
499
|
+
return 'text';
|
|
500
|
+
}
|
|
501
|
+
|
|
321
502
|
return `import { signal } from 'what-framework';
|
|
322
503
|
import { useForm, Input, ErrorMessage, rules } from 'what-framework';
|
|
323
504
|
|
|
@@ -335,7 +516,7 @@ ${fieldDecls}
|
|
|
335
516
|
<form onsubmit={handleSubmit}>
|
|
336
517
|
${fields.map(f => ` <div>
|
|
337
518
|
<label for="${f}">${f.charAt(0).toUpperCase() + f.slice(1)}</label>
|
|
338
|
-
<Input field={fields.${f}} id="${f}" type="
|
|
519
|
+
<Input field={fields.${f}} id="${f}" type="${inferInputType(f)}" />
|
|
339
520
|
<ErrorMessage field={fields.${f}} />
|
|
340
521
|
</div>`).join('\n')}
|
|
341
522
|
<button type="submit" disabled={isSubmitting()}>
|
|
@@ -460,7 +641,7 @@ export function registerAgentTools(server, bridge) {
|
|
|
460
641
|
'Static analysis for What Framework code. Pass a code snippet, get back structured issues with fix suggestions. Works offline — no browser connection needed.',
|
|
461
642
|
{
|
|
462
643
|
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'),
|
|
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'),
|
|
464
645
|
},
|
|
465
646
|
async ({ code, rules: ruleFilter }) => {
|
|
466
647
|
let rulesToRun = LINT_RULES;
|