sillyspec 3.20.1 → 3.20.2
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 +2 -2
- package/src/index.js +7 -4
- package/src/run.js +162 -74
- package/src/scan-postcheck.js +15 -12
- package/src/stages/scan.js +20 -3
- package/test/run-scan-project-parse.test.mjs +166 -43
- package/test/scan-postcheck.test.mjs +18 -4
- package/.npmrc-tmp +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sillyspec",
|
|
3
|
-
"version": "3.20.
|
|
3
|
+
"version": "3.20.2",
|
|
4
4
|
"description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
|
|
5
5
|
"icon": "logo.jpg",
|
|
6
6
|
"homepage": "https://sillyspec.ppdmq.top/",
|
|
@@ -37,4 +37,4 @@
|
|
|
37
37
|
"sql.js": "^1.14.1",
|
|
38
38
|
"ws": "^8.18"
|
|
39
39
|
}
|
|
40
|
-
}
|
|
40
|
+
}
|
package/src/index.js
CHANGED
|
@@ -189,7 +189,7 @@ async function main() {
|
|
|
189
189
|
break;
|
|
190
190
|
case 'progress': {
|
|
191
191
|
const pm = new ProgressManager();
|
|
192
|
-
const progDir = resolveEffectiveDir(dir);
|
|
192
|
+
const progDir = specDir ? dir : resolveEffectiveDir(dir);
|
|
193
193
|
const subCommand = filteredArgs[1];
|
|
194
194
|
const stageIdx = filteredArgs.indexOf('--stage');
|
|
195
195
|
const stage = stageIdx >= 0 && filteredArgs[stageIdx + 1] ? filteredArgs[stageIdx + 1] : null;
|
|
@@ -293,7 +293,9 @@ async function main() {
|
|
|
293
293
|
}
|
|
294
294
|
case 'run': {
|
|
295
295
|
const { runCommand } = await import('./run.js')
|
|
296
|
-
|
|
296
|
+
// 平台模式(--spec-dir 已指定)时,--dir 是明确的 source_root,不应被 resolveEffectiveDir 纠正
|
|
297
|
+
const effectiveDir = specDir ? dir : resolveEffectiveDir(dir)
|
|
298
|
+
await runCommand(filteredArgs.slice(1), effectiveDir, specDir)
|
|
297
299
|
break
|
|
298
300
|
}
|
|
299
301
|
// task-10: 顶层命令别名,转发 runCommand,与 case 'run': 路径行为一致
|
|
@@ -308,12 +310,13 @@ async function main() {
|
|
|
308
310
|
case 'explore': {
|
|
309
311
|
const { runCommand } = await import('./run.js')
|
|
310
312
|
const stageArgs = [command, ...filteredArgs.slice(1)]
|
|
311
|
-
|
|
313
|
+
const effectiveDir = specDir ? dir : resolveEffectiveDir(dir)
|
|
314
|
+
await runCommand(stageArgs, effectiveDir, specDir)
|
|
312
315
|
break
|
|
313
316
|
}
|
|
314
317
|
case 'knowledge': {
|
|
315
318
|
const { cmdKnowledge } = await import('./stages/knowledge.js')
|
|
316
|
-
await cmdKnowledge(filteredArgs.slice(1), resolveEffectiveDir(dir), { specDir })
|
|
319
|
+
await cmdKnowledge(filteredArgs.slice(1), specDir ? dir : resolveEffectiveDir(dir), { specDir })
|
|
317
320
|
break
|
|
318
321
|
}
|
|
319
322
|
case 'dashboard': {
|
package/src/run.js
CHANGED
|
@@ -27,6 +27,52 @@ export function sanitizeProjectName(name) {
|
|
|
27
27
|
return clean
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* 校验从 step 2 解析出的项目列表。
|
|
32
|
+
* 不通过则不落盘 projects/*.yaml、不展开 perProject 步骤。
|
|
33
|
+
*
|
|
34
|
+
* @param {Array<{id: string, path?: string}>} projects - 项目列表(含可选 path)
|
|
35
|
+
* @param {string} sourceRoot - 源码根目录,用于 path 安全校验
|
|
36
|
+
*/
|
|
37
|
+
export function validateParsedProjects(projects, sourceRoot) {
|
|
38
|
+
const errors = []
|
|
39
|
+
if (!projects || projects.length === 0) {
|
|
40
|
+
return { ok: false, errors: ['项目列表为空'] }
|
|
41
|
+
}
|
|
42
|
+
if (projects.length > 10) {
|
|
43
|
+
return { ok: false, errors: [`项目数量 ${projects.length} 超过上限 10,疑似误解析`] }
|
|
44
|
+
}
|
|
45
|
+
const safeRoot = resolve(sourceRoot)
|
|
46
|
+
const seen = new Set()
|
|
47
|
+
for (const proj of projects) {
|
|
48
|
+
const id = proj.id || proj
|
|
49
|
+
if (seen.has(id)) {
|
|
50
|
+
errors.push(`重复项目名: ${id}`)
|
|
51
|
+
}
|
|
52
|
+
seen.add(id)
|
|
53
|
+
// slug 合法性:只允许 a-z 0-9 _ - .,长度 2-64
|
|
54
|
+
if (!/^[a-zA-Z][\w\-.]{1,63}$/.test(id)) {
|
|
55
|
+
errors.push(`项目名 "${id}" 不合法(需 slug 格式:字母开头,只含 a-zA-Z0-9_-., 长度 2-64)`)
|
|
56
|
+
}
|
|
57
|
+
// path 安全校验(如果提供了 path)
|
|
58
|
+
if (proj.path) {
|
|
59
|
+
if (proj.path.includes('..')) {
|
|
60
|
+
errors.push(`项目 "${id}" 的 path 包含 .. ,拒绝越界`)
|
|
61
|
+
} else {
|
|
62
|
+
const absPath = resolve(safeRoot, proj.path)
|
|
63
|
+
if (!absPath.startsWith(safeRoot + '/') && absPath !== safeRoot) {
|
|
64
|
+
errors.push(`项目 "${id}" 的 path "${proj.path}" 解析后超出 source_root`)
|
|
65
|
+
}
|
|
66
|
+
if (!existsSync(absPath)) {
|
|
67
|
+
errors.push(`项目 "${id}" 的 path "${proj.path}" 不存在`)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (errors.length > 0) return { ok: false, errors }
|
|
73
|
+
return { ok: true, errors: [] }
|
|
74
|
+
}
|
|
75
|
+
|
|
30
76
|
/**
|
|
31
77
|
* 在容器/Docker 环境下,git 可能因目录所有权不匹配报 dubious ownership。
|
|
32
78
|
* 使用 -c safe.directory= 临时参数,不污染全局 git config。
|
|
@@ -2254,91 +2300,132 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
2254
2300
|
|
|
2255
2301
|
// scan 阶段 step 2 "构建扫描项目列表" 完成后,按项目展开 perProject 步骤
|
|
2256
2302
|
if (stageName === 'scan' && steps[currentIdx]?.name === '构建扫描项目列表') {
|
|
2257
|
-
//
|
|
2258
|
-
|
|
2259
|
-
|
|
2303
|
+
// 解析项目列表:只接受结构化输出(YAML block 或 BEGIN_PROJECT_LIST 标记)
|
|
2304
|
+
// 不再从自由文本猜测项目名——自由文本列表的误解析会导致垃圾项目落盘
|
|
2305
|
+
let parsedProjects = [] // Array<{id, path?}>
|
|
2306
|
+
let parsedFromStructuredOutput = false
|
|
2260
2307
|
if (outputText) {
|
|
2261
|
-
//
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
if (projectNames.length === 0) {
|
|
2273
|
-
const parenMatch = outputText.match(/(?:子项目|项目)[\s::]*(\S+(?:[\/、,,]+\S+)*)/)
|
|
2274
|
-
if (parenMatch) {
|
|
2275
|
-
const raw = parenMatch[1].split(/[\/、,,]+/).map(s => s.trim()).filter(Boolean)
|
|
2276
|
-
projectNames = raw.map(sanitizeProjectName).filter(Boolean)
|
|
2277
|
-
if (projectNames.length > 0) { stageData.scanMeta = stageData.scanMeta || {}; stageData.scanMeta.projectListParsed = true; }
|
|
2308
|
+
// 格式 A: YAML block — 匹配 scan_projects: 下所有 - id: xxx 条目(含多行属性)
|
|
2309
|
+
const yamlBlock = outputText.match(/scan_projects:\s*\n([\s\S]+?)(?=$|\n[^\s])/)
|
|
2310
|
+
if (yamlBlock) {
|
|
2311
|
+
const entries = [...yamlBlock[1].matchAll(/-\s+id:\s*(\S+)(?:[\s\S]*?)(?=\n\s+-\s+id:|$)/g)]
|
|
2312
|
+
for (const m of entries) {
|
|
2313
|
+
const id = sanitizeProjectName(m[1])
|
|
2314
|
+
if (!id) continue
|
|
2315
|
+
// 提取可选 path 字段
|
|
2316
|
+
const pathMatch = m[0].match(/path:\s*(\S+)/)
|
|
2317
|
+
const entry = pathMatch ? { id, path: pathMatch[1].trim() } : { id }
|
|
2318
|
+
parsedProjects.push(entry)
|
|
2278
2319
|
}
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2320
|
+
parsedFromStructuredOutput = parsedProjects.length > 0
|
|
2321
|
+
}
|
|
2322
|
+
// 格式 B: BEGIN_PROJECT_LIST ... END_PROJECT_LIST 标记块
|
|
2323
|
+
if (!parsedFromStructuredOutput) {
|
|
2324
|
+
const blockMatch = outputText.match(/BEGIN_PROJECT_LIST\s*\n([\s\S]*?)\n*END_PROJECT_LIST/)
|
|
2325
|
+
if (blockMatch) {
|
|
2326
|
+
const raw = [...blockMatch[1].matchAll(/^-\s+(\S+)/gm)].map(m => m[1])
|
|
2327
|
+
parsedProjects = raw.map(s => sanitizeProjectName(s)).filter(Boolean).map(id => ({ id }))
|
|
2328
|
+
parsedFromStructuredOutput = parsedProjects.length > 0
|
|
2287
2329
|
}
|
|
2288
2330
|
}
|
|
2289
2331
|
}
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2332
|
+
|
|
2333
|
+
const projectNames = parsedProjects.map(p => p.id)
|
|
2334
|
+
|
|
2335
|
+
if (parsedFromStructuredOutput) {
|
|
2336
|
+
stageData.scanMeta = stageData.scanMeta || {}
|
|
2337
|
+
stageData.scanMeta.projectListParsed = true
|
|
2338
|
+
} else {
|
|
2339
|
+
// 结构化输出未解析到 → 回退读取已有 projects/*.yaml
|
|
2340
|
+
// 读取时也校验:path 不存在的 yaml 视为垃圾,直接跳过
|
|
2341
|
+
console.warn('⚠️ step 2 未输出结构化项目列表,回退扫描已注册项目')
|
|
2342
|
+
stageData.scanMeta = stageData.scanMeta || {}
|
|
2343
|
+
stageData.scanMeta.projectListParsed = false
|
|
2294
2344
|
const projectsDir = join(specBase, 'projects')
|
|
2295
2345
|
if (existsSync(projectsDir)) {
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2346
|
+
const yamlFiles = readdirSync(projectsDir).filter(f => f.endsWith('.yaml'))
|
|
2347
|
+
const fallbackProjects = []
|
|
2348
|
+
const fallbackSkipped = []
|
|
2349
|
+
for (const yf of yamlFiles) {
|
|
2350
|
+
const pName = yf.replace(/\.yaml$/, '')
|
|
2351
|
+
const yamlContent = readFileSync(join(projectsDir, yf), 'utf8')
|
|
2352
|
+
const pathMatch = yamlContent.match(/^path:\s*(.+)/m)
|
|
2353
|
+
const pPath = pathMatch ? pathMatch[1].trim() : pName
|
|
2354
|
+
// 校验 path 是否存在且在 source_root 内
|
|
2355
|
+
const absPath = resolve(cwd, pPath)
|
|
2356
|
+
if (pPath.includes('..') || (!absPath.startsWith(resolve(cwd)) && absPath !== resolve(cwd))) {
|
|
2357
|
+
fallbackSkipped.push(`${pName} (path 越界: ${pPath})`)
|
|
2358
|
+
continue
|
|
2359
|
+
}
|
|
2360
|
+
if (!existsSync(absPath)) {
|
|
2361
|
+
fallbackSkipped.push(`${pName} (path 不存在: ${pPath})`)
|
|
2362
|
+
continue
|
|
2363
|
+
}
|
|
2364
|
+
fallbackProjects.push({ id: pName, path: pPath })
|
|
2365
|
+
}
|
|
2366
|
+
if (fallbackSkipped.length > 0) {
|
|
2367
|
+
console.warn(`⚠️ 跳过 ${fallbackSkipped.length} 个垃圾/过期项目配置:${fallbackSkipped.join(', ')}`)
|
|
2368
|
+
console.warn(' 建议清理 projects/ 下的无效 yaml 文件')
|
|
2369
|
+
}
|
|
2370
|
+
parsedProjects = fallbackProjects
|
|
2371
|
+
projectNames.length = 0
|
|
2372
|
+
projectNames.push(...fallbackProjects.map(p => p.id))
|
|
2373
|
+
}
|
|
2374
|
+
if (parsedProjects.length === 0) {
|
|
2375
|
+
// 无结构化输出 + 无合法已有项目 → step 2 失败
|
|
2376
|
+
console.error('❌ step 2 未输出结构化项目列表,且 projects/ 下无合法项目配置')
|
|
2377
|
+
console.error(' 请在 --output 中输出 scan_projects YAML block 或 BEGIN_PROJECT_LIST 标记块')
|
|
2378
|
+
steps[currentIdx].validationError = '未输出结构化项目列表且无合法 fallback'
|
|
2379
|
+
// 不展开 perProject 步骤,直接跳到下一步
|
|
2299
2380
|
}
|
|
2300
2381
|
}
|
|
2301
|
-
|
|
2302
|
-
|
|
2382
|
+
|
|
2383
|
+
// 校验解析出的项目列表(原子守卫:不通过就不落盘)
|
|
2384
|
+
const validation = validateParsedProjects(parsedProjects, cwd)
|
|
2385
|
+
if (!validation.ok) {
|
|
2386
|
+
console.error(`❌ 项目列表校验失败: ${validation.errors.join('; ')}`)
|
|
2387
|
+
console.error(' step 2 完成,但不展开 perProject 步骤。请检查 --output 中的项目列表。')
|
|
2388
|
+
steps[currentIdx].validationError = validation.errors.join('; ')
|
|
2303
2389
|
}
|
|
2304
2390
|
|
|
2305
|
-
//
|
|
2391
|
+
// 自动注册 + 保存 runtime + 展开 perProject 步骤(仅在校验通过时)
|
|
2306
2392
|
const projectsDir = join(specBase, 'projects')
|
|
2307
|
-
|
|
2308
|
-
const
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
// 收集当前步骤之后所有 perProject 步骤
|
|
2335
|
-
const stageDef = stageRegistry[stageName]
|
|
2336
|
-
const allSteps = stageDef?.steps || []
|
|
2337
|
-
const perProjectSteps = allSteps.filter(s => s.perProject)
|
|
2393
|
+
if (validation.ok) {
|
|
2394
|
+
for (const proj of parsedProjects) {
|
|
2395
|
+
const pName = proj.id
|
|
2396
|
+
const projYaml = join(projectsDir, `${pName}.yaml`)
|
|
2397
|
+
if (!existsSync(projYaml)) {
|
|
2398
|
+
mkdirSync(projectsDir, { recursive: true })
|
|
2399
|
+
const candidates = [
|
|
2400
|
+
join(cwd, pName),
|
|
2401
|
+
join(cwd, 'backend', pName),
|
|
2402
|
+
join(cwd, 'packages', pName),
|
|
2403
|
+
join(cwd, 'apps', pName),
|
|
2404
|
+
join(cwd, 'services', pName),
|
|
2405
|
+
]
|
|
2406
|
+
const detected = candidates.find(c => existsSync(c))
|
|
2407
|
+
const regPath = detected || join(cwd, pName)
|
|
2408
|
+
writeFileSync(projYaml, `name: ${pName}\npath: ${regPath}\nstatus: active\n`)
|
|
2409
|
+
console.log(` 📝 自动注册子项目: ${pName} → ${regPath}`)
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
// 保存 runtime 状态
|
|
2414
|
+
const scanStatePath = join(specBase, '.runtime', 'scan-projects.json')
|
|
2415
|
+
mkdirSync(join(specBase, '.runtime'), { recursive: true })
|
|
2416
|
+
let scanState = { projects: projectNames, expanded: false }
|
|
2417
|
+
if (existsSync(scanStatePath)) {
|
|
2418
|
+
try { scanState = JSON.parse(readFileSync(scanStatePath, 'utf8')) } catch {}
|
|
2419
|
+
}
|
|
2338
2420
|
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2421
|
+
// 收集当前步骤之后所有 perProject 步骤
|
|
2422
|
+
const stageDef = stageRegistry[stageName]
|
|
2423
|
+
const allSteps = stageDef?.steps || []
|
|
2424
|
+
const perProjectSteps = allSteps.filter(s => s.perProject)
|
|
2425
|
+
|
|
2426
|
+
// 防重复展开
|
|
2427
|
+
const alreadyExpanded = scanState.expanded || steps.some(s => s.name?.match(/\[.+\]\s*$/))
|
|
2428
|
+
if (!alreadyExpanded && perProjectSteps.length > 0) {
|
|
2342
2429
|
// 找到当前步骤(step 2)在动态 steps 中的位置
|
|
2343
2430
|
const insertBase = currentIdx + 1
|
|
2344
2431
|
let insertPos = insertBase
|
|
@@ -2377,8 +2464,9 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
2377
2464
|
// 标记已展开,防止 resume 重复插入
|
|
2378
2465
|
scanState.expanded = true
|
|
2379
2466
|
writeFileSync(scanStatePath, JSON.stringify(scanState))
|
|
2380
|
-
}
|
|
2381
|
-
|
|
2467
|
+
} // end !alreadyExpanded
|
|
2468
|
+
} // end validation.ok
|
|
2469
|
+
} // end step 2
|
|
2382
2470
|
|
|
2383
2471
|
const nextPendingIdx = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
|
|
2384
2472
|
|
package/src/scan-postcheck.js
CHANGED
|
@@ -151,19 +151,22 @@ export function runScanPostCheck({ cwd, specDir, outputText = '', scanMeta = {}
|
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
// 5. 检查 AI
|
|
154
|
+
// 5. 检查 AI 输出中的真实错误标记
|
|
155
|
+
// 注意:不对 agent 描述性文本做全文正则匹配,防止误报。
|
|
156
|
+
// 只检测明显是 agent 运行时失败的信号(未被捕获的错误块),
|
|
157
|
+
// 而非 agent 正常描述中提到这些词。
|
|
155
158
|
if (outputText) {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
{
|
|
160
|
-
|
|
161
|
-
]
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
checks.push({ name: ep.name, severity: CHECK_SEVERITY.WARNING, detail: ep.detail })
|
|
165
|
-
}
|
|
159
|
+
// 5a: 检测未被恢复的 API 错误(连续多次、非描述性提及)
|
|
160
|
+
const apiErrorCount = (outputText.match(/API Error\b.*?\b529\b/gi) || []).length
|
|
161
|
+
if (apiErrorCount >= 2) {
|
|
162
|
+
checks.push({ name: 'api_error_529', severity: CHECK_SEVERITY.WARNING, detail: 'AI 输出中包含多次 API Error 529' })
|
|
163
|
+
}
|
|
164
|
+
const rateLimitCount = (outputText.match(/rate.?limit.*?exhausted/gi) || []).length
|
|
165
|
+
if (rateLimitCount >= 2) {
|
|
166
|
+
checks.push({ name: 'rate_limit_exhausted', severity: CHECK_SEVERITY.WARNING, detail: 'AI 输出中包含多次 rate_limit exhausted' })
|
|
166
167
|
}
|
|
168
|
+
// tool_use_error 和 fallback 已移除:agent 描述性文本中正常提及这些词
|
|
169
|
+
// 不应触发 warning。真正的问题会通过文档缺失、manifest 失败等其他检查捕获。
|
|
167
170
|
}
|
|
168
171
|
|
|
169
172
|
// 6. manifest 写入状态检查
|
|
@@ -300,7 +303,7 @@ export function formatStructuredResult(result, meta = {}) {
|
|
|
300
303
|
structured.failure_categories.bad_references.push(entry)
|
|
301
304
|
}
|
|
302
305
|
// AI 输出质量类
|
|
303
|
-
else if (['
|
|
306
|
+
else if (['api_error_529', 'rate_limit_exhausted'].includes(check.name)) {
|
|
304
307
|
structured.failure_categories.quality_warnings.push(entry)
|
|
305
308
|
}
|
|
306
309
|
// manifest/project 列表问题
|
package/src/stages/scan.js
CHANGED
|
@@ -62,9 +62,26 @@ export const definition = {
|
|
|
62
62
|
- 最终确定的项目列表将用于后续所有步骤
|
|
63
63
|
- **后续每个需要生成文档的步骤,都必须对列表中的每个项目分别执行**
|
|
64
64
|
|
|
65
|
-
###
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
### 输出格式规范
|
|
66
|
+
--output 必须使用以下结构化格式之一(CLI 只解析这些格式,**不要输出自由文本列表**):
|
|
67
|
+
|
|
68
|
+
**格式 A:scan_projects YAML block**
|
|
69
|
+
在 --output 中输出:
|
|
70
|
+
scan_projects:
|
|
71
|
+
- id: backend
|
|
72
|
+
- id: frontend
|
|
73
|
+
- id: daemon
|
|
74
|
+
|
|
75
|
+
**格式 B:BEGIN_PROJECT_LIST 标记块**
|
|
76
|
+
在 --output 中输出:
|
|
77
|
+
BEGIN_PROJECT_LIST
|
|
78
|
+
- backend
|
|
79
|
+
- frontend
|
|
80
|
+
- daemon
|
|
81
|
+
END_PROJECT_LIST
|
|
82
|
+
|
|
83
|
+
如果不需要解析项目列表(如单项目已明确),--output 写普通摘要即可。`,
|
|
84
|
+
outputHint: '结构化项目列表(YAML block 或 BEGIN_PROJECT_LIST)',
|
|
68
85
|
optional: false
|
|
69
86
|
},
|
|
70
87
|
{
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* scan step 2 项目列表解析 — 强契约测试
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* 解析只接受两种结构化格式:
|
|
5
|
+
* A) YAML block: scan_projects:\n - id: name
|
|
6
|
+
* B) BEGIN_PROJECT_LIST ... END_PROJECT_LIST
|
|
7
|
+
*
|
|
8
|
+
* 自由文本列表不再解析,防止误识别垃圾项目名。
|
|
9
9
|
*/
|
|
10
|
-
import { sanitizeProjectName } from '../src/run.js'
|
|
10
|
+
import { sanitizeProjectName, validateParsedProjects } from '../src/run.js'
|
|
11
11
|
|
|
12
12
|
let passed = 0
|
|
13
13
|
let failed = 0
|
|
@@ -17,60 +17,183 @@ function assertDeepEqual (actual, expected, msg) {
|
|
|
17
17
|
if (a === e) { console.log(`✅ PASS: ${msg}`); passed++ }
|
|
18
18
|
else { console.error(`❌ FAIL: ${msg}\n expected: ${e}\n actual: ${a}`); failed++ }
|
|
19
19
|
}
|
|
20
|
+
function assertEqual (actual, expected, msg) {
|
|
21
|
+
if (actual === expected) { console.log(`✅ PASS: ${msg}`); passed++ }
|
|
22
|
+
else { console.error(`❌ FAIL: ${msg}\n expected: ${expected}\n actual: ${actual}`); failed++ }
|
|
23
|
+
}
|
|
24
|
+
function assertIncludes (str, sub, msg) {
|
|
25
|
+
if (str.includes(sub)) { console.log(`✅ PASS: ${msg}`); passed++ }
|
|
26
|
+
else { console.error(`❌ FAIL: ${msg}\n expected to include: ${sub}\n actual: ${str}`); failed++ }
|
|
27
|
+
}
|
|
20
28
|
|
|
21
|
-
// 复刻 run.js
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
if (
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
// ---- 复刻 run.js 中新的解析逻辑 ----
|
|
30
|
+
function parseProjectListFromOutput(outputText) {
|
|
31
|
+
let parsedProjects = [] // Array<{id, path?}>
|
|
32
|
+
let parsedFromStructuredOutput = false
|
|
33
|
+
if (outputText) {
|
|
34
|
+
// 格式 A: YAML block
|
|
35
|
+
const yamlBlock = outputText.match(/scan_projects:\s*\n([\s\S]+?)(?=$|\n[^\s])/)
|
|
36
|
+
if (yamlBlock) {
|
|
37
|
+
const entries = [...yamlBlock[1].matchAll(/-\s+id:\s*(\S+)(?:[\s\S]*?)(?=\n\s+-\s+id:|$)/g)]
|
|
38
|
+
for (const m of entries) {
|
|
39
|
+
const id = sanitizeProjectName(m[1])
|
|
40
|
+
if (!id) continue
|
|
41
|
+
const pathMatch = m[0].match(/path:\s*(\S+)/)
|
|
42
|
+
const entry = pathMatch ? { id, path: pathMatch[1].trim() } : { id }
|
|
43
|
+
parsedProjects.push(entry)
|
|
44
|
+
}
|
|
45
|
+
parsedFromStructuredOutput = parsedProjects.length > 0
|
|
46
|
+
}
|
|
47
|
+
// 格式 B: BEGIN_PROJECT_LIST
|
|
48
|
+
if (!parsedFromStructuredOutput) {
|
|
49
|
+
const blockMatch = outputText.match(/BEGIN_PROJECT_LIST\s*\n([\s\S]*?)\n*END_PROJECT_LIST/)
|
|
50
|
+
if (blockMatch) {
|
|
51
|
+
const raw = [...blockMatch[1].matchAll(/^-\s+(\S+)/gm)].map(m => m[1])
|
|
52
|
+
parsedProjects = raw.map(s => sanitizeProjectName(s)).filter(Boolean).map(id => ({ id }))
|
|
53
|
+
parsedFromStructuredOutput = parsedProjects.length > 0
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { parsedProjects, parsedFromStructuredOutput }
|
|
30
58
|
}
|
|
31
59
|
|
|
32
|
-
//
|
|
60
|
+
// ======== 解析测试 ========
|
|
61
|
+
|
|
62
|
+
// 1. YAML block 正常解析
|
|
33
63
|
assertDeepEqual(
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
64
|
+
parseProjectListFromOutput(`scan_projects:
|
|
65
|
+
- id: backend
|
|
66
|
+
- id: frontend
|
|
67
|
+
- id: daemon
|
|
68
|
+
`).parsedProjects,
|
|
69
|
+
[{ id: 'backend' }, { id: 'frontend' }, { id: 'daemon' }],
|
|
70
|
+
'YAML block 正常解析 3 个项目'
|
|
37
71
|
)
|
|
38
72
|
|
|
39
|
-
//
|
|
73
|
+
// 2. YAML block 带 path 字段(多行属性)
|
|
74
|
+
const r2 = parseProjectListFromOutput(`scan_projects:
|
|
75
|
+
- id: api
|
|
76
|
+
path: backend/
|
|
77
|
+
- id: web
|
|
78
|
+
path: frontend/
|
|
79
|
+
`).parsedProjects
|
|
80
|
+
assertDeepEqual(r2, [{ id: 'api', path: 'backend/' }, { id: 'web', path: 'frontend/' }],
|
|
81
|
+
'YAML block 带 path 字段正常解析')
|
|
82
|
+
|
|
83
|
+
// 3. BEGIN_PROJECT_LIST 正常解析
|
|
40
84
|
assertDeepEqual(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
85
|
+
parseProjectListFromOutput(`BEGIN_PROJECT_LIST
|
|
86
|
+
- backend
|
|
87
|
+
- frontend
|
|
88
|
+
- daemon
|
|
89
|
+
END_PROJECT_LIST`).parsedProjects,
|
|
90
|
+
[{ id: 'backend' }, { id: 'frontend' }, { id: 'daemon' }],
|
|
91
|
+
'BEGIN_PROJECT_LIST 标记块正常解析'
|
|
44
92
|
)
|
|
45
93
|
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
94
|
+
// 4. 自由文本编号列表 → 不解析
|
|
95
|
+
const { parsedProjects: fn, parsedFromStructuredOutput: fnParsed } =
|
|
96
|
+
parseProjectListFromOutput('扫描项目列表:\n1. scan1backendmulti-agent-platform-api FastAPI Python\n2. frontendmulti-agent-platform-web Next.js TS\n3. sillyhub-daemon Node.js TS')
|
|
97
|
+
assertEqual(fnParsed, false, '自由文本编号列表不触发解析')
|
|
98
|
+
assertDeepEqual(fn, [], '自由文本编号列表不产生任何项目名')
|
|
99
|
+
|
|
100
|
+
// 5. 自由文本括号枚举 → 不解析
|
|
101
|
+
const { parsedProjects: fn2, parsedFromStructuredOutput: fn2Parsed } =
|
|
102
|
+
parseProjectListFromOutput('子项目: backend / frontend / user-service')
|
|
103
|
+
assertEqual(fn2Parsed, false, '自由文本括号枚举不触发解析')
|
|
104
|
+
assertDeepEqual(fn2, [], '自由文本括号枚举不产生任何项目名')
|
|
105
|
+
|
|
106
|
+
// 6. 空 outputText → 不解析
|
|
107
|
+
const { parsedFromStructuredOutput: emptyParsed } = parseProjectListFromOutput('')
|
|
108
|
+
assertEqual(emptyParsed, false, '空 outputText 不触发解析')
|
|
109
|
+
|
|
110
|
+
// 7. 普通摘要文本 → 不解析
|
|
111
|
+
const { parsedProjects: summary, parsedFromStructuredOutput: summaryParsed } =
|
|
112
|
+
parseProjectListFromOutput('确认扫描 3 个子项目:backend、frontend、daemon,全部重新扫描')
|
|
113
|
+
assertEqual(summaryParsed, false, '普通摘要文本不触发解析')
|
|
114
|
+
assertDeepEqual(summary, [], '普通摘要文本不产生任何项目名')
|
|
115
|
+
|
|
116
|
+
// ======== validateParsedProjects — 基础校验 ========
|
|
117
|
+
|
|
118
|
+
// 8. 正常列表(无 path)
|
|
51
119
|
assertDeepEqual(
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
'
|
|
120
|
+
validateParsedProjects([{ id: 'backend' }, { id: 'frontend' }, { id: 'daemon' }], '/tmp/proj'),
|
|
121
|
+
{ ok: true, errors: [] },
|
|
122
|
+
'正常列表校验通过(无 path)'
|
|
55
123
|
)
|
|
56
124
|
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
125
|
+
// 9. 正常列表(有合法 path)——需要用真实路径
|
|
126
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'
|
|
127
|
+
import { join } from 'path'
|
|
128
|
+
import os from 'os'
|
|
129
|
+
const tmpDir = mkdtempSync(join(os.tmpdir(), 'sillyspec-test-'))
|
|
130
|
+
mkdirSync(join(tmpDir, 'backend'))
|
|
131
|
+
mkdirSync(join(tmpDir, 'frontend'))
|
|
61
132
|
assertDeepEqual(
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
133
|
+
validateParsedProjects([
|
|
134
|
+
{ id: 'backend', path: 'backend' },
|
|
135
|
+
{ id: 'frontend', path: 'frontend' }
|
|
136
|
+
], tmpDir),
|
|
137
|
+
{ ok: true, errors: [] },
|
|
138
|
+
'合法 path 校验通过'
|
|
65
139
|
)
|
|
66
140
|
|
|
67
|
-
//
|
|
141
|
+
// 10. path 不存在 → 失败
|
|
142
|
+
const v10 = validateParsedProjects([{ id: 'backend', path: 'nonexistent' }], tmpDir)
|
|
143
|
+
assertEqual(v10.ok, false, 'path 不存在校验失败')
|
|
144
|
+
assertIncludes(v10.errors[0], '不存在', '错误提示包含"不存在"')
|
|
145
|
+
|
|
146
|
+
// 11. path 含 .. 越界 → 失败
|
|
147
|
+
const v11 = validateParsedProjects([{ id: 'backend', path: '../etc/passwd' }], tmpDir)
|
|
148
|
+
assertEqual(v11.ok, false, 'path 包含 .. 校验失败')
|
|
149
|
+
assertIncludes(v11.errors[0], '..', '错误提示包含 ..')
|
|
150
|
+
|
|
151
|
+
// 12. path 解析后超出 source_root → 失败
|
|
152
|
+
const v12 = validateParsedProjects([{ id: 'backend', path: '/etc/passwd' }], tmpDir)
|
|
153
|
+
assertEqual(v12.ok, false, '绝对路径超出 source_root 校验失败')
|
|
154
|
+
assertIncludes(v12.errors[0], '超出', '错误提示包含"超出"')
|
|
155
|
+
|
|
156
|
+
// 13. 超过 10 个项目 → 失败
|
|
157
|
+
const many = Array.from({ length: 11 }, (_, i) => ({ id: `project${i}` }))
|
|
158
|
+
const v13 = validateParsedProjects(many, tmpDir)
|
|
159
|
+
assertEqual(v13.ok, false, '超过 10 个项目校验失败')
|
|
160
|
+
assertIncludes(v13.errors[0], '超过上限', '错误提示包含"超过上限"')
|
|
161
|
+
|
|
162
|
+
// 14. 重复项目名 → 失败
|
|
163
|
+
const v14 = validateParsedProjects([{ id: 'backend' }, { id: 'frontend' }, { id: 'backend' }], tmpDir)
|
|
164
|
+
assertEqual(v14.ok, false, '重复项目名校验失败')
|
|
165
|
+
assertIncludes(v14.errors[0], '重复', '错误提示包含"重复"')
|
|
166
|
+
|
|
167
|
+
// 15. 非法 slug(中文)→ 失败
|
|
168
|
+
const v15 = validateParsedProjects([{ id: 'backend' }, { id: '前端服务' }], tmpDir)
|
|
169
|
+
assertEqual(v15.ok, false, '非法 slug 校验失败')
|
|
170
|
+
|
|
171
|
+
// 16. 空列表 → 失败
|
|
172
|
+
const v16 = validateParsedProjects([], tmpDir)
|
|
173
|
+
assertEqual(v16.ok, false, '空列表校验失败')
|
|
174
|
+
|
|
175
|
+
// 17. null 列表 → 失败
|
|
176
|
+
const v17 = validateParsedProjects(null, tmpDir)
|
|
177
|
+
assertEqual(v17.ok, false, 'null 列表校验失败')
|
|
178
|
+
|
|
179
|
+
// 18. 单字符 → 失败
|
|
180
|
+
const v18 = validateParsedProjects([{ id: 'a' }], tmpDir)
|
|
181
|
+
assertEqual(v18.ok, false, '单字符项目名校验失败')
|
|
182
|
+
|
|
183
|
+
// 19. 纯数字 → 失败
|
|
184
|
+
const v19 = validateParsedProjects([{ id: '123' }], tmpDir)
|
|
185
|
+
assertEqual(v19.ok, false, '纯数字项目名校验失败')
|
|
186
|
+
|
|
187
|
+
// 20. 兼容旧 API(纯字符串数组)
|
|
68
188
|
assertDeepEqual(
|
|
69
|
-
|
|
70
|
-
[
|
|
71
|
-
'
|
|
189
|
+
validateParsedProjects(['backend', 'frontend'], tmpDir),
|
|
190
|
+
{ ok: true, errors: [] },
|
|
191
|
+
'旧 API 兼容:纯字符串数组仍通过'
|
|
72
192
|
)
|
|
73
193
|
|
|
194
|
+
// 清理
|
|
195
|
+
rmSync(tmpDir, { recursive: true, force: true })
|
|
196
|
+
|
|
74
197
|
console.log(`\n${'='.repeat(50)}`)
|
|
75
198
|
console.log(`✅ 通过: ${passed} ❌ 失败: ${failed}`)
|
|
76
199
|
console.log(`${'='.repeat(50)}`)
|
|
@@ -106,11 +106,10 @@ console.log('\n=== Test 4: local.yaml 命令不存在 → completed_with_warning
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
// ── 5-8: AI 输出错误标记 → warnings ──
|
|
109
|
+
// 注意:tool_use_error 和 fallback 已移除(agent 描述性文本正常提及不应触发)
|
|
109
110
|
const errorCases = [
|
|
110
|
-
{ id: '
|
|
111
|
-
{ id: '
|
|
112
|
-
{ id: 'e7', name: 'rate_limit', output: 'rate limit exhausted' },
|
|
113
|
-
{ id: 'e8', name: 'fallback', output: 'fallback to default, skipped validation' },
|
|
111
|
+
{ id: 'e6', name: 'API Error 529', output: 'API Error 529 server overloaded. API Error 529 retry failed' },
|
|
112
|
+
{ id: 'e7', name: 'rate_limit', output: 'rate limit exhausted, rate limit exhausted again' },
|
|
114
113
|
]
|
|
115
114
|
for (const ec of errorCases) {
|
|
116
115
|
console.log(`\n=== Test: ${ec.name} → completed_with_warnings ===`)
|
|
@@ -121,6 +120,21 @@ for (const ec of errorCases) {
|
|
|
121
120
|
clean(cwd, spec)
|
|
122
121
|
}
|
|
123
122
|
|
|
123
|
+
// ── tool_use_error / fallback 不再触发 warning(描述性文本正常提及) ──
|
|
124
|
+
const noWarnCases = [
|
|
125
|
+
{ id: 'e5', name: 'tool_use_error', output: 'tool_use_error: file not found' },
|
|
126
|
+
{ id: 'e8', name: 'fallback', output: '作为 fallback 方案,跳过了这个步骤' },
|
|
127
|
+
]
|
|
128
|
+
for (const ec of noWarnCases) {
|
|
129
|
+
console.log(`\n=== Test: ${ec.name} → 不触发 warning ===`)
|
|
130
|
+
const cwd = setup(ec.id), spec = specSetup(ec.id)
|
|
131
|
+
writeFull(cwd, spec)
|
|
132
|
+
const r = runScanPostCheck({ cwd, specDir: spec, outputText: ec.output })
|
|
133
|
+
assert(!r.checks.some(c => c.name === 'tool_use_error' || c.name === 'fallback_or_skip'),
|
|
134
|
+
`${ec.name}: 不应有 tool_use_error/fallback_or_skip warning`)
|
|
135
|
+
clean(cwd, spec)
|
|
136
|
+
}
|
|
137
|
+
|
|
124
138
|
// ── 9: 文档缺 header → warnings ──
|
|
125
139
|
console.log('\n=== Test 9: 文档缺 header → completed_with_warnings ===')
|
|
126
140
|
{
|
package/.npmrc-tmp
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
//registry.npmjs.org/:_authToken=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
|