Skip to content
Closed
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
24 changes: 24 additions & 0 deletions src/tools/LintTool/LintTool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, it, expect } from 'bun:test'
import { LINT_TOOL_NAME } from './prompt.js'
import { LintTool } from './LintTool.js'

describe('LintTool', () => {
it('has the correct name', () => { expect(LintTool.name).toBe(LINT_TOOL_NAME) })
it('has a non-empty description', async () => { expect((await LintTool.description()).length).toBeGreaterThan(0) })
it('has isEnabled from buildTool', () => { expect(LintTool.isEnabled()).toBe(true) })
it('marks check mode as read-only', () => { expect(LintTool.isReadOnly?.({ fix: false })).toBe(true) })
it('marks fix mode as destructive', () => { expect(LintTool.isDestructive?.({ fix: true })).toBe(true) })
it('asks permission for execution', async () => {
const p = await LintTool.checkPermissions!({ tool: 'eslint', path: 'src/' })
expect(p.behavior).toBe('ask')
})
it('rejects invalid linter', async () => { expect((await LintTool.validateInput({ tool: 'invalid' })).result).toBe(false) })
it('has mapToolResultToToolResultBlockParam', () => {
const b = LintTool.mapToolResultToToolResultBlockParam({ success: true, tool: 'eslint', errors: 0, warnings: 0, findings: [], durationMs: 10 }, 'tid')
expect(b.tool_use_id).toBe('tid'); expect(b.type).toBe('tool_result')
})
it('renders tool use message', () => { expect(LintTool.renderToolUseMessage?.({ tool: 'eslint', path: 'src/' })).toContain('eslint') })
it('renders success result', () => { expect(LintTool.renderToolResultMessage?.({ success: true, tool: 'eslint', errors: 2, warnings: 5, findings: [], durationMs: 100 })).toContain('2 errors') })
it('renders error result', () => { expect(LintTool.renderToolResultMessage?.({ success: false, tool: 'eslint', errors: 0, warnings: 0, findings: [], durationMs: 5, error: 'not found' })).toContain('not found') })
it('provides auto-classifier input', () => { expect(LintTool.toAutoClassifierInput?.({ tool: 'eslint', path: 'src/' })).toBe('eslint src/') })
})
176 changes: 176 additions & 0 deletions src/tools/LintTool/LintTool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { spawnSync } from 'child_process'
import { existsSync } from 'fs'
import { resolve, dirname, basename } from 'path'
import { z } from 'zod/v4'
import { buildTool, type ToolResult } from '../../Tool.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { expandPath } from '../../utils/path.js'
import { DESCRIPTION, LINT_TOOL_NAME, PROMPT } from './prompt.js'

