sillyspec 3.18.3 → 3.18.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/package.json +1 -1
- package/src/contract-matrix.js +278 -0
- package/src/endpoint-extractor.js +315 -0
- package/src/index.js +36 -5
- package/src/init.js +31 -4
- package/src/run.js +87 -4
- package/src/stages/execute.js +48 -2
- package/src/stages/verify.js +25 -0
- package/src/worktree.js +264 -35
- package/test/contract-artifacts.test.mjs +323 -0
package/package.json
CHANGED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* contract-matrix.js — API Contract Matrix 生成与注入
|
|
3
|
+
*
|
|
4
|
+
* plan 阶段:识别 task 之间的 provider/consumer 关系,生成契约矩阵
|
|
5
|
+
* execute 阶段:
|
|
6
|
+
* - 后端 task 完成后自动提取 endpoint artifact
|
|
7
|
+
* - 前端 task 开始时注入上游契约
|
|
8
|
+
* verify 阶段:读取 artifact 做 parity check
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs'
|
|
12
|
+
import { join, resolve, basename, relative } from 'path'
|
|
13
|
+
import {
|
|
14
|
+
scanBackendEndpoints,
|
|
15
|
+
scanFrontendApiCalls,
|
|
16
|
+
normalizePath,
|
|
17
|
+
} from './endpoint-extractor.js'
|
|
18
|
+
|
|
19
|
+
// ─── 关键词检测 ─────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
const PROVIDER_KEYWORDS = /router|routes|endpoint|api|backend|controller|fastapi|flask|express|koa|spring/i
|
|
22
|
+
const CONSUMER_KEYWORDS = /frontend|client|service|apiFetch|request|fetch|axios|http/i
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 判断一个 task 文档是 provider(产出 API)还是 consumer(消费 API)
|
|
26
|
+
* @param {string} taskContent - task markdown 内容
|
|
27
|
+
* @returns {{ isProvider: boolean, isConsumer: boolean, confidence: number }}
|
|
28
|
+
*/
|
|
29
|
+
export function classifyTask(taskContent) {
|
|
30
|
+
const isProvider = PROVIDER_KEYWORDS.test(taskContent)
|
|
31
|
+
const isConsumer = CONSUMER_KEYWORDS.test(taskContent)
|
|
32
|
+
// 避免所有 task 都被标记(因为几乎所有 task 都含 "api")
|
|
33
|
+
// 加强判定:provider 要命中 router/endpoint/backend/controller + api
|
|
34
|
+
// consumer 要命中 frontend/client/apiFetch + api
|
|
35
|
+
const providerStrong = /router|endpoint|backend|controller|fastapi|flask/i.test(taskContent)
|
|
36
|
+
const consumerStrong = /frontend|apiFetch|axios|api.*client/i.test(taskContent)
|
|
37
|
+
const providerConfidence = providerStrong ? 0.8 : (isProvider ? 0.4 : 0)
|
|
38
|
+
const consumerConfidence = consumerStrong ? 0.8 : (isConsumer ? 0.4 : 0)
|
|
39
|
+
return {
|
|
40
|
+
isProvider: providerConfidence >= 0.4,
|
|
41
|
+
isConsumer: consumerConfidence >= 0.4,
|
|
42
|
+
confidence: Math.max(providerConfidence, consumerConfidence),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 从 plan.md 解析 task 依赖关系,识别 provider → consumer 对
|
|
48
|
+
* @param {string} planContent - plan.md 内容
|
|
49
|
+
* @param {string} changeDir - changes/<name>/ 目录
|
|
50
|
+
* @returns {Array<{ provider: string, consumer: string, type: string }>}
|
|
51
|
+
*/
|
|
52
|
+
export function buildContractMatrix(planContent, changeDir) {
|
|
53
|
+
const contracts = []
|
|
54
|
+
|
|
55
|
+
// 解析 task 依赖关系
|
|
56
|
+
// 格式: - [ ] task-04: ... (depends_on: [task-01]) 或
|
|
57
|
+
// | task-04 | ... | 01 |
|
|
58
|
+
const taskDeps = parseTaskDependencies(planContent)
|
|
59
|
+
|
|
60
|
+
// 读取各 task 文档,分类 provider/consumer
|
|
61
|
+
const taskClasses = {}
|
|
62
|
+
for (const taskName of Object.keys(taskDeps)) {
|
|
63
|
+
const taskFile = join(changeDir, 'tasks', `${taskName}.md`)
|
|
64
|
+
if (existsSync(taskFile)) {
|
|
65
|
+
taskClasses[taskName] = classifyTask(readFileSync(taskFile, 'utf8'))
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 识别契约对:A depends_on B,且 A 是 consumer,B 是 provider
|
|
70
|
+
for (const [consumer, deps] of Object.entries(taskDeps)) {
|
|
71
|
+
const consumerClass = taskClasses[consumer]
|
|
72
|
+
if (!consumerClass?.isConsumer) continue
|
|
73
|
+
|
|
74
|
+
for (const provider of deps) {
|
|
75
|
+
const providerClass = taskClasses[provider]
|
|
76
|
+
if (!providerClass?.isProvider) continue
|
|
77
|
+
|
|
78
|
+
// 避免自引用和重复
|
|
79
|
+
if (consumer === provider) continue
|
|
80
|
+
const alreadyExists = contracts.some(
|
|
81
|
+
c => c.provider === provider && c.consumer === consumer
|
|
82
|
+
)
|
|
83
|
+
if (alreadyExists) continue
|
|
84
|
+
|
|
85
|
+
contracts.push({
|
|
86
|
+
provider,
|
|
87
|
+
consumer,
|
|
88
|
+
type: 'api',
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return contracts
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 从 plan.md 解析 task 依赖关系
|
|
98
|
+
* @param {string} planContent
|
|
99
|
+
* @returns {Record<string, string[]>} task → depends_on list
|
|
100
|
+
*/
|
|
101
|
+
function parseTaskDependencies(planContent) {
|
|
102
|
+
const deps = {}
|
|
103
|
+
|
|
104
|
+
// 方式 1: 表格形式 | task-04 | ... | 01,02 |
|
|
105
|
+
const tableRows = planContent.matchAll(/\|[^|]*task-(\d+)[^|]*\|[^|]*\|[^|]*(?:task-)?(\d+(?:\s*[,,]\s*\d+)*)[^|]*\|/gi)
|
|
106
|
+
for (const match of tableRows) {
|
|
107
|
+
const task = `task-${match[1]}`
|
|
108
|
+
const depList = match[2].split(/[,,\s]+/).map(d => `task-${d.trim()}`).filter(d => d.startsWith('task-'))
|
|
109
|
+
deps[task] = depList
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 方式 2: depends_on 关键字
|
|
113
|
+
const dependsPattern = planContent.matchAll(/task-(\d+).*?depends_on.*?(\d+(?:\s*[,,]\s*\d+)*)/gi)
|
|
114
|
+
for (const match of dependsPattern) {
|
|
115
|
+
const task = `task-${match[1]}`
|
|
116
|
+
const depList = match[2].split(/[,,\s]+/).map(d => `task-${d.trim()}`).filter(d => d.startsWith('task-'))
|
|
117
|
+
if (!deps[task]) deps[task] = []
|
|
118
|
+
for (const d of depList) {
|
|
119
|
+
if (!deps[task].includes(d)) deps[task].push(d)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return deps
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ─── Execute 阶段:后端 task 完成后提取 artifact ───────────────────────
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 后端 task 完成后,扫描变更文件提取 endpoint artifact
|
|
130
|
+
* @param {string} changeDir - changes/<name>/ 目录
|
|
131
|
+
* @param {string} worktreePath - worktree 路径(扫描源码用)
|
|
132
|
+
* @param {string} specBase - .sillyspec 目录
|
|
133
|
+
* @param {string} taskName - task-04
|
|
134
|
+
* @returns {{ ok: boolean, endpoints: Array, artifactPath: string|null }}
|
|
135
|
+
*/
|
|
136
|
+
export function extractProviderArtifact(changeDir, worktreePath, specBase, taskName) {
|
|
137
|
+
const artifactDir = join(specBase, '.runtime', 'contract-artifacts', taskName)
|
|
138
|
+
const artifactPath = join(artifactDir, 'endpoints.json')
|
|
139
|
+
|
|
140
|
+
if (!worktreePath || !existsSync(worktreePath)) {
|
|
141
|
+
return { ok: false, endpoints: [], artifactPath: null, error: 'worktree not found' }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const endpoints = scanBackendEndpoints(worktreePath)
|
|
146
|
+
|
|
147
|
+
if (endpoints.length > 0) {
|
|
148
|
+
mkdirSync(artifactDir, { recursive: true })
|
|
149
|
+
const artifact = {
|
|
150
|
+
task: taskName,
|
|
151
|
+
type: 'backend_endpoints',
|
|
152
|
+
extractedAt: new Date().toISOString(),
|
|
153
|
+
endpoints: endpoints.map(e => ({
|
|
154
|
+
method: e.method,
|
|
155
|
+
path: normalizePath(e.path),
|
|
156
|
+
source: relative(worktreePath, e.source),
|
|
157
|
+
line: e.line,
|
|
158
|
+
})),
|
|
159
|
+
}
|
|
160
|
+
writeFileSync(artifactPath, JSON.stringify(artifact, null, 2) + '\n')
|
|
161
|
+
return { ok: true, endpoints: artifact.endpoints, artifactPath }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 无端点提取到 — 不算错误(可能不是 router task)
|
|
165
|
+
return { ok: true, endpoints: [], artifactPath: null }
|
|
166
|
+
} catch (e) {
|
|
167
|
+
return { ok: false, endpoints: [], artifactPath: null, error: e.message }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ─── Execute 阶段:前端 task 开始时注入契约 ─────────────────────────────
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 为 consumer task 构建上游契约注入文本
|
|
175
|
+
* @param {string} changeDir - changes/<name>/ 目录
|
|
176
|
+
* @param {string} specBase - .sillyspec 目录
|
|
177
|
+
* @param {string} taskName - 当前 task(consumer)
|
|
178
|
+
* @param {Array<{ provider: string, consumer: string, type: string }>} contracts
|
|
179
|
+
* @returns {string|null} 注入到 prompt 的契约文本,无契约时返回 null
|
|
180
|
+
*/
|
|
181
|
+
export function buildConsumerInjection(changeDir, specBase, taskName, contracts) {
|
|
182
|
+
const myContracts = contracts.filter(c => c.consumer === taskName)
|
|
183
|
+
if (myContracts.length === 0) return null
|
|
184
|
+
|
|
185
|
+
const parts = []
|
|
186
|
+
for (const contract of myContracts) {
|
|
187
|
+
const artifactDir = join(specBase, '.runtime', 'contract-artifacts', contract.provider)
|
|
188
|
+
const artifactFile = join(artifactDir, 'endpoints.json')
|
|
189
|
+
|
|
190
|
+
let endpoints = []
|
|
191
|
+
if (existsSync(artifactFile)) {
|
|
192
|
+
try {
|
|
193
|
+
const artifact = JSON.parse(readFileSync(artifactFile, 'utf8'))
|
|
194
|
+
endpoints = artifact.endpoints || []
|
|
195
|
+
} catch {}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
parts.push(`### Upstream Contract: ${contract.provider}`)
|
|
199
|
+
if (endpoints.length > 0) {
|
|
200
|
+
parts.push(`\nAvailable endpoints from **${contract.provider}**:`)
|
|
201
|
+
for (const ep of endpoints) {
|
|
202
|
+
parts.push(`- **${ep.method}** \`${ep.path}\``)
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
parts.push(`\n⚠️ No endpoint artifact found for ${contract.provider}. This may indicate a contract gap.`)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (parts.length === 0) return null
|
|
210
|
+
|
|
211
|
+
parts.unshift('## Upstream API Contracts')
|
|
212
|
+
parts.push('')
|
|
213
|
+
parts.push('### Rules')
|
|
214
|
+
parts.push('1. Do not invent API paths. Use only endpoints listed above.')
|
|
215
|
+
parts.push('2. If a required endpoint is missing, **stop and report the contract gap** instead of coding around it.')
|
|
216
|
+
parts.push('3. If you need to add new endpoints, you must also update the backend provider task.')
|
|
217
|
+
|
|
218
|
+
return parts.join('\n')
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ─── Verify 阶段:parity check ──────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* verify 阶段执行 API parity check
|
|
225
|
+
* @param {string} specBase - .sillyspec 目录
|
|
226
|
+
* @param {string} worktreePath - worktree 路径
|
|
227
|
+
* @returns {{ ok: boolean, missingBackend: Array, unusedBackend: Array, summary: string }}
|
|
228
|
+
*/
|
|
229
|
+
export function verifyApiParity(specBase, worktreePath) {
|
|
230
|
+
const { diffApiParity } = require('./endpoint-extractor.js')
|
|
231
|
+
|
|
232
|
+
// 读取所有 provider artifacts
|
|
233
|
+
const artifactBase = join(specBase, '.runtime', 'contract-artifacts')
|
|
234
|
+
const allProviderEndpoints = []
|
|
235
|
+
|
|
236
|
+
if (existsSync(artifactBase)) {
|
|
237
|
+
for (const entry of readdirSync(artifactBase, { withFileTypes: true })) {
|
|
238
|
+
if (!entry.isDirectory()) continue
|
|
239
|
+
const epFile = join(artifactBase, entry.name, 'endpoints.json')
|
|
240
|
+
if (existsSync(epFile)) {
|
|
241
|
+
try {
|
|
242
|
+
const artifact = JSON.parse(readFileSync(epFile, 'utf8'))
|
|
243
|
+
for (const ep of (artifact.endpoints || [])) {
|
|
244
|
+
allProviderEndpoints.push({
|
|
245
|
+
method: ep.method,
|
|
246
|
+
path: ep.path,
|
|
247
|
+
source: `${entry.name}/${ep.source}`,
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
} catch {}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// 扫描前端调用
|
|
256
|
+
if (!worktreePath || !existsSync(worktreePath)) {
|
|
257
|
+
return {
|
|
258
|
+
ok: true,
|
|
259
|
+
missingBackend: [],
|
|
260
|
+
unusedBackend: [],
|
|
261
|
+
summary: 'No worktree to scan for parity check',
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const frontendCalls = scanFrontendApiCalls(worktreePath)
|
|
266
|
+
const { missingBackend, unusedBackend } = diffApiParity(frontendCalls, allProviderEndpoints)
|
|
267
|
+
|
|
268
|
+
const ok = missingBackend.length === 0
|
|
269
|
+
let summary = ok
|
|
270
|
+
? `✅ API parity check passed: ${allProviderEndpoints.length} backend endpoints, ${frontendCalls.length} frontend calls`
|
|
271
|
+
: `❌ API parity check failed: ${missingBackend.length} frontend calls have no matching backend endpoint`
|
|
272
|
+
|
|
273
|
+
if (unusedBackend.length > 0) {
|
|
274
|
+
summary += ` | ${unusedBackend.length} backend endpoints unused by frontend`
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return { ok, missingBackend, unusedBackend, summary }
|
|
278
|
+
}
|
|
@@ -0,0 +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
|
+
}
|
package/src/index.js
CHANGED
|
@@ -329,7 +329,8 @@ SillySpec worktree — git worktree 隔离管理
|
|
|
329
329
|
sillyspec worktree create <change-name> [--base <branch>] 创建隔离 worktree
|
|
330
330
|
sillyspec worktree apply <change-name> [--check-only] 校验并应用变更到主工作区
|
|
331
331
|
sillyspec worktree list 列出所有活跃 worktree
|
|
332
|
-
sillyspec worktree cleanup <change-name>
|
|
332
|
+
sillyspec worktree cleanup <change-name> [--force] 强制清理 worktree
|
|
333
|
+
sillyspec worktree doctor [--fix] [--stale-hours N] 健康检查 + 修复
|
|
333
334
|
|
|
334
335
|
选项:
|
|
335
336
|
--base <branch> create: 指定基础分支(默认当前 HEAD)
|
|
@@ -429,11 +430,13 @@ SillySpec worktree — git worktree 隔离管理
|
|
|
429
430
|
const forceFlag = args.includes('--force');
|
|
430
431
|
try {
|
|
431
432
|
const result = wm.cleanup(wtName, { force: forceFlag });
|
|
432
|
-
if (result.result === 'cleaned') {
|
|
433
|
+
if (result.result === 'cleaned' || result.result === 'force-cleaned') {
|
|
433
434
|
console.log(`✅ worktree 已清理: ${wtName} (mode: ${result.mode})`);
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
435
|
+
if (result.details?.length > 0) {
|
|
436
|
+
for (const d of result.details) {
|
|
437
|
+
if (d.startsWith('⚠️')) console.log(` ${d}`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
437
440
|
} else if (result.result === 'skipped') {
|
|
438
441
|
console.log(`⏭️ worktree 跳过清理: ${wtName} (mode: ${result.mode})`);
|
|
439
442
|
console.log(` 原因: in-place 模式没有隔离目录需要清理`);
|
|
@@ -446,6 +449,34 @@ SillySpec worktree — git worktree 隔离管理
|
|
|
446
449
|
}
|
|
447
450
|
break;
|
|
448
451
|
}
|
|
452
|
+
case 'doctor': {
|
|
453
|
+
const fixFlag = args.includes('--fix');
|
|
454
|
+
const staleIdx = args.indexOf('--stale-hours');
|
|
455
|
+
const staleHours = staleIdx !== -1 && args[staleIdx + 1] ? parseInt(args[staleIdx + 1], 10) : 24;
|
|
456
|
+
const diag = wm.doctor({ fix: fixFlag, staleHours });
|
|
457
|
+
if (diag.issues.length === 0) {
|
|
458
|
+
console.log('✅ worktree 健康检查通过,无异常');
|
|
459
|
+
} else {
|
|
460
|
+
console.log(`🔍 发现 ${diag.issues.length} 个问题:\n`);
|
|
461
|
+
for (const issue of diag.issues) {
|
|
462
|
+
const icon = issue.fixable ? '⚠️' : '❌';
|
|
463
|
+
console.log(` ${icon} [${issue.type}] ${issue.name}: ${issue.detail}`);
|
|
464
|
+
}
|
|
465
|
+
if (fixFlag) {
|
|
466
|
+
console.log(`\n🔧 修复完成:`);
|
|
467
|
+
for (const f of diag.fixed) console.log(` ✅ ${f}`);
|
|
468
|
+
if (diag.unfixable.length > 0) {
|
|
469
|
+
for (const u of diag.unfixable) console.log(` ❌ ${u}`);
|
|
470
|
+
}
|
|
471
|
+
if (diag.fixed.length === 0 && diag.unfixable.length === 0) {
|
|
472
|
+
console.log(' 无需修复');
|
|
473
|
+
}
|
|
474
|
+
} else {
|
|
475
|
+
console.log(`\n💡 运行 sillyspec worktree doctor --fix 自动修复`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
449
480
|
default:
|
|
450
481
|
console.error(`❌ 未知子命令: worktree ${wtSubCmd}`);
|
|
451
482
|
console.log(' 运行 sillyspec worktree --help 查看帮助');
|