diff --git a/src/tools/LintTool/LintTool.test.ts b/src/tools/LintTool/LintTool.test.ts new file mode 100644 index 000000000..9ed9ca1f8 --- /dev/null +++ b/src/tools/LintTool/LintTool.test.ts @@ -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/') }) +}) diff --git a/src/tools/LintTool/LintTool.ts b/src/tools/LintTool/LintTool.ts new file mode 100644 index 000000000..88f19d118 --- /dev/null +++ b/src/tools/LintTool/LintTool.ts @@ -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 + +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 +export type Output = z.infer + +const MAX_FINDINGS = 200 + +const LINTERS: Record = { + 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> { + 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 } } + } + }, +}) diff --git a/src/tools/LintTool/prompt.ts b/src/tools/LintTool/prompt.ts new file mode 100644 index 000000000..c8a133998 --- /dev/null +++ b/src/tools/LintTool/prompt.ts @@ -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 +` diff --git a/src/tools/UnitTestTool/UnitTestTool.test.ts b/src/tools/UnitTestTool/UnitTestTool.test.ts new file mode 100644 index 000000000..492f6de1f --- /dev/null +++ b/src/tools/UnitTestTool/UnitTestTool.test.ts @@ -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') }) +}) diff --git a/src/tools/UnitTestTool/UnitTestTool.ts b/src/tools/UnitTestTool/UnitTestTool.ts new file mode 100644 index 000000000..e11a23608 --- /dev/null +++ b/src/tools/UnitTestTool/UnitTestTool.ts @@ -0,0 +1,140 @@ +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, UNIT_TEST_TOOL_NAME, PROMPT } from './prompt.js' + +const inputSchema = lazySchema(() => + z.strictObject({ + framework: z.enum(['jest', 'vitest', 'bun', 'pytest', 'go', 'cargo']).optional().describe('Test framework. Auto-detected.'), + path: z.string().optional().default('.').describe('File or directory to test.'), + filter: z.string().optional().describe('Test name pattern filter.'), + coverage: z.boolean().optional().default(false).describe('Generate coverage report.'), + timeout: z.number().min(1).max(3600).optional().default(300).describe('Timeout in seconds.'), + }), +) +type InputSchema = ReturnType + +const outputSchema = lazySchema(() => + z.object({ + success: z.boolean(), + framework: z.string(), + passed: z.number(), + failed: z.number(), + total: z.number(), + durationMs: z.number(), + output: z.string().optional(), + coverage: z.object({ lines: z.number().optional(), branches: z.number().optional(), functions: z.number().optional() }).optional(), + error: z.string().optional(), + }), +) +type OutputSchema = ReturnType +export type Output = z.infer + +const FRAMEWORKS: Record = { + jest: { binary: 'jest', detectFiles: ['jest.config.js', 'jest.config.ts', 'jest.config.json'], defaultArgs: ['--no-coverage'] }, + vitest: { binary: 'vitest', detectFiles: ['vitest.config.ts', 'vitest.config.js'], defaultArgs: ['run'] }, + bun: { binary: 'bun', detectFiles: ['bun.lockb', 'bun.lock'], defaultArgs: ['test'] }, + pytest: { binary: 'python', detectFiles: ['pytest.ini', 'pyproject.toml'], defaultArgs: ['-m', 'pytest'] }, + go: { binary: 'go', detectFiles: ['go.mod'], defaultArgs: ['test'] }, + cargo: { binary: 'cargo', detectFiles: ['Cargo.toml'], defaultArgs: ['test'] }, +} + +function detectFramework(dir: string): string | null { + for (const [name, fw] of Object.entries(FRAMEWORKS)) { + for (const f of fw.detectFiles) { + if (existsSync(resolve(dir, f))) return name + } + } + return null +} + +export const UnitTestTool = buildTool({ + name: UNIT_TEST_TOOL_NAME, + searchHint: 'run unit tests with structured results', + maxResultSizeChars: 200_000, + strict: true, + get inputSchema(): InputSchema { return inputSchema() }, + get outputSchema(): OutputSchema { return outputSchema() }, + userFacingName: () => 'Unit Test', + isReadOnly() { return false }, + isDestructive() { return false }, + toAutoClassifierInput(input) { return `${input.framework ?? 'auto'}: ${input.filter ?? input.path}` }, + async description() { return DESCRIPTION }, + async prompt() { return PROMPT }, + async validateInput(input) { + if (input.timeout !== undefined && (input.timeout < 1 || input.timeout > 3600)) return { result: false, message: 'Timeout must be between 1 and 3600 seconds', errorCode: 1 } + return { result: true } + }, + async checkPermissions(input) { + return { behavior: 'ask', message: `${input.framework ?? 'auto'} test on ${input.path}${input.coverage ? ' (with coverage)' : ''}`, updatedInput: input } + }, + mapToolResultToToolResultBlockParam(output, toolUseID) { + return { tool_use_id: toolUseID, type: 'tool_result', content: JSON.stringify(output) } + }, + renderToolUseMessage(input) { + const fw = input.framework ?? 'auto-detected' + const cov = input.coverage ? ' with coverage' : '' + return `Running ${fw} tests${cov} on ${input.path}` + }, + renderToolResultMessage(output) { + if (!output.success && output.error) return `Tests failed (${output.framework}): ${output.error}` + if (output.total === 0) return `${output.framework}: No tests found in ${output.durationMs}ms` + let msg = `${output.framework}: ${output.passed}/${output.total} passed in ${output.durationMs}ms` + if (output.failed > 0) msg = `${output.framework}: ${output.failed} failed, ${output.passed} passed in ${output.durationMs}ms` + if (output.coverage?.lines) msg += ` (lines: ${output.coverage.lines}%)` + return msg + }, + async call(input, _ctx, _canUseTool?, _parentMessage?, _onProgress?): Promise> { + const startTime = Date.now() + const targetPath = resolve(expandPath(input.path ?? '.')) + const hasFileExt = /\.[a-zA-Z0-9]+$/.test(basename(targetPath)) + const workingDir = hasFileExt && !existsSync(targetPath) ? dirname(targetPath) : targetPath + const fwName = input.framework ?? detectFramework(workingDir) + + if (!fwName) return { data: { success: false, framework: 'unknown', passed: 0, failed: 0, total: 0, durationMs: Date.now() - startTime, error: 'No test framework detected.' } } + + const fw = FRAMEWORKS[fwName] + if (!fw) return { data: { success: false, framework: fwName, passed: 0, failed: 0, total: 0, durationMs: Date.now() - startTime, error: `Unsupported framework: ${fwName}` } } + + try { + const args = [...fw.defaultArgs] + if (input.coverage) args.push('--coverage') + if (input.filter && (fwName === 'jest' || fwName === 'vitest')) args.push('--testNamePattern', input.filter) + if (fwName === 'go' && input.filter) args.push('-run', input.filter) + if (fwName === 'cargo' && input.filter) args.push('--', input.filter) + if (fwName !== 'bun') args.push(targetPath) + else if (input.filter) args.push('--test-name-pattern', input.filter) + + const result = spawnSync(fw.binary, args, { cwd: workingDir, timeout: (input.timeout ?? 300) * 1000, maxBuffer: 200_000, encoding: 'utf-8' }) + + if (result.error) return { data: { success: false, framework: fwName, passed: 0, failed: 0, total: 0, durationMs: Date.now() - startTime, error: `Failed to run ${fw.binary}: ${result.error.message}` } } + + const stdout = result.stdout ?? '' + const stderr = result.stderr ?? '' + const status = result.status ?? 1 + + const passedMatch = stdout.match(/(\d+)\s+pass/) + const failedMatch = stdout.match(/(\d+)\s+fail/) + const passed = passedMatch ? parseInt(passedMatch[1], 10) : 0 + const failed = failedMatch ? parseInt(failedMatch[1], 10) : status !== 0 && passed === 0 ? 1 : 0 + const total = passed + failed + + let coverage: Output['coverage'] | undefined + if (input.coverage) { + const cl = stdout.match(/Lines:\s+(\d+\.?\d*)%/) + const cb = stdout.match(/Branches:\s+(\d+\.?\d*)%/) + const cf = stdout.match(/Functions:\s+(\d+\.?\d*)%/) + if (cl) coverage = { lines: parseFloat(cl[1]), branches: cb ? parseFloat(cb[1]) : undefined, functions: cf ? parseFloat(cf[1]) : undefined } + } + + return { data: { success: failed === 0, framework: fwName, passed, failed, total, output: stdout.slice(0, 5000), coverage, durationMs: Date.now() - startTime } } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { data: { success: false, framework: fwName, passed: 0, failed: 1, total: 1, durationMs: Date.now() - startTime, error: msg } } + } + }, +}) diff --git a/src/tools/UnitTestTool/prompt.ts b/src/tools/UnitTestTool/prompt.ts new file mode 100644 index 000000000..f9a302e44 --- /dev/null +++ b/src/tools/UnitTestTool/prompt.ts @@ -0,0 +1,22 @@ +export const UNIT_TEST_TOOL_NAME = 'UnitTest' +export const DESCRIPTION = 'Run unit tests and return structured results. Supports Jest, Vitest, Bun test, pytest, go test, and cargo test.' +export const PROMPT = `Run unit tests using the project's test framework. + +## Usage +- Auto-detects test framework from config files and dependencies +- Returns structured results with pass/fail counts and failure details +- Supports focused test runs by file or test name pattern + +## Supported Frameworks +- Jest: package.json (jest), jest.config.* +- Vitest: vitest.config.*, vite.config.* (with vitest) +- Bun test: bun.lockb (no config needed) +- pytest: pytest.ini, pyproject.toml +- go test: go.mod +- cargo test: Cargo.toml + +## Safety +- Tests run in the project directory +- Output is captured and returned as structured data +- Long-running tests have a configurable timeout (default: 5min) +`