Skip to content
Open
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
7 changes: 6 additions & 1 deletion packages/ai/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
48 changes: 48 additions & 0 deletions packages/ai/src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -134,4 +181,5 @@ export const chatToolSet: ToolSet = {
list_files: listFilesTool,
read_files: readFilesTool,
onlook_instructions: onlookInstructionsTool,
lint: lintTool,
};
202 changes: 202 additions & 0 deletions packages/ai/src/tools/lint.ts
Original file line number Diff line number Diff line change
@@ -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<LintResult> {
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<LintSummary> {
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'}`,
);
}
}
}