Compare commits

...

8 Commits

Author SHA1 Message Date
Paolo Ricciuti
06b3100600 Update README.md
Co-authored-by: Stanislav Khromov <stanislav.khromov+github@gmail.com>
2025-09-20 09:21:43 +02:00
paoloricciuti
107731b720 fix: add tunnel mode to test the http mcp with cloudflare tunnels 2025-09-19 12:22:21 +02:00
paoloricciuti
f04cb139e3 chore: change setup to allow for hosted turso version 2025-09-19 12:21:47 +02:00
Paolo Ricciuti
fd355e872d Merge pull request #9 from sveltejs/task-prompt
feat: add Svelte task prompt
2025-09-19 00:38:49 +02:00
Paolo Ricciuti
d4edef1d82 Merge pull request #8 from sveltejs/set-update-autofixer
feat: add `set` or `update` stateful variables autofixer & imported runes autofixer
2025-09-19 00:38:29 +02:00
paoloricciuti
13719d0646 feat: add derived with functions suggestions 2025-09-18 17:45:51 +02:00
paoloricciuti
4c8cadd585 feat: add imported runes autofixer 2025-09-18 17:07:31 +02:00
paoloricciuti
72e89af60f feat: add set or update stateful variables autofixer 2025-09-18 16:32:25 +02:00
15 changed files with 451 additions and 70 deletions

View File

@@ -1,2 +1,3 @@
DATABASE_URL=file:test.db
DATABASE_TOKEN=needs_to_be_set_but_it_can_be_anything
VOYAGE_API_KEY=your_actual_api_key_here

View File

@@ -32,3 +32,9 @@ pnpm run db:studio
```
https://local.drizzle.studio/
#### Try the MCP server
To try the mcp server you can run `pnpm build:mcp`...the local `mpc.json` file it's already using the output of that folder so you can use it automatically in VSCode/Cursor.
To try the MCP over HTTP and/or remotely, you can use Cloudflare tunnels to expose it via `cloudflared tunnel --url http://localhost:[port]` and then running the server with `pnpm tunnel` (pay attention as this will expose your dev server to the world wide web).

View File

@@ -1,11 +1,12 @@
import { defineConfig } from 'drizzle-kit';
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
if (!process.env.DATABASE_TOKEN) throw new Error('DATABASE_TOKEN is not set');
export default defineConfig({
schema: './src/lib/server/db/schema.ts',
dialect: 'sqlite',
dbCredentials: { url: process.env.DATABASE_URL },
dialect: 'turso',
dbCredentials: { url: process.env.DATABASE_URL, authToken: process.env.DATABASE_TOKEN },
verbose: true,
strict: true,
});

View File

@@ -10,6 +10,7 @@
"scripts": {
"start": "node src/index.js",
"dev": "vite dev",
"tunnel": "vite dev -m tunnel",
"build": "vite build",
"build:mcp": "tsc --project tsconfig.build.json",
"prepublishOnly": "pnpm build:mcp",
@@ -48,6 +49,7 @@
"@types/estree": "^1.0.8",
"@types/node": "^24.3.1",
"@typescript-eslint/types": "^8.43.0",
"dotenv": "^17.2.2",
"drizzle-kit": "^0.30.2",
"drizzle-orm": "^0.40.0",
"eslint": "^9.18.0",

9
pnpm-lock.yaml generated
View File

@@ -66,6 +66,9 @@ importers:
'@typescript-eslint/types':
specifier: ^8.43.0
version: 8.43.0
dotenv:
specifier: ^17.2.2
version: 17.2.2
drizzle-kit:
specifier: ^0.30.2
version: 0.30.6
@@ -1737,6 +1740,10 @@ packages:
resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==}
engines: {node: '>=0.3.1'}
dotenv@17.2.2:
resolution: {integrity: sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==}
engines: {node: '>=12'}
drizzle-kit@0.30.6:
resolution: {integrity: sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g==}
hasBin: true
@@ -4566,6 +4573,8 @@ snapshots:
diff@4.0.2: {}
dotenv@17.2.2: {}
drizzle-kit@0.30.6:
dependencies:
'@drizzle-team/brocli': 0.10.2

