Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 0 additions & 47 deletions src/lib/mcp/autofixers.ts

This file was deleted.

113 changes: 113 additions & 0 deletions src/lib/mcp/autofixers/add-autofixers-issues.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, expect, it } from 'vitest';
import { add_autofixers_issues } from './add-autofixers-issues';

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 = `
<script>
const count = $state(0);
$effect(() => {
count = 43;
});
</script>`;
add_autofixers_issues(content, code, 5);

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 = { issues: [], suggestions: [] };
const code = `
<script>
const count = $state(0);
const count2 = $state(0);
$effect(() => {
count = 43;
count2 = 44;
});
</script>`;
add_autofixers_issues(content, code, 5);

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 = { issues: [], suggestions: [] };
const 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.',
);
});

it("should not add a suggestions for variables that are assigned within an effect but aren't stateful", () => {
const content = { issues: [], suggestions: [] };
const code = `
<script>
const count = 0;
$effect(() => {
count = 43;
});
</script>`;
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.',
);
});

it('should add a suggestion for variables that are assigned within an effect with an update', () => {
const content = { issues: [], suggestions: [] };
const code = `
<script>
let count = $state(0);
$effect(() => {
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 = `
<script>
let count = $state({ value: 0 });
$effect(() => {
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.',
);
});
});
});
22 changes: 22 additions & 0 deletions src/lib/mcp/autofixers/add-autofixers-issues.ts
Original file line number Diff line number Diff line change
@@ -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,
);
}
}
20 changes: 20 additions & 0 deletions src/lib/mcp/autofixers/add-compile-issues.ts
Original file line number Diff line number Diff line change
@@ -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}`,
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,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,
Expand All @@ -68,3 +68,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}`,
);
}
}
}
16 changes: 16 additions & 0 deletions src/lib/mcp/autofixers/ast/utils.ts
Original file line number Diff line number Diff line change
@@ -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;
}
62 changes: 62 additions & 0 deletions src/lib/mcp/autofixers/visitors/assign-in-effect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { AssignmentExpression, Identifier, Node, UpdateExpression } from 'estree';
import type { Autofixer, AutofixerState } from '.';
import { left_most_id } from '../ast/utils';
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,
};
14 changes: 14 additions & 0 deletions src/lib/mcp/autofixers/visitors/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
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';
Loading