phase1-verify.js 7.6 KB
#!/usr/bin/env node
/**
 * scripts/phase1-verify.js
 *
 * P1 Docs MVP 验收 (mock 模式端到端):
 *   1. 启动只挂 devloop router 的 express app (随机端口)
 *   2. POST /api/devloop/projects 创建 todo-demo 项目
 *   3. 校验响应体: ok / pid / pm.files_written / architect.files_written / commit sha
 *   4. 校验文件系统:对齐 plan-migration.md §P1 验收的 22 个必要路径
 *   5. 校验 units.json 含 l2_rounds/l2_budget
 *   6. GET /state 返 prd_rev/sdd_rev/units
 *   7. git log 至少 4 个 commit (init meta + PM + Architect + project init)
 *
 * 通过 → exit 0;失败 → exit 1。
 *
 * 用法:  node scripts/phase1-verify.js
 */

process.env.LEMONBOSS_CLAUDE_MODE = 'mock';

const fs = require('fs');
const path = require('path');
// 复用 server/ 下的 express (top-level scripts/ 没自己 node_modules)
const serverNodeModules = require('path').resolve(__dirname, '../server/node_modules');
require('module').globalPaths.push(serverNodeModules);
const express = (() => {
  try { return require('express'); }
  catch {
    return require(require.resolve('express', { paths: [require('path').resolve(__dirname, '../server')] }));
  }
})();

const pathResolver = require('../server/services/devloop/path-resolver');
const gitVault = require('../server/services/devloop/git-vault');
const devloopRouter = require('../server/routes/devloop');

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', () => {
      const port = srv.address().port;
      resolve({ srv, base: `http://127.0.0.1:${port}` });
    });
  });
}

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

  try {
    const r = await step('POST /api/devloop/projects', 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 列表:能新增、列表、Vue 前端',
        }),
      });
      if (resp.status !== 200) {
        const t = await resp.text();
        throw new Error(`POST status ${resp.status}: ${t}`);
      }
      const j = await resp.json();
      if (!j.ok || !j.pid?.startsWith('p-')) throw new Error('bad body: ' + JSON.stringify(j));
      if (j.pm.files_written < 10) throw new Error('pm.files_written too few: ' + j.pm.files_written);
      if (j.architect.files_written < 10) throw new Error('architect.files_written too few');
      if (!j.pm.commit) throw new Error('no pm commit');
      if (!j.architect.commit) throw new Error('no architect commit');
      pid = j.pid;
      return { detail: `pid=${pid} pm=${j.pm.files_written} arch=${j.architect.files_written}` };
    });

    await step('P1 acceptance file tree complete', async () => {
      const root = pathResolver.resolveProjectRoot(pid);
      const required = [
        'project.json',
        'prd/index.html',
        'prd/reading-guide.html',
        'prd/product/background.html',
        'prd/product/personas.html',
        'prd/product/scope.html',
        'prd/feature/feature-tree.html',
        'prd/flow/user-flows.html',
        'prd/prototype/proto-list.html',
        'prd/assets/shell.css',
        'sdd/index.html',
        'sdd/mvcp/model.html',
        'sdd/mvcp/view.html',
        'sdd/mvcp/control.html',
        'sdd/mvcp/process.html',
        'units.json',
        'integration/spec.json',
        'agent-state/pm.json',
        'agent-state/pm-todo.json',
        'agent-state/architect.json',
        'agent-state/architect-todo.json',
        'history/prd-rev-001.html',
        'history/sdd-rev-001.html',
      ];
      const missing = required.filter(f => !fs.existsSync(path.join(root, f)));
      if (missing.length) throw new Error('missing files:\n  ' + missing.join('\n  '));
      return { detail: `${required.length}/${required.length} files present` };
    });

    await step('units.json has 3 units with l2_rounds/l2_budget', () => {
      const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8'));
      if (!Array.isArray(u.units) || u.units.length !== 3) {
        throw new Error('want 3 units, got: ' + (u.units?.length || 0));
      }
      for (const x of u.units) {
        if (typeof x.l2_rounds !== 'number') throw new Error(`unit ${x.id} missing l2_rounds`);
        if (typeof x.l2_budget !== 'number') throw new Error(`unit ${x.id} missing l2_budget`);
      }
      return { detail: '3 units · l2 fields present · DAG: ' + u.units.map(x => x.id).join(' → ') };
    });

    await step('integration/spec.json has cases', () => {
      const sp = JSON.parse(fs.readFileSync(path.join(pathResolver.resolveProjectRoot(pid), 'integration/spec.json'), 'utf8'));
      if (!Array.isArray(sp.cases) || sp.cases.length === 0) {
        throw new Error('no integration cases');
      }
      return { detail: `${sp.cases.length} case(s)` };
    });

    await step('GET /state', async () => {
      const resp = await fetch(`${base}/api/devloop/projects/${pid}/state`);
      if (resp.status !== 200) throw new Error('state status: ' + resp.status);
      const s = await resp.json();
      if (!s.prd_site) throw new Error('prd_site=false');
      if (!s.sdd_site) throw new Error('sdd_site=false');
      if (s.units?.length !== 3) throw new Error('state.units length: ' + s.units?.length);
      return { detail: `prd_rev=${s.prd_rev} sdd_rev=${s.sdd_rev} units=${s.units.length}` };
    });

    await step('git log ≥4 commits', async () => {
      const log = await gitVault.log(pid, { limit: 20 });
      const commits = Array.isArray(log) ? log : (log.commits || []);
      if (commits.length < 4) {
        throw new Error('want ≥4 commits, got ' + commits.length);
      }
      return { detail: `${commits.length} commits` };
    });

    await step('timeline.ndjson has agent.call + docs.complete', () => {
      const tl = pathResolver.resolveTimelineLog(pid);
      const kinds = fs.readFileSync(tl, 'utf8').trim().split('\n')
        .map(l => { try { return JSON.parse(l).kind; } catch { return ''; }});
      if (!kinds.includes('agent.call')) throw new Error('no agent.call in timeline');
      if (!kinds.includes('docs.complete')) throw new Error('no docs.complete in timeline');
      return { detail: kinds.length + ' timeline events' };
    });

  } finally {
    srv.close();
    // cleanup sandbox project
    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 1 — Docs MVP Acceptance ───────────');
  for (const r of results) {
    const tag = r.ok ? '✓' : '✗';
    console.log(`│ ${tag} ${r.name.padEnd(50)} ${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[phase1-verify] fatal:', e);
  for (const r of results) {
    console.log(' ', r.ok ? '✓' : '✗', r.name, r.ok ? r.detail : '— ' + r.reason);
  }
  process.exit(1);
});