sillyspec 3.20.2 → 3.20.4

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.
Files changed (133) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +21 -21
  2. package/.claude/skills/sillyspec-auto/SKILL.md +83 -83
  3. package/.claude/skills/sillyspec-brainstorm/SKILL.md +44 -44
  4. package/.claude/skills/sillyspec-commit/SKILL.md +106 -106
  5. package/.claude/skills/sillyspec-continue/SKILL.md +45 -45
  6. package/.claude/skills/sillyspec-doctor/SKILL.md +31 -31
  7. package/.claude/skills/sillyspec-execute/SKILL.md +30 -30
  8. package/.claude/skills/sillyspec-explore/SKILL.md +109 -109
  9. package/.claude/skills/sillyspec-knowledge/SKILL.md +269 -269
  10. package/.claude/skills/sillyspec-plan/SKILL.md +21 -21
  11. package/.claude/skills/sillyspec-propose/SKILL.md +21 -21
  12. package/.claude/skills/sillyspec-quick/SKILL.md +21 -21
  13. package/.claude/skills/sillyspec-resume/SKILL.md +68 -68
  14. package/.claude/skills/sillyspec-scan/SKILL.md +21 -21
  15. package/.claude/skills/sillyspec-state/SKILL.md +54 -54
  16. package/.claude/skills/sillyspec-status/SKILL.md +21 -21
  17. package/.claude/skills/sillyspec-verify/SKILL.md +21 -21
  18. package/.claude/skills/sillyspec-workspace/SKILL.md +157 -157
  19. package/.husky/pre-push +13 -13
  20. package/CLAUDE.md +18 -18
  21. package/README.md +198 -188
  22. package/SKILL.md +90 -91
  23. package/bin/sillyspec.js +2 -2
  24. package/docs/brainstorm-plan-contract.md +64 -64
  25. package/docs/plan-execute-contract.md +123 -123
  26. package/docs/platform-scan-protocol.md +298 -298
  27. package/docs/revision-mode.md +115 -115
  28. package/docs/sillyspec/file-lifecycle/known-implementation-gaps.md +99 -99
  29. package/docs/sillyspec/file-lifecycle/platform-workflows-sync.md +218 -218
  30. package/docs/sillyspec/file-lifecycle/stage-artifacts.md +167 -167
  31. package/docs/sillyspec/file-lifecycle/storage-and-state.md +148 -148
  32. package/docs/sillyspec/file-lifecycle/worktree-and-guard.md +211 -193
  33. package/docs/sillyspec/file-lifecycle.md +125 -125
  34. package/docs/workflow-contract-regression.md +106 -106
  35. package/docs/worktree-isolation.md +252 -252
  36. package/package.json +40 -40
  37. package/packages/dashboard/dist/assets/index-Bq_Z2hne.js +7446 -7446
  38. package/packages/dashboard/dist/assets/index-O2W5RV4z.css +1 -1
  39. package/packages/dashboard/dist/index.html +16 -16
  40. package/packages/dashboard/index.html +15 -15
  41. package/packages/dashboard/package-lock.json +2384 -2384
  42. package/packages/dashboard/package.json +25 -25
  43. package/packages/dashboard/server/executor.js +86 -86
  44. package/packages/dashboard/server/index.js +588 -588
  45. package/packages/dashboard/server/parser.js +526 -526
  46. package/packages/dashboard/server/watcher.js +344 -344
  47. package/packages/dashboard/src/App.vue +558 -558
  48. package/packages/dashboard/src/components/ActionBar.vue +93 -93
  49. package/packages/dashboard/src/components/CommandPalette.vue +96 -96
  50. package/packages/dashboard/src/components/DetailPanel.vue +137 -137
  51. package/packages/dashboard/src/components/LogStream.vue +65 -65
  52. package/packages/dashboard/src/components/PipelineStage.vue +95 -95
  53. package/packages/dashboard/src/components/PipelineView.vue +156 -156
  54. package/packages/dashboard/src/components/ProjectList.vue +210 -210
  55. package/packages/dashboard/src/components/StageBadge.vue +67 -67
  56. package/packages/dashboard/src/components/StepCard.vue +94 -94
  57. package/packages/dashboard/src/components/detail/DocsDetail.vue +48 -48
  58. package/packages/dashboard/src/components/detail/GitDetail.vue +61 -61
  59. package/packages/dashboard/src/components/detail/TechDetail.vue +43 -43
  60. package/packages/dashboard/src/composables/useDashboard.js +170 -170
  61. package/packages/dashboard/src/composables/useKeyboard.js +119 -119
  62. package/packages/dashboard/src/composables/useWebSocket.js +129 -129
  63. package/packages/dashboard/src/main.js +8 -8
  64. package/packages/dashboard/src/style.css +132 -132
  65. package/packages/dashboard/vite.config.js +18 -18
  66. package/src/brainstorm-postcheck.js +158 -158
  67. package/src/change-list.js +52 -52
  68. package/src/change-risk-profile.js +352 -352
  69. package/src/classify-change.js +73 -73
  70. package/src/constants.js +70 -70
  71. package/src/contract-matrix.js +278 -278
  72. package/src/db.js +201 -201
  73. package/src/endpoint-extractor.js +315 -315
  74. package/src/hooks/claude-pre-tool-use.cjs +125 -125
  75. package/src/hooks/worktree-guard.js +653 -653
  76. package/src/index.js +922 -900
  77. package/src/init.js +431 -431
  78. package/src/knowledge-match.js +130 -130
  79. package/src/migrate.js +117 -117
  80. package/src/modules.js +482 -482
  81. package/src/progress.js +1734 -1734
  82. package/src/run.js +3465 -3358
  83. package/src/scan-postcheck.js +387 -383
  84. package/src/setup.js +398 -398
  85. package/src/stage-contract.js +700 -700
  86. package/src/stages/archive.js +160 -160
  87. package/src/stages/brainstorm-auto.js +229 -229
  88. package/src/stages/brainstorm.js +645 -645
  89. package/src/stages/doctor.js +365 -365
  90. package/src/stages/execute.js +625 -625
  91. package/src/stages/explore.js +34 -34
  92. package/src/stages/index.js +29 -29
  93. package/src/stages/knowledge.js +498 -498
  94. package/src/stages/plan-postcheck.js +511 -513
  95. package/src/stages/plan.js +582 -582
  96. package/src/stages/propose.js +174 -174
  97. package/src/stages/quick.js +82 -82
  98. package/src/stages/scan.js +558 -558
  99. package/src/stages/status.js +65 -65
  100. package/src/stages/verify.js +322 -322
  101. package/src/sync.js +497 -497
  102. package/src/task-review.js +346 -346
  103. package/src/workflow.js +785 -785
  104. package/src/worktree-apply.js +549 -549
  105. package/src/worktree-deps.js +185 -0
  106. package/src/worktree.js +982 -932
  107. package/templates/workflows/archive-impact.yaml +79 -79
  108. package/templates/workflows/scan-docs.yaml +132 -132
  109. package/test/brainstorm-plan-contract.test.mjs +273 -273
  110. package/test/check-syntax.mjs +26 -26
  111. package/test/contract-artifacts.test.mjs +323 -323
  112. package/test/decision-supersede.test.mjs +277 -277
  113. package/test/knowledge-match.test.mjs +231 -231
  114. package/test/plan-execute-contract.test.mjs +330 -330
  115. package/test/plan-optimization.test.mjs +572 -572
  116. package/test/platform-artifacts.test.mjs +166 -166
  117. package/test/platform-failure-samples.test.mjs +199 -199
  118. package/test/platform-recovery-chain.test.mjs +167 -167
  119. package/test/platform-recovery.test.mjs +136 -136
  120. package/test/platform-scan-p0.test.mjs +168 -168
  121. package/test/revision-v1.test.mjs +1145 -1145
  122. package/test/run-scan-project-parse.test.mjs +200 -200
  123. package/test/run-tests.mjs +48 -48
  124. package/test/scan-knowledge.test.mjs +175 -175
  125. package/test/scan-paths.test.mjs +68 -68
  126. package/test/scan-postcheck.test.mjs +197 -197
  127. package/test/spec-dir.test.mjs +206 -206
  128. package/test/stage-contract.test.mjs +299 -299
  129. package/test/stage-definitions.test.mjs +39 -39
  130. package/test/wait-gates.test.mjs +496 -496
  131. package/test/worktree-deps-provision.test.mjs +148 -0
  132. package/test/worktree-guard.test.mjs +71 -71
  133. package/test/worktree-native-overlay.test.mjs +188 -188
@@ -1,323 +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
- })
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
+ })