Compare commits

...

40 Commits

Author SHA1 Message Date
paoloricciuti
8258a1c9ba fix: lint 2025-09-22 10:33:35 +02:00
paoloricciuti
5aa2827c91 chore: add eslint to prevent importing with $lib inthe lib folder 2025-09-22 10:00:37 +02:00
paoloricciuti
a35d72cc6b fix: update deps 2025-09-22 09:44:18 +02:00
paoloricciuti
d82c20acd6 fix: update to latest transport 2025-09-21 16:22:06 +02:00
Paolo Ricciuti
731b4f6548 Merge pull request #14 from sveltejs/close-sse-stream 2025-09-21 13:50:22 +02:00
paoloricciuti
582e0e1dea fix: close SSE stream immediately 2025-09-20 23:53:42 +02:00
Paolo Ricciuti
bb9a6e07ea Merge pull request #13 from sveltejs/import-stores-autofixer
feat: add use runes instead of stores autofixer
2025-09-20 09:19:36 +02:00
Stanislav Khromov
0d17b81948 Merge pull request #12 from sveltejs/eslint-conventions
chore: add eslint rules for naming convention and function declaration
2025-09-20 02:18:16 +02:00
paoloricciuti
81640c9a16 fix: describe filename and accept string version 2025-09-20 01:39:17 +02:00
paoloricciuti
8587bc8625 fix: wat? 2025-09-20 01:22:48 +02:00
paoloricciuti
0475e3b0f9 fix: us ts parser in svelte parser 2025-09-19 22:47:36 +02:00
paoloricciuti
4e1a42ab52 fix: remove from recommended 2025-09-19 22:21:10 +02:00
paoloricciuti
862f614afc fix: duh 2025-09-19 22:09:59 +02:00
paoloricciuti
a92ae954bd fix: nuke rules completely 2025-09-19 22:08:11 +02:00
paoloricciuti
e3b5188c6d fix: maybe this? 2025-09-19 22:02:13 +02:00
paoloricciuti
6b5f2092b5 fix: again 2025-09-19 21:56:32 +02:00
paoloricciuti
089e690f3e fix: what about this? 2025-09-19 21:53:04 +02:00
paoloricciuti
1c60e350a6 fix: what about this? 2025-09-19 20:38:30 +02:00
paoloricciuti
a93a6554b5 fix: is this the issue? 2025-09-19 20:37:07 +02:00
paoloricciuti
94f7d65db3 fix: latest plugin svelte? 2025-09-19 19:48:56 +02:00
paoloricciuti
d7492bb1cb fix: more stuff to deps? 2025-09-19 19:43:30 +02:00
paoloricciuti
e1e2bf68ae fix: move typescript eslint to the dependencies? 2025-09-19 19:39:41 +02:00
paoloricciuti
0ff628f5b4 fix: latest eslint maybe? 2025-09-19 19:32:40 +02:00
paoloricciuti
ea35d600e4 fix: lock 2025-09-19 19:18:49 +02:00
paoloricciuti
dcbcd5b690 fix: move eslint deps to dependencies 2025-09-19 19:16:38 +02:00
paoloricciuti
9f580a36ef feat: add use runes instead of stores autofixer 2025-09-19 16:28:23 +02:00
paoloricciuti
d81d6a3d95 chore: add eslint rules for naming convention and function declaration 2025-09-19 12:30:44 +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
26d30c73c7 feat: add Svelte task prompt 2025-09-18 19:48:51 +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
Paolo Ricciuti
13c4832d1b Merge pull request #7 from sveltejs/autofixer-folders-and-tests 2025-09-18 15:42:33 +02:00
paoloricciuti
7c7a1f939f fix: restructure autofixer folders and add tests 2025-09-18 14:54:41 +02:00
Paolo Ricciuti
5becaf3b5e Merge pull request #1 from sveltejs/copy-tables-from-current-mcp 2025-09-17 23:06:36 +02:00
Stanislav Khromov
7f858929ad Merge branch 'main' into copy-tables-from-current-mcp 2025-09-17 22:34:56 +02:00
Stanislav Khromov
6b5588c2f9 Merge pull request #6 from sveltejs/add-devtools
Dev tools docs
2025-09-17 22:34:43 +02:00
paoloricciuti
b4259dd117 chore: copy tables from current mcp 2025-09-11 23:05:09 +02:00
29 changed files with 1406 additions and 685 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

