#!/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 + project-level 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)); } if (typeof u.l2_rounds !== 'number') throw new Error('l2_rounds missing'); if (typeof u.l2_budget !== 'number') throw new Error('l2_budget missing'); for (const x of u.units) { if (x.status !== 'pending') throw new Error(`unit ${x.id} status=${x.status}, want pending`); if (typeof x.retry_budget !== 'number') throw new Error(`unit ${x.id} missing retry_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); });