diff --git a/packages/ai/package.json b/packages/ai/package.json index afd1a25cbe..ebe4d67283 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -35,6 +35,11 @@ "ai": "^4.2.6", "diff-match-patch": "^1.0.5", "fg": "^0.0.3", - "marked": "^15.0.7" + "marked": "^15.0.7", + "eslint": "^8.56.0", + "@typescript-eslint/parser": "^6.19.0", + "@typescript-eslint/eslint-plugin": "^6.19.0", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.6.0" } } diff --git a/packages/ai/src/tools/index.ts b/packages/ai/src/tools/index.ts index d16e1ead8e..d8e96b8266 100644 --- a/packages/ai/src/tools/index.ts +++ b/packages/ai/src/tools/index.ts @@ -4,6 +4,7 @@ import { readFile } from 'fs/promises'; import { z } from 'zod'; import { ONLOOK_PROMPT } from '../prompt/onlook'; import { getAllFiles } from './helpers'; +import { LintingService } from './lint'; export const listFilesTool = tool({ description: 'List all files in the current directory, including subdirectories', @@ -51,6 +52,52 @@ export const onlookInstructionsTool = tool({ }, }); +export const lintTool = tool({ + description: 'Analyze code quality and optionally fix issues in TypeScript/JavaScript files', + parameters: z.object({ + path: z.string().describe('The absolute path to the file or directory to lint'), + fix: z.boolean().optional().default(false).describe('Whether to auto-fix the issues'), + detailed: z + .boolean() + .optional() + .default(false) + .describe('Whether to return detailed rule-based statistics'), + }), + execute: async ({ path, fix = false, detailed = false }) => { + try { + const lintingService = LintingService.getInstance(fix); + const result = await lintingService.lintProject(path); + + if (!detailed) { + return { + summary: { + totalFiles: result.totalFiles, + totalErrors: result.totalErrors, + totalWarnings: result.totalWarnings, + fixedFiles: fix ? result.fixedFiles : undefined, + }, + fileResults: result.results.map((r) => ({ + file: r.filePath, + errors: r.messages.filter((m) => m.severity === 2).length, + warnings: r.messages.filter((m) => m.severity === 1).length, + ...(fix && { fixed: r.fixed }), + })), + }; + } + + return result; + } catch (error) { + return { + error: + error instanceof Error + ? error.message + : 'Unknown error occurred during linting', + success: false, + }; + } + }, +}); + // https://docs.anthropic.com/en/docs/agents-and-tools/computer-use#understand-anthropic-defined-tools // https://sdk.vercel.ai/docs/guides/computer-use#get-started-with-computer-use @@ -134,4 +181,5 @@ export const chatToolSet: ToolSet = { list_files: listFilesTool, read_files: readFilesTool, onlook_instructions: onlookInstructionsTool, + lint: lintTool, }; diff --git a/packages/ai/src/tools/lint.ts b/packages/ai/src/tools/lint.ts new file mode 100644 index 0000000000..a49de8e262 --- /dev/null +++ b/packages/ai/src/tools/lint.ts @@ -0,0 +1,202 @@ +import { ESLint } from 'eslint'; +import type { Linter } from 'eslint'; +import path from 'path'; +import fs from 'fs/promises'; + +type ESLintMessage = { + ruleId: string | null; + severity: 2 | 1 | 0; + message: string; + line: number; + column: number; + nodeType?: string; + messageId?: string; + endLine?: number; + endColumn?: number; + fix?: { + range: [number, number]; + text: string; + }; + suggestions?: { + desc: string; + fix: { + range: [number, number]; + text: string; + }; + }[]; +}; + +type ESLintResult = { + filePath: string; + messages: ESLintMessage[]; + fixed: boolean; + output?: string; +}; + +export interface LintResult { + filePath: string; + messages: ESLintMessage[]; + fixed: boolean; + output?: string; +} + +export interface LintSummary { + totalFiles: number; + totalErrors: number; + totalWarnings: number; + fixedFiles: number; + results: LintResult[]; + errorsByRule: { + [ruleId: string]: number; + }; + warningsByRule: { + [ruleId: string]: number; + }; +} + +export class LintingService { + private static instance: LintingService; + private eslint: ESLint; + + private constructor(fix: boolean = true) { + this.eslint = new ESLint({ + fix, + baseConfig: { + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react/recommended', + 'plugin:react-hooks/recommended', + ], + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + ecmaVersion: 12, + sourceType: 'module', + }, + plugins: ['@typescript-eslint', 'react', 'react-hooks'], + rules: { + 'react/react-in-jsx-scope': 'off', + 'react/prop-types': 'off', + '@typescript-eslint/no-unused-vars': ['warn'], + '@typescript-eslint/no-explicit-any': 'off', + 'no-console': 'warn', + }, + settings: { + react: { + version: 'detect', + }, + }, + } as unknown as Linter.Config, + }); + } + + public static getInstance(fix: boolean = true): LintingService { + if (!LintingService.instance) { + LintingService.instance = new LintingService(fix); + } + return LintingService.instance; + } + + public async lintAndFix(filePath: string, content: string): Promise { + const tempPath = path.join( + path.dirname(filePath), + `.temp.${Date.now()}.${path.basename(filePath)}`, + ); + try { + await fs.writeFile(tempPath, content); + + const results = await this.eslint.lintFiles([tempPath]); + if (!results.length) { + throw new Error('No lint result returned'); + } + + const result = results[0] as unknown as ESLintResult; + + let fixedContent = content; + if (result.output) { + fixedContent = result.output; + await fs.writeFile(tempPath, fixedContent); + } + + return { + filePath, + messages: result.messages, + fixed: result.fixed, + output: fixedContent, + }; + } catch (error) { + console.error(`Failed to lint file: ${filePath}\n`, error); + return { + filePath, + messages: [], + fixed: false, + output: content, + }; + } finally { + await fs.unlink(tempPath).catch(() => {}); + } + } + + public async lintProject(projectPath: string): Promise { + const results: LintResult[] = []; + let totalErrors = 0; + let totalWarnings = 0; + let fixedFiles = 0; + const errorsByRule: { [key: string]: number } = {}; + const warningsByRule: { [key: string]: number } = {}; + + try { + const files = await this.eslint.lintFiles([ + `${projectPath}/**/*.{ts,tsx,js,jsx}`, + `!${projectPath}/node_modules/**`, + `!${projectPath}/**/dist/**`, + `!${projectPath}/**/build/**`, + `!${projectPath}/**/.next/**`, + ]); + + for (const result of files as unknown as ESLintResult[]) { + const messages = result.messages; + + // Process each message + messages.forEach((msg) => { + if (!msg.ruleId) return; + + if (msg.severity === 2) { + totalErrors++; + errorsByRule[msg.ruleId] = (errorsByRule[msg.ruleId] || 0) + 1; + } else if (msg.severity === 1) { + totalWarnings++; + warningsByRule[msg.ruleId] = (warningsByRule[msg.ruleId] || 0) + 1; + } + }); + + if (result.fixed) fixedFiles++; + + results.push({ + filePath: result.filePath, + messages, + fixed: result.fixed, + output: result.output, + }); + } + + return { + totalFiles: files.length, + totalErrors, + totalWarnings, + fixedFiles, + results, + errorsByRule, + warningsByRule, + }; + } catch (error) { + console.error('Error during project linting:', error); + throw new Error( + `Failed to lint project: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } +}