tester-tools.js 8.28 KB
/**
 * 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 };