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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sillyspec",
3
- "version": "3.20.1",
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
- await runCommand(filteredArgs.slice(1), resolveEffectiveDir(dir), specDir)
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
- await runCommand(stageArgs, resolveEffectiveDir(dir), specDir)
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
- // 解析项目列表:从 step 2 输出提取,或回退读取 projects/*.yaml
2258
- let projectNames = []
2259
- // sanitizeProjectName 已提取到模块顶层(含字母校验 + 长度≥2)
2303
+ // 解析项目列表:只接受结构化输出(YAML block BEGIN_PROJECT_LIST 标记)
2304
+ // 不再从自由文本猜测项目名——自由文本列表的误解析会导致垃圾项目落盘
2305
+ let parsedProjects = [] // Array<{id, path?}>
2306
+ let parsedFromStructuredOutput = false
2260
2307
  if (outputText) {
2261
- // 匹配方式 1: "1. project-name" 编号列表
2262
- // 正则收紧:token 必须以字母开头,避免误捕获纯数字 "0"/"7" 和步骤说明中英文行
2263
- const numbered = outputText.match(/^\s*\d+\.\s+([a-zA-Z][\w\-.]*)/gm)
2264
- if (numbered) {
2265
- // task-05 B2 延伸修正:原 /[—\-:].*$/ 会把 ASCII 连字符当后缀分隔符,
2266
- // order-service 切成 order。现只针对中文长破折号 `—`(LLM 输出列表时常作分隔符)。
2267
- const raw = numbered.map(m => m.replace(/^\s*\d+\.\s+/, '').replace(/—.*$/, '').trim())
2268
- projectNames = raw.map(sanitizeProjectName).filter(Boolean)
2269
- if (projectNames.length > 0) { stageData.scanMeta = stageData.scanMeta || {}; stageData.scanMeta.projectListParsed = true; }
2270
- }
2271
- // 匹配方式 2: 括号枚举 "子项目frontend/order-service/user-service" 或 "项目: a, b, c"
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
- // 匹配方式 3: 结构化 YAML block "scan_projects:\n - id: name"
2281
- if (projectNames.length === 0) {
2282
- const yamlMatch = outputText.match(/scan_projects:\s*\n((?:\s+-\s+id:\s+\S+\s*\n?)+)/)
2283
- if (yamlMatch) {
2284
- const raw = [...yamlMatch[1].matchAll(/-\s+id:\s*(\S+)/g)].map(m => m[1])
2285
- projectNames = raw.map(sanitizeProjectName).filter(Boolean)
2286
- if (projectNames.length > 0) { stageData.scanMeta = stageData.scanMeta || {}; stageData.scanMeta.projectListParsed = true; }
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
- if (projectNames.length === 0) {
2291
- // 回退:读取所有已注册项目
2292
- console.warn('⚠️ 未能从 step 2 输出解析项目列表,回退扫描所有注册项目')
2293
- stageData.scanMeta = stageData.scanMeta || {}; stageData.scanMeta.projectListParsed = false;
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
- projectNames = readdirSync(projectsDir)
2297
- .filter(f => f.endsWith('.yaml'))
2298
- .map(f => f.replace(/\.yaml$/, ''))
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
- if (projectNames.length === 0) {
2302
- projectNames = ['sillyspec'] // 最终兜底
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
- // 自动注册未注册的子项目(确保 projects/*.yaml 存在,避免展开时 projectRoot 缺失)
2391
+ // 自动注册 + 保存 runtime + 展开 perProject 步骤(仅在校验通过时)
2306
2392
  const projectsDir = join(specBase, 'projects')
2307
- for (const pName of projectNames) {
2308
- const projYaml = join(projectsDir, `${pName}.yaml`)
2309
- if (!existsSync(projYaml)) {
2310
- mkdirSync(projectsDir, { recursive: true })
2311
- // 子项目路径推测:检查 cwd 下是否有同名目录
2312
- const candidates = [
2313
- join(cwd, pName), // cwd/frontend
2314
- join(cwd, 'backend', pName), // cwd/backend/user-service
2315
- join(cwd, 'packages', pName), // monorepo packages
2316
- join(cwd, 'apps', pName), // monorepo apps
2317
- join(cwd, 'services', pName), // monorepo services
2318
- ]
2319
- const detected = candidates.find(c => existsSync(c))
2320
- const regPath = detected || join(cwd, pName)
2321
- writeFileSync(projYaml, `name: ${pName}\npath: ${regPath}\nstatus: active\n`)
2322
- console.log(` 📝 自动注册子项目: ${pName} ${regPath}`)
2323
- }
2324
- }
2325
-
2326
- // 保存到 runtime 供后续使用 + 防重复展开
2327
- const scanStatePath = join(specBase, '.runtime', 'scan-projects.json')
2328
- mkdirSync(join(specBase, '.runtime'), { recursive: true })
2329
- let scanState = { projects: projectNames, expanded: false }
2330
- if (existsSync(scanStatePath)) {
2331
- try { scanState = JSON.parse(readFileSync(scanStatePath, 'utf8')) } catch {}
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
- // 防重复展开:runtime 标记 或 steps 已含项目标识
2340
- const alreadyExpanded = scanState.expanded || steps.some(s => s.name?.match(/\[.+\]\s*$/))
2341
- if (!alreadyExpanded && perProjectSteps.length > 0) {
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
 
@@ -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
- const errorPatterns = [
157
- { pattern: /tool_use_error/i, name: 'tool_use_error', detail: 'AI 输出中包含 tool_use_error' },
158
- { pattern: /API Error.*529/i, name: 'api_error_529', detail: 'AI 输出中包含 API Error 529' },
159
- { pattern: /rate.?limit.*exhausted/i, name: 'rate_limit_exhausted', detail: 'AI 输出中包含 rate_limit exhausted' },
160
- { pattern: /fallback|retry.*failed|skipped.*validat/i, name: 'fallback_or_skip', detail: 'AI 输出中出现 fallback/retry failed/skipped validation' },
161
- ]
162
- for (const ep of errorPatterns) {
163
- if (ep.pattern.test(outputText)) {
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 (['tool_use_error', 'api_error_529', 'rate_limit_exhausted', 'fallback_or_skip'].includes(check.name)) {
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 列表问题
@@ -62,9 +62,26 @@ export const definition = {
62
62
  - 最终确定的项目列表将用于后续所有步骤
63
63
  - **后续每个需要生成文档的步骤,都必须对列表中的每个项目分别执行**
64
64
 
65
- ### 输出
66
- 确认后的扫描项目列表(项目名 + 扫描策略)`,
67
- outputHint: '扫描项目列表',
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
- * task-09 集成:编号正则收紧 + sanitize 联动
2
+ * scan step 2 项目列表解析 — 强契约测试
3
3
  *
4
- * 模拟 run.js:2176 处的解析逻辑(正则 + raw 处理 + sanitizeProjectName + filter(Boolean))
5
- * 验证:
6
- * - "1. frontend\n2. 0\n3. 7\n4. order-service" → ['frontend', 'order-service']("0"/"7" 被拒)
7
- * - 步骤说明"1. 执行 init"不进 projectNames(中文不匹配 [a-zA-Z] 开头)
8
- * - 兜底分支不被污染(outputText 为空 → 走兜底)
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:2175-2179 的解析链路
22
- // task-05 B2 延伸修正:编号解析链 .replace 只针对中文长破折号 `—`,
23
- // 不再误伤 ASCII `-`(否则 order-service 会被切成 order)。
24
- function parseNumberedList(outputText) {
25
- if (!outputText) return []
26
- const numbered = outputText.match(/^\s*\d+\.\s+([a-zA-Z][\w\-.]*)/gm)
27
- if (!numbered) return []
28
- const raw = numbered.map(m => m.replace(/^\s*\d+\.\s+/, '').replace(/—.*$/, '').trim())
29
- return raw.map(sanitizeProjectName).filter(Boolean)
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
- // 用例 1:脏数据列表(task-09 核心场景)+ ASCII 连字符保留(task-05 B2 延伸修正)
60
+ // ======== 解析测试 ========
61
+
62
+ // 1. YAML block 正常解析
33
63
  assertDeepEqual(
34
- parseNumberedList('扫描项目列表:\n1. frontend\n2. 0\n3. 7\n4. order-service\n'),
35
- ['frontend', 'order-service'],
36
- '脏数据列表:纯数字被拒(task-09);order-service 完整保留(ASCII - 不再被误切)'
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
- // 用例 2:步骤说明干扰(方案 A 边界)
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
- parseNumberedList('以下是步骤:\n1. 执行 init\n2. 启动 scan\n3. frontend\n'),
42
- ['frontend'],
43
- '步骤说明中"1. 执行 init"中文不匹配,仅"3. frontend"入选'
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
- // 用例 3:英文步骤说明干扰(方案 A 已知边界,task-09 §TDD 第 5 步)
47
- // 注:英文"1. Run scan"会匹配到 "Run",经 sanitize 通过。这是方案 A 的已知边界,
48
- // task-09 §实现要求 2 A 的前提是"sanitizeProjectName 字母校验双保险"——
49
- // 纯数字场景已解决(本任务目标),英文步骤误匹配留待 execute 发现再切方案 B。
50
- const r3 = parseNumberedList('Steps:\n1. Run scan first\n2. backend\n')
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
- r3,
53
- ['Run', 'backend'],
54
- '英文步骤会误匹配(方案 A 已知边界,纯数字目标已达成)'
120
+ validateParsedProjects([{ id: 'backend' }, { id: 'frontend' }, { id: 'daemon' }], '/tmp/proj'),
121
+ { ok: true, errors: [] },
122
+ '正常列表校验通过(无 path)'
55
123
  )
56
124
 
57
- // 用例 4:空 outputText
58
- assertDeepEqual(parseNumberedList(''), [], '空 outputText 返回空列表')
59
-
60
- // 用例 5:含下划线/点的项目名(避开既有 -replace bug,专测 sanitize)
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
- parseNumberedList('1. app.v2\n2. web_api\n'),
63
- ['app.v2', 'web_api'],
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
- // 用例 6:0/7 这种斜杠分隔的会被单条编号捕获再 sanitize 拒绝
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
- parseNumberedList('1. 0/7\n2. frontend\n'),
70
- ['frontend'],
71
- '"0/7" 单条:正则匹配失败(首字符 0 非字母)→ 整条丢弃'
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: 'e5', name: 'tool_use_error', output: 'tool_use_error: file not found' },
111
- { id: 'e6', name: 'API Error 529', output: 'API Error 529 server overloaded' },
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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