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.
- package/.claude/skills/sillyspec-auto/SKILL.md +1 -1
- package/.claude/skills/sillyspec-doctor/SKILL.md +1 -1
- package/.claude/skills/sillyspec-execute/SKILL.md +2 -0
- package/.claude/skills/sillyspec-resume/SKILL.md +5 -5
- package/.claude/skills/sillyspec-state/SKILL.md +8 -8
- package/docs/sillyspec/file-lifecycle/storage-and-state.md +1 -1
- package/package.json +1 -1
- package/packages/dashboard/server/index.js +4 -3
- package/packages/dashboard/server/parser.js +30 -70
- package/src/contract-matrix.js +278 -0
- package/src/endpoint-extractor.js +315 -0
- package/src/index.js +57 -10
- package/src/init.js +31 -4
- package/src/run.js +87 -4
- package/src/stages/execute.js +49 -2
- package/src/stages/verify.js +25 -0
- package/src/worktree-apply.js +5 -4
- package/src/worktree.js +264 -35
- package/test/contract-artifacts.test.mjs +323 -0
|
@@ -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
|
@@ -145,6 +145,21 @@ async function main() {
|
|
|
145
145
|
targetDir = resolve(filteredArgs[1]);
|
|
146
146
|
filteredArgs.splice(1, 1);
|
|
147
147
|
}
|
|
148
|
+
// ── 自动纠正 cwd ──
|
|
149
|
+
// 当 agent 在 worktree 内跑 pnpm 等工具后 shell cwd 可能被改变,
|
|
150
|
+
// 导致 sillyspec 命令找不到 .sillyspec。此函数尝试从 git root 解析。
|
|
151
|
+
function resolveEffectiveDir(baseDir) {
|
|
152
|
+
if (existsSync(join(baseDir, '.sillyspec'))) return baseDir
|
|
153
|
+
try {
|
|
154
|
+
const { execSync } = require('child_process')
|
|
155
|
+
const gitRoot = execSync('git rev-parse --show-toplevel', {
|
|
156
|
+
cwd: baseDir, encoding: 'utf8', timeout: 5000
|
|
157
|
+
}).trim()
|
|
158
|
+
if (gitRoot && existsSync(join(gitRoot, '.sillyspec'))) return gitRoot
|
|
159
|
+
} catch {}
|
|
160
|
+
return baseDir
|
|
161
|
+
}
|
|
162
|
+
|
|
148
163
|
const dir = targetDir;
|
|
149
164
|
|
|
150
165
|
if (command === 'init' && !existsSync(dir)) {
|
|
@@ -167,6 +182,7 @@ async function main() {
|
|
|
167
182
|
break;
|
|
168
183
|
case 'progress': {
|
|
169
184
|
const pm = new ProgressManager();
|
|
185
|
+
const progDir = resolveEffectiveDir(dir);
|
|
170
186
|
const subCommand = filteredArgs[1];
|
|
171
187
|
const stageIdx = filteredArgs.indexOf('--stage');
|
|
172
188
|
const stage = stageIdx >= 0 && filteredArgs[stageIdx + 1] ? filteredArgs[stageIdx + 1] : null;
|
|
@@ -176,18 +192,18 @@ async function main() {
|
|
|
176
192
|
|
|
177
193
|
switch (subCommand) {
|
|
178
194
|
case 'init':
|
|
179
|
-
pm.init(
|
|
195
|
+
pm.init(progDir);
|
|
180
196
|
break;
|
|
181
197
|
case 'status':
|
|
182
198
|
case 'show':
|
|
183
|
-
pm.show(
|
|
199
|
+
pm.show(progDir, progChangeName);
|
|
184
200
|
break;
|
|
185
201
|
case 'check':
|
|
186
|
-
await pm.checkConsistency(
|
|
202
|
+
await pm.checkConsistency(progDir, progChangeName);
|
|
187
203
|
break;
|
|
188
204
|
case 'repair': {
|
|
189
205
|
const repairApply = filteredArgs.includes('--apply');
|
|
190
|
-
await pm.repairConsistency(
|
|
206
|
+
await pm.repairConsistency(progDir, { apply: repairApply, changeName: progChangeName });
|
|
191
207
|
break;
|
|
192
208
|
}
|
|
193
209
|
case 'validate':
|
|
@@ -270,7 +286,7 @@ async function main() {
|
|
|
270
286
|
}
|
|
271
287
|
case 'run': {
|
|
272
288
|
const { runCommand } = await import('./run.js')
|
|
273
|
-
await runCommand(filteredArgs.slice(1), dir, specDir)
|
|
289
|
+
await runCommand(filteredArgs.slice(1), resolveEffectiveDir(dir), specDir)
|
|
274
290
|
break
|
|
275
291
|
}
|
|
276
292
|
case 'dashboard': {
|
|
@@ -329,7 +345,8 @@ SillySpec worktree — git worktree 隔离管理
|
|
|
329
345
|
sillyspec worktree create <change-name> [--base <branch>] 创建隔离 worktree
|
|
330
346
|
sillyspec worktree apply <change-name> [--check-only] 校验并应用变更到主工作区
|
|
331
347
|
sillyspec worktree list 列出所有活跃 worktree
|
|
332
|
-
sillyspec worktree cleanup <change-name>
|
|
348
|
+
sillyspec worktree cleanup <change-name> [--force] 强制清理 worktree
|
|
349
|
+
sillyspec worktree doctor [--fix] [--stale-hours N] 健康检查 + 修复
|
|
333
350
|
|
|
334
351
|
选项:
|
|
335
352
|
--base <branch> create: 指定基础分支(默认当前 HEAD)
|
|
@@ -429,11 +446,13 @@ SillySpec worktree — git worktree 隔离管理
|
|
|
429
446
|
const forceFlag = args.includes('--force');
|
|
430
447
|
try {
|
|
431
448
|
const result = wm.cleanup(wtName, { force: forceFlag });
|
|
432
|
-
if (result.result === 'cleaned') {
|
|
449
|
+
if (result.result === 'cleaned' || result.result === 'force-cleaned') {
|
|
433
450
|
console.log(`✅ worktree 已清理: ${wtName} (mode: ${result.mode})`);
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
451
|
+
if (result.details?.length > 0) {
|
|
452
|
+
for (const d of result.details) {
|
|
453
|
+
if (d.startsWith('⚠️')) console.log(` ${d}`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
437
456
|
} else if (result.result === 'skipped') {
|
|
438
457
|
console.log(`⏭️ worktree 跳过清理: ${wtName} (mode: ${result.mode})`);
|
|
439
458
|
console.log(` 原因: in-place 模式没有隔离目录需要清理`);
|
|
@@ -446,6 +465,34 @@ SillySpec worktree — git worktree 隔离管理
|
|
|
446
465
|
}
|
|
447
466
|
break;
|
|
448
467
|
}
|
|
468
|
+
case 'doctor': {
|
|
469
|
+
const fixFlag = args.includes('--fix');
|
|
470
|
+
const staleIdx = args.indexOf('--stale-hours');
|
|
471
|
+
const staleHours = staleIdx !== -1 && args[staleIdx + 1] ? parseInt(args[staleIdx + 1], 10) : 24;
|
|
472
|
+
const diag = wm.doctor({ fix: fixFlag, staleHours });
|
|
473
|
+
if (diag.issues.length === 0) {
|
|
474
|
+
console.log('✅ worktree 健康检查通过,无异常');
|
|
475
|
+
} else {
|
|
476
|
+
console.log(`🔍 发现 ${diag.issues.length} 个问题:\n`);
|
|
477
|
+
for (const issue of diag.issues) {
|
|
478
|
+
const icon = issue.fixable ? '⚠️' : '❌';
|
|
479
|
+
console.log(` ${icon} [${issue.type}] ${issue.name}: ${issue.detail}`);
|
|
480
|
+
}
|
|
481
|
+
if (fixFlag) {
|
|
482
|
+
console.log(`\n🔧 修复完成:`);
|
|
483
|
+
for (const f of diag.fixed) console.log(` ✅ ${f}`);
|
|
484
|
+
if (diag.unfixable.length > 0) {
|
|
485
|
+
for (const u of diag.unfixable) console.log(` ❌ ${u}`);
|
|
486
|
+
}
|
|
487
|
+
if (diag.fixed.length === 0 && diag.unfixable.length === 0) {
|
|
488
|
+
console.log(' 无需修复');
|
|
489
|
+
}
|
|
490
|
+
} else {
|
|
491
|
+
console.log(`\n💡 运行 sillyspec worktree doctor --fix 自动修复`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
break;
|
|
495
|
+
}
|
|
449
496
|
default:
|
|
450
497
|
console.error(`❌ 未知子命令: worktree ${wtSubCmd}`);
|
|
451
498
|
console.log(' 运行 sillyspec worktree --help 查看帮助');
|
package/src/init.js
CHANGED
|
@@ -108,12 +108,39 @@ async function doInstall(projectDir, tools, subprojects = [], specDir = null) {
|
|
|
108
108
|
// projectDir: 源码项目根目录(用于工具检测、指令注入、.gitignore)
|
|
109
109
|
const spec = specDir || join(projectDir, '.sillyspec');
|
|
110
110
|
|
|
111
|
-
// 外部 specDir 时清理旧版本残留的 cwd/.sillyspec
|
|
111
|
+
// 外部 specDir 时清理旧版本残留的 cwd/.sillyspec/(防止源码污染)。
|
|
112
|
+
// ⚠️ 必须保护真实资产:若本地 .sillyspec 含 changes/(非空)、projects/(非空)
|
|
113
|
+
// 或 sillyspec.db(进度库),说明该项目本身就用 SillySpec 管理,整体删除会丢资产。
|
|
114
|
+
// 此时只清运行时残留,拒绝整删;确无资产时才视为旧残留清理。
|
|
112
115
|
const legacyDir = join(projectDir, '.sillyspec');
|
|
113
116
|
if (specDir && existsSync(legacyDir)) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
+
let hasChanges = false;
|
|
118
|
+
try {
|
|
119
|
+
const changesDir = join(legacyDir, 'changes');
|
|
120
|
+
if (existsSync(changesDir)) hasChanges = readdirSync(changesDir).length > 0;
|
|
121
|
+
} catch {}
|
|
122
|
+
let hasProjects = false;
|
|
123
|
+
try {
|
|
124
|
+
const projectsDir = join(legacyDir, 'projects');
|
|
125
|
+
if (existsSync(projectsDir)) hasProjects = readdirSync(projectsDir).length > 0;
|
|
126
|
+
} catch {}
|
|
127
|
+
const hasDb = existsSync(join(legacyDir, 'sillyspec.db'));
|
|
128
|
+
|
|
129
|
+
if (hasChanges || hasProjects || hasDb) {
|
|
130
|
+
// 真实资产存在:拒绝整体删除,仅清理运行时残留
|
|
131
|
+
console.error('❌ [sillyspec] 拒绝删除源码目录的 .sillyspec/:检测到真实资产(changes/、projects/ 或 sillyspec.db)。');
|
|
132
|
+
console.error(' 该项目似乎本身就用 SillySpec 管理。如需改用外部 spec 目录,请先手动迁移/备份。');
|
|
133
|
+
console.error(' 本次仅清理运行时残留(.runtime/、local.yaml、codebase/)。');
|
|
134
|
+
for (const residue of ['.runtime', 'local.yaml', 'codebase']) {
|
|
135
|
+
const p = join(legacyDir, residue);
|
|
136
|
+
if (existsSync(p)) { try { rmSync(p, { recursive: true, force: true }) } catch {} }
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
// 无真实资产:确属旧版本残留,安全删除
|
|
140
|
+
try { rmSync(legacyDir, { recursive: true, force: true }) } catch {}
|
|
141
|
+
if (!existsSync(legacyDir)) console.log('🧹 已清理旧版本残留的源码 .sillyspec/ 目录');
|
|
142
|
+
else console.error('⚠️ 清理残留 .sillyspec/ 失败');
|
|
143
|
+
}
|
|
117
144
|
}
|
|
118
145
|
|
|
119
146
|
// 创建基础目录
|
package/src/run.js
CHANGED
|
@@ -1148,11 +1148,32 @@ export async function runCommand(args, cwd, specDir = null) {
|
|
|
1148
1148
|
// runCommand 后续所有 .sillyspec/ 操作必须用 specBase
|
|
1149
1149
|
const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
|
|
1150
1150
|
|
|
1151
|
-
// 平台模式:清理旧版本残留的 cwd/.sillyspec
|
|
1151
|
+
// 平台模式:清理旧版本残留的 cwd/.sillyspec/(防止源码污染)。
|
|
1152
|
+
// ⚠️ 同 init.js:必须保护真实资产(changes/、projects/、sillyspec.db)。
|
|
1152
1153
|
if (platformOpts.specRoot) {
|
|
1153
1154
|
const legacyDir = join(cwd, '.sillyspec')
|
|
1154
1155
|
if (existsSync(legacyDir)) {
|
|
1155
|
-
|
|
1156
|
+
let hasChanges = false;
|
|
1157
|
+
try {
|
|
1158
|
+
const cd = join(legacyDir, 'changes');
|
|
1159
|
+
if (existsSync(cd)) hasChanges = readdirSync(cd).length > 0;
|
|
1160
|
+
} catch {}
|
|
1161
|
+
let hasProjects = false;
|
|
1162
|
+
try {
|
|
1163
|
+
const pd = join(legacyDir, 'projects');
|
|
1164
|
+
if (existsSync(pd)) hasProjects = readdirSync(pd).length > 0;
|
|
1165
|
+
} catch {}
|
|
1166
|
+
const hasDb = existsSync(join(legacyDir, 'sillyspec.db'));
|
|
1167
|
+
|
|
1168
|
+
if (hasChanges || hasProjects || hasDb) {
|
|
1169
|
+
console.error('❌ [sillyspec] 拒绝删除源码目录的 .sillyspec/:检测到真实资产。仅清理运行时残留。');
|
|
1170
|
+
for (const residue of ['.runtime', 'local.yaml', 'codebase']) {
|
|
1171
|
+
const p = join(legacyDir, residue);
|
|
1172
|
+
if (existsSync(p)) { try { rmSync(p, { recursive: true, force: true }) } catch {} }
|
|
1173
|
+
}
|
|
1174
|
+
} else {
|
|
1175
|
+
try { rmSync(legacyDir, { recursive: true, force: true }) } catch {}
|
|
1176
|
+
}
|
|
1156
1177
|
}
|
|
1157
1178
|
}
|
|
1158
1179
|
|
|
@@ -1943,12 +1964,38 @@ async function continueStep(pm, progress, stageName, cwd, answer, options = {})
|
|
|
1943
1964
|
stageData.completedAt = now
|
|
1944
1965
|
await pm._write(cwd, progress, changeName)
|
|
1945
1966
|
console.log(`\n✅ ${stageName} 阶段已完成(${stageData.steps.length}/${stageData.steps.length} 步)`)
|
|
1967
|
+
// ── execute 阶段完成时条件性清理 worktree ──
|
|
1968
|
+
if (stageName === 'execute' && changeName) {
|
|
1969
|
+
try {
|
|
1970
|
+
const { WorktreeManager } = await import('./worktree.js');
|
|
1971
|
+
const wm = new WorktreeManager({ cwd });
|
|
1972
|
+
const meta = wm.getMeta(changeName);
|
|
1973
|
+
if (!meta) {
|
|
1974
|
+
console.log('🔗 Worktree: n/a (no meta)');
|
|
1975
|
+
} else if (meta.mode === 'native-worktree') {
|
|
1976
|
+
console.log('🔗 Worktree: kept (外部隔离环境)');
|
|
1977
|
+
} else if (meta.mode === 'in-place-fallback') {
|
|
1978
|
+
console.log('🔗 Worktree: n/a (in-place 模式)');
|
|
1979
|
+
} else {
|
|
1980
|
+
const check = wm.hasUnappliedChanges(changeName);
|
|
1981
|
+
if (check.hasChanges) {
|
|
1982
|
+
console.log(`🔗 Worktree: pending apply (${check.changedFiles.length} 个未应用变更)`);
|
|
1983
|
+
console.log(` 下一步: sillyspec worktree apply ${changeName}`);
|
|
1984
|
+
} else {
|
|
1985
|
+
const cleanResult = wm.cleanup(changeName);
|
|
1986
|
+
console.log(`🔗 Worktree: ${cleanResult.result}`);
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
} catch (e) {
|
|
1990
|
+
console.warn(`🔗 Worktree: check failed — ${e.message}`);
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1946
1993
|
return { stageCompleted: true, currentIdx, nextPendingIdx: -1 }
|
|
1947
1994
|
}
|
|
1948
1995
|
|
|
1949
1996
|
// 输出下一步
|
|
1950
|
-
|
|
1951
|
-
|
|
1997
|
+
if (nextPendingIdx !== -1 && defSteps) {
|
|
1998
|
+
console.log('')
|
|
1952
1999
|
await outputStep(stageName, nextPendingIdx, defSteps, cwd, changeName, progress.project || null, platformOpts, answer)
|
|
1953
2000
|
} else if (nextWaitingIdx !== -1 && defSteps) {
|
|
1954
2001
|
// 下一个步骤也在等待状态
|
|
@@ -2517,6 +2564,42 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
2517
2564
|
console.log(`\n⚠️ 阶段校验跳过:${actualTotal} 步中仅 ${actualCompleted} 步标记为已完成,可能存在状态不同步。如确认阶段已完成,请运行 --status 确认。`)
|
|
2518
2565
|
}
|
|
2519
2566
|
|
|
2567
|
+
// ── execute 阶段完成时条件性清理 worktree(不依赖 AI agent 的完成确认步骤)──
|
|
2568
|
+
if (stageName === 'execute' && changeName) {
|
|
2569
|
+
try {
|
|
2570
|
+
const { WorktreeManager } = await import('./worktree.js');
|
|
2571
|
+
const wm = new WorktreeManager({ cwd });
|
|
2572
|
+
const meta = wm.getMeta(changeName);
|
|
2573
|
+
if (!meta) {
|
|
2574
|
+
console.log('🔗 Worktree: n/a (no meta)');
|
|
2575
|
+
} else if (meta.mode === 'native-worktree') {
|
|
2576
|
+
console.log('🔗 Worktree: kept (外部隔离环境)');
|
|
2577
|
+
} else if (meta.mode === 'in-place-fallback') {
|
|
2578
|
+
console.log('🔗 Worktree: n/a (in-place 模式)');
|
|
2579
|
+
} else {
|
|
2580
|
+
const check = wm.hasUnappliedChanges(changeName);
|
|
2581
|
+
if (check.hasChanges) {
|
|
2582
|
+
console.log(`🔗 Worktree: pending apply (${check.changedFiles.length} 个未应用变更)`);
|
|
2583
|
+
console.log(` 下一步: sillyspec worktree apply ${changeName}`);
|
|
2584
|
+
} else {
|
|
2585
|
+
const cleanResult = wm.cleanup(changeName);
|
|
2586
|
+
if (cleanResult.result === 'skipped' || cleanResult.result === 'kept') {
|
|
2587
|
+
console.log(`🔗 Worktree: ${cleanResult.result}`);
|
|
2588
|
+
} else {
|
|
2589
|
+
console.log(`🔗 Worktree: ${cleanResult.result}`);
|
|
2590
|
+
if (cleanResult.details?.length > 0) {
|
|
2591
|
+
for (const d of cleanResult.details) {
|
|
2592
|
+
if (d.startsWith('⚠️')) console.log(` ${d}`);
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
} catch (e) {
|
|
2599
|
+
console.warn(`🔗 Worktree: check failed — ${e.message}`);
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2520
2603
|
return { stageCompleted: true, currentIdx, nextPendingIdx: -1 }
|
|
2521
2604
|
}
|
|
2522
2605
|
|
package/src/stages/execute.js
CHANGED
|
@@ -165,6 +165,7 @@ const fixedPrefix = [
|
|
|
165
165
|
- **worktree 已由 CLI 在 execute 阶段启动时自动创建,不要自行创建或跳过**
|
|
166
166
|
- **后续所有子代理的 cwd 必须设为该 worktree 路径**
|
|
167
167
|
- 如果 meta.json 不存在(说明创建失败),停止并报错
|
|
168
|
+
- **不要自行检查 git dirty/uncommitted 状态来判断是否可以进入 worktree,CLI 已自动处理**
|
|
168
169
|
|
|
169
170
|
### 输出
|
|
170
171
|
worktree 路径 + 分支名 + 模式
|
|
@@ -406,6 +407,48 @@ function parseWavesFromPlan(planContent) {
|
|
|
406
407
|
* 为 Wave 生成 prompt(强制子代理执行)
|
|
407
408
|
*/
|
|
408
409
|
function buildWavePrompt(wave, waveIndex, changeDir, worktreePath) {
|
|
410
|
+
// ── Contract Matrix:检查是否有 provider/consumer 契约需要注入 ──
|
|
411
|
+
let contractInjection = ''
|
|
412
|
+
if (changeDir) {
|
|
413
|
+
try {
|
|
414
|
+
const { buildContractMatrix, buildConsumerInjection } = require('../contract-matrix.js')
|
|
415
|
+
const planFile = path.join(changeDir, 'plan.md')
|
|
416
|
+
if (existsSync(planFile)) {
|
|
417
|
+
const planContent = readFileSync(planFile, 'utf8')
|
|
418
|
+
const contracts = buildContractMatrix(planContent, changeDir)
|
|
419
|
+
if (contracts.length > 0) {
|
|
420
|
+
// 收集本 wave 所有 task 的注入内容
|
|
421
|
+
const waveTasks = wave.tasks.map((t, ti) => {
|
|
422
|
+
const num = String(t.index || (ti + 1)).padStart(2, '0')
|
|
423
|
+
return `task-${num}`
|
|
424
|
+
})
|
|
425
|
+
const relevantContracts = contracts.filter(c => waveTasks.includes(c.consumer))
|
|
426
|
+
if (relevantContracts.length > 0) {
|
|
427
|
+
contractInjection = `
|
|
428
|
+
### API Contract Matrix
|
|
429
|
+
本 Wave 存在前端/后端跨 task 契约:
|
|
430
|
+
${relevantContracts.map(c => `- **${c.consumer}** 消费 **${c.provider}** 产出的 API`).join('\n')}
|
|
431
|
+
`
|
|
432
|
+
// 为每个 consumer task 生成详细注入
|
|
433
|
+
for (const taskName of waveTasks) {
|
|
434
|
+
const injection = buildConsumerInjection(changeDir, join(changeDir, '..', '..'), taskName, contracts)
|
|
435
|
+
if (injection) {
|
|
436
|
+
contractInjection += `
|
|
437
|
+
### 子代理 ${taskName} 的契约注入
|
|
438
|
+
为 ${taskName} 启动子代理时,在子代理 prompt 末尾追加以下内容:
|
|
439
|
+
|
|
440
|
+
<contract-injection>
|
|
441
|
+
${injection}
|
|
442
|
+
</contract-injection>
|
|
443
|
+
`
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
} catch {}
|
|
450
|
+
}
|
|
451
|
+
|
|
409
452
|
// 构建任务摘要(不再内联完整蓝图,减少上下文污染)
|
|
410
453
|
const taskSummary = wave.tasks.map((t, ti) => {
|
|
411
454
|
const taskNum = String(t.index || (ti + 1)).padStart(2, '0')
|
|
@@ -476,7 +519,7 @@ ${taskSummary}
|
|
|
476
519
|
- 🔥 热上下文:design.md 编码铁律 + 当前 Wave 任务(必须加载)
|
|
477
520
|
- 🌡️ 温上下文:CONVENTIONS.md + ARCHITECTURE.md(需要时加载)
|
|
478
521
|
- ❄️ 冷上下文:其他变更的 design.md、历史 plan.md(不要主动加载,除非明确需要)
|
|
479
|
-
|
|
522
|
+
${contractInjection}
|
|
480
523
|
### 本 Wave 任务
|
|
481
524
|
${taskList}
|
|
482
525
|
|
|
@@ -493,7 +536,11 @@ ${taskList}
|
|
|
493
536
|
5. 遇到 BLOCKED → 记录原因,选择:重试/跳过/停止
|
|
494
537
|
|
|
495
538
|
### 完成后
|
|
496
|
-
|
|
539
|
+
1. 为每个后端 router task,扫描变更文件提取 API 端点 artifact:
|
|
540
|
+
- 在变更文件中搜索所有 router 注册路径(@router.get/post/put/delete)
|
|
541
|
+
- 将端点清单写入 .sillyspec/.runtime/contract-artifacts/<task-name>/endpoints.json
|
|
542
|
+
- 格式: { "task": "task-XX", "type": "backend_endpoints", "endpoints": [{ "method": "GET", "path": "/api/ppm/xxx" }] }
|
|
543
|
+
2. 运行 sillyspec run execute --done --input "用户原始反馈" --output "Wave ${waveIndex} 结果摘要"`
|
|
497
544
|
}
|
|
498
545
|
|
|
499
546
|
|