phase2-verify.js 7.33 KB
#!/usr/bin/env node
/**
 * scripts/phase2-verify.js
 *
 * P2 L1 单 Unit 验收 (mock 模式):
 *   1. 启动 devloop router
 *   2. POST /api/devloop/projects 创建 todo-demo
 *   3. POST /api/devloop/projects/:pid/run?only=todo-be 跑 L1 loop
 *   4. 校验 state 链路: pending → coding → testing → failed → coding → testing → passed
 *   5. 校验 coder.log 第二条决策含 last_failure (反思生效)
 *   6. 校验 runs/ 下至少 2 个 result.json,首失败末通过
 *   7. 校验沙箱越权被 _sandbox.assertWritable 拒绝
 *
 * 通过 → exit 0;失败 → exit 1
 */

process.env.LEMONBOSS_CLAUDE_MODE = 'mock';

const fs = require('fs');
const path = require('path');

const serverNodeModules = path.resolve(__dirname, '../server/node_modules');
require('module').globalPaths.push(serverNodeModules);
const express = (() => {
  try { return require('express'); }
  catch {
    return require(require.resolve('express', { paths: [path.resolve(__dirname, '../server')] }));
  }
})();

const pathResolver = require('../server/services/devloop/path-resolver');
const unitRegistry = require('../server/services/devloop/unit-registry');
const devloopRouter = require('../server/routes/devloop');
const { assertWritable } = require('../server/src/tools/devloop/_sandbox');
const { ToolSandboxError } = require('../server/src/tools/devloop/_errors');

const results = [];
function step(name, fn) {
  return (async () => {
    try {
      const r = await fn();
      results.push({ name, ok: true, detail: r?.detail || '' });
      return r;
    } catch (e) {
      results.push({ name, ok: false, reason: e.message });
      throw e;
    }
  })();
}

async function startApp() {
  const app = express();
  app.use(express.json());
  app.use('/api/devloop', devloopRouter);
  return new Promise((resolve) => {
    const srv = app.listen(0, '127.0.0.1', () => {
      resolve({ srv, base: `http://127.0.0.1:${srv.address().port}` });
    });
  });
}

