phase1-verify.js
7.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
#!/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);
});