triager-tools.js 8.18 KB
/**
 * 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,
};