/** * DevLoop · tester-tools (5 个) * parse_vitest vitest JSON reporter 解析 → result.json + hint 生成 * hint_builder 从 failure stack + expected/got 启发式生成 hint * bug_open 调 bugTracker.open + 自动 dedup * bug_transition bug 状态转换 * bug_list 按 filter 查 bug * * 规格: docs/prd/newfunction/_spec/agents-tester.md · §7 */ const fs = require('fs'); const path = require('path'); const pathResolver = require('../../../services/devloop/path-resolver'); const bugTracker = require('../../../services/devloop/bug-tracker'); const { ToolValidationError, ToolMissingDependencyError } = require('./_errors'); // ──────────────────── hint_builder ──────────────────── const HINT_PATTERNS = [ { match: s => /cannot read.*?of undefined/i.test(s), hint: () => '某个对象/字段是 undefined,先做存在性判断再访问' }, { match: s => /TypeError.*compare/i.test(s), hint: () => '调用 bcrypt.compare 前先确认 user 存在;否则 null 参数会抛 TypeError' }, { match: s => /SQLITE_CONSTRAINT/i.test(s), hint: () => '数据库约束冲突(unique/foreign key),业务层应该先查再写' }, { match: s => /timeout/i.test(s), hint: () => '某个异步操作超时;检查是否漏 await 或外部依赖未 mock' }, ]; function buildHint(ctx) { const stack = ctx.stack || ''; for (const p of HINT_PATTERNS) { if (p.match(stack)) return p.hint(ctx); } // status code 启发式 const m = String(stack).match(/[Ee]xpected[:\s]+(\d{3})[\s\S]{0,200}?[Rr]eceived[:\s]+(\d{3})/); const e = m ? parseInt(m[1], 10) : ctx.expected; const g = m ? parseInt(m[2], 10) : ctx.got; if (typeof e === 'number' && typeof g === 'number') { if (e === 401 && g === 500) return '凭据错误应返 401 不是 500;检查认证路径是否 try/catch 兜底'; if (e === 400 && g === 500) return '参数校验错应返 400 不是 500;在 route 层检查后再传 service'; if (e === 404 && g === 200) return '不存在的资源不应返 200;返回前判断查询结果'; return `期望 ${e} 收 ${g},检查对应分支处理`; } return `${ctx.testName || 'unknown'}: 检查 stack 推断原因 (Tester 应补充)`; } const hint_builder = { name: 'hint_builder', description: '从 failure stack/expected/got 生成给 Coder 反思用的 hint(1-2 句人话)', inputSchema: { type: 'object', properties: { testName: { type: 'string' }, expected: {}, got: {}, stack: { type: 'string' }, }, }, async execute(failure, _ctx) { return { ok: true, hint: buildHint(failure) }; }, }; // ──────────────────── parse_vitest ──────────────────── const parse_vitest = { name: 'parse_vitest', description: 'vitest --reporter=json 输出 → 结构化 result.json + auto-fill hint for failures', inputSchema: { type: 'object', required: ['raw_json_path'], properties: { raw_json_path: { type: 'string' }, result_out_path: { type: 'string', description: '可选,result.json 写入位置' }, }, }, async execute({ raw_json_path, result_out_path }, _ctx) { if (!fs.existsSync(raw_json_path)) { return { ok: false, crashed: true, crash_reason: 'vitest raw json missing — process likely crashed early', summary: { total: 0, passed: 0, failed: 0, skipped: 0 }, failures: [], }; } let raw; try { raw = JSON.parse(fs.readFileSync(raw_json_path, 'utf8')); } catch (e) { return { ok: false, crashed: true, crash_reason: 'invalid vitest json: ' + e.message }; } const summary = { total: 0, passed: 0, failed: 0, skipped: 0 }; const failures = []; const testResults = raw.testResults || raw.tests || []; for (const file of testResults) { const asserts = file.assertionResults || file.assertions || []; for (const a of asserts) { summary.total++; if (a.status === 'passed') summary.passed++; else if (a.status === 'failed') { summary.failed++; const stack = (a.failureMessages || a.errors || []).join('\n'); const { expected, got } = _extractAssertion(stack); failures.push({ test: a.fullName || a.title, expected, got, stack, hint: buildHint({ testName: a.fullName, expected, got, stack }), }); } else summary.skipped++; } } const result = { ok: summary.failed === 0 && summary.total > 0, crashed: false, summary, failures, duration_ms: raw.testResults?.[0]?.perfStats?.runtime || 0, }; if (result_out_path) { fs.mkdirSync(path.dirname(result_out_path), { recursive: true }); fs.writeFileSync(result_out_path, JSON.stringify(result, null, 2)); } return result; }, }; function _extractAssertion(stack) { const exp = stack.match(/Expected[:\s]+([^\n]+)/); const got = stack.match(/(?:Received|Actual)[:\s]+([^\n]+)/); return { expected: exp ? exp[1].trim() : null, got: got ? got[1].trim() : null, }; } // ──────────────────── bug_open / transition / list ──────────────────── const bug_open = { name: 'bug_open', description: 'append bugs.ndjson 一条 status=open;dedupKey 命中 open/fixing 时复用旧 id', inputSchema: { type: 'object', required: ['title'], properties: { source: { type: 'string', enum: ['unit_test','integration_test','triage','human'] }, unit_id: { type: 'string' }, run_id: { type: 'string' }, test_name: { type: 'string' }, category: { type: 'string' }, severity: { type: 'string', enum: ['critical','high','medium','low'] }, title: { type: 'string', maxLength: 120 }, detail: { type: 'string' }, affected_units: { type: 'array', items: { type: 'string' } }, }, }, async execute(bug, ctx) { _req(ctx, ['pid']); // 注入 sseBroker + timelineLogger (如果 ctx 有) if (ctx.sseBroker) bugTracker.attachSseBroker(ctx.sseBroker); if (ctx.timelineLogger) bugTracker.attachTimelineLogger(ctx.timelineLogger); const b = bugTracker.open(ctx.pid, { ...bug, by: ctx.agent || 'tester' }); return { ok: true, bug_id: b.id, dedup: b.discovered_at !== new Date().toISOString().slice(0, b.discovered_at.length) }; }, }; const bug_transition = { name: 'bug_transition', description: 'bug 状态转换 (open → fixing → fixed → verified 等),走 P-9 状态机', inputSchema: { type: 'object', required: ['bug_id', 'to'], properties: { bug_id: { type: 'string' }, to: { type: 'string', enum: ['open','fixing','fixed','verified','wontfix','duplicate'] }, note: { type: 'string' }, fix_refs: { type: 'array', items: { type: 'string' } }, duplicate_of: { type: 'string' }, }, }, async execute({ bug_id, to, note, fix_refs, duplicate_of }, ctx) { _req(ctx, ['pid']); if (ctx.sseBroker) bugTracker.attachSseBroker(ctx.sseBroker); if (ctx.timelineLogger) bugTracker.attachTimelineLogger(ctx.timelineLogger); try { bugTracker.transition(ctx.pid, bug_id, { to, by: ctx.agent || 'system', note, fix_refs, duplicate_of, }); return { ok: true }; } catch (e) { if (e.name === 'InvalidBugTransitionError') { throw new ToolValidationError('to', e.message); } throw e; } }, }; const bug_list = { name: 'bug_list', description: '列 bug,可按 status/severity/unit_id 过滤;返回每个 bug.id 的最新态', inputSchema: { type: 'object', properties: { status: { type: 'array', items: { type: 'string' } }, severity: { type: 'array', items: { type: 'string' } }, unit_id: { type: 'string' }, since: { type: 'string' }, }, }, async execute(filter, ctx) { _req(ctx, ['pid']); const bugs = bugTracker.list(ctx.pid, filter || {}); return { ok: true, bugs, total: bugs.length }; }, }; function _req(ctx, keys) { for (const k of keys) { if (!ctx || ctx[k] == null) throw new ToolMissingDependencyError(`ctx.${k}`); } } module.exports = { hint_builder, parse_vitest, bug_open, bug_transition, bug_list };