@@ -12,6 +12,9 @@ pnpm dev
1. Set the VOYAGE_API_KEY for embeddings support
> [!NOTE]
> Currently to prevent having a bunch of Timeout logs on vercel we shut down the SSE channel immediately. This means that we can't use `server.log` and we are not sending `list-changed` notifications. We can use elicitation and sampling since those are sent on the same stream of the POST request
### Local dev tools
#### MCP inspector

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

@@ -7,10 +7,10 @@ import { fileURLToPath } from 'node:url';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
const gitignore_path = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default ts.config(
includeIgnoreFile(gitignorePath),
includeIgnoreFile(gitignore_path),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
@@ -24,6 +24,32 @@ export default ts.config(
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off',
'@typescript-eslint/naming-convention': [
'error',
{
selector: ['variableLike'],
format: ['snake_case', 'UPPER_CASE'],
leadingUnderscore: 'allow',
},
],
'func-style': ['error', 'declaration', { allowTypeAnnotation: true }],
},
},
{
files: ['./src/lib/**/*'],
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['$lib'],
message:
"Since we will build the mcp server with typescript we can't use `$lib` imports here, please import with relative paths",
},
],
},
],
},
},
{

View File

@@ -37,8 +37,6 @@
],
"private": true,
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@libsql/client": "^0.14.0",
"@modelcontextprotocol/inspector": "^0.16.7",
"@sveltejs/adapter-vercel": "^5.6.3",
@@ -48,29 +46,32 @@
"@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",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
"globals": "^16.0.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",
"vite": "^7.0.4",
"vite-plugin-devtools-json": "^1.0.0",
"vitest": "^3.2.3"
},
"dependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@tmcp/adapter-valibot": "^0.1.4",
"@tmcp/transport-http": "^0.6.0",
"@tmcp/transport-stdio": "^0.1.3",
"@tmcp/transport-http": "^0.6.2",
"@tmcp/transport-stdio": "^0.2.0",
"@typescript-eslint/parser": "^8.43.0",
"eslint": "^9.36.0",
"eslint-plugin-svelte": "^3.12.3",
"svelte-eslint-parser": "^1.3.2",
"tmcp": "^1.12.2",
"tmcp": "^1.13.0",
"typescript-eslint": "^8.20.0",
"valibot": "^1.1.0",
"zimmerframe": "^1.1.4"
},

960
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,20 @@
import { http_transport } from '$lib/mcp';
export async function handle({ event, resolve }) {
return (await http_transport.respond(event.request)) ?? resolve(event);
const mcp_response = await http_transport.respond(event.request);
// we are deploying on vercel the SSE connection will timeout after 5 minutes...for
// the moment we are not sending back any notifications (logs, or list changed notifications)
// so it's a waste of resources to keep a connection open that will error
// after 5 minutes making the logs dirty. For this reason if we have a response from
// the MCP server and it's a GET request we just return an empty response (it has to be
// 200 or the MCP client will complain)
if (mcp_response && event.request.method === 'GET') {
try {
await mcp_response.body?.cancel();
} catch {
// ignore
}
return new Response('', { status: 200 });
}
return mcp_response ?? resolve(event);
}

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,47 +0,0 @@
import type { Node } from 'estree';
import type { AST } from 'svelte-eslint-parser';
import type { Visitors } from 'zimmerframe';
import type { ParseResult } from '../server/analyze/parse.js';
export type Autofixer = Visitors<
Node | AST.SvelteNode,
{
output: { issues: string[]; suggestions: string[] };
parsed: ParseResult;
desired_svelte_version: number;
}
>;
export const assign_in_effect: Autofixer = {
AssignmentExpression(node, { path, state }) {
const in_effect = path.findLast(
(node) =>
node.type === 'CallExpression' &&
node.callee.type === 'Identifier' &&
node.callee.name === '$effect',
);
if (
in_effect &&
in_effect.type === 'CallExpression' &&
(in_effect.callee.type === 'Identifier' || in_effect.callee.type === 'MemberExpression')
) {
if (state.parsed.is_rune(in_effect, ['$effect', '$effect.pre'])) {
if (node.left.type === 'Identifier') {
const reference = state.parsed.find_reference_by_id(node.left);
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'])
) {
state.output.suggestions.push(
`The stateful variable "${node.left.name}" is assigned inside an $effect which is generally consider a malpractice. Consider using $derived if possible.`,
);
}
}
}
}
}
},
};

