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
6 changes: 6 additions & 0 deletions src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ import { TaskCreateTool } from './tools/TaskCreateTool/TaskCreateTool.js'
import { TaskGetTool } from './tools/TaskGetTool/TaskGetTool.js'
import { TaskUpdateTool } from './tools/TaskUpdateTool/TaskUpdateTool.js'
import { TaskListTool } from './tools/TaskListTool/TaskListTool.js'
import { LintTool } from './tools/LintTool/LintTool.js'
import { UnitTestTool } from './tools/UnitTestTool/UnitTestTool.js'
import { CoverageTool } from './tools/CoverageTool/CoverageTool.js'
import uniqBy from 'lodash-es/uniqBy.js'
import { isToolSearchEnabledOptimistic } from './utils/toolSearch.js'
import { isTodoV2Enabled } from './utils/tasks.js'
Expand Down Expand Up @@ -198,6 +201,9 @@ export function getAllBaseTools(): Tools {
WebSearchTool,
TaskStopTool,
AskUserQuestionTool,
LintTool,
UnitTestTool,
CoverageTool,
SkillTool,
EnterPlanModeTool,
...(SuggestBackgroundPRTool ? [SuggestBackgroundPRTool] : []),
Expand Down
22 changes: 22 additions & 0 deletions src/tools/CoverageTool/CoverageTool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, it, expect } from 'bun:test'
import { COVERAGE_TOOL_NAME } from './prompt.js'
import { CoverageTool } from './CoverageTool.js'

describe('CoverageTool', () => {
it('has the correct name', () => { expect(CoverageTool.name).toBe(COVERAGE_TOOL_NAME) })
it('has a non-empty description', async () => { expect((await CoverageTool.description()).length).toBeGreaterThan(0) })
it('has isEnabled from buildTool', () => { expect(CoverageTool.isEnabled()).toBe(true) })
it('is always read-only', () => { expect(CoverageTool.isReadOnly?.()).toBe(true) })
it('validates always passes', async () => { expect((await CoverageTool.validateInput({})).result).toBe(true) })
it('has mapToolResultToToolResultBlockParam', () => {
const b = CoverageTool.mapToolResultToToolResultBlockParam({ success: true, format: 'lcov', lines: 85, durationMs: 100 }, 't1')
expect(b.tool_use_id).toBe('t1'); expect(b.type).toBe('tool_result')
})
it('renders tool use message', () => { expect(CoverageTool.renderToolUseMessage?.({ path: '.' })).toContain('Reading') })
it('renders success result', () => { expect(CoverageTool.renderToolResultMessage?.({ success: true, format: 'lcov', lines: 72.5, durationMs: 200 })).toContain('72.5% lines') })
it('renders with branches', () => { expect(CoverageTool.renderToolResultMessage?.({ success: true, format: 'lcov', lines: 85, branches: 70, durationMs: 300 })).toContain('70% branches') })
it('renders uncovered files', () => { expect(CoverageTool.renderToolResultMessage?.({ success: true, format: 'lcov', lines: 60, uncoveredFiles: ['a.ts'], durationMs: 100 })).toContain('1 files with 0%') })
it('renders threshold met', () => { expect(CoverageTool.renderToolResultMessage?.({ success: true, format: 'lcov', lines: 85, meetsThreshold: true, durationMs: 100 })).toContain('✅ meets threshold') })
it('renders error result', () => { expect(CoverageTool.renderToolResultMessage?.({ success: false, format: 'lcov', lines: 0, durationMs: 5, error: 'report not found' })).toContain('report not found') })
it('provides auto-classifier input', () => { expect(CoverageTool.toAutoClassifierInput?.({ format: 'lcov', path: '.' })).toBe('lcov .') })
})
117 changes: 117 additions & 0 deletions src/tools/CoverageTool/CoverageTool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { readFileSync, existsSync } from 'fs'
import { resolve } 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, COVERAGE_TOOL_NAME, PROMPT } from './prompt.js'