const inputSchema = lazySchema(() =>
z.strictObject({
tool: z.enum(['eslint', 'prettier', 'ruff', 'biome', 'golangci-lint', 'clippy']).optional().describe('Linter tool. Auto-detected from config if omitted.'),
path: z.string().optional().default('.').describe('File or directory to lint.'),
fix: z.boolean().optional().default(false).describe('Auto-fix issues when supported.'),
config: z.string().optional().describe('Path to linter config file.'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>

const findingSchema = z.object({
file: z.string(),
line: z.number().optional(),
column: z.number().optional(),
message: z.string(),
severity: z.enum(['error', 'warning', 'info']),
rule: z.string().optional(),
})

const outputSchema = lazySchema(() =>
z.object({
success: z.boolean(),
tool: z.string(),
errors: z.number(),
warnings: z.number(),
fixed: z.number().optional(),
findings: z.array(findingSchema),
configFile: z.string().optional(),
durationMs: z.number(),
error: z.string().optional(),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
export type Output = z.infer<OutputSchema>

const MAX_FINDINGS = 200

const LINTERS: Record<string, { binary: string; configFiles: string[] }> = {
eslint: { binary: 'eslint', configFiles: ['.eslintrc', '.eslintrc.js', '.eslintrc.json', 'eslint.config.js'] },
prettier: { binary: 'prettier', configFiles: ['.prettierrc', '.prettierrc.json', 'prettier.config.js'] },
ruff: { binary: 'ruff', configFiles: ['ruff.toml', '.ruff.toml', 'pyproject.toml'] },
biome: { binary: 'biome', configFiles: ['biome.json'] },
'golangci-lint': { binary: 'golangci-lint', configFiles: ['.golangci.yml'] },
clippy: { binary: 'cargo', configFiles: ['Cargo.toml'] },
}

function detectTool(dir: string): string | null {
for (const [name, l] of Object.entries(LINTERS)) {
for (const f of l.configFiles) {
if (existsSync(resolve(dir, f))) return name
}
}
return null
}

function parseEslintOutput(stdout: string): Output['findings'] {
try {
const data = JSON.parse(stdout)
if (!Array.isArray(data)) return []
const findings: Output['findings'] = []
for (const file of data) {
if (!file.messages) continue
for (const msg of file.messages) {
findings.push({
file: file.filePath || '',
line: msg.line,
column: msg.column,
message: msg.message,
severity: msg.severity === 2 ? 'error' : 'warning',
rule: msg.ruleId || undefined,
})
}
}
return findings.slice(0, MAX_FINDINGS)
} catch { return [] }
}

function parseGenericOutput(stdout: string): Output['findings'] {
const findings: Output['findings'] = []
for (const line of stdout.split('\n')) {
const m = line.trim().match(/^([^:]+):(\d+):(\d+):\s+(error|warning):\s+(.+)$/)
if (m) findings.push({ file: m[1], line: parseInt(m[2], 10), column: parseInt(m[3], 10), severity: m[4] as 'error' | 'warning', message: m[5] })
}
return findings.slice(0, MAX_FINDINGS)
}

export const LintTool = buildTool({
name: LINT_TOOL_NAME,
searchHint: 'run linters and code formatters',
maxResultSizeChars: 100_000,
strict: true,
get inputSchema(): InputSchema { return inputSchema() },
get outputSchema(): OutputSchema { return outputSchema() },
userFacingName: () => 'Lint',
isReadOnly(input) { return input ? !input.fix : true },
isDestructive(input) { return input ? input.fix === true : false },
toAutoClassifierInput(input) { return `${input.tool ?? 'auto'} ${input.path}` },
async description() { return DESCRIPTION },
async prompt() { return PROMPT },
async validateInput(input) {
if (input.tool && !LINTERS[input.tool]) return { result: false, message: `Unsupported linter: ${input.tool}`, errorCode: 1 }
return { result: true }
},
async checkPermissions(input) {
return { behavior: 'ask', message: `${input.tool ?? 'auto'} linter on ${input.path}${input.fix ? ' (fix mode)' : ''}`, updatedInput: input }
},
mapToolResultToToolResultBlockParam(output, toolUseID) {
return { tool_use_id: toolUseID, type: 'tool_result', content: JSON.stringify(output) }
},
renderToolUseMessage(input) {
return `Running ${input.tool ?? 'auto'} linter on ${input.path}`
},
renderToolResultMessage(output) {
if (!output.success) return `Linter failed: ${output.error}`
const parts = [`${output.tool}: ${output.errors} errors, ${output.warnings} warnings`]
if (output.fixed) parts.push(`${output.fixed} auto-fixed`)
parts.push(`in ${output.durationMs}ms`)
return parts.join(', ')
},
async call(input, _ctx, _canUseTool?, _parentMessage?, _onProgress?): Promise<ToolResult<Output>> {
const startTime = Date.now()
const targetPath = resolve(expandPath(input.path ?? '.'))
// Separate working dir from file target: use parent dir when path targets a specific file
const hasFileExt = /\.[a-zA-Z0-9]+$/.test(basename(targetPath))
const workingDir = hasFileExt && !existsSync(targetPath) ? dirname(targetPath) : targetPath
const toolName = input.tool ?? detectTool(workingDir)

if (!toolName) return { data: { success: false, tool: 'unknown', errors: 0, warnings: 0, findings: [], durationMs: Date.now() - startTime, error: 'No linter config detected. Specify a tool or add a config file.' } }

try {
const args: string[] = []
if (toolName === 'eslint') { args.push('-f', 'json'); if (input.fix) args.push('--fix'); args.push(targetPath) }
else if (toolName === 'prettier') { args.push('--check'); if (input.fix) args.push('--write'); args.push(targetPath) }
else if (toolName === 'ruff') { args.push('check'); if (input.fix) args.push('--fix'); args.push(targetPath) }
else if (toolName === 'biome') { args.push('check'); if (input.fix) args.push('--apply'); args.push(targetPath) }
else if (toolName === 'golangci-lint') { args.push('run'); if (input.fix) args.push('--fix'); args.push(targetPath) }
else if (toolName === 'clippy') { args.push('clippy'); args.push('--'); args.push('-D', 'warnings') }

const binary = LINTERS[toolName].binary
const result = spawnSync(binary, args, { cwd: workingDir, timeout: 120_000, maxBuffer: 100_000, encoding: 'utf-8' })

if (result.error) return { data: { success: false, tool: toolName, errors: 0, warnings: 0, findings: [], durationMs: Date.now() - startTime, error: `Failed to run ${binary}: ${result.error.message}` } }

const stdout = result.stdout ?? ''
const stderr = result.stderr ?? ''

if (result.status !== 0 && !stdout && !stderr) return { data: { success: false, tool: toolName, errors: 0, warnings: 0, findings: [], durationMs: Date.now() - startTime, error: `${binary} exited with code ${result.status} (no output)` } }

let findings: Output['findings'] = []
if (toolName === 'eslint') { findings = parseEslintOutput(stdout) }
else { findings = parseGenericOutput(stdout) }

if (findings.length === 0 && stderr) findings = parseGenericOutput(stderr)
if (findings.length === 0 && result.status !== 0) {
return { data: { success: false, tool: toolName, errors: 0, warnings: 0, findings: [], durationMs: Date.now() - startTime, error: (stderr || `${binary} exited with code ${result.status}`).slice(0, 2000) } }
}

const errors = findings.filter(f => f.severity === 'error').length
const warnings = findings.filter(f => f.severity === 'warning').length

return { data: { success: true, tool: toolName, errors, warnings, findings, durationMs: Date.now() - startTime } }
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
return { data: { success: false, tool: toolName, errors: 0, warnings: 0, findings: [], durationMs: Date.now() - startTime, error: msg } }
}
},
})
22 changes: 22 additions & 0 deletions src/tools/LintTool/prompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export const LINT_TOOL_NAME = 'Lint'
export const DESCRIPTION = 'Run linters and code formatters on project files. Supports ESLint, Prettier, Ruff, Biome, golangci-lint, and clippy.'
export const PROMPT = `Run linters and code formatters on project files.

## Usage
- Auto-detects linter config from project files (.eslintrc, .prettierrc, ruff.toml, biome.json, etc.)
- Runs linter and returns structured results with error/warning counts
- Supports auto-fix mode where the linter supports it

## Supported Tools
- ESLint (JavaScript/TypeScript): .eslintrc, eslint.config.js
- Prettier: .prettierrc, prettier.config.js
- Ruff (Python): ruff.toml, pyproject.toml
- Biome: biome.json
- golangci-lint: .golangci.yml
- Clippy (Rust): Cargo.toml

## Safety
- Auto-fix is opt-in via the fix parameter
- Lint check mode never modifies files
- Results show file:line:column for each finding
`
25 changes: 25 additions & 0 deletions src/tools/UnitTestTool/UnitTestTool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'bun:test'
import { UNIT_TEST_TOOL_NAME } from './prompt.js'
import { UnitTestTool } from './UnitTestTool.js'

describe('UnitTestTool', () => {
it('has the correct name', () => { expect(UnitTestTool.name).toBe(UNIT_TEST_TOOL_NAME) })
it('has a non-empty description', async () => { expect((await UnitTestTool.description()).length).toBeGreaterThan(0) })
it('has isEnabled from buildTool', () => { expect(UnitTestTool.isEnabled()).toBe(true) })
it('is not read-only', () => { expect(UnitTestTool.isReadOnly?.()).toBe(false) })
it('asks permission for execution', async () => {
const p = await UnitTestTool.checkPermissions!({ framework: 'bun', path: '.' })
expect(p.behavior).toBe('ask')
})
it('rejects timeout < 1', async () => { expect((await UnitTestTool.validateInput({ timeout: 0 })).result).toBe(false) })
it('has mapToolResultToToolResultBlockParam', () => {
const b = UnitTestTool.mapToolResultToToolResultBlockParam({ success: true, framework: 'bun', passed: 10, failed: 0, total: 10, durationMs: 100 }, 't1')
expect(b.tool_use_id).toBe('t1'); expect(b.type).toBe('tool_result')
})
it('renders tool use message', () => { expect(UnitTestTool.renderToolUseMessage?.({ framework: 'jest', path: 'src/' })).toContain('jest') })
it('renders success result', () => { expect(UnitTestTool.renderToolResultMessage?.({ success: true, framework: 'bun', passed: 42, failed: 0, total: 42, durationMs: 1500 })).toContain('42/42') })
it('renders failure result', () => { expect(UnitTestTool.renderToolResultMessage?.({ success: false, framework: 'jest', passed: 40, failed: 2, total: 42, durationMs: 2000, error: '2 tests failed' })).toContain('2 tests failed') })
it('renders no-tests result', () => { expect(UnitTestTool.renderToolResultMessage?.({ success: true, framework: 'pytest', passed: 0, failed: 0, total: 0, durationMs: 500 })).toContain('No tests found') })
it('renders success with coverage', () => { expect(UnitTestTool.renderToolResultMessage?.({ success: true, framework: 'vitest', passed: 50, failed: 0, total: 50, durationMs: 3000, coverage: { lines: 85 } })).toContain('85%') })
it('provides auto-classifier input', () => { expect(UnitTestTool.toAutoClassifierInput?.({ framework: 'jest', path: 'src/', filter: 'auth' })).toBe('jest: auth') })
})
Loading