sillyspec 3.18.3 → 3.18.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,323 @@
1
+ /**
2
+ * endpoint-extractor 和 contract-matrix 测试
3
+ */
4
+
5
+ import { describe, it } from 'node:test'
6
+ import assert from 'node:assert/strict'
7
+ import { writeFileSync, mkdirSync, rmSync } from 'fs'
8
+ import { join } from 'path'
9
+ import { tmpdir } from 'os'
10
+ import {
11
+ extractFastApiEndpoints,
12
+ extractFrontendApiCalls,
13
+ normalizePath,
14
+ diffApiParity,
15
+ } from '../src/endpoint-extractor.js'
16
+ import {
17
+ classifyTask,
18
+ } from '../src/contract-matrix.js'
19
+
20
+ // ─── 路径归一化 ─────────────────────────────────────────────────────────
21
+
22
+ describe('normalizePath', () => {
23
+ it('模板字符串归一化', () => {
24
+ assert.equal(normalizePath('/api/ppm/project-plan/${id}/plan-nodes'), '/api/ppm/project-plan/{param}/plan-nodes')
25
+ })
26
+
27
+ it('Express 风格参数归一化', () => {
28
+ assert.equal(normalizePath('/api/users/:userId/posts'), '/api/users/{param}/posts')
29
+ })
30
+
31
+ it('无参数不改变', () => {
32
+ assert.equal(normalizePath('/api/ppm/plan-node'), '/api/ppm/plan-node')
33
+ })
34
+ })
35
+
36
+ // ─── FastAPI 端点提取 ──────────────────────────────────────────────────
37
+
38
+ describe('extractFastApiEndpoints', () => {
39
+ const tmpDir = join(tmpdir(), 'sillyspec-test-fastapi')
40
+ const routerFile = join(tmpDir, 'router.py')
41
+
42
+ it('提取单行装饰器端点', () => {
43
+ mkdirSync(tmpDir, { recursive: true })
44
+ writeFileSync(routerFile, [
45
+ 'from fastapi import APIRouter',
46
+ 'router = APIRouter(prefix="/api/ppm")',
47
+ '',
48
+ '@router.get("/plan-node")',
49
+ 'async def list_plan_nodes():',
50
+ ' pass',
51
+ '',
52
+ '@router.post("/plan-node")',
53
+ 'async def create_plan_node():',
54
+ ' pass',
55
+ '',
56
+ '@router.get("/project-plan/{plan_id}/plan-nodes")',
57
+ 'async def list_ps_plan_nodes(plan_id: str):',
58
+ ' pass',
59
+ ].join('\n'), 'utf8')
60
+
61
+ const endpoints = extractFastApiEndpoints(routerFile)
62
+ assert.equal(endpoints.length, 3)
63
+ assert.equal(endpoints[0].method, 'GET')
64
+ assert.equal(endpoints[0].path, '/api/ppm/plan-node')
65
+ assert.equal(endpoints[1].method, 'POST')
66
+ assert.equal(endpoints[1].path, '/api/ppm/plan-node')
67
+ assert.equal(endpoints[2].method, 'GET')
68
+ assert.equal(endpoints[2].path, '/api/ppm/project-plan/{plan_id}/plan-nodes')
69
+ })
70
+
71
+ it('prefix + 路径正确合并', () => {
72
+ mkdirSync(tmpDir, { recursive: true })
73
+ writeFileSync(routerFile, [
74
+ 'router = APIRouter(prefix="/api/v2")',
75
+ '@router.get("/users")',
76
+ 'async def list_users():',
77
+ ' pass',
78
+ ].join('\n'), 'utf8')
79
+
80
+ const endpoints = extractFastApiEndpoints(routerFile)
81
+ assert.equal(endpoints.length, 1)
82
+ assert.equal(endpoints[0].path, '/api/v2/users')
83
+ })
84
+
85
+ it('空文件返回空', () => {
86
+ mkdirSync(tmpDir, { recursive: true })
87
+ writeFileSync(routerFile, '', 'utf8')
88
+ const endpoints = extractFastApiEndpoints(routerFile)
89
+ assert.equal(endpoints.length, 0)
90
+ })
91
+
92
+ // cleanup
93
+ it('cleanup', () => {
94
+ try { rmSync(tmpDir, { recursive: true, force: true }) } catch {}
95
+ })
96
+ })
97
+
98
+ // ─── 前端 API 调用提取 ─────────────────────────────────────────────────
99
+
100
+ describe('extractFrontendApiCalls', () => {
101
+ const tmpDir = join(tmpdir(), 'sillyspec-test-frontend')
102
+ const apiFile = join(tmpDir, 'plan.ts')
103
+
104
+ it('提取 apiFetch 调用', () => {
105
+ mkdirSync(tmpDir, { recursive: true })
106
+ writeFileSync(apiFile, [
107
+ 'export async function listPlanNodes(params: PageReq): Promise<PlanNode[]> {',
108
+ ' return apiFetch<PlanNode[]>("/api/ppm/plan-node", { query: params });',
109
+ '}',
110
+ '',
111
+ 'export async function getProjectPlan(planId: string): Promise<ProjectPlan> {',
112
+ ' return apiFetch<ProjectPlan>(`/api/ppm/project-plan/${planId}`);',
113
+ '}',
114
+ '',
115
+ 'export async function listPlanNodesByPlan(planId: string): Promise<PsPlanNode[]> {',
116
+ ' return apiFetch<PsPlanNode[]>(`/api/ppm/project-plan/${planId}/plan-nodes`);',
117
+ '}',
118
+ '',
119
+ 'export async function createPlanNode(body: CreateReq): Promise<PlanNode> {',
120
+ ' return apiFetch<PlanNode>("/api/ppm/plan-node", {',
121
+ ' method: "POST",',
122
+ ' json: body,',
123
+ ' });',
124
+ '}',
125
+ '',
126
+ 'export async function deletePlan(id: string): Promise<void> {',
127
+ ' await apiFetch(`/api/ppm/plan-node/${id}`, { method: "DELETE" });',
128
+ '}',
129
+ ].join('\n'), 'utf8')
130
+
131
+ const calls = extractFrontendApiCalls(apiFile)
132
+ assert.ok(calls.length >= 5)
133
+
134
+ // GET /api/ppm/plan-node
135
+ const listCall = calls.find(c => c.raw === '/api/ppm/plan-node' && c.method === 'GET')
136
+ assert.ok(listCall, 'should find GET /api/ppm/plan-node')
137
+
138
+ // GET with template string → 归一化
139
+ const detailCall = calls.find(c => c.path === '/api/ppm/project-plan/{param}')
140
+ assert.ok(detailCall, 'should find GET /api/ppm/project-plan/{param}')
141
+
142
+ // POST
143
+ const createCall = calls.find(c => c.method === 'POST')
144
+ assert.ok(createCall, 'should find POST call')
145
+
146
+ // DELETE
147
+ const deleteCall = calls.find(c => c.method === 'DELETE')
148
+ assert.ok(deleteCall, 'should find DELETE call')
149
+ })
150
+
151
+ it('模板字符串归一化为 {param}', () => {
152
+ mkdirSync(tmpDir, { recursive: true })
153
+ writeFileSync(apiFile, [
154
+ 'const id = "123";',
155
+ 'apiFetch(`/api/users/${id}/profile`);',
156
+ ].join('\n'), 'utf8')
157
+
158
+ const calls = extractFrontendApiCalls(apiFile)
159
+ assert.equal(calls.length, 1)
160
+ assert.equal(calls[0].path, '/api/users/{param}/profile')
161
+ })
162
+
163
+ // cleanup
164
+ it('cleanup', () => {
165
+ try { rmSync(tmpDir, { recursive: true, force: true }) } catch {}
166
+ })
167
+ })
168
+
169
+ // ─── Parity Check ──────────────────────────────────────────────────────
170
+
171
+ describe('diffApiParity', () => {
172
+ it('前端调用后端不存在路径时 missingBackend', () => {
173
+ const frontendCalls = [
174
+ { method: 'GET', path: '/api/ppm/plan-node', source: 'plan.ts', line: 10 },
175
+ { method: 'GET', path: '/api/ppm/project-plan/{param}/plan-nodes', source: 'plan.ts', line: 20 },
176
+ ]
177
+ const backendEndpoints = [
178
+ { method: 'GET', path: '/api/ppm/plan-node', source: 'router.py' },
179
+ // 缺少 /project-plan/{id}/plan-nodes
180
+ ]
181
+
182
+ const result = diffApiParity(frontendCalls, backendEndpoints)
183
+ assert.equal(result.missingBackend.length, 1)
184
+ assert.equal(result.missingBackend[0].path, '/api/ppm/project-plan/{param}/plan-nodes')
185
+ assert.equal(result.unusedBackend.length, 0)
186
+ assert.equal(result.ok, false)
187
+ })
188
+
189
+ it('全部匹配时 ok', () => {
190
+ const frontendCalls = [
191
+ { method: 'GET', path: '/api/ppm/plan-node', source: 'plan.ts', line: 10 },
192
+ ]
193
+ const backendEndpoints = [
194
+ { method: 'GET', path: '/api/ppm/plan-node', source: 'router.py' },
195
+ ]
196
+
197
+ const result = diffApiParity(frontendCalls, backendEndpoints)
198
+ assert.equal(result.ok, true)
199
+ assert.equal(result.missingBackend.length, 0)
200
+ })
201
+
202
+ it('后端有但前端未调用的路径在 unusedBackend', () => {
203
+ const frontendCalls = []
204
+ const backendEndpoints = [
205
+ { method: 'GET', path: '/api/ppm/internal/health', source: 'router.py' },
206
+ ]
207
+
208
+ const result = diffApiParity(frontendCalls, backendEndpoints)
209
+ assert.equal(result.unusedBackend.length, 1)
210
+ assert.equal(result.unusedBackend[0].path, '/api/ppm/internal/health')
211
+ })
212
+ })
213
+
214
+ // ─── Task 分类 ──────────────────────────────────────────────────────────
215
+
216
+ describe('classifyTask', () => {
217
+ it('后端 router task 识别为 provider', () => {
218
+ const content = '## 目标\n实现 plan 子域后端 router,包含 APIRouter 路由注册。'
219
+ const result = classifyTask(content)
220
+ assert.ok(result.isProvider)
221
+ })
222
+
223
+ it('前端 API client task 识别为 consumer', () => {
224
+ const content = '## 目标\n为前端提供统一 API client,使用 apiFetch 封装。'
225
+ const result = classifyTask(content)
226
+ assert.ok(result.isConsumer)
227
+ })
228
+
229
+ it('纯文档 task 两者都不是', () => {
230
+ const content = '## 目标\n更新 README 文档。'
231
+ const result = classifyTask(content)
232
+ assert.ok(!result.isProvider || result.confidence < 0.5)
233
+ assert.ok(!result.isConsumer || result.confidence < 0.5)
234
+ })
235
+ })
236
+
237
+ // ─── 集成测试:复现 PPM 真实场景 ───────────────────────────────────────
238
+
239
+ describe('PPM 真实场景:跨 task 端点漏实现', () => {
240
+ const tmpDir = join(tmpdir(), 'sillyspec-test-ppm')
241
+ const routerFile = join(tmpDir, 'router.py')
242
+ const apiClientFile = join(tmpDir, 'plan.ts')
243
+
244
+ it('verify 应该 FAIL:前端调用 /project-plan/{id}/plan-nodes 但后端未实现', () => {
245
+ mkdirSync(tmpDir, { recursive: true })
246
+
247
+ // 模拟 task-04 后端实现:只注册了基础 CRUD,遗漏嵌套端点
248
+ writeFileSync(routerFile, [
249
+ 'router = APIRouter(prefix="/api/ppm")',
250
+ '',
251
+ '@router.get("/plan-node")',
252
+ 'async def list_plan_nodes():',
253
+ ' pass',
254
+ '',
255
+ '@router.post("/plan-node")',
256
+ 'async def create_plan_node():',
257
+ ' pass',
258
+ '',
259
+ '@router.get("/project-plan")',
260
+ 'async def list_project_plans():',
261
+ ' pass',
262
+ '',
263
+ '@router.post("/project-plan")',
264
+ 'async def create_project_plan():',
265
+ ' pass',
266
+ ].join('\n'), 'utf8')
267
+
268
+ // 模拟 task-09 前端 API client:调用了后端不存在的嵌套端点
269
+ writeFileSync(apiClientFile, [
270
+ 'export async function listProjectPlans(params: PageReq) {',
271
+ ' return apiFetch<ProjectPlan[]>("/api/ppm/project-plan", pageQuery(params));',
272
+ '}',
273
+ '',
274
+ 'export async function listPlanNodes(planId: string) {',
275
+ ' // ← 后端没实现这个嵌套端点',
276
+ ' return apiFetch<PsPlanNode[]>(`/api/ppm/project-plan/${planId}/plan-nodes`);',
277
+ '}',
278
+ '',
279
+ 'export async function listPlanNodeDetails(nodeId: string) {',
280
+ ' // ← 后端也没实现这个',
281
+ ' return apiFetch<PlanNodeDetail[]>(`/api/ppm/plan-node/${nodeId}/details`);',
282
+ '}',
283
+ ].join('\n'), 'utf8')
284
+
285
+ // 提取后端端点
286
+ const backendEndpoints = extractFastApiEndpoints(routerFile)
287
+ const backendPaths = new Set(backendEndpoints.map(e => normalizePath(e.path)))
288
+
289
+ // 提取前端调用
290
+ const frontendCalls = extractFrontendApiCalls(apiClientFile)
291
+ const { missingBackend, ok } = diffApiParity(frontendCalls, backendEndpoints)
292
+
293
+ // 断言:后端只实现了基础 CRUD
294
+ assert.ok(backendPaths.has('/api/ppm/plan-node'), '后端应有 GET /plan-node')
295
+ assert.ok(backendPaths.has('/api/ppm/project-plan'), '后端应有 GET /project-plan')
296
+
297
+ // 断言:parity check 失败
298
+ assert.equal(ok, false, 'verify 应该 FAIL')
299
+ assert.equal(missingBackend.length, 2, '应发现 2 个缺失端点')
300
+
301
+ // 断言:缺失的端点正是 PPM 真实 bug 的那两个
302
+ const missingPaths = missingBackend.map(m => m.path)
303
+ assert.ok(
304
+ missingPaths.includes('/api/ppm/project-plan/{param}/plan-nodes'),
305
+ '应捕获缺失的 /project-plan/{id}/plan-nodes'
306
+ )
307
+ assert.ok(
308
+ missingPaths.includes('/api/ppm/plan-node/{param}/details'),
309
+ '应捕获缺失的 /plan-node/{id}/details'
310
+ )
311
+
312
+ // 断言:diff 输出带 source 文件路径
313
+ const planNodesGap = missingBackend.find(
314
+ m => m.path === '/api/ppm/project-plan/{param}/plan-nodes'
315
+ )
316
+ assert.ok(planNodesGap.consumerFile.includes('plan.ts'), '应带前端文件路径')
317
+ assert.equal(planNodesGap.consumerLine, 7, '应带行号')
318
+ })
319
+
320
+ it('cleanup', () => {
321
+ try { rmSync(tmpDir, { recursive: true, force: true }) } catch {}
322
+ })
323
+ })