View File

@@ -0,0 +1,377 @@
import { describe, expect, it } from 'vitest';
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 = run_autofixers_on_code(`
<script>
const count = $state(0);
$effect(() => {
count = 43;
});
</script>`);
expect(content.suggestions.length).toBeGreaterThanOrEqual(1);
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 each variable assigned within an effect`, () => {
const content = run_autofixers_on_code(`
<script>
const count = $state(0);
const count2 = $state(0);
$effect(() => {
count = 43;
count2 = 44;
});
</script>`);
expect(content.suggestions.length).toBeGreaterThanOrEqual(2);
expect(content.suggestions).toContain(
'The stateful variable "count" is assigned inside an $effect which is generally consider a malpractice. Consider using $derived if possible.',
);
expect(content.suggestions).toContain(
'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 = run_autofixers_on_code(`
<script>
const count = $state(0);
</script>
<button onclick={() => count = 43}>Increment</button>
`);
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 not add a suggestions for variables that are assigned within an effect but aren't stateful", () => {
const content = run_autofixers_on_code(`
<script>
const count = 0;
$effect(() => {
count = 43;
});
</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 = run_autofixers_on_code(`
<script>
let count = $state(0);
$effect(() => {
count++;
});
</script>
`);
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 = run_autofixers_on_code(`
<script>
let count = $state({ value: 0 });
$effect(() => {
count.value = 42;
});
</script>
`);
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.',
);
});
});
describe('use_runes_instead_of_store', () => {
describe.each([{ import: 'derived' }, { import: 'writable' }, { import: 'readable' }])(
'importing $import from svelte/store',
({ import: imported }) => {
it(`should add suggestions when importing '${imported}' from 'svelte/store'`, () => {
const content = run_autofixers_on_code(`
<script>
import { ${imported} } from 'svelte/store';
</script>`);
expect(content.suggestions.length).toBeGreaterThanOrEqual(1);
expect(content.suggestions).toContain(
`You are importing "${imported}" from "svelte/store". Unless the user specifically asked for stores or it's required because some library/component requires a store as input consider using runes like \`$state\` or \`$derived\` instead, all runes are globally available.`,
);
});
},
);
it(`should not add suggestions when importing other identifiers from 'svelte/store'`, () => {
const content = run_autofixers_on_code(`
<script>
import { get } from 'svelte/store';
</script>`);
expect(content.suggestions).not.toContain(
`You are importing "get" from "svelte/store". Unless the user specifically asked for stores or it's required because some library/component requires a store as input consider using runes like \`$state\` or \`$derived\` instead, all runes are globally available.`,
);
});
});
});

View File

@@ -0,0 +1,22 @@
import { parse } from '../../parse/parse.js';
import { walk } from '../../index.js';
import type { Node } from 'estree';
import * as autofixers from './visitors/index.js';
export function add_autofixers_issues(
content: { issues: string[]; suggestions: string[] },
code: string,
desired_svelte_version: number,
filename = 'Component.svelte',
) {
const parsed = parse(code, filename);
// Run each autofixer separately to avoid interrupting logic flow
for (const autofixer of Object.values(autofixers)) {
walk(
parsed.ast as unknown as Node,
{ output: content, parsed, desired_svelte_version },
autofixer,
);
}
}