async function main() {
  const { srv, base } = await startApp();
  const cid = 'c-phase2-' + Date.now().toString(36);
  let pid;
  const fsmTrail = [];

  try {
    // 准备:跑 PM + Architect 把 units.json 写好
    const r0 = await step('POST /projects (PM + Architect)', async () => {
      const resp = await fetch(`${base}/api/devloop/projects`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          company_id: cid,
          name: 'todo-demo',
          requirement: '做一个 TODO 列表 API + Vue 前端',
        }),
      });
      if (resp.status !== 200) throw new Error('status ' + resp.status);
      const j = await resp.json();
      pid = j.pid;
      return { detail: `pid=${pid}` };
    });

    // 监听 unit-registry SSE 收 FSM 转换
    unitRegistry.attachSseBroker({
      emit: (_pid, kind, ev) => {
        if (kind === 'unit.state' && ev.unit_id === 'todo-be') {
          fsmTrail.push(`${ev.from}→${ev.to}`);
        }
      },
    });

    await step('POST /run?only=todo-be (L1 loop)', async () => {
      const resp = await fetch(`${base}/api/devloop/projects/${pid}/run?only=todo-be`, { method: 'POST' });
      if (resp.status !== 200) throw new Error('status ' + resp.status);
      const j = await resp.json();
      if (!j.ok) throw new Error('runUnit ok=false');
      if (j.attempts !== 2) throw new Error('want 2 attempts, got ' + j.attempts);
      if (j.final_state !== 'passed') throw new Error('final_state=' + j.final_state);
      return { detail: `attempts=2 · final=passed · history=${j.history.length} steps` };
    });

    await step('FSM trail: pending → coding → testing → failed → coding → testing → passed', () => {
      const expected = [
        'pending→coding',
        'coding→testing',
        'testing→failed',
        'failed→coding',
        'coding→testing',
        'testing→passed',
      ];
      if (JSON.stringify(fsmTrail) !== JSON.stringify(expected)) {
        throw new Error('got: ' + fsmTrail.join(', '));
      }
      return { detail: fsmTrail.join(' · ') };
    });

    await step('coder.log second entry references previous failure', () => {
      const logPath = path.join(pathResolver.resolveUnitDir(pid, 'todo-be'), 'coder.log');
      const log = fs.readFileSync(logPath, 'utf8');
      if (!/attempt: 1/.test(log)) throw new Error('missing attempt: 1');
      if (!/attempt: 2/.test(log)) throw new Error('missing attempt: 2');
      const att2 = log.split('attempt: 2')[1] || '';
      for (const field of ['last_failure', 'last_cause', 'this_fix', 'rationale']) {
        if (!att2.includes(field)) throw new Error('attempt:2 missing ' + field);
      }
      return { detail: 'reflection template (last_failure/last_cause/this_fix/rationale) present' };
    });

    await step('runs/ has ≥2 result.json with first.ok=false, last.ok=true', () => {
      const runsDir = path.join(pathResolver.resolveUnitDir(pid, 'todo-be'), 'runs');
      const ids = fs.readdirSync(runsDir).sort();
      if (ids.length < 2) throw new Error('want ≥2 runs, got ' + ids.length);
      const first = JSON.parse(fs.readFileSync(path.join(runsDir, ids[0], 'result.json'), 'utf8'));
      const last = JSON.parse(fs.readFileSync(path.join(runsDir, ids[ids.length - 1], 'result.json'), 'utf8'));
      if (first.ok !== false) throw new Error('first run.ok should be false');
      if (last.ok !== true) throw new Error('last run.ok should be true');
      return { detail: `${ids.length} runs · first.ok=false · last.ok=true` };
    });

    await step('sandbox: writing other unit / prd is rejected', () => {
      const root = pathResolver.resolveProjectRoot(pid);
      const allowList = ['units/{unitId}/code/**', 'units/{unitId}/contract.json', 'units/{unitId}/coder.log'];
      const ctx = { agent: 'devloop-coder', projectDir: root, unit_id: 'todo-be' };
      // allowed
      assertWritable(path.join(root, 'units/todo-be/code/server.js'), allowList, ctx);
      // denied (cross-unit)
      let blockedCross = false;
      try { assertWritable(path.join(root, 'units/todo-fe/code/foo.js'), allowList, ctx); }
      catch (e) { blockedCross = e instanceof ToolSandboxError; }
      if (!blockedCross) throw new Error('cross-unit write was NOT blocked');
      // denied (prd)
      let blockedPrd = false;
      try { assertWritable(path.join(root, 'prd/index.html'), allowList, ctx); }
      catch (e) { blockedPrd = e instanceof ToolSandboxError; }
      if (!blockedPrd) throw new Error('prd write was NOT blocked');
      return { detail: 'cross-unit + prd writes blocked by ToolSandboxError' };
    });

  } finally {
    srv.close();
    try {
      fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true });
    } catch { /* ignore */ }
  }

  let pass = 0, fail = 0;
  console.log('\n┌─ DevLoop Phase 2 — L1 Single Unit Acceptance ────');
  for (const r of results) {
    const tag = r.ok ? '✓' : '✗';
    console.log(`│ ${tag} ${r.name.padEnd(60)} ${r.ok ? r.detail : '— ' + r.reason}`);
    r.ok ? pass++ : fail++;
  }
  console.log(`└─ ${pass}/${pass + fail} steps passed${fail ? ' · ' + fail + ' failed' : ''}\n`);
  process.exit(fail ? 1 : 0);
}

main().catch(e => {
  console.error('\n[phase2-verify] fatal:', e);
  for (const r of results) {
    console.log(' ', r.ok ? '✓' : '✗', r.name, r.ok ? r.detail : '— ' + r.reason);
  }
  process.exit(1);
});