23
src/lib/constants.ts Normal file
View File

@@ -0,0 +1,23 @@
export const base_runes = [
'$state',
'$effect',
'$derived',
'$inspect',
'$props',
'$bindable',
'$host',
] as const;
export const nested_runes = [
'$state.raw',
'$state.snapshot',
'$effect.pre',
'$effect.tracking',
'$effect.pending',
'$effect.root',
'$derived.by',
'$inspect.trace',
'$props.id',
] as const;
export const runes = [...base_runes, ...nested_runes] as const;

View File

@@ -1,18 +1,25 @@
import { describe, expect, it } from 'vitest';
import { add_autofixers_issues } from './add-autofixers-issues';
import { add_autofixers_issues } from './add-autofixers-issues.js';
import { base_runes } from '../../constants.js';
const dollarless_runes = base_runes.map((r) => ({ rune: r.replace('$', '') }));
function run_autofixers_on_code(code: string, desired_svelte_version = 5) {
const content = { issues: [], suggestions: [] };
add_autofixers_issues(content, code, desired_svelte_version);
return content;
}
describe('add_autofixers_issues', () => {
describe('assign_in_effect', () => {
it('should add suggestions when assigning to a stateful variable inside an effect', () => {
const content = { issues: [], suggestions: [] };
const code = `
it(`should add suggestions when assigning to a stateful variable inside an effect`, () => {
const content = run_autofixers_on_code(`
<script>
const count = $state(0);
$effect(() => {
count = 43;
});
</script>`;
add_autofixers_issues(content, code, 5);
</script>`);
expect(content.suggestions.length).toBeGreaterThanOrEqual(1);
expect(content.suggestions).toContain(
@@ -20,9 +27,8 @@ describe('add_autofixers_issues', () => {
);
});
it('should add a suggestion for each variable assigned within an effect', () => {
const content = { issues: [], suggestions: [] };
const code = `
it(`should add a suggestion for each variable assigned within an effect`, () => {
const content = run_autofixers_on_code(`
<script>
const count = $state(0);
const count2 = $state(0);
@@ -30,8 +36,7 @@ describe('add_autofixers_issues', () => {
count = 43;
count2 = 44;
});
</script>`;
add_autofixers_issues(content, code, 5);
</script>`);
expect(content.suggestions.length).toBeGreaterThanOrEqual(2);
expect(content.suggestions).toContain(
@@ -41,16 +46,14 @@ describe('add_autofixers_issues', () => {
'The stateful variable "count2" is assigned inside an $effect which is generally consider a malpractice. Consider using $derived if possible.',
);
});
it('should not add a suggestion for variables that are not assigned within an effect', () => {
const content = { issues: [], suggestions: [] };
const code = `
it(`should not add a suggestion for variables that are not assigned within an effect`, () => {
const content = run_autofixers_on_code(`
<script>
const count = $state(0);
</script>
<button onclick={() => count = 43}>Increment</button>
`;
add_autofixers_issues(content, code, 5);
`);
expect(content.suggestions).not.toContain(
'The stateful variable "count" is assigned inside an $effect which is generally consider a malpractice. Consider using $derived if possible.',
@@ -58,25 +61,22 @@ describe('add_autofixers_issues', () => {
});
it("should not add a suggestions for variables that are assigned within an effect but aren't stateful", () => {
const content = { issues: [], suggestions: [] };
const code = `
const content = run_autofixers_on_code(`
<script>
const count = 0;
$effect(() => {
count = 43;
});
</script>`;
add_autofixers_issues(content, code, 5);
</script>`);
expect(content.suggestions).not.toContain(
'The stateful variable "count" is assigned inside an $effect which is generally consider a malpractice. Consider using $derived if possible.',
);
});
it('should add a suggestion for variables that are assigned within an effect with an update', () => {
const content = { issues: [], suggestions: [] };
const code = `
it(`should add a suggestion for variables that are assigned within an effect with an update`, () => {
const content = run_autofixers_on_code(`
<script>
let count = $state(0);
@@ -84,17 +84,15 @@ describe('add_autofixers_issues', () => {
count++;
});
</script>
`;
add_autofixers_issues(content, code, 5);
`);
expect(content.suggestions).toContain(
'The stateful variable "count" is assigned inside an $effect which is generally consider a malpractice. Consider using $derived if possible.',
);
});
it('should add a suggestion for variables that are mutated within an effect', () => {
const content = { issues: [], suggestions: [] };
const code = `
it(`should add a suggestion for variables that are mutated within an effect`, () => {
const content = run_autofixers_on_code(`
<script>
let count = $state({ value: 0 });
@@ -102,12 +100,248 @@ describe('add_autofixers_issues', () => {
count.value = 42;
});
</script>
`;
add_autofixers_issues(content, code, 5);
`);
expect(content.suggestions).toContain(
'The stateful variable "count" is assigned inside an $effect which is generally consider a malpractice. Consider using $derived if possible.',
);
});
});
describe.each([{ method: 'set' }, { method: 'update' }])(
'set_or_update_state ($method)',
({ method }) => {
it(`should add suggestions when using .${method}() on a stateful variable with a literal init`, () => {
const content = run_autofixers_on_code(`
<script>
const count = $state(0);
function update_count() {
count.${method}(43);
}
</script>`);
expect(content.suggestions.length).toBeGreaterThanOrEqual(1);
expect(content.suggestions).toContain(
`You are trying to update the stateful variable "count" using "${method}". stateful variables should be updated with a normal assignment/mutation, do not use methods to update them.`,
);
});
it(`should add suggestions when using .${method}() on a stateful variable with an array init`, () => {
const content = run_autofixers_on_code(`
<script>
const count = $state([0]);
function update_count() {
count.${method}([1]);
}
</script>`);
expect(content.suggestions.length).toBeGreaterThanOrEqual(1);
expect(content.suggestions).toContain(
`You are trying to update the stateful variable "count" using "${method}". stateful variables should be updated with a normal assignment/mutation, do not use methods to update them.`,
);
});
it(`should add suggestions when using .${method}() on a stateful variable with conditional if it's not sure if the method could actually be present on the variable ($state({}))`, () => {
const content = run_autofixers_on_code(`
<script>
const count = $state({ value: 0 });
function update_count() {
count.${method}({ value: 43 });
}
</script>`);
expect(content.suggestions.length).toBeGreaterThanOrEqual(1);
expect(content.suggestions).toContain(
`You are trying to update the stateful variable "count" using "${method}". stateful variables should be updated with a normal assignment/mutation, do not use methods to update them. However I can't verify if "count" is a state variable of an object or a class with a "${method}" method on it. Please verify that before updating the code to use a normal assignment`,
);
});
it(`should add suggestions when using .${method}() on a stateful variable with conditional if it's not sure if the method could actually be present on the variable ($state(new Class()))`, () => {
const content = run_autofixers_on_code(`
<script>
const count = $state(new Class());
function update_count() {
count.${method}(new Class());
}
</script>`);
expect(content.suggestions.length).toBeGreaterThanOrEqual(1);
expect(content.suggestions).toContain(
`You are trying to update the stateful variable "count" using "${method}". stateful variables should be updated with a normal assignment/mutation, do not use methods to update them. However I can't verify if "count" is a state variable of an object or a class with a "${method}" method on it. Please verify that before updating the code to use a normal assignment`,
);
});
it(`should add suggestions when using .${method}() on a stateful variable with conditional if it's not sure if the method could actually be present on the variable ($state(variable_name))`, () => {
const content = run_autofixers_on_code(`
<script>
const { init } = $props();
const count = $state(init);
function update_count() {
count.${method}(43);
}
</script>`);
expect(content.suggestions.length).toBeGreaterThanOrEqual(1);
expect(content.suggestions).toContain(
`You are trying to update the stateful variable "count" using "${method}". stateful variables should be updated with a normal assignment/mutation, do not use methods to update them. However I can't verify if "count" is a state variable of an object or a class with a "${method}" method on it. Please verify that before updating the code to use a normal assignment`,
);
});
it(`should not add suggestions when using .${method} on a stateful variable if it's not a method call`, () => {
const content = run_autofixers_on_code(`
<script>
const count = $state({});
function update_count() {
console.log(count.${method});
}
</script>`);
expect(content.suggestions).not.toContain(
`You are trying to update the stateful variable "count" using "${method}". stateful variables should be updated with a normal assignment/mutation, do not use methods to update them. However I can't verify if "count" is a state variable of an object or a class with a "${method}" method on it. Please verify that before updating the code to use a normal assignment`,
);
});
},
);
describe('imported_runes', () => {
describe.each([{ source: 'svelte' }, { source: 'svelte/runes' }])(
'from "$source"',
({ source }) => {
describe.each(dollarless_runes)('single import ($rune)', ({ rune }) => {
it(`should add suggestions when importing '${rune}' from '${source}'`, () => {
const content = run_autofixers_on_code(`
<script>
import { ${rune} } from '${source}';
</script>`);
expect(content.suggestions.length).toBeGreaterThanOrEqual(1);
expect(content.suggestions).toContain(
`You are importing "${rune}" from "${source}". This is not necessary, all runes are globally available. Please remove this import and use "$${rune}" directly.`,
);
});
it(`should add suggestions when importing "${rune}" as the default export from '${source}'`, () => {
const content = run_autofixers_on_code(`
<script>
import ${rune} from '${source}';
</script>`);
expect(content.suggestions.length).toBeGreaterThanOrEqual(1);
expect(content.suggestions).toContain(
`You are importing "${rune}" from "${source}". This is not necessary, all runes are globally available. Please remove this import and use "$${rune}" directly.`,
);
});
it(`should add suggestions when importing '${rune}' as the namespace export from '${source}'`, () => {
const content = run_autofixers_on_code(`
<script>
import * as ${rune} from '${source}';
</script>`);
expect(content.suggestions.length).toBeGreaterThanOrEqual(1);
expect(content.suggestions).toContain(
`You are importing "${rune}" from "${source}". This is not necessary, all runes are globally available. Please remove this import and use "$${rune}" directly.`,
);
});
});
it(`should add suggestions when importing multiple runes from '${source}'`, () => {
const content = run_autofixers_on_code(`
<script>
import { onMount, state, effect } from '${source}';
</script>`);
expect(content.suggestions.length).toBeGreaterThanOrEqual(2);
expect(content.suggestions).toContain(
`You are importing "state" from "${source}". This is not necessary, all runes are globally available. Please remove this import and use "$state" directly.`,
);
expect(content.suggestions).toContain(
`You are importing "effect" from "${source}". This is not necessary, all runes are globally available. Please remove this import and use "$effect" directly.`,
);
});
it(`should not add suggestions when importing other identifiers from '${source}'`, () => {
const content = run_autofixers_on_code(`
<script>
import { onMount } from '${source}';
</script>`);
expect(content.suggestions).not.toContain(
`You are importing "onMount" from "${source}". This is not necessary, all runes are globally available. Please remove this import and use "$onMount" directly.`,
);
});
},
);
});
describe('derived_with_function', () => {
it(`should add suggestions when using a function as the first argument to $derived`, () => {
const content = run_autofixers_on_code(`
<script>
const value = $derived(() => {
return 43;
});
</script>`);
expect(content.suggestions.length).toBeGreaterThanOrEqual(1);
expect(content.suggestions).toContain(
'You are passing a function to $derived when declaring "value" but $derived expects an expression. You can use $derived.by instead.',
);
});
it(`should add suggestions when using a function as the first argument to $derived in classes`, () => {
const content = run_autofixers_on_code(`
<script>
class Double {
value = $derived(() => 43);
}
</script>`);
expect(content.suggestions.length).toBeGreaterThanOrEqual(1);
expect(content.suggestions).toContain(
'You are passing a function to $derived when declaring "value" but $derived expects an expression. You can use $derived.by instead.',
);
});
it(`should add suggestions when using a function as the first argument to $derived in classes constructors`, () => {
const content = run_autofixers_on_code(`
<script>
class Double {
value;
constructor(){
this.value = $derived(function() { return 44; });
}
}
</script>`);
expect(content.suggestions.length).toBeGreaterThanOrEqual(1);
expect(content.suggestions).toContain(
'You are passing a function to $derived when declaring "value" but $derived expects an expression. You can use $derived.by instead.',
);
});
it(`should add suggestions when using a function as the first argument to $derived without the declaring part if it's not an identifier`, () => {
const content = run_autofixers_on_code(`
<script>
const { destructured } = $derived(() => 43);
</script>`);
expect(content.suggestions.length).toBeGreaterThanOrEqual(1);
expect(content.suggestions).toContain(
'You are passing a function to $derived but $derived expects an expression. You can use $derived.by instead.',
);
});
it(`should add suggestions when using a function as the first argument to $derived.by`, () => {
const content = run_autofixers_on_code(`
<script>
const { destructured } = $derived.by(() => 43);
</script>`);
expect(content.suggestions).not.toContain(
'You are passing a function to $derived but $derived expects an expression. You can use $derived.by instead.',
);
});
});
});

View File

@@ -1,6 +1,6 @@
import type { AssignmentExpression, Identifier, Node, UpdateExpression } from 'estree';
import type { Autofixer, AutofixerState } from '.';
import { left_most_id } from '../ast/utils';
import { left_most_id } from '../ast/utils.js';
import type { SvelteNode } from 'svelte-eslint-parser/lib/ast';
import type { Context } from 'zimmerframe';

View File

@@ -0,0 +1,41 @@
import type { Identifier, PrivateIdentifier } from 'estree';
import type { Autofixer } from '.';
export const derived_with_function: Autofixer = {
CallExpression(node, { state, path }) {
if (
node.callee.type === 'Identifier' &&
node.callee.name === '$derived' &&
state.parsed.is_rune(node, ['$derived']) &&
(node.arguments[0].type === 'ArrowFunctionExpression' ||
node.arguments[0].type === 'FunctionExpression')
) {
const parent = path[path.length - 1];
let variable_id: Identifier | PrivateIdentifier | undefined;
if (parent.type === 'VariableDeclarator' && parent.id.type === 'Identifier') {
// const something = $derived(...)
variable_id = parent.id;
} else if (parent.type === 'PropertyDefinition') {
// class X { something = $derived(...) }
variable_id =
parent.key.type === 'Identifier'
? parent.key
: parent.key.type === 'PrivateIdentifier'
? parent.key
: undefined;
} else if (parent.type === 'AssignmentExpression') {
// this.something = $derived(...)
variable_id =
parent.left.type === 'MemberExpression'
? parent.left.property.type === 'Identifier'
? parent.left.property
: undefined
: undefined;
}
state.output.suggestions.push(
`You are passing a function to $derived ${variable_id ? `when declaring "${variable_id.name}" ` : ''}but $derived expects an expression. You can use $derived.by instead.`,
);
}
},
};

View File

@@ -0,0 +1,28 @@
import { base_runes } from '../../../constants.js';
import type { Autofixer } from '.';
const dollarless_runes = base_runes.map((r) => r.replace('$', ''));
export const imported_runes: Autofixer = {
ImportDeclaration(node, { state, next }) {
const source = (node.source.value || node.source.raw?.slice(1, -1))?.toString();
if (source && source.startsWith('svelte')) {
for (const specifier of node.specifiers) {
const id =
specifier.type === 'ImportDefaultSpecifier'
? specifier.local
: specifier.type === 'ImportNamespaceSpecifier'
? specifier.local
: specifier.type === 'ImportSpecifier'
? specifier.imported
: null;
if (id && id.type === 'Identifier' && dollarless_runes.includes(id.name)) {
state.output.suggestions.push(
`You are importing "${id.name}" from "${source}". This is not necessary, all runes are globally available. Please remove this import and use "$${id.name}" directly.`,
);
}
}
}
next();
},
};

View File

@@ -12,3 +12,6 @@ export type AutofixerState = {
export type Autofixer = Visitors<Node | AST.SvelteNode, AutofixerState>;
export * from './assign-in-effect.js';
export * from './set-or-update-state.js';
export * from './imported-runes.js';
export * from './derived-with-function.js';

View File

@@ -0,0 +1,37 @@
import type { Autofixer } from '.';
import { left_most_id } from '../ast/utils.js';
const UPDATE_PROPERTIES = ['set', 'update'];
export const set_or_update_state: Autofixer = {
MemberExpression(node, { state, next, path }) {
const parent = path[path.length - 1];
if (
parent.type === 'CallExpression' &&
parent.callee === node &&
node.property.type === 'Identifier' &&
UPDATE_PROPERTIES.includes(node.property.name)
) {
const id = left_most_id(node);
if (id) {
const reference = state.parsed.find_reference_by_id(id);
const definition = reference?.resolved?.defs[0];
if (definition && definition.type === 'Variable') {
const init = definition.node.init;
if (
init?.type === 'CallExpression' &&
state.parsed.is_rune(init, ['$state', '$state.raw'])
) {
let suggestion = `You are trying to update the stateful variable "${id.name}" using "${node.property.name}". stateful variables should be updated with a normal assignment/mutation, do not use methods to update them.`;
const argument = init.arguments[0];
if (!argument || (argument.type !== 'Literal' && argument.type !== 'ArrayExpression')) {
suggestion += ` However I can't verify if "${id.name}" is a state variable of an object or a class with a "${node.property.name}" method on it. Please verify that before updating the code to use a normal assignment`;
}
state.output.suggestions.push(suggestion);
}
}
}
}
next();
},
};

View File

@@ -2,6 +2,7 @@ import ts_parser from '@typescript-eslint/parser';
import type { CallExpression, Identifier } from 'estree';
import type { Reference, Variable } from 'eslint-scope';
import { parseForESLint as svelte_eslint_parse } from 'svelte-eslint-parser';
import { runes } from '../constants.js';
type Scope = {
variables?: Variable[];
@@ -18,25 +19,6 @@ function collect_scopes(scope: Scope, acc: Scope[] = []) {
return acc;
}
const runes = [
'$state',
'$state.raw',
'$state.snapshot',
'$effect',
'$effect.pre',
'$effect.tracking',
'$effect.pending',
'$effect.root',
'$derived',
'$derived.by',
'$inspect',
'$inspect.trace',
'$props',
'$props.id',
'$bindable',
'$host',
] as const;
export type ParseResult = ReturnType<typeof parse>;
export function parse(code: string, file_path: string) {

View File

@@ -1,10 +1,13 @@
import { DATABASE_URL } from '$env/static/private';
import { createClient } from '@libsql/client';
import { drizzle } from 'drizzle-orm/libsql';
import * as schema from './schema';
if (!DATABASE_URL) throw new Error('DATABASE_URL is not set');
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
if (!process.env.DATABASE_TOKEN) throw new Error('DATABASE_TOKEN is not set');
const client = createClient({ url: DATABASE_URL });
const client = createClient({
url: process.env.DATABASE_URL,
authToken: process.env.DATABASE_TOKEN,
});
export const db = drizzle(client, { schema, logger: true });

View File

@@ -1,21 +1,32 @@
import devtoolsJson from 'vite-plugin-devtools-json';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import { config } from 'dotenv';
export default defineConfig({
plugins: [sveltekit(), devtoolsJson()],
test: {
expect: { requireAssertions: true },
projects: [
{
extends: './vite.config.ts',
test: {
name: 'server',
environment: 'node',
include: ['src/**/*.{test,spec}.{js,ts}'],
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'],
export default defineConfig(({ mode }) => {
config({ path: ['.env', `.env.${mode}`] });
return {
plugins: [sveltekit(), devtoolsJson()],
server:
mode === 'tunnel'
? {
allowedHosts: true,
}
: undefined,
test: {
expect: { requireAssertions: true },
projects: [
{
extends: './vite.config.ts',
test: {
name: 'server',
environment: 'node',
include: ['src/**/*.{test,spec}.{js,ts}'],
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'],
},
},
},
],
},
],
},
};
});