View File

@@ -0,0 +1,20 @@
import { compile } from 'svelte/compiler';
export function add_compile_issues(
content: { issues: string[]; suggestions: string[] },
code: string,
desired_svelte_version: number,
filename = 'Component.svelte',
) {
const compilation_result = compile(code, {
filename: filename || 'Component.svelte',
generate: false,
runes: desired_svelte_version >= 5,
});
for (const warning of compilation_result.warnings) {
content.issues.push(
`${warning.message} at line ${warning.start?.line}, column ${warning.start?.column}`,
);
}
}

View File

@@ -2,6 +2,7 @@ import { ESLint } from 'eslint';
import svelte_parser from 'svelte-eslint-parser';
import svelte from 'eslint-plugin-svelte';
import type { Config } from '@sveltejs/kit';
import ts from 'typescript-eslint';
let svelte_5_linter: ESLint | undefined;
@@ -41,6 +42,8 @@ function base_config(svelte_config: Config): ESLint.Options['baseConfig'] {
sourceType: 'module',
parser: svelte_parser,
parserOptions: {
extraFileExtensions: ['.svelte'],
parser: ts.parser,
svelteConfig: svelte_config,
},
},
@@ -48,7 +51,7 @@ function base_config(svelte_config: Config): ESLint.Options['baseConfig'] {
];
}
export function get_linter(version: number) {
function get_linter(version: number) {
if (version < 5) {
return (svelte_4_linter ??= new ESLint({
overrideConfigFile: true,
@@ -68,3 +71,23 @@ export function get_linter(version: number) {
}),
}));
}
export async function add_eslint_issues(
content: { issues: string[]; suggestions: string[] },
code: string,
desired_svelte_version: number,
filename = 'Component.svelte',
) {
const eslint = get_linter(desired_svelte_version);
const results = await eslint.lintText(code, { filePath: filename || './Component.svelte' });
for (const message of results[0].messages) {
if (message.severity === 2) {
content.issues.push(`${message.message} at line ${message.line}, column ${message.column}`);
} else if (message.severity === 1) {
content.suggestions.push(
`${message.message} at line ${message.line}, column ${message.column}`,
);
}
}
}

View File

@@ -0,0 +1,16 @@
import type { Identifier, MemberExpression } from 'estree';
/**
* Gets the left-most identifier of a member expression or identifier.
*/
export function left_most_id(expression: MemberExpression | Identifier) {
while (expression.type === 'MemberExpression') {
expression = expression.object as MemberExpression | Identifier;
}
if (expression.type !== 'Identifier') {
return null;
}
return expression;
}

View File

@@ -0,0 +1,62 @@
import type { AssignmentExpression, Identifier, Node, UpdateExpression } from 'estree';
import type { Autofixer, AutofixerState } from '.';
import { left_most_id } from '../ast/utils.js';
import type { SvelteNode } from 'svelte-eslint-parser/lib/ast';
import type { Context } from 'zimmerframe';
function run_if_in_effect(path: (Node | SvelteNode)[], state: AutofixerState, to_run: () => void) {
const in_effect = path.findLast(
(node) =>
node.type === 'CallExpression' &&
node.callee.type === 'Identifier' &&
node.callee.name === '$effect',
);
if (
in_effect &&
in_effect.type === 'CallExpression' &&
(in_effect.callee.type === 'Identifier' || in_effect.callee.type === 'MemberExpression')
) {
if (state.parsed.is_rune(in_effect, ['$effect', '$effect.pre'])) {
to_run();
}
}
}
function visitor(
node: UpdateExpression | AssignmentExpression,
{ state, path }: Context<Node | SvelteNode, AutofixerState>,
) {
run_if_in_effect(path, state, () => {
function check_if_stateful_id(id: Identifier) {
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'])
) {
state.output.suggestions.push(
`The stateful variable "${id.name}" is assigned inside an $effect which is generally consider a malpractice. Consider using $derived if possible.`,
);
}
}
}
const variable = node.type === 'UpdateExpression' ? node.argument : node.left;
if (variable.type === 'Identifier') {
check_if_stateful_id(variable);
} else if (variable.type === 'MemberExpression') {
const object = left_most_id(variable);
if (object) {
check_if_stateful_id(object);
}
}
});
}
export const assign_in_effect: Autofixer = {
UpdateExpression: visitor,
AssignmentExpression: visitor,
};

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

