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.
- package/.claude/skills/sillyspec-archive/SKILL.md +21 -21
- package/.claude/skills/sillyspec-auto/SKILL.md +83 -83
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +44 -44
- package/.claude/skills/sillyspec-commit/SKILL.md +106 -106
- package/.claude/skills/sillyspec-continue/SKILL.md +45 -45
- package/.claude/skills/sillyspec-doctor/SKILL.md +31 -31
- package/.claude/skills/sillyspec-execute/SKILL.md +30 -30
- package/.claude/skills/sillyspec-explore/SKILL.md +109 -109
- package/.claude/skills/sillyspec-knowledge/SKILL.md +269 -269
- package/.claude/skills/sillyspec-plan/SKILL.md +21 -21
- package/.claude/skills/sillyspec-propose/SKILL.md +21 -21
- package/.claude/skills/sillyspec-quick/SKILL.md +21 -21
- package/.claude/skills/sillyspec-resume/SKILL.md +68 -68
- package/.claude/skills/sillyspec-scan/SKILL.md +21 -21
- package/.claude/skills/sillyspec-state/SKILL.md +54 -54
- package/.claude/skills/sillyspec-status/SKILL.md +21 -21
- package/.claude/skills/sillyspec-verify/SKILL.md +21 -21
- package/.claude/skills/sillyspec-workspace/SKILL.md +157 -157
- package/.husky/pre-push +13 -13
- package/CLAUDE.md +18 -18
- package/README.md +198 -188
- package/SKILL.md +90 -91
- package/bin/sillyspec.js +2 -2
- package/docs/brainstorm-plan-contract.md +64 -64
- package/docs/plan-execute-contract.md +123 -123
- package/docs/platform-scan-protocol.md +298 -298
- package/docs/revision-mode.md +115 -115
- package/docs/sillyspec/file-lifecycle/known-implementation-gaps.md +99 -99
- package/docs/sillyspec/file-lifecycle/platform-workflows-sync.md +218 -218
- package/docs/sillyspec/file-lifecycle/stage-artifacts.md +167 -167
- package/docs/sillyspec/file-lifecycle/storage-and-state.md +148 -148
- package/docs/sillyspec/file-lifecycle/worktree-and-guard.md +211 -193
- package/docs/sillyspec/file-lifecycle.md +125 -125
- package/docs/workflow-contract-regression.md +106 -106
- package/docs/worktree-isolation.md +252 -252
- package/package.json +40 -40
- package/packages/dashboard/dist/assets/index-Bq_Z2hne.js +7446 -7446
- package/packages/dashboard/dist/assets/index-O2W5RV4z.css +1 -1
- package/packages/dashboard/dist/index.html +16 -16
- package/packages/dashboard/index.html +15 -15
- package/packages/dashboard/package-lock.json +2384 -2384
- package/packages/dashboard/package.json +25 -25
- package/packages/dashboard/server/executor.js +86 -86
- package/packages/dashboard/server/index.js +588 -588
- package/packages/dashboard/server/parser.js +526 -526
- package/packages/dashboard/server/watcher.js +344 -344
- package/packages/dashboard/src/App.vue +558 -558
- package/packages/dashboard/src/components/ActionBar.vue +93 -93
- package/packages/dashboard/src/components/CommandPalette.vue +96 -96
- package/packages/dashboard/src/components/DetailPanel.vue +137 -137
- package/packages/dashboard/src/components/LogStream.vue +65 -65
- package/packages/dashboard/src/components/PipelineStage.vue +95 -95
- package/packages/dashboard/src/components/PipelineView.vue +156 -156
- package/packages/dashboard/src/components/ProjectList.vue +210 -210
- package/packages/dashboard/src/components/StageBadge.vue +67 -67
- package/packages/dashboard/src/components/StepCard.vue +94 -94
- package/packages/dashboard/src/components/detail/DocsDetail.vue +48 -48
- package/packages/dashboard/src/components/detail/GitDetail.vue +61 -61
- package/packages/dashboard/src/components/detail/TechDetail.vue +43 -43
- package/packages/dashboard/src/composables/useDashboard.js +170 -170
- package/packages/dashboard/src/composables/useKeyboard.js +119 -119
- package/packages/dashboard/src/composables/useWebSocket.js +129 -129
- package/packages/dashboard/src/main.js +8 -8
- package/packages/dashboard/src/style.css +132 -132
- package/packages/dashboard/vite.config.js +18 -18
- package/src/brainstorm-postcheck.js +158 -158
- package/src/change-list.js +52 -52
- package/src/change-risk-profile.js +352 -352
- package/src/classify-change.js +73 -73
- package/src/constants.js +70 -70
- package/src/contract-matrix.js +278 -278
- package/src/db.js +201 -201
- package/src/endpoint-extractor.js +315 -315
- package/src/hooks/claude-pre-tool-use.cjs +125 -125
- package/src/hooks/worktree-guard.js +653 -653
- package/src/index.js +922 -900
- package/src/init.js +431 -431
- package/src/knowledge-match.js +130 -130
- package/src/migrate.js +117 -117
- package/src/modules.js +482 -482
- package/src/progress.js +1734 -1734
- package/src/run.js +3465 -3358
- package/src/scan-postcheck.js +387 -383
- package/src/setup.js +398 -398
- package/src/stage-contract.js +700 -700
- package/src/stages/archive.js +160 -160
- package/src/stages/brainstorm-auto.js +229 -229
- package/src/stages/brainstorm.js +645 -645
- package/src/stages/doctor.js +365 -365
- package/src/stages/execute.js +625 -625
- package/src/stages/explore.js +34 -34
- package/src/stages/index.js +29 -29
- package/src/stages/knowledge.js +498 -498
- package/src/stages/plan-postcheck.js +511 -513
- package/src/stages/plan.js +582 -582
- package/src/stages/propose.js +174 -174
- package/src/stages/quick.js +82 -82
- package/src/stages/scan.js +558 -558
- package/src/stages/status.js +65 -65
- package/src/stages/verify.js +322 -322
- package/src/sync.js +497 -497
- package/src/task-review.js +346 -346
- package/src/workflow.js +785 -785
- package/src/worktree-apply.js +549 -549
- package/src/worktree-deps.js +185 -0
- package/src/worktree.js +982 -932
- package/templates/workflows/archive-impact.yaml +79 -79
- package/templates/workflows/scan-docs.yaml +132 -132
- package/test/brainstorm-plan-contract.test.mjs +273 -273
- package/test/check-syntax.mjs +26 -26
- package/test/contract-artifacts.test.mjs +323 -323
- package/test/decision-supersede.test.mjs +277 -277
- package/test/knowledge-match.test.mjs +231 -231
- package/test/plan-execute-contract.test.mjs +330 -330
- package/test/plan-optimization.test.mjs +572 -572
- package/test/platform-artifacts.test.mjs +166 -166
- package/test/platform-failure-samples.test.mjs +199 -199
- package/test/platform-recovery-chain.test.mjs +167 -167
- package/test/platform-recovery.test.mjs +136 -136
- package/test/platform-scan-p0.test.mjs +168 -168
- package/test/revision-v1.test.mjs +1145 -1145
- package/test/run-scan-project-parse.test.mjs +200 -200
- package/test/run-tests.mjs +48 -48
- package/test/scan-knowledge.test.mjs +175 -175
- package/test/scan-paths.test.mjs +68 -68
- package/test/scan-postcheck.test.mjs +197 -197
- package/test/spec-dir.test.mjs +206 -206
- package/test/stage-contract.test.mjs +299 -299
- package/test/stage-definitions.test.mjs +39 -39
- package/test/wait-gates.test.mjs +496 -496
- package/test/worktree-deps-provision.test.mjs +148 -0
- package/test/worktree-guard.test.mjs +71 -71
- package/test/worktree-native-overlay.test.mjs +188 -188
|
@@ -1,315 +1,315 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* endpoint-extractor.js — 从代码中提取 HTTP 端点定义和调用
|
|
3
|
-
*
|
|
4
|
-
* provider 端:扫描 router 文件,提取注册的 API 路径
|
|
5
|
-
* consumer 端:扫描前端文件,提取 apiFetch/request 调用路径
|
|
6
|
-
*
|
|
7
|
-
* 契约对账时:provider 产出 ≠ consumer 消费 → gap
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { existsSync, readFileSync, readdirSync, statSync } from 'fs'
|
|
11
|
-
import { join, extname, basename, dirname, resolve } from 'path'
|
|
12
|
-
import { execSync } from 'child_process'
|
|
13
|
-
|
|
14
|
-
// ─── Provider: 扫描后端 router 注册的端点 ───────────────────────────────
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* 从单个文件提取 FastAPI router 端点
|
|
18
|
-
* 支持 APIRouter(prefix=...) 和 @router.get/post/put/delete/patch("/path")
|
|
19
|
-
*
|
|
20
|
-
* @param {string} filePath - 文件绝对路径
|
|
21
|
-
* @returns {Array<{ method: string, path: string, source: string, line: number }>}
|
|
22
|
-
*/
|
|
23
|
-
export function extractFastApiEndpoints(filePath) {
|
|
24
|
-
const content = readFileSync(filePath, 'utf8')
|
|
25
|
-
const lines = content.split('\n')
|
|
26
|
-
const endpoints = []
|
|
27
|
-
|
|
28
|
-
// 1. 提取 router prefix
|
|
29
|
-
let routerPrefix = ''
|
|
30
|
-
for (const line of lines) {
|
|
31
|
-
const prefixMatch = line.match(/(?:APIRouter|router)\s*\(\s*(?:prefix\s*=\s*)?["'`]([^"'`]+)["'`]/)
|
|
32
|
-
|| line.match(/\.include_router\s*\([^)]*prefix\s*=\s*["'`]([^"'`]+)["'`]/)
|
|
33
|
-
if (prefixMatch) {
|
|
34
|
-
routerPrefix = prefixMatch[1]
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// 2. 提取 @router.method("/path") 或分散式定义
|
|
39
|
-
// FastAPI 支持两种写法:
|
|
40
|
-
// a) @router.get("/path", ...) 下一行 def func():
|
|
41
|
-
// b) @router.get 下一行 ("/path", ...)
|
|
42
|
-
for (let i = 0; i < lines.length; i++) {
|
|
43
|
-
const line = lines[i]
|
|
44
|
-
const decoratorMatch = line.match(
|
|
45
|
-
/@(?:router|api_router)\.(get|post|put|delete|patch)\s*\(\s*["'`]([^"'`]+)["'`]/
|
|
46
|
-
)
|
|
47
|
-
if (decoratorMatch) {
|
|
48
|
-
const method = decoratorMatch[1].toUpperCase()
|
|
49
|
-
const rawPath = decoratorMatch[2]
|
|
50
|
-
endpoints.push({
|
|
51
|
-
method,
|
|
52
|
-
path: routerPrefix + rawPath,
|
|
53
|
-
source: filePath,
|
|
54
|
-
line: i + 1,
|
|
55
|
-
})
|
|
56
|
-
continue
|
|
57
|
-
}
|
|
58
|
-
// 分散式: @router.get\n ("/path",
|
|
59
|
-
const splitMatch = line.match(/@(?:router|api_router)\.(get|post|put|delete|patch)\s*$/)
|
|
60
|
-
if (splitMatch && i + 1 < lines.length) {
|
|
61
|
-
const nextLine = lines[i + 1]
|
|
62
|
-
const pathMatch = nextLine.match(/\(\s*["'`]([^"'`]+)["'`]/)
|
|
63
|
-
if (pathMatch) {
|
|
64
|
-
endpoints.push({
|
|
65
|
-
method: splitMatch[1].toUpperCase(),
|
|
66
|
-
path: routerPrefix + pathMatch[1],
|
|
67
|
-
source: filePath,
|
|
68
|
-
line: i + 1,
|
|
69
|
-
})
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
return endpoints
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* 从目录递归扫描所有 Python router 文件的端点
|
|
79
|
-
* @param {string} dir
|
|
80
|
-
* @param {{ filePattern?: RegExp, excludePatterns?: RegExp[] }} opts
|
|
81
|
-
* @returns {Array<{ method: string, path: string, source: string, line: number }>}
|
|
82
|
-
*/
|
|
83
|
-
export function scanBackendEndpoints(dir, opts = {}) {
|
|
84
|
-
const filePattern = opts.filePattern || /(?:router|routes|api|endpoint|controller)\.py$/i
|
|
85
|
-
const excludePatterns = opts.excludePatterns || [/__pycache__/, /node_modules/, /\.venv/, /test/i]
|
|
86
|
-
|
|
87
|
-
const results = []
|
|
88
|
-
if (!existsSync(dir)) return results
|
|
89
|
-
|
|
90
|
-
function walk(d) {
|
|
91
|
-
for (const entry of readdirSync(d, { withFileTypes: true })) {
|
|
92
|
-
const full = join(d, entry.name)
|
|
93
|
-
if (entry.isDirectory()) {
|
|
94
|
-
if (excludePatterns.some(p => p.test(entry.name))) continue
|
|
95
|
-
walk(full)
|
|
96
|
-
} else if (entry.isFile() && filePattern.test(entry.name)) {
|
|
97
|
-
try {
|
|
98
|
-
results.push(...extractFastApiEndpoints(full))
|
|
99
|
-
} catch {}
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
walk(dir)
|
|
104
|
-
return results
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// ─── Consumer: 扫描前端 API 调用路径 ─────────────────────────────────────
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* 从前端文件提取 API 调用路径
|
|
111
|
-
* 支持:
|
|
112
|
-
* apiFetch("/api/xxx")
|
|
113
|
-
* request("/api/xxx")
|
|
114
|
-
* axios.get("/api/xxx")
|
|
115
|
-
* axios.post("/api/xxx")
|
|
116
|
-
* fetch("/api/xxx")
|
|
117
|
-
*
|
|
118
|
-
* @param {string} filePath
|
|
119
|
-
* @returns {Array<{ method: string, path: string, source: string, line: number, raw: string }>}
|
|
120
|
-
*/
|
|
121
|
-
export function extractFrontendApiCalls(filePath) {
|
|
122
|
-
const content = readFileSync(filePath, 'utf8')
|
|
123
|
-
const lines = content.split('\n')
|
|
124
|
-
const results = []
|
|
125
|
-
|
|
126
|
-
for (let i = 0; i < lines.length; i++) {
|
|
127
|
-
const line = lines[i]
|
|
128
|
-
|
|
129
|
-
// Pattern 1: apiFetch<T>("/path", { method: "POST" }) — default GET
|
|
130
|
-
const apiFetchMatch = line.match(/apiFetch\s*(?:<[^>]*>)?\s*\(\s*["'`]([^"'`]+)["'`]/)
|
|
131
|
-
if (apiFetchMatch) {
|
|
132
|
-
// 检查是否有 method 字段(在后续行或同一行)
|
|
133
|
-
let method = 'GET'
|
|
134
|
-
const snippet = lines.slice(i, Math.min(i + 3, lines.length)).join(' ')
|
|
135
|
-
const methodMatch = snippet.match(/method\s*:\s*["'`](GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)["'`]/i)
|
|
136
|
-
if (methodMatch) method = methodMatch[1].toUpperCase()
|
|
137
|
-
|
|
138
|
-
results.push({
|
|
139
|
-
method,
|
|
140
|
-
path: normalizePath(apiFetchMatch[1]),
|
|
141
|
-
source: filePath,
|
|
142
|
-
line: i + 1,
|
|
143
|
-
raw: apiFetchMatch[1],
|
|
144
|
-
})
|
|
145
|
-
continue
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Pattern 2: axios.get/post/put/delete("/path")
|
|
149
|
-
const axiosMatch = line.match(/axios\.(get|post|put|delete|patch)\s*\(\s*["'`]([^"'`]+)["'`]/i)
|
|
150
|
-
if (axiosMatch) {
|
|
151
|
-
results.push({
|
|
152
|
-
method: axiosMatch[1].toUpperCase(),
|
|
153
|
-
path: normalizePath(axiosMatch[2]),
|
|
154
|
-
source: filePath,
|
|
155
|
-
line: i + 1,
|
|
156
|
-
raw: axiosMatch[2],
|
|
157
|
-
})
|
|
158
|
-
continue
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Pattern 3: fetch("/api/xxx", { method: "POST" })
|
|
162
|
-
const fetchMatch = line.match(/(?:api_?)?fetch\s*\(\s*["'`]([^"'`]+)["'`]/)
|
|
163
|
-
if (fetchMatch && !apiFetchMatch) {
|
|
164
|
-
let method = 'GET'
|
|
165
|
-
const snippet = lines.slice(i, Math.min(i + 3, lines.length)).join(' ')
|
|
166
|
-
const methodMatch = snippet.match(/method\s*:\s*["'`](GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)["'`]/i)
|
|
167
|
-
if (methodMatch) method = methodMatch[1].toUpperCase()
|
|
168
|
-
|
|
169
|
-
results.push({
|
|
170
|
-
method,
|
|
171
|
-
path: normalizePath(fetchMatch[1]),
|
|
172
|
-
source: filePath,
|
|
173
|
-
line: i + 1,
|
|
174
|
-
raw: fetchMatch[1],
|
|
175
|
-
})
|
|
176
|
-
continue
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
return results
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* 递归扫描前端目录的 API 调用
|
|
185
|
-
* @param {string} dir
|
|
186
|
-
* @param {{ filePattern?: RegExp, excludePatterns?: RegExp[] }} opts
|
|
187
|
-
* @returns {Array<{ method: string, path: string, source: string, line: number, raw: string }>}
|
|
188
|
-
*/
|
|
189
|
-
export function scanFrontendApiCalls(dir, opts = {}) {
|
|
190
|
-
const filePattern = opts.filePattern || /\.(ts|tsx|js|jsx)$/
|
|
191
|
-
const excludePatterns = opts.excludePatterns || [/node_modules/, /\.next/, /dist/, /__tests__/, /\.d\.ts$/]
|
|
192
|
-
|
|
193
|
-
const results = []
|
|
194
|
-
if (!existsSync(dir)) return results
|
|
195
|
-
|
|
196
|
-
function walk(d) {
|
|
197
|
-
for (const entry of readdirSync(d, { withFileTypes: true })) {
|
|
198
|
-
const full = join(d, entry.name)
|
|
199
|
-
if (entry.isDirectory()) {
|
|
200
|
-
if (excludePatterns.some(p => p.test(entry.name))) continue
|
|
201
|
-
walk(full)
|
|
202
|
-
} else if (entry.isFile() && filePattern.test(entry.name)) {
|
|
203
|
-
try {
|
|
204
|
-
results.push(...extractFrontendApiCalls(full))
|
|
205
|
-
} catch {}
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
walk(dir)
|
|
210
|
-
return results
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// ─── 路径归一化 ─────────────────────────────────────────────────────────
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* 将动态路径归一化为参数占位符
|
|
217
|
-
* /api/ppm/project-plan/${id}/plan-nodes → /api/ppm/project-plan/{param}/plan-nodes
|
|
218
|
-
* /api/ppm/project-plan/:planId/plan-nodes → /api/ppm/project-plan/{param}/plan-nodes
|
|
219
|
-
* @param {string} rawPath
|
|
220
|
-
* @returns {string}
|
|
221
|
-
*/
|
|
222
|
-
export function normalizePath(rawPath) {
|
|
223
|
-
return rawPath
|
|
224
|
-
.replace(/\$\{[^}]+\}/g, '{param}')
|
|
225
|
-
.replace(/:\w+/g, '{param}')
|
|
226
|
-
.replace(/\$\w+/g, '{param}')
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// ─── 对账 ───────────────────────────────────────────────────────────────
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* 比较前端调用的路径和后端注册的端点,返回差异
|
|
233
|
-
*
|
|
234
|
-
* @param {Array<{ path: string, method: string, source: string }>} frontendCalls
|
|
235
|
-
* @param {Array<{ path: string, method: string, source: string }>} backendEndpoints
|
|
236
|
-
* @returns {{
|
|
237
|
-
* missingBackend: Array<{ path: string, method: string, consumerFile: string, consumerLine: number }>,
|
|
238
|
-
* unusedBackend: Array<{ path: string, method: string, providerFile: string }>
|
|
239
|
-
* }}
|
|
240
|
-
*/
|
|
241
|
-
export function diffApiParity(frontendCalls, backendEndpoints) {
|
|
242
|
-
// 构建 backend 注册表:归一化 path + method → endpoint
|
|
243
|
-
const backendMap = new Map()
|
|
244
|
-
for (const ep of backendEndpoints) {
|
|
245
|
-
const key = `${ep.method}:${normalizePath(ep.path)}`
|
|
246
|
-
if (!backendMap.has(key)) backendMap.set(key, ep)
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
const missingBackend = []
|
|
250
|
-
for (const call of frontendCalls) {
|
|
251
|
-
const key = `${call.method}:${normalizePath(call.path)}`
|
|
252
|
-
if (!backendMap.has(key)) {
|
|
253
|
-
missingBackend.push({
|
|
254
|
-
path: normalizePath(call.path),
|
|
255
|
-
method: call.method,
|
|
256
|
-
consumerFile: call.source,
|
|
257
|
-
consumerLine: call.line,
|
|
258
|
-
})
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// 构建 frontend 调用表
|
|
263
|
-
const frontendSet = new Set(
|
|
264
|
-
frontendCalls.map(c => `${c.method}:${normalizePath(c.path)}`)
|
|
265
|
-
)
|
|
266
|
-
|
|
267
|
-
const unusedBackend = []
|
|
268
|
-
for (const ep of backendEndpoints) {
|
|
269
|
-
const key = `${ep.method}:${normalizePath(ep.path)}`
|
|
270
|
-
if (!frontendSet.has(key)) {
|
|
271
|
-
unusedBackend.push({
|
|
272
|
-
path: normalizePath(ep.path),
|
|
273
|
-
method: ep.method,
|
|
274
|
-
providerFile: ep.source,
|
|
275
|
-
})
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
return { missingBackend, unusedBackend, ok: missingBackend.length === 0 }
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// ─── CLI 入口 ────────────────────────────────────────────────────────────
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* CLI 子命令入口:sillyspec contract scan [--backend dir] [--frontend dir]
|
|
286
|
-
* 输出 JSON 格式的端点清单和对账结果
|
|
287
|
-
*/
|
|
288
|
-
export async function contractScan(args, cwd) {
|
|
289
|
-
const backendIdx = args.indexOf('--backend')
|
|
290
|
-
const frontendIdx = args.indexOf('--frontend')
|
|
291
|
-
const backendDir = backendIdx !== -1 && args[backendIdx + 1]
|
|
292
|
-
? resolve(cwd, args[backendIdx + 1])
|
|
293
|
-
: resolve(cwd, 'backend')
|
|
294
|
-
const frontendDir = frontendIdx !== -1 && args[frontendIdx + 1]
|
|
295
|
-
? resolve(cwd, args[frontendIdx + 1])
|
|
296
|
-
: resolve(cwd, 'frontend')
|
|
297
|
-
|
|
298
|
-
const backendEndpoints = scanBackendEndpoints(backendDir)
|
|
299
|
-
const frontendCalls = scanFrontendApiCalls(frontendDir)
|
|
300
|
-
const { missingBackend, unusedBackend } = diffApiParity(frontendCalls, backendEndpoints)
|
|
301
|
-
|
|
302
|
-
return {
|
|
303
|
-
backend: backendEndpoints.map(e => ({ method: e.method, path: normalizePath(e.path), file: e.source })),
|
|
304
|
-
frontend: frontendCalls.map(c => ({ method: c.method, path: normalizePath(c.path), file: c.source })),
|
|
305
|
-
missingBackend,
|
|
306
|
-
unusedBackend,
|
|
307
|
-
summary: {
|
|
308
|
-
backendEndpointCount: backendEndpoints.length,
|
|
309
|
-
frontendCallCount: frontendCalls.length,
|
|
310
|
-
missingBackendCount: missingBackend.length,
|
|
311
|
-
unusedBackendCount: unusedBackend.length,
|
|
312
|
-
ok: missingBackend.length === 0,
|
|
313
|
-
},
|
|
314
|
-
}
|
|
315
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* endpoint-extractor.js — 从代码中提取 HTTP 端点定义和调用
|
|
3
|
+
*
|
|
4
|
+
* provider 端:扫描 router 文件,提取注册的 API 路径
|
|
5
|
+
* consumer 端:扫描前端文件,提取 apiFetch/request 调用路径
|
|
6
|
+
*
|
|
7
|
+
* 契约对账时:provider 产出 ≠ consumer 消费 → gap
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'fs'
|
|
11
|
+
import { join, extname, basename, dirname, resolve } from 'path'
|
|
12
|
+
import { execSync } from 'child_process'
|
|
13
|
+
|
|
14
|
+
// ─── Provider: 扫描后端 router 注册的端点 ───────────────────────────────
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 从单个文件提取 FastAPI router 端点
|
|
18
|
+
* 支持 APIRouter(prefix=...) 和 @router.get/post/put/delete/patch("/path")
|
|
19
|
+
*
|
|
20
|
+
* @param {string} filePath - 文件绝对路径
|
|
21
|
+
* @returns {Array<{ method: string, path: string, source: string, line: number }>}
|
|
22
|
+
*/
|
|
23
|
+
export function extractFastApiEndpoints(filePath) {
|
|
24
|
+
const content = readFileSync(filePath, 'utf8')
|
|
25
|
+
const lines = content.split('\n')
|
|
26
|
+
const endpoints = []
|
|
27
|
+
|
|
28
|
+
// 1. 提取 router prefix
|
|
29
|
+
let routerPrefix = ''
|
|
30
|
+
for (const line of lines) {
|
|
31
|
+
const prefixMatch = line.match(/(?:APIRouter|router)\s*\(\s*(?:prefix\s*=\s*)?["'`]([^"'`]+)["'`]/)
|
|
32
|
+
|| line.match(/\.include_router\s*\([^)]*prefix\s*=\s*["'`]([^"'`]+)["'`]/)
|
|
33
|
+
if (prefixMatch) {
|
|
34
|
+
routerPrefix = prefixMatch[1]
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 2. 提取 @router.method("/path") 或分散式定义
|
|
39
|
+
// FastAPI 支持两种写法:
|
|
40
|
+
// a) @router.get("/path", ...) 下一行 def func():
|
|
41
|
+
// b) @router.get 下一行 ("/path", ...)
|
|
42
|
+
for (let i = 0; i < lines.length; i++) {
|
|
43
|
+
const line = lines[i]
|
|
44
|
+
const decoratorMatch = line.match(
|
|
45
|
+
/@(?:router|api_router)\.(get|post|put|delete|patch)\s*\(\s*["'`]([^"'`]+)["'`]/
|
|
46
|
+
)
|
|
47
|
+
if (decoratorMatch) {
|
|
48
|
+
const method = decoratorMatch[1].toUpperCase()
|
|
49
|
+
const rawPath = decoratorMatch[2]
|
|
50
|
+
endpoints.push({
|
|
51
|
+
method,
|
|
52
|
+
path: routerPrefix + rawPath,
|
|
53
|
+
source: filePath,
|
|
54
|
+
line: i + 1,
|
|
55
|
+
})
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
// 分散式: @router.get\n ("/path",
|
|
59
|
+
const splitMatch = line.match(/@(?:router|api_router)\.(get|post|put|delete|patch)\s*$/)
|
|
60
|
+
if (splitMatch && i + 1 < lines.length) {
|
|
61
|
+
const nextLine = lines[i + 1]
|
|
62
|
+
const pathMatch = nextLine.match(/\(\s*["'`]([^"'`]+)["'`]/)
|
|
63
|
+
if (pathMatch) {
|
|
64
|
+
endpoints.push({
|
|
65
|
+
method: splitMatch[1].toUpperCase(),
|
|
66
|
+
path: routerPrefix + pathMatch[1],
|
|
67
|
+
source: filePath,
|
|
68
|
+
line: i + 1,
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return endpoints
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 从目录递归扫描所有 Python router 文件的端点
|
|
79
|
+
* @param {string} dir
|
|
80
|
+
* @param {{ filePattern?: RegExp, excludePatterns?: RegExp[] }} opts
|
|
81
|
+
* @returns {Array<{ method: string, path: string, source: string, line: number }>}
|
|
82
|
+
*/
|
|
83
|
+
export function scanBackendEndpoints(dir, opts = {}) {
|
|
84
|
+
const filePattern = opts.filePattern || /(?:router|routes|api|endpoint|controller)\.py$/i
|
|
85
|
+
const excludePatterns = opts.excludePatterns || [/__pycache__/, /node_modules/, /\.venv/, /test/i]
|
|
86
|
+
|
|
87
|
+
const results = []
|
|
88
|
+
if (!existsSync(dir)) return results
|
|
89
|
+
|
|
90
|
+
function walk(d) {
|
|
91
|
+
for (const entry of readdirSync(d, { withFileTypes: true })) {
|
|
92
|
+
const full = join(d, entry.name)
|
|
93
|
+
if (entry.isDirectory()) {
|
|
94
|
+
if (excludePatterns.some(p => p.test(entry.name))) continue
|
|
95
|
+
walk(full)
|
|
96
|
+
} else if (entry.isFile() && filePattern.test(entry.name)) {
|
|
97
|
+
try {
|
|
98
|
+
results.push(...extractFastApiEndpoints(full))
|
|
99
|
+
} catch {}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
walk(dir)
|
|
104
|
+
return results
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── Consumer: 扫描前端 API 调用路径 ─────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 从前端文件提取 API 调用路径
|
|
111
|
+
* 支持:
|
|
112
|
+
* apiFetch("/api/xxx")
|
|
113
|
+
* request("/api/xxx")
|
|
114
|
+
* axios.get("/api/xxx")
|
|
115
|
+
* axios.post("/api/xxx")
|
|
116
|
+
* fetch("/api/xxx")
|
|
117
|
+
*
|
|
118
|
+
* @param {string} filePath
|
|
119
|
+
* @returns {Array<{ method: string, path: string, source: string, line: number, raw: string }>}
|
|
120
|
+
*/
|
|
121
|
+
export function extractFrontendApiCalls(filePath) {
|
|
122
|
+
const content = readFileSync(filePath, 'utf8')
|
|
123
|
+
const lines = content.split('\n')
|
|
124
|
+
const results = []
|
|
125
|
+
|
|
126
|
+
for (let i = 0; i < lines.length; i++) {
|
|
127
|
+
const line = lines[i]
|
|
128
|
+
|
|
129
|
+
// Pattern 1: apiFetch<T>("/path", { method: "POST" }) — default GET
|
|
130
|
+
const apiFetchMatch = line.match(/apiFetch\s*(?:<[^>]*>)?\s*\(\s*["'`]([^"'`]+)["'`]/)
|
|
131
|
+
if (apiFetchMatch) {
|
|
132
|
+
// 检查是否有 method 字段(在后续行或同一行)
|
|
133
|
+
let method = 'GET'
|
|
134
|
+
const snippet = lines.slice(i, Math.min(i + 3, lines.length)).join(' ')
|
|
135
|
+
const methodMatch = snippet.match(/method\s*:\s*["'`](GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)["'`]/i)
|
|
136
|
+
if (methodMatch) method = methodMatch[1].toUpperCase()
|
|
137
|
+
|
|
138
|
+
results.push({
|
|
139
|
+
method,
|
|
140
|
+
path: normalizePath(apiFetchMatch[1]),
|
|
141
|
+
source: filePath,
|
|
142
|
+
line: i + 1,
|
|
143
|
+
raw: apiFetchMatch[1],
|
|
144
|
+
})
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Pattern 2: axios.get/post/put/delete("/path")
|
|
149
|
+
const axiosMatch = line.match(/axios\.(get|post|put|delete|patch)\s*\(\s*["'`]([^"'`]+)["'`]/i)
|
|
150
|
+
if (axiosMatch) {
|
|
151
|
+
results.push({
|
|
152
|
+
method: axiosMatch[1].toUpperCase(),
|
|
153
|
+
path: normalizePath(axiosMatch[2]),
|
|
154
|
+
source: filePath,
|
|
155
|
+
line: i + 1,
|
|
156
|
+
raw: axiosMatch[2],
|
|
157
|
+
})
|
|
158
|
+
continue
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Pattern 3: fetch("/api/xxx", { method: "POST" })
|
|
162
|
+
const fetchMatch = line.match(/(?:api_?)?fetch\s*\(\s*["'`]([^"'`]+)["'`]/)
|
|
163
|
+
if (fetchMatch && !apiFetchMatch) {
|
|
164
|
+
let method = 'GET'
|
|
165
|
+
const snippet = lines.slice(i, Math.min(i + 3, lines.length)).join(' ')
|
|
166
|
+
const methodMatch = snippet.match(/method\s*:\s*["'`](GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)["'`]/i)
|
|
167
|
+
if (methodMatch) method = methodMatch[1].toUpperCase()
|
|
168
|
+
|
|
169
|
+
results.push({
|
|
170
|
+
method,
|
|
171
|
+
path: normalizePath(fetchMatch[1]),
|
|
172
|
+
source: filePath,
|
|
173
|
+
line: i + 1,
|
|
174
|
+
raw: fetchMatch[1],
|
|
175
|
+
})
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return results
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 递归扫描前端目录的 API 调用
|
|
185
|
+
* @param {string} dir
|
|
186
|
+
* @param {{ filePattern?: RegExp, excludePatterns?: RegExp[] }} opts
|
|
187
|
+
* @returns {Array<{ method: string, path: string, source: string, line: number, raw: string }>}
|
|
188
|
+
*/
|
|
189
|
+
export function scanFrontendApiCalls(dir, opts = {}) {
|
|
190
|
+
const filePattern = opts.filePattern || /\.(ts|tsx|js|jsx)$/
|
|
191
|
+
const excludePatterns = opts.excludePatterns || [/node_modules/, /\.next/, /dist/, /__tests__/, /\.d\.ts$/]
|
|
192
|
+
|
|
193
|
+
const results = []
|
|
194
|
+
if (!existsSync(dir)) return results
|
|
195
|
+
|
|
196
|
+
function walk(d) {
|
|
197
|
+
for (const entry of readdirSync(d, { withFileTypes: true })) {
|
|
198
|
+
const full = join(d, entry.name)
|
|
199
|
+
if (entry.isDirectory()) {
|
|
200
|
+
if (excludePatterns.some(p => p.test(entry.name))) continue
|
|
201
|
+
walk(full)
|
|
202
|
+
} else if (entry.isFile() && filePattern.test(entry.name)) {
|
|
203
|
+
try {
|
|
204
|
+
results.push(...extractFrontendApiCalls(full))
|
|
205
|
+
} catch {}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
walk(dir)
|
|
210
|
+
return results
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ─── 路径归一化 ─────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* 将动态路径归一化为参数占位符
|
|
217
|
+
* /api/ppm/project-plan/${id}/plan-nodes → /api/ppm/project-plan/{param}/plan-nodes
|
|
218
|
+
* /api/ppm/project-plan/:planId/plan-nodes → /api/ppm/project-plan/{param}/plan-nodes
|
|
219
|
+
* @param {string} rawPath
|
|
220
|
+
* @returns {string}
|
|
221
|
+
*/
|
|
222
|
+
export function normalizePath(rawPath) {
|
|
223
|
+
return rawPath
|
|
224
|
+
.replace(/\$\{[^}]+\}/g, '{param}')
|
|
225
|
+
.replace(/:\w+/g, '{param}')
|
|
226
|
+
.replace(/\$\w+/g, '{param}')
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ─── 对账 ───────────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* 比较前端调用的路径和后端注册的端点,返回差异
|
|
233
|
+
*
|
|
234
|
+
* @param {Array<{ path: string, method: string, source: string }>} frontendCalls
|
|
235
|
+
* @param {Array<{ path: string, method: string, source: string }>} backendEndpoints
|
|
236
|
+
* @returns {{
|
|
237
|
+
* missingBackend: Array<{ path: string, method: string, consumerFile: string, consumerLine: number }>,
|
|
238
|
+
* unusedBackend: Array<{ path: string, method: string, providerFile: string }>
|
|
239
|
+
* }}
|
|
240
|
+
*/
|
|
241
|
+
export function diffApiParity(frontendCalls, backendEndpoints) {
|
|
242
|
+
// 构建 backend 注册表:归一化 path + method → endpoint
|
|
243
|
+
const backendMap = new Map()
|
|
244
|
+
for (const ep of backendEndpoints) {
|
|
245
|
+
const key = `${ep.method}:${normalizePath(ep.path)}`
|
|
246
|
+
if (!backendMap.has(key)) backendMap.set(key, ep)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const missingBackend = []
|
|
250
|
+
for (const call of frontendCalls) {
|
|
251
|
+
const key = `${call.method}:${normalizePath(call.path)}`
|
|
252
|
+
if (!backendMap.has(key)) {
|
|
253
|
+
missingBackend.push({
|
|
254
|
+
path: normalizePath(call.path),
|
|
255
|
+
method: call.method,
|
|
256
|
+
consumerFile: call.source,
|
|
257
|
+
consumerLine: call.line,
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// 构建 frontend 调用表
|
|
263
|
+
const frontendSet = new Set(
|
|
264
|
+
frontendCalls.map(c => `${c.method}:${normalizePath(c.path)}`)
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
const unusedBackend = []
|
|
268
|
+
for (const ep of backendEndpoints) {
|
|
269
|
+
const key = `${ep.method}:${normalizePath(ep.path)}`
|
|
270
|
+
if (!frontendSet.has(key)) {
|
|
271
|
+
unusedBackend.push({
|
|
272
|
+
path: normalizePath(ep.path),
|
|
273
|
+
method: ep.method,
|
|
274
|
+
providerFile: ep.source,
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return { missingBackend, unusedBackend, ok: missingBackend.length === 0 }
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ─── CLI 入口 ────────────────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* CLI 子命令入口:sillyspec contract scan [--backend dir] [--frontend dir]
|
|
286
|
+
* 输出 JSON 格式的端点清单和对账结果
|
|
287
|
+
*/
|
|
288
|
+
export async function contractScan(args, cwd) {
|
|
289
|
+
const backendIdx = args.indexOf('--backend')
|
|
290
|
+
const frontendIdx = args.indexOf('--frontend')
|
|
291
|
+
const backendDir = backendIdx !== -1 && args[backendIdx + 1]
|
|
292
|
+
? resolve(cwd, args[backendIdx + 1])
|
|
293
|
+
: resolve(cwd, 'backend')
|
|
294
|
+
const frontendDir = frontendIdx !== -1 && args[frontendIdx + 1]
|
|
295
|
+
? resolve(cwd, args[frontendIdx + 1])
|
|
296
|
+
: resolve(cwd, 'frontend')
|
|
297
|
+
|
|
298
|
+
const backendEndpoints = scanBackendEndpoints(backendDir)
|
|
299
|
+
const frontendCalls = scanFrontendApiCalls(frontendDir)
|
|
300
|
+
const { missingBackend, unusedBackend } = diffApiParity(frontendCalls, backendEndpoints)
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
backend: backendEndpoints.map(e => ({ method: e.method, path: normalizePath(e.path), file: e.source })),
|
|
304
|
+
frontend: frontendCalls.map(c => ({ method: c.method, path: normalizePath(c.path), file: c.source })),
|
|
305
|
+
missingBackend,
|
|
306
|
+
unusedBackend,
|
|
307
|
+
summary: {
|
|
308
|
+
backendEndpointCount: backendEndpoints.length,
|
|
309
|
+
frontendCallCount: frontendCalls.length,
|
|
310
|
+
missingBackendCount: missingBackend.length,
|
|
311
|
+
unusedBackendCount: unusedBackend.length,
|
|
312
|
+
ok: missingBackend.length === 0,
|
|
313
|
+
},
|
|
314
|
+
}
|
|
315
|
+
}
|