triager-tools.js
8.18 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
/**
* 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,
};