@@ -0,0 +1,18 @@
import type { Node } from 'estree';
import type { AST } from 'svelte-eslint-parser';
import type { Visitors } from 'zimmerframe';
import type { ParseResult } from '../../../parse/parse.js';
export type AutofixerState = {
output: { issues: string[]; suggestions: string[] };
parsed: ParseResult;
desired_svelte_version: number;
};
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';
export * from './use-runes-instead-of-store.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

@@ -0,0 +1,21 @@
import type { Autofixer } from '.';
export const use_runes_instead_of_store: Autofixer = {
ImportDeclaration(node, { state, next }) {
const source = (node.source.value || node.source.raw?.slice(1, -1))?.toString();
if (source && source === 'svelte/store') {
for (const specifier of node.specifiers) {
if (
specifier.type === 'ImportSpecifier' &&
specifier.imported.type === 'Identifier' &&
['derived', 'writable', 'readable'].includes(specifier.imported.name)
) {
state.output.suggestions.push(
`You are importing "${specifier.imported.name}" from "svelte/store". Unless the user specifically asked for stores or it's required because some library/component requires a store as input consider using runes like \`$state\` or \`$derived\` instead, all runes are globally available.`,
);
}
}
}
next();
},
};

View File

@@ -1,14 +1,12 @@
import { walk } from '../index.js';
import { ValibotJsonSchemaAdapter } from '@tmcp/adapter-valibot';
import { HttpTransport } from '@tmcp/transport-http';
import { StdioTransport } from '@tmcp/transport-stdio';
import type { Node } from 'estree';
import { McpServer } from 'tmcp';
import * as v from 'valibot';
import { parse } from '../server/analyze/parse.js';
import * as autofixers from './autofixers.js';
import { get_linter } from './eslint.js';
import { compile } from 'svelte/compiler';
import { add_autofixers_issues } from './autofixers/add-autofixers-issues.js';
import { add_compile_issues } from './autofixers/add-compile-issues.js';
import { add_eslint_issues } from './autofixers/add-eslint-issues.js';
import { basename } from 'node:path';
const server = new McpServer(
{
@@ -38,12 +36,17 @@ server.tool(
schema: v.object({
code: v.string(),
desired_svelte_version: v.pipe(
v.union([v.literal(4), v.literal(5)]),
v.union([v.literal(4), v.literal(5), v.literal('4'), v.literal('5')]),
v.description(
'The desired svelte version...if possible read this from the package.json of the user project, otherwise use some hint from the wording (if the user asks for runes it wants version 5). Default to 5 in case of doubt.',
),
),
filename: v.optional(v.string()),
filename: v.pipe(
v.optional(v.string()),
v.description(
'The filename of the component if available, it MUST be only the Component name with .svelte or .svelte.ts extension and not the entire path.',
),
),
}),
outputSchema: v.object({
issues: v.array(v.string()),
@@ -57,52 +60,23 @@ server.tool(
openWorldHint: false,
},
},
async ({ code, filename, desired_svelte_version }) => {
async ({ code, filename: filename_or_path, desired_svelte_version }) => {
const content: {
issues: string[];
suggestions: string[];
require_another_tool_call_after_fixing: boolean;
} = { issues: [], suggestions: [], require_another_tool_call_after_fixing: false };
try {
// compile without generating to get warnings and errors
// just in case the LLM sends a full path we extract the filename...it's not really needed
// but it's nice to have a filename in the errors
const compilation_result = compile(code, {
filename: filename || 'Component.svelte',
generate: false,
runes: desired_svelte_version >= 5,
});
const filename = filename_or_path ? basename(filename_or_path) : 'Component.svelte';
for (const warning of compilation_result.warnings) {
content.issues.push(
`${warning.message} at line ${warning.start?.line}, column ${warning.start?.column}`,
);
}
add_compile_issues(content, code, +desired_svelte_version, filename);
const parsed = parse(code, filename ?? 'Component.svelte');
add_autofixers_issues(content, code, +desired_svelte_version, filename);
// Run each autofixer separately to avoid interrupting logic flow
for (const autofixer of Object.values(autofixers)) {
walk(
parsed.ast as unknown as Node,
{ output: content, parsed, desired_svelte_version },
autofixer,
);
}
const eslint = get_linter(desired_svelte_version);
const results = await eslint.lintText(code, { filePath: filename || './Component.svelte' });
for (const message of results[0].messages) {
if (message.severity === 2) {
content.issues.push(
`${message.message} at line ${message.line}, column ${message.column}`,
);
} else if (message.severity === 1) {
content.suggestions.push(
`${message.message} at line ${message.line}, column ${message.column}`,
);
}
}
await add_eslint_issues(content, code, +desired_svelte_version, filename);
} catch (e: unknown) {
const error = e as Error & { start?: { line: number; column: number } };
content.issues.push(
@@ -126,6 +100,46 @@ server.tool(
},
);
server.prompt(
{
name: 'svelte-task-prompt',
title: 'Svelte Task Prompt',
description:
'Use this Prompt to ask for any svelte related task. It will automatically instruct the LLM on how to best use the autofixer and how to query for documentation pages.',
schema: v.object({
task: v.pipe(v.string(), v.description('The task to be performed')),
}),
},
async ({ task }) => {
// TODO: implement logic to fetch the available docs paths to return in the prompt
const available_docs: string[] = [];
return {
messages: [
{
role: 'user',
content: {
type: 'text',
text: `You are a Svelte expert tasked to build components and utilities for Svelte developers. If you need documentation for anything related to Svelte you can invoke the tool \`get_documentation\` with one of the following paths:
<available-docs-paths>
${JSON.stringify(available_docs, null, 2)}
</available-docs-paths>
Every time you write a Svelte component or a Svelte module you MUST invoke the \`svelte-autofixer\` tool providing the code. The tool will return a list of issues or suggestions. If there are any issues or suggestions you MUST fix them and call the tool again with the updated code. You MUST keep doing this until the tool returns no issues or suggestions. Only then you can return the code to the user.
This is the task you will work on:
<task>
${task}
</task>
`,
},
},
],
};
},
);
export const http_transport = new HttpTransport(server, {
cors: true,
});

View File

@@ -1,6 +1,6 @@
import type { TSESTree } from '@typescript-eslint/types';
import { describe, expect, it } from 'vitest';
import { walk } from '$lib/index.js';
import { walk } from '../index.js';
import { parse, type ParseResult } from './parse.js';
// ----------------------------------------------------------------------

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

@@ -12,9 +12,80 @@ import { float_32_array } from './utils';
* to the generated migration file
*/
// this is just an example of a vector table...we can change this with the docs table later
export const vector_table = sqliteTable('vector_table', {
export const distillations = sqliteTable('distillations', {
id: integer('id').primaryKey(),
text: text('text'),
vector: float_32_array('vector', { dimensions: 3 }),
preset_name: text('preset_name').notNull(),
version: text('version').notNull(),
content: text('content').notNull(),
size_kb: integer('size_kb').notNull(),
document_count: integer('document_count').notNull(),
distillation_job_id: integer('distillation_job_id').references(() => distillation_jobs.id),
created_at: integer('created_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
});
export const distillation_jobs = sqliteTable('distillation_jobs', {
id: integer('id').primaryKey(),
preset_name: text('preset_name').notNull(),
batch_id: text('batch_id'),
status: text('status', { enum: ['pending', 'processing', 'completed', 'failed'] }).notNull(),
model_used: text('model_used').notNull(),
total_files: integer('total_files').notNull(),
processed_files: integer('processed_files').notNull().default(0),
successful_files: integer('successful_files').notNull().default(0),
minimize_applied: integer('minimize_applied', { mode: 'boolean' }).notNull().default(false),
total_input_tokens: integer('total_input_tokens').notNull().default(0),
total_output_tokens: integer('total_output_tokens').notNull().default(0),
started_at: integer('started_at', { mode: 'timestamp' }),
completed_at: integer('completed_at', { mode: 'timestamp' }),
error_message: text('error_message'),
metadata: text('metadata', { mode: 'json' })
.$type<Record<string, unknown>>()
.notNull()
.default({}),
created_at: integer('created_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
updated_at: integer('updated_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
});
export const content = sqliteTable('content', {
id: integer('id').primaryKey(),
path: text('path').notNull(),
filename: text('filename').notNull(),
content: text('content').notNull(),
size_bytes: integer('size_bytes').notNull(),
embeddings: float_32_array('embeddings', { dimensions: 1024 }),
metadata: text('metadata', { mode: 'json' })
.$type<Record<string, unknown>>()
.notNull()
.default({}),
created_at: integer('created_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
updated_at: integer('updated_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
});
export const content_distilled = sqliteTable('content_distilled', {
id: integer('id').primaryKey(),
path: text('path').notNull(),
filename: text('filename').notNull(),
content: text('content').notNull(),
size_bytes: integer('size_bytes').notNull(),
embeddings: float_32_array('embeddings', { dimensions: 1024 }),
metadata: text('metadata', { mode: 'json' })
.$type<Record<string, unknown>>()
.notNull()
.default({}),
created_at: integer('created_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
updated_at: integer('updated_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
});

View File

@@ -26,7 +26,9 @@ export function vector(arr: number[]) {
* .execute();
*/
export function distance(column: Column, arr: number[], as = 'distance') {
return sql<number>`vector_distance_cos(${column}, vector32(${JSON.stringify(arr)}))`.as(as);
return sql<number>`CASE ${column} ISNULL WHEN 1 THEN 1 ELSE vector_distance_cos(${column}, vector32(${JSON.stringify(arr)})) END`.as(
as,
);
}
/**

View File

@@ -1,50 +0,0 @@
import { VOYAGE_API_KEY } from '$env/static/private';
import { db } from '$lib/server/db/index.js';
import { vector_table } from '$lib/server/db/schema.js';
import { distance, vector } from '$lib/server/db/utils.js';
import { sql } from 'drizzle-orm';
async function get_embeddings(text: string) {
const result = await fetch('https://api.voyageai.com/v1/embeddings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${VOYAGE_API_KEY}`,
},
body: JSON.stringify({
input: [text],
model: 'voyage-3.5',
}),
}).then((res) => res.json());
return result.data[0].embedding as number[];
}
export async function load({ url: { searchParams } }) {
const sentence = searchParams.get('sentence');
if (!sentence) return { top: [], sentence: '' };
const top = await db
.select({
id: vector_table.id,
text: vector_table.text,
distance: distance(vector_table.vector, await get_embeddings(sentence)),
})
.from(vector_table)
.orderBy(sql`distance`)
.execute();
return { top, sentence };
}
export const actions = {
async default({ request }) {
const data = await request.formData();
const text = data.get('text')?.toString();
const embeddings = await get_embeddings(text ?? '');
if (text && embeddings) {
await db
.insert(vector_table)
.values({ text, vector: vector(embeddings) })
.execute();
}
},
};

View File

@@ -1,21 +1 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { data } = $props();
</script>
<h1>Official Svelte MCP</h1>
<form method="POST" use:enhance>
<textarea name="text" rows="4" cols="50" placeholder="Enter text to store in the vector database"
></textarea>
<br />
<button>Submit</button>
</form>
Comparing with
<pre>{data.sentence}</pre>
{#each data.top as item (item.id)}
<p>{item.text} - {item.distance}</p>
{/each}

View File

@@ -1,21 +1,26 @@
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()],
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}'],
},
},
},
],
},
],
},
};
});