/** * DevLoop · triager-tools (4 个) * extract_evidence 验证 evidence 字符串在原文 grep 命中 * category_classifier 启发式 4 类粗判(contract_mismatch / impl_bug / spec_ambiguous / infra_flake) * diff_contract_vs_impl 对比 contract.json 声明 vs code 实际行为(轻量,看 endpoint 存在性) * find_similar_bug 在 bugs.ndjson 找历史相似 bug * * 规格: docs/prd/newfunction/_spec/agents-triager.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 docStore = require('../../../services/devloop/doc-store'); const { ToolMissingDependencyError } = require('./_errors'); // ──────────────────── extract_evidence ──────────────────── function verifyOneEvidence(projectRoot, evidence) { const sep = evidence.indexOf(': '); if (sep < 0) return { ok: false, reason: 'malformed: need "path: quote"' }; const relPath = evidence.slice(0, sep).replace(/^['"]|['"]$/g, '').trim(); const quote = evidence.slice(sep + 2).replace(/^['"]|['"]$/g, '').trim(); const norm = s => s.replace(/\s+/g, ' ').toLowerCase(); // 支持 "path:lineno" 形式 let pathOnly = relPath; let line = null; const lineMatch = relPath.match(/^(.+):(\d+)$/); if (lineMatch) { pathOnly = lineMatch[1]; line = parseInt(lineMatch[2], 10); } const abs = path.join(projectRoot, pathOnly); if (!fs.existsSync(abs)) { return { ok: false, reason: 'file not found: ' + pathOnly }; } const content = fs.readFileSync(abs, 'utf8'); if (line) { const lines = content.split('\n'); const lineContent = lines[line - 1] || ''; if (norm(lineContent).includes(norm(quote))) { return { ok: true, hit_file: pathOnly, line }; } } if (norm(content).includes(norm(quote))) { return { ok: true, hit_file: pathOnly }; } return { ok: false, reason: 'quote not found in file', quote, file: pathOnly }; } const extract_evidence = { name: 'extract_evidence', description: '验证 evidence 列表每条能在原文 grep 命中(防 Triager 编造证据)', inputSchema: { type: 'object', required: ['evidence'], properties: { evidence: { type: 'array', items: { type: 'string' } }, }, }, async execute({ evidence }, ctx) { _req(ctx, ['pid']); const projectRoot = pathResolver.resolveProjectRoot(ctx.pid); const results = evidence.map(e => ({ evidence: e, ...verifyOneEvidence(projectRoot, e) })); return { ok: results.every(r => r.ok), results, failed: results.filter(r => !r.ok), }; }, }; // ──────────────────── category_classifier ──────────────────── const category_classifier = { name: 'category_classifier', description: '启发式粗判:contract_mismatch / impl_bug / spec_ambiguous / infra_flake', inputSchema: { type: 'object', required: ['result_json', 'process_log'], properties: { result_json: { type: 'object' }, process_log: { type: 'string' }, sdd_text: { type: 'string' }, }, }, async execute({ result_json, process_log, sdd_text = '' }, _ctx) { const hints = []; const score = { contract_mismatch: 0, impl_bug: 0, spec_ambiguous: 0, infra_flake: 0 }; // infra_flake 信号 const infraSignals = [ /EADDRINUSE/, /ECONNREFUSED/, /ETIMEDOUT/, /EAI_AGAIN/, /Cannot find module/, /port already in use/i, /health check timeout/i, ]; if (infraSignals.some(re => re.test(process_log || ''))) { score.infra_flake += 3; hints.push('infra signal in process.log'); } // spec_ambiguous 信号 const failTxt = JSON.stringify(result_json || {}); const failTokens = [...new Set(failTxt.toLowerCase().match(/[a-z]{4,}/g) || [])] .filter(t => !['function','expected','received','stack','message','error'].includes(t)); const sddLc = sdd_text.toLowerCase(); const tokensNotInSdd = failTokens.filter(t => sddLc && !sddLc.includes(t)); if (failTokens.length > 0 && tokensNotInSdd.length >= Math.max(2, failTokens.length / 2)) { score.spec_ambiguous += 2; hints.push('key tokens not found in SDD: ' + tokensNotInSdd.slice(0, 5).join(',')); } // impl_bug 信号:5xx got vs 4xx expected const fail0 = result_json?.failures?.[0]; if (fail0?.got?.status >= 500 && fail0?.expected?.status >= 400 && fail0?.expected?.status < 500) { score.impl_bug += 2; hints.push('5xx got vs 4xx expected → impl_bug'); } // contract_mismatch 兜底(默认) if (Object.values(score).every(v => v === 0)) { score.contract_mismatch += 1; hints.push('no strong signal → default contract_mismatch'); } const sorted = Object.entries(score).sort(([,a],[,b]) => b - a); const [topCat, topScore] = sorted[0]; let confidence; if (topScore >= 3) confidence = 0.85; else if (topScore === 2) confidence = 0.65; else if (topScore === 1) confidence = 0.45; else confidence = 0.30; return { ok: true, category: topCat, confidence, hints, score }; }, }; // ──────────────────── diff_contract_vs_impl ──────────────────── const diff_contract_vs_impl = { name: 'diff_contract_vs_impl', description: '轻量对比:contract.json 声明的 endpoint 是否在 code/ 文本中出现(只看签名,不验语义)', inputSchema: { type: 'object', required: ['unit_id'], properties: { unit_id: { type: 'string' } }, }, async execute({ unit_id }, ctx) { _req(ctx, ['pid']); const unitDir = pathResolver.resolveUnitDir(ctx.pid, unit_id); const contractPath = path.join(unitDir, 'contract.json'); if (!fs.existsSync(contractPath)) { return { ok: false, reason: 'contract.json missing' }; } const contract = JSON.parse(fs.readFileSync(contractPath, 'utf8')); const codeDir = path.join(unitDir, 'code'); if (!fs.existsSync(codeDir)) { return { ok: false, reason: 'code/ missing' }; } // 把 code/ 全部文本拼起来 let codeBlob = ''; function walk(dir) { for (const e of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, e.name); if (e.isDirectory()) walk(full); else if (/\.(js|ts|vue|jsx|tsx|mjs|cjs)$/.test(e.name)) { codeBlob += fs.readFileSync(full, 'utf8') + '\n'; } } } walk(codeDir); const divergences = []; const endpoints = contract.exports?.http || []; for (const ep of endpoints) { if (!codeBlob.includes(ep.path)) { divergences.push(`declared ${ep.method} ${ep.path} not found in code`); } } return { matches: divergences.length === 0, divergences }; }, }; // ──────────────────── find_similar_bug ──────────────────── const find_similar_bug = { name: 'find_similar_bug', description: '在 bugs.ndjson 找历史相似 bug(同 unit + 类似 test_name)', inputSchema: { type: 'object', required: ['signature'], properties: { signature: { type: 'object', properties: { unit_id: { type: 'string' }, test_name: { type: 'string' }, status: { type: 'array', items: { type: 'string' } }, }, }, }, }, async execute({ signature }, ctx) { _req(ctx, ['pid']); const bugs = bugTracker.list(ctx.pid, { unit_id: signature.unit_id, status: signature.status, }); const matches = bugs.filter(b => { if (!signature.test_name) return true; return b.test_name === signature.test_name || (b.test_name && b.test_name.includes(signature.test_name)); }); return { ok: true, matches, total: matches.length }; }, }; function _req(ctx, keys) { for (const k of keys) { if (!ctx || ctx[k] == null) throw new ToolMissingDependencyError(`ctx.${k}`); } } module.exports = { extract_evidence, category_classifier, diff_contract_vs_impl, find_similar_bug, };