const inputSchema = lazySchema(() =>
z.strictObject({
path: z.string().optional().default('.').describe('Project directory with coverage reports.'),
format: z.enum(['lcov', 'auto']).optional().default('auto').describe('Coverage report format. Only lcov is supported.'),
threshold: z.number().min(0).max(100).optional().describe('Minimum coverage percentage.'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>

const outputSchema = lazySchema(() =>
z.object({
success: z.boolean(),
format: z.string(),
lines: z.number().min(0).max(100),
branches: z.number().optional(),
totalLines: z.number().optional(),
coveredLines: z.number().optional(),
files: z.array(z.object({ file: z.string(), lines: z.number(), covered: z.number(), total: z.number() })).optional(),
uncoveredFiles: z.array(z.string()).optional(),
meetsThreshold: z.boolean().optional(),
durationMs: z.number(),
error: z.string().optional(),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
export type Output = z.infer<OutputSchema>

const MAX_FILES = 100

function parseLcov(content: string): { lines: number; branches?: number; totalLines: number; coveredLines: number; files: Output['files'] } {
let totalLines = 0, coveredLines = 0
let currentFile = ''
const fileMap = new Map<string, { covered: number; total: number }>()
let branchHits = 0, branchFound = 0

for (const line of content.split('\n')) {
const t = line.trim()
if (t.startsWith('SF:')) { currentFile = t.slice(3); if (!fileMap.has(currentFile)) fileMap.set(currentFile, { covered: 0, total: 0 }) }
else if (t.startsWith('DA:')) {
const p = t.slice(3).split(',')
if (p.length >= 2) {
const e = fileMap.get(currentFile)!; e.total++; totalLines++
if (parseInt(p[1], 10) > 0) { e.covered++; coveredLines++ }
}
} else if (t.startsWith('BRF:')) branchFound += parseInt(t.slice(4), 10) // accumulate across files
else if (t.startsWith('BRH:')) branchHits += parseInt(t.slice(4), 10) // accumulate across files
}

const lines = totalLines > 0 ? Math.round((coveredLines / totalLines) * 10000) / 100 : 0
const branches = branchFound > 0 ? Math.round((branchHits / branchFound) * 10000) / 100 : undefined

const files = [...fileMap.entries()].map(([file, d]) => ({ file, lines: d.total > 0 ? Math.round((d.covered / d.total) * 10000) / 100 : 0, covered: d.covered, total: d.total }))
.sort((a, b) => a.lines - b.lines)
.slice(0, MAX_FILES)

return { lines, branches, totalLines, coveredLines, files }
}

export const CoverageTool = buildTool({
name: COVERAGE_TOOL_NAME,
searchHint: 'analyze code coverage from lcov reports',
maxResultSizeChars: 100_000,
strict: true,
get inputSchema(): InputSchema { return inputSchema() },
get outputSchema(): OutputSchema { return outputSchema() },
userFacingName: () => 'Coverage',
isReadOnly() { return true },
isDestructive() { return false },
toAutoClassifierInput(input) { return `${input.format ?? 'auto'} ${input.path}` },
async description() { return DESCRIPTION },
async prompt() { return PROMPT },
async validateInput() { return { result: true } },
mapToolResultToToolResultBlockParam(output, toolUseID) {
return { tool_use_id: toolUseID, type: 'tool_result', content: JSON.stringify(output) }
},
renderToolUseMessage(input) {
return `Reading coverage report at ${input.path}`
},
renderToolResultMessage(output) {
if (!output.success) return `Coverage analysis failed: ${output.error}`
let msg = `lcov: ${output.lines}% lines`
if (output.branches !== undefined) msg += `, ${output.branches}% branches`
msg += ` in ${output.durationMs}ms`
if (output.uncoveredFiles?.length) msg += `, ${output.uncoveredFiles.length} files with 0% coverage`
if (output.meetsThreshold !== undefined) msg += output.meetsThreshold ? ' ✅ meets threshold' : ' ❌ below threshold'
return msg
},
async call(input, _ctx, _canUseTool?, _parentMessage?, _onProgress?): Promise<ToolResult<Output>> {
const startTime = Date.now()
const targetPath = resolve(expandPath(input.path ?? '.'))

const lcovPath = resolve(targetPath, 'coverage/lcov.info')
if (!existsSync(lcovPath)) {
return { data: { success: false, format: 'lcov', lines: 0, durationMs: Date.now() - startTime, error: `Coverage report not found at ${lcovPath}. Run tests with coverage first.` } }
}

try {
const content = readFileSync(lcovPath, 'utf-8')
const { lines, branches, totalLines, coveredLines, files } = parseLcov(content)
const uncoveredFiles = files?.filter(f => f.lines === 0).map(f => f.file)
const meetsThreshold = input.threshold !== undefined ? lines >= input.threshold : undefined

return { data: { success: true, format: 'lcov', lines, branches, totalLines, coveredLines, files, uncoveredFiles, meetsThreshold, durationMs: Date.now() - startTime } }
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
return { data: { success: false, format: 'lcov', lines: 0, durationMs: Date.now() - startTime, error: msg } }
}
},
})
13 changes: 13 additions & 0 deletions src/tools/CoverageTool/prompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export const COVERAGE_TOOL_NAME = 'Coverage'
export const DESCRIPTION = 'Read and analyze lcov coverage reports. Returns line/branch percentages, per-file breakdown, and uncovered files list.'
export const PROMPT = `Read and analyze code coverage reports.

## Usage
- Reads existing lcov coverage report files (coverage/lcov.info)
- Returns structured coverage data with line/branch percentages
- Identifies uncovered files and optionally checks against a threshold
- Only lcov format is currently supported

## Safety
- Read-only operation: never modifies files
`
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 } }
}
},
})
Loading
Loading