sillyspec 3.18.5 → 3.18.6
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/index.js +15 -0
- package/src/run.js +58 -12
- package/src/stage-contract.js +14 -1
- package/src/workflow.js +12 -8
- package/templates/workflows/scan-docs.yaml +8 -8
- package/test/cli-top-level-aliases.test.mjs +174 -0
- package/test/run-sanitize-project-name.test.mjs +51 -0
- package/test/run-scan-postcheck-fail.test.mjs +64 -0
- package/test/run-scan-project-parse.test.mjs +77 -0
- package/test/scan-docs-yaml-placeholders.test.mjs +84 -0
- package/test/scan-postcheck-project-priority.test.mjs +85 -0
- package/test/scan-workflow-anyfailed-block.test.mjs +52 -0
- package/test/stage-contract-failed-post-check.test.mjs +102 -0
- package/test/workflow-spec-base.test.mjs +142 -0
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -289,6 +289,21 @@ async function main() {
|
|
|
289
289
|
await runCommand(filteredArgs.slice(1), resolveEffectiveDir(dir), specDir)
|
|
290
290
|
break
|
|
291
291
|
}
|
|
292
|
+
// task-10: 顶层命令别名,转发 runCommand,与 case 'run': 路径行为一致
|
|
293
|
+
// help 文本(:44-46)已宣称这些 stage 可直接使用,这里补齐路由避免落 default 分支。
|
|
294
|
+
// 注意:filteredArgs[0] === command,直接透传 filteredArgs 即可让 runCommand
|
|
295
|
+
// 从 args[0] 取到 stage 名(run.js:1036)。与 case 'run': 的 filteredArgs.slice(1)
|
|
296
|
+
// 区别只在于 slice(1) 去掉的是 'run' 字面量,这里 command 本身就是 stage 名不能丢。
|
|
297
|
+
case 'doctor':
|
|
298
|
+
case 'scan':
|
|
299
|
+
case 'status':
|
|
300
|
+
case 'quick':
|
|
301
|
+
case 'explore': {
|
|
302
|
+
const { runCommand } = await import('./run.js')
|
|
303
|
+
const stageArgs = [command, ...filteredArgs.slice(1)]
|
|
304
|
+
await runCommand(stageArgs, resolveEffectiveDir(dir), specDir)
|
|
305
|
+
break
|
|
306
|
+
}
|
|
292
307
|
case 'dashboard': {
|
|
293
308
|
// Parse dashboard options
|
|
294
309
|
let port = 3456;
|
package/src/run.js
CHANGED
|
@@ -11,6 +11,22 @@ const require = createRequire(import.meta.url)
|
|
|
11
11
|
import { ProgressManager } from './progress.js'
|
|
12
12
|
import { SCAN_STATUS, POINTER_STATUS, isPointerCorrupted } from './constants.js'
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* 清洗项目名:只保留 ASCII 字母/数字/横线/下划线/点,过滤中文和特殊字符。
|
|
16
|
+
* - 必须含至少一个字母(拒绝纯数字 "0"/"7"/"07",避免 scan-projects.json 脏数据)
|
|
17
|
+
* - 长度必须 ≥ 2(拒绝单字符 "a"/"0")
|
|
18
|
+
* @param {string} name - 原始项目名候选
|
|
19
|
+
* @returns {string | null} 合法项目名或 null(拒绝)
|
|
20
|
+
*/
|
|
21
|
+
export function sanitizeProjectName(name) {
|
|
22
|
+
if (!name) return null
|
|
23
|
+
const clean = String(name).replace(/[^a-zA-Z0-9_\-.]/g, '').trim()
|
|
24
|
+
if (!clean) return null
|
|
25
|
+
if (!/[a-zA-Z]/.test(clean)) return null // 纯数字/符号拒绝("0"/"7"/"07")
|
|
26
|
+
if (clean.length < 2) return null // 单字符拒绝("a"/"0")
|
|
27
|
+
return clean
|
|
28
|
+
}
|
|
29
|
+
|
|
14
30
|
/**
|
|
15
31
|
* 在容器/Docker 环境下,git 可能因目录所有权不匹配报 dubious ownership。
|
|
16
32
|
* 使用 -c safe.directory= 临时参数,不污染全局 git config。
|
|
@@ -666,6 +682,12 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
|
|
|
666
682
|
`2. **不允许**用 cat >、tee、heredoc 等 Bash 方式绕过 Write 工具。\n` +
|
|
667
683
|
`3. 如果 Write 和 Read 均失败,记录失败并停止当前 step。\n` +
|
|
668
684
|
`\n` +
|
|
685
|
+
`### 📍 Workflow YAML 占位符映射(task-05)\n` +
|
|
686
|
+
`读取 \`{WORKFLOWS_ROOT}/scan-docs.yaml\` 时,yaml 内的占位符按以下映射替换为绝对路径:\n` +
|
|
687
|
+
`- \`{SPEC_ROOT}\` → \`${specSillyspec}\`(规范目录根)\n` +
|
|
688
|
+
`- \`<project>\` → 当前项目名(见下方 step 提示,等于 \`${projectName}\`)\n` +
|
|
689
|
+
`- 例:\`{SPEC_ROOT}/docs/<project>/scan/ARCHITECTURE.md\` → \`${docsRoot}/scan/ARCHITECTURE.md\`\n` +
|
|
690
|
+
`\n` +
|
|
669
691
|
`创建目录: \`mkdir -p ${docsRoot}/{scan,modules,flows} ${projectsRoot} ${changesRoot}\`\n`
|
|
670
692
|
)
|
|
671
693
|
if (platformOpts.runtimeRoot) {
|
|
@@ -1416,7 +1438,9 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
|
|
|
1416
1438
|
const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
|
|
1417
1439
|
// 状态转换校验
|
|
1418
1440
|
const prevStage = progress.currentStage || ''
|
|
1419
|
-
|
|
1441
|
+
// task-07: 提取 prevStage 的 stageData,传给 checkTransition 检测 failed_post_check 门控
|
|
1442
|
+
const fromStageData = (progress.stages && prevStage && progress.stages[prevStage]) || undefined
|
|
1443
|
+
const transition = checkTransition(prevStage, stageName, fromStageData ? { fromStageData } : {})
|
|
1420
1444
|
if (!transition.allowed) {
|
|
1421
1445
|
console.error(`❌ 阶段转换不允许: ${prevStage || '(起始)'} → ${stageName}`)
|
|
1422
1446
|
console.error(` 原因: ${transition.reason}`)
|
|
@@ -2150,16 +2174,15 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
2150
2174
|
if (stageName === 'scan' && steps[currentIdx]?.name === '构建扫描项目列表') {
|
|
2151
2175
|
// 解析项目列表:从 step 2 输出提取,或回退读取 projects/*.yaml
|
|
2152
2176
|
let projectNames = []
|
|
2153
|
-
//
|
|
2154
|
-
const sanitizeProjectName = (name) => {
|
|
2155
|
-
const clean = name.replace(/[^a-zA-Z0-9_\-.]/g, '').trim()
|
|
2156
|
-
return clean || null
|
|
2157
|
-
}
|
|
2177
|
+
// sanitizeProjectName 已提取到模块顶层(含字母校验 + 长度≥2)
|
|
2158
2178
|
if (outputText) {
|
|
2159
2179
|
// 匹配方式 1: "1. project-name" 编号列表
|
|
2160
|
-
|
|
2180
|
+
// 正则收紧:token 必须以字母开头,避免误捕获纯数字 "0"/"7" 和步骤说明中英文行
|
|
2181
|
+
const numbered = outputText.match(/^\s*\d+\.\s+([a-zA-Z][\w\-.]*)/gm)
|
|
2161
2182
|
if (numbered) {
|
|
2162
|
-
|
|
2183
|
+
// task-05 B2 延伸修正:原 /[—\-:].*$/ 会把 ASCII 连字符当后缀分隔符,
|
|
2184
|
+
// 把 order-service 切成 order。现只针对中文长破折号 `—`(LLM 输出列表时常作分隔符)。
|
|
2185
|
+
const raw = numbered.map(m => m.replace(/^\s*\d+\.\s+/, '').replace(/—.*$/, '').trim())
|
|
2163
2186
|
projectNames = raw.map(sanitizeProjectName).filter(Boolean)
|
|
2164
2187
|
if (projectNames.length > 0) { stageData.scanMeta = stageData.scanMeta || {}; stageData.scanMeta.projectListParsed = true; }
|
|
2165
2188
|
}
|
|
@@ -2434,8 +2457,18 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
2434
2457
|
stageData.status = SCAN_STATUS.FAILED_POST_CHECK
|
|
2435
2458
|
stageData.completedAt = new Date().toLocaleString('zh-CN',{hour12:false})
|
|
2436
2459
|
await pm._write(cwd, progress, changeName)
|
|
2460
|
+
triggerSync(cwd, changeName, platformOpts)
|
|
2437
2461
|
console.error(`\n❌ scan post-check 失败,状态设为 failed_post_check。不允许 clean success。`)
|
|
2438
2462
|
console.error(` 请检查上方错误信息并修复后重新 scan。`)
|
|
2463
|
+
// 平台模式:exit(1) 让 daemon/SillyHub 感知非 0 退出码(manifest.json 已落盘,不会被撤销)
|
|
2464
|
+
if (platformOpts.specRoot || platformOpts.runtimeRoot) {
|
|
2465
|
+
console.error(' 平台模式:CLI 将以 exit code 1 退出,通知 SillyHub scan 失败。')
|
|
2466
|
+
process.exit(1)
|
|
2467
|
+
}
|
|
2468
|
+
// 接口与 plan contract (run.js:2551 附近 plan 失败分支) 对齐:
|
|
2469
|
+
// 返回 { stageCompleted:false, currentIdx, nextPendingIdx: currentIdx }
|
|
2470
|
+
// 让上层 runStage 走"完成但不推进"分支,--done 被拒
|
|
2471
|
+
return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
|
|
2439
2472
|
} else if (postResult.status === 'completed_with_warnings') {
|
|
2440
2473
|
// 警告不阻止完成,但记录
|
|
2441
2474
|
stageData.status = 'completed'
|
|
@@ -2623,8 +2656,17 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
2623
2656
|
const { loadWorkflow, runPostCheck, formatCheckReport, saveWorkflowRun } = await import('./workflow.js')
|
|
2624
2657
|
const wf = loadWorkflow(cwd, 'scan-docs')
|
|
2625
2658
|
if (wf) {
|
|
2626
|
-
//
|
|
2627
|
-
|
|
2659
|
+
// 确定当前项目(优先级链):
|
|
2660
|
+
// progress.project (dbProjectName,平台模式真实项目名,与 outputStep 占位符渲染对齐)
|
|
2661
|
+
// > change?.project (变更对象的项目字段,平台模式 change 创建时传入)
|
|
2662
|
+
// > steps[idx].project (perProject 展开标记,兼容旧模式)
|
|
2663
|
+
// > steps[idx].name 正则提取 [xxx] 后缀
|
|
2664
|
+
// > null(回退检查所有项目)
|
|
2665
|
+
// task-05 修复:日志显示项目名变 frontend 是 perProject 误展开 bug,
|
|
2666
|
+
// 用 progress.project(与 outputStep 占位符渲染路径一致)修正 myaaa/frontend 分裂。
|
|
2667
|
+
const currentProjectName = progress.project
|
|
2668
|
+
|| (typeof change !== 'undefined' && change ? change.project : null)
|
|
2669
|
+
|| steps[currentIdx].project
|
|
2628
2670
|
|| (steps[currentIdx].name.match(/\[([^\]]+)\]\s*$/) || [])[1]
|
|
2629
2671
|
|| null
|
|
2630
2672
|
|
|
@@ -2644,7 +2686,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
2644
2686
|
|
|
2645
2687
|
let anyFailed = false
|
|
2646
2688
|
for (const pName of projectsToCheck) {
|
|
2647
|
-
const result = runPostCheck(wf, cwd, pName)
|
|
2689
|
+
const result = runPostCheck(wf, cwd, pName, {}, specBase)
|
|
2648
2690
|
const report = formatCheckReport(result)
|
|
2649
2691
|
console.log(report)
|
|
2650
2692
|
if (result.status === 'fail') {
|
|
@@ -2667,6 +2709,10 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
2667
2709
|
}
|
|
2668
2710
|
if (anyFailed) {
|
|
2669
2711
|
console.log(`\n⚠️ 存在检查失败项,请按上面的重试提示修复后再继续。`)
|
|
2712
|
+
// task-07: 阻断推进(与 task-06 平台模式 scan-postcheck 失败分支 return 结构对齐)
|
|
2713
|
+
// scan 深度扫描产物校验未通过时,不允许 clean success / 进入下一 step,
|
|
2714
|
+
// 让上层走"完成但不推进"分支,--done 被拒。
|
|
2715
|
+
return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
|
|
2670
2716
|
}
|
|
2671
2717
|
}
|
|
2672
2718
|
} catch (e) {
|
|
@@ -2682,7 +2728,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
2682
2728
|
if (wf && changeName) {
|
|
2683
2729
|
const raw = JSON.stringify(wf)
|
|
2684
2730
|
const resolved = JSON.parse(raw.replace(/<change-name>/g, changeName))
|
|
2685
|
-
const result = runPostCheck(resolved, cwd, 'sillyspec')
|
|
2731
|
+
const result = runPostCheck(resolved, cwd, 'sillyspec', {}, specBase)
|
|
2686
2732
|
// 只报告 impact-analyzer 的结果(doc-syncer 是后续步骤)
|
|
2687
2733
|
const impactResult = (result.roles || []).find(r => r.id === 'impact-analyzer')
|
|
2688
2734
|
if (impactResult) {
|
package/src/stage-contract.js
CHANGED
|
@@ -587,9 +587,11 @@ export function getContract(stageName) {
|
|
|
587
587
|
* 校验状态转换是否允许
|
|
588
588
|
* @param {string} fromStage - 当前阶段(空字符串表示变更起始)
|
|
589
589
|
* @param {string} toStage - 目标阶段
|
|
590
|
+
* @param {{ fromStageData?: { status?: string } | undefined }} [options] - 可选,从 progress.stages[prevStage] 提取
|
|
590
591
|
* @returns {{ allowed: boolean, reason?: string }}
|
|
591
592
|
*/
|
|
592
|
-
export function checkTransition(fromStage, toStage) {
|
|
593
|
+
export function checkTransition(fromStage, toStage, options = {}) {
|
|
594
|
+
const { fromStageData } = options // { status?: string } | undefined
|
|
593
595
|
const contract = contracts[toStage]
|
|
594
596
|
if (!contract) {
|
|
595
597
|
return { allowed: false, reason: `未知阶段: ${toStage}` }
|
|
@@ -605,6 +607,17 @@ export function checkTransition(fromStage, toStage) {
|
|
|
605
607
|
return { allowed: true }
|
|
606
608
|
}
|
|
607
609
|
|
|
610
|
+
// task-07: failed_post_check 门控
|
|
611
|
+
// scan post-check 未通过时,禁止进入主流程的下游阶段(brainstorm/plan/execute/verify/archive)
|
|
612
|
+
// 必须先重跑 scan 修复。toStage === 'scan' 的重跑路径已被上方 fromStage === toStage 放行。
|
|
613
|
+
// fromStageData.status 缺失(旧数据)时门控不触发(向后兼容)。
|
|
614
|
+
if (fromStage === 'scan' && fromStageData?.status === 'failed_post_check' && toStage !== 'scan') {
|
|
615
|
+
return {
|
|
616
|
+
allowed: false,
|
|
617
|
+
reason: 'scan post-check 未通过(failed_post_check),需修复后重跑 scan 再进入 ' + toStage,
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
608
621
|
// archive 特殊处理:从 verify 来的允许,从其他主流程阶段来的需要校验
|
|
609
622
|
if (toStage === 'archive') {
|
|
610
623
|
if (fromStage === 'verify') {
|
package/src/workflow.js
CHANGED
|
@@ -149,10 +149,13 @@ function replaceProjectPlaceholder(wf, projectName) {
|
|
|
149
149
|
* @param {string} cwd - 项目根目录
|
|
150
150
|
* @returns {CheckResult}
|
|
151
151
|
*/
|
|
152
|
-
function checkOutput(outputDef, projectName, cwd) {
|
|
152
|
+
function checkOutput(outputDef, projectName, cwd, specBase) {
|
|
153
|
+
// specBase 优先(平台模式 platformOpts.specRoot,已含或不含 .sillyspec 语义);
|
|
154
|
+
// 未传时回退 join(cwd, '.sillyspec'),等价于旧行为 resolve(cwd, '.sillyspec/...')
|
|
155
|
+
const effectiveBase = specBase || join(cwd, '.sillyspec')
|
|
153
156
|
// 将 <project> 替换为实际项目名
|
|
154
157
|
const rawPath = (outputDef.path || '').replace(/<project>/g, projectName)
|
|
155
|
-
const fullPath = resolve(
|
|
158
|
+
const fullPath = resolve(effectiveBase, rawPath)
|
|
156
159
|
const checks = outputDef.checks || []
|
|
157
160
|
const results = []
|
|
158
161
|
|
|
@@ -241,7 +244,7 @@ function checkOutput(outputDef, projectName, cwd) {
|
|
|
241
244
|
* retry_prompts: [{ role_id, role_name, prompt }]
|
|
242
245
|
* }
|
|
243
246
|
*/
|
|
244
|
-
export function runPostCheck(wf, cwd, projectName, placeholders = {}) {
|
|
247
|
+
export function runPostCheck(wf, cwd, projectName, placeholders = {}, specBase) {
|
|
245
248
|
let resolved = replaceProjectPlaceholder(wf, projectName)
|
|
246
249
|
if (Object.keys(placeholders).length > 0) {
|
|
247
250
|
let json = JSON.stringify(resolved)
|
|
@@ -250,10 +253,11 @@ export function runPostCheck(wf, cwd, projectName, placeholders = {}) {
|
|
|
250
253
|
}
|
|
251
254
|
resolved = JSON.parse(json)
|
|
252
255
|
}
|
|
253
|
-
return _checkWorkflow(resolved, cwd, projectName)
|
|
256
|
+
return _checkWorkflow(resolved, cwd, projectName, specBase)
|
|
254
257
|
}
|
|
255
258
|
|
|
256
|
-
function _checkWorkflow(wf, cwd, projectName) {
|
|
259
|
+
function _checkWorkflow(wf, cwd, projectName, specBase) {
|
|
260
|
+
const effectiveBase = specBase || join(cwd, '.sillyspec')
|
|
257
261
|
const workflowName = wf.name || 'unknown'
|
|
258
262
|
const specVersion = wf.spec_version || wf.version || 0
|
|
259
263
|
const workflowChecks = wf.checks?.workflow_level || []
|
|
@@ -270,7 +274,7 @@ function _checkWorkflow(wf, cwd, projectName) {
|
|
|
270
274
|
|
|
271
275
|
for (const outputDef of outputDefs) {
|
|
272
276
|
const rawPath = (outputDef.path || '').replace(/<project>/g, projectName)
|
|
273
|
-
const checkResults = checkOutput(outputDef, projectName, cwd)
|
|
277
|
+
const checkResults = checkOutput(outputDef, projectName, cwd, effectiveBase)
|
|
274
278
|
const outputPassed = checkResults.every(c => c.passed)
|
|
275
279
|
|
|
276
280
|
outputs.push({
|
|
@@ -309,7 +313,7 @@ function _checkWorkflow(wf, cwd, projectName) {
|
|
|
309
313
|
for (const check of workflowChecks) {
|
|
310
314
|
switch (check.type) {
|
|
311
315
|
case 'file_count': {
|
|
312
|
-
const scanDir = join(
|
|
316
|
+
const scanDir = join(effectiveBase, 'docs', projectName, check.path || 'scan/')
|
|
313
317
|
if (existsSync(scanDir)) {
|
|
314
318
|
const files = readdirSync(scanDir).filter(f => f.endsWith('.md'))
|
|
315
319
|
const min = check.min || 0
|
|
@@ -328,7 +332,7 @@ function _checkWorkflow(wf, cwd, projectName) {
|
|
|
328
332
|
break
|
|
329
333
|
}
|
|
330
334
|
case 'no_empty_files': {
|
|
331
|
-
const scanDir = join(
|
|
335
|
+
const scanDir = join(effectiveBase, 'docs', projectName, check.path || 'scan/')
|
|
332
336
|
if (existsSync(scanDir)) {
|
|
333
337
|
const files = readdirSync(scanDir).filter(f => f.endsWith('.md'))
|
|
334
338
|
let anyEmpty = false
|
|
@@ -13,7 +13,7 @@ roles:
|
|
|
13
13
|
hints:
|
|
14
14
|
grep_patterns: ["class ", "export ", "import ", "schema", "CREATE TABLE"]
|
|
15
15
|
outputs:
|
|
16
|
-
- path: "
|
|
16
|
+
- path: "{SPEC_ROOT}/docs/<project>/scan/ARCHITECTURE.md"
|
|
17
17
|
required: true
|
|
18
18
|
checks:
|
|
19
19
|
- type: file_exists
|
|
@@ -33,7 +33,7 @@ roles:
|
|
|
33
33
|
hints:
|
|
34
34
|
grep_patterns: ["function ", "const ", "async ", "try ", "catch "]
|
|
35
35
|
outputs:
|
|
36
|
-
- path: "
|
|
36
|
+
- path: "{SPEC_ROOT}/docs/<project>/scan/CONVENTIONS.md"
|
|
37
37
|
required: true
|
|
38
38
|
checks:
|
|
39
39
|
- type: file_exists
|
|
@@ -53,13 +53,13 @@ roles:
|
|
|
53
53
|
hints:
|
|
54
54
|
grep_patterns: ["fetch", "http", "WebSocket", "ws", "chokidar"]
|
|
55
55
|
outputs:
|
|
56
|
-
- path: "
|
|
56
|
+
- path: "{SPEC_ROOT}/docs/<project>/scan/STRUCTURE.md"
|
|
57
57
|
required: true
|
|
58
58
|
checks:
|
|
59
59
|
- type: file_exists
|
|
60
60
|
- type: min_lines
|
|
61
61
|
min: 15
|
|
62
|
-
- path: "
|
|
62
|
+
- path: "{SPEC_ROOT}/docs/<project>/scan/INTEGRATIONS.md"
|
|
63
63
|
required: true
|
|
64
64
|
checks:
|
|
65
65
|
- type: file_exists
|
|
@@ -77,14 +77,14 @@ roles:
|
|
|
77
77
|
hints:
|
|
78
78
|
grep_patterns: ["TODO", "FIXME", "deprecated", "test", "describe"]
|
|
79
79
|
outputs:
|
|
80
|
-
- path: "
|
|
80
|
+
- path: "{SPEC_ROOT}/docs/<project>/scan/TESTING.md"
|
|
81
81
|
required: true
|
|
82
82
|
checks:
|
|
83
83
|
- type: file_exists
|
|
84
84
|
- type: min_lines
|
|
85
85
|
min: 10
|
|
86
86
|
- type: no_placeholder
|
|
87
|
-
- path: "
|
|
87
|
+
- path: "{SPEC_ROOT}/docs/<project>/scan/CONCERNS.md"
|
|
88
88
|
required: true
|
|
89
89
|
checks:
|
|
90
90
|
- type: file_exists
|
|
@@ -92,7 +92,7 @@ roles:
|
|
|
92
92
|
min: 10
|
|
93
93
|
- type: contains_sections
|
|
94
94
|
sections: ["代码质量", "依赖风险"]
|
|
95
|
-
- path: "
|
|
95
|
+
- path: "{SPEC_ROOT}/docs/<project>/scan/PROJECT.md"
|
|
96
96
|
required: true
|
|
97
97
|
checks:
|
|
98
98
|
- type: file_exists
|
|
@@ -126,7 +126,7 @@ on_check_failure: prompt_retry
|
|
|
126
126
|
permissions:
|
|
127
127
|
write_mode: direct
|
|
128
128
|
write_scope:
|
|
129
|
-
- "
|
|
129
|
+
- "{SPEC_ROOT}/docs/<project>/scan/"
|
|
130
130
|
allow_shell: true
|
|
131
131
|
allow_network: false
|
|
132
132
|
allow_git: false
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* task-10: 顶层命令别名 doctor/scan/status/quick/explore 转发 runCommand
|
|
3
|
+
*
|
|
4
|
+
* 设计依据:task-10.md §TDD + §验收标准
|
|
5
|
+
* - sillyspec doctor / scan / status / quick / explore 不再落 default 分支报"未知命令"
|
|
6
|
+
* - 行为与 sillyspec run <stage> 字节一致
|
|
7
|
+
* - sillyspec worktree doctor 仍走 worktree 分支
|
|
8
|
+
* - sillyspec foobar 仍报未知命令
|
|
9
|
+
*
|
|
10
|
+
* 断言策略:
|
|
11
|
+
* stage 在空目录下可能因无 .sillyspec 进度而 exit != 0(这是 stage 自身行为,
|
|
12
|
+
* 不属于本任务路由范围)。因此测试只验证"路由正确"——即 stderr 不含"未知命令"
|
|
13
|
+
* 字样(default 分支的特征文案),并且 doctor/scan 两路 stdout 字节一致。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { spawnSync } from 'node:child_process'
|
|
17
|
+
import { mkdirSync, rmSync } from 'node:fs'
|
|
18
|
+
import { join, resolve, dirname } from 'node:path'
|
|
19
|
+
import { tmpdir } from 'node:os'
|
|
20
|
+
import { fileURLToPath } from 'node:url'
|
|
21
|
+
|
|
22
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
23
|
+
const __dirname = dirname(__filename)
|
|
24
|
+
const cliBin = resolve(__dirname, '..', 'bin', 'sillyspec.js')
|
|
25
|
+
|
|
26
|
+
let passed = 0
|
|
27
|
+
let failed = 0
|
|
28
|
+
|
|
29
|
+
function assert(cond, msg) {
|
|
30
|
+
if (cond) {
|
|
31
|
+
console.log(` ✅ PASS: ${msg}`)
|
|
32
|
+
passed++
|
|
33
|
+
} else {
|
|
34
|
+
console.log(` ❌ FAIL: ${msg}`)
|
|
35
|
+
failed++
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function runCLI(args, cwd) {
|
|
40
|
+
const res = spawnSync(process.execPath, [cliBin, ...args], {
|
|
41
|
+
cwd,
|
|
42
|
+
encoding: 'utf8',
|
|
43
|
+
timeout: 15000,
|
|
44
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
45
|
+
})
|
|
46
|
+
return {
|
|
47
|
+
stdout: res.stdout || '',
|
|
48
|
+
stderr: res.stderr || '',
|
|
49
|
+
status: res.status,
|
|
50
|
+
combined: (res.stdout || '') + (res.stderr || ''),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function cleanSillySpec(cwd) {
|
|
55
|
+
// 清掉 sillyspec 写入 cwd 的进度副作用,保证两路字节级环境一致
|
|
56
|
+
try { rmSync(join(cwd, '.sillyspec'), { recursive: true, force: true }) } catch {}
|
|
57
|
+
try { rmSync(join(cwd, '.sillyspec-platform.json'), { force: true }) } catch {}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const tmpRoot = join(tmpdir(), `sillyspec-cli-aliases-${Date.now()}`)
|
|
61
|
+
mkdirSync(tmpRoot, { recursive: true })
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
// ── Red/Green: 5 个顶层命令不再报"未知命令" ──
|
|
65
|
+
const aliases = ['doctor', 'scan', 'status', 'quick', 'explore']
|
|
66
|
+
console.log('\n=== Test 1: 顶层命令别名不落 default 分支 ===')
|
|
67
|
+
for (const stage of aliases) {
|
|
68
|
+
const res = runCLI([stage], tmpRoot)
|
|
69
|
+
const hitUnknown =
|
|
70
|
+
res.combined.includes('未知命令') ||
|
|
71
|
+
/unknown command/i.test(res.combined)
|
|
72
|
+
assert(
|
|
73
|
+
!hitUnknown,
|
|
74
|
+
`sillyspec ${stage} 不报"未知命令" (exit=${res.status})`
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Green: doctor 顶层别名 与 sillyspec run doctor 字节一致 ──
|
|
79
|
+
// 两路必须在字节级相同环境下运行:同 cwd + 每次跑前清空 .sillyspec
|
|
80
|
+
// (否则 progress 持久化会让第二次跑读到旧数据触发平台同步检查)
|
|
81
|
+
console.log('\n=== Test 2: sillyspec doctor 与 sillyspec run doctor 等价 ===')
|
|
82
|
+
{
|
|
83
|
+
const cwd = join(tmpRoot, 'doctor-cmp')
|
|
84
|
+
mkdirSync(cwd, { recursive: true })
|
|
85
|
+
cleanSillySpec(cwd)
|
|
86
|
+
const top = runCLI(['doctor'], cwd)
|
|
87
|
+
cleanSillySpec(cwd)
|
|
88
|
+
const viaRun = runCLI(['run', 'doctor'], cwd)
|
|
89
|
+
assert(
|
|
90
|
+
top.status === viaRun.status,
|
|
91
|
+
`exit code 一致: doctor=${top.status}, run doctor=${viaRun.status}`
|
|
92
|
+
)
|
|
93
|
+
assert(
|
|
94
|
+
top.stdout === viaRun.stdout,
|
|
95
|
+
`stdout 字节一致 (len=${top.stdout.length})`
|
|
96
|
+
)
|
|
97
|
+
assert(
|
|
98
|
+
top.stderr === viaRun.stderr,
|
|
99
|
+
`stderr 字节一致 (len=${top.stderr.length})`
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Green: scan 顶层别名 与 sillyspec run scan 字节一致 ──
|
|
104
|
+
console.log('\n=== Test 3: sillyspec scan 与 sillyspec run scan 等价 ===')
|
|
105
|
+
{
|
|
106
|
+
const cwd = join(tmpRoot, 'scan-cmp')
|
|
107
|
+
mkdirSync(cwd, { recursive: true })
|
|
108
|
+
cleanSillySpec(cwd)
|
|
109
|
+
const top = runCLI(['scan'], cwd)
|
|
110
|
+
cleanSillySpec(cwd)
|
|
111
|
+
const viaRun = runCLI(['run', 'scan'], cwd)
|
|
112
|
+
assert(
|
|
113
|
+
top.status === viaRun.status,
|
|
114
|
+
`exit code 一致: scan=${top.status}, run scan=${viaRun.status}`
|
|
115
|
+
)
|
|
116
|
+
assert(
|
|
117
|
+
top.stdout === viaRun.stdout,
|
|
118
|
+
`stdout 字节一致 (len=${top.stdout.length})`
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── 回归: worktree doctor 走 worktree 分支(与顶层 doctor 不同) ──
|
|
123
|
+
console.log('\n=== Test 4: sillyspec worktree doctor 走 worktree 分支 ===')
|
|
124
|
+
{
|
|
125
|
+
const res = runCLI(['worktree', 'doctor'], tmpRoot)
|
|
126
|
+
// worktree doctor 不应报顶层 default 的"未知命令",也不应报"未知阶段"
|
|
127
|
+
const hitUnknownCmd =
|
|
128
|
+
res.combined.includes('未知命令') && !/worktree/.test(res.combined)
|
|
129
|
+
assert(!hitUnknownCmd, `worktree doctor 不报顶层未知命令`)
|
|
130
|
+
// worktree 子命令 default 分支会输出"未知子命令: worktree",这里 doctor 合法不应出现
|
|
131
|
+
assert(
|
|
132
|
+
!res.combined.includes('未知子命令'),
|
|
133
|
+
`worktree doctor 不是未知子命令`
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── 回归: foobar 仍落 default 报未知命令 ──
|
|
138
|
+
console.log('\n=== Test 5: sillyspec foobar 仍报未知命令 ===')
|
|
139
|
+
{
|
|
140
|
+
const res = runCLI(['foobar'], tmpRoot)
|
|
141
|
+
assert(
|
|
142
|
+
res.combined.includes('未知命令'),
|
|
143
|
+
`foobar 命中 default 分支,报"未知命令"`
|
|
144
|
+
)
|
|
145
|
+
assert(
|
|
146
|
+
res.status !== 0,
|
|
147
|
+
`foobar exit code 非 0 (got ${res.status})`
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── 选项透传: sillyspec doctor --json 与 sillyspec run doctor --json 等价 ──
|
|
152
|
+
console.log('\n=== Test 6: doctor --json 选项透传正确 ===')
|
|
153
|
+
{
|
|
154
|
+
const top = runCLI(['doctor', '--json'], tmpRoot)
|
|
155
|
+
const viaRun = runCLI(['run', 'doctor', '--json'], tmpRoot)
|
|
156
|
+
assert(
|
|
157
|
+
top.stdout === viaRun.stdout,
|
|
158
|
+
`doctor --json stdout 与 run doctor --json 一致 (len=${top.stdout.length})`
|
|
159
|
+
)
|
|
160
|
+
assert(
|
|
161
|
+
top.status === viaRun.status,
|
|
162
|
+
`doctor --json exit 与 run doctor --json 一致`
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
} finally {
|
|
166
|
+
try {
|
|
167
|
+
rmSync(tmpRoot, { recursive: true, force: true })
|
|
168
|
+
} catch {}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
console.log(`\n${'='.repeat(50)}`)
|
|
172
|
+
console.log(`✅ 通过: ${passed} ❌ 失败: ${failed}`)
|
|
173
|
+
console.log(`${'='.repeat(50)}`)
|
|
174
|
+
process.exit(failed > 0 ? 1 : 0)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* task-09: sanitizeProjectName 字母校验 + 长度≥2 + 编号正则收紧
|
|
3
|
+
*
|
|
4
|
+
* 覆盖:
|
|
5
|
+
* - 纯数字 "0"/"7" → null(核心目标,scan-projects.json 脏数据来源)
|
|
6
|
+
* - "0/7" 清洗 "07" 无字母 → null
|
|
7
|
+
* - "a" 长度<2 → null
|
|
8
|
+
* - "fe" 含字母长度≥2 → 通过
|
|
9
|
+
* - "frontend" / "order-service" → 通过
|
|
10
|
+
* - "前端项目" 全中文清洗后 "" → null
|
|
11
|
+
* - "" → null
|
|
12
|
+
*/
|
|
13
|
+
import { sanitizeProjectName } from '../src/run.js'
|
|
14
|
+
|
|
15
|
+
let passed = 0
|
|
16
|
+
let failed = 0
|
|
17
|
+
function assertEqual (actual, expected, msg) {
|
|
18
|
+
const ok = actual === expected
|
|
19
|
+
if (ok) { console.log(`✅ PASS: ${msg}`); passed++ }
|
|
20
|
+
else { console.error(`❌ FAIL: ${msg}\n expected: ${JSON.stringify(expected)}\n actual: ${JSON.stringify(actual)}`); failed++ }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 通过用例
|
|
24
|
+
assertEqual(sanitizeProjectName('frontend'), 'frontend', '"frontend" 含字母长度≥2 通过')
|
|
25
|
+
assertEqual(sanitizeProjectName('order-service'), 'order-service', '"order-service" 含字母+横线 通过')
|
|
26
|
+
assertEqual(sanitizeProjectName('fe'), 'fe', '"fe" 最小长度 2 通过')
|
|
27
|
+
assertEqual(sanitizeProjectName('user_service'), 'user_service', '"user_service" 含下划线 通过')
|
|
28
|
+
assertEqual(sanitizeProjectName('app.v2'), 'app.v2', '"app.v2" 含点+数字 通过')
|
|
29
|
+
|
|
30
|
+
// 拒绝用例(核心目标)
|
|
31
|
+
assertEqual(sanitizeProjectName('0'), null, '"0" 纯数字拒绝')
|
|
32
|
+
assertEqual(sanitizeProjectName('7'), null, '"7" 纯数字拒绝')
|
|
33
|
+
assertEqual(sanitizeProjectName('07'), null, '"07" 纯数字拒绝(无字母)')
|
|
34
|
+
assertEqual(sanitizeProjectName('0/7'), null, '"0/7" 清洗后 "07" 无字母拒绝')
|
|
35
|
+
assertEqual(sanitizeProjectName('123'), null, '"123" 纯数字拒绝')
|
|
36
|
+
|
|
37
|
+
// 长度<2
|
|
38
|
+
assertEqual(sanitizeProjectName('a'), null, '"a" 长度<2 拒绝(即使含字母)')
|
|
39
|
+
assertEqual(sanitizeProjectName('z'), null, '"z" 长度<2 拒绝')
|
|
40
|
+
|
|
41
|
+
// 中文 / 空
|
|
42
|
+
assertEqual(sanitizeProjectName('前端项目'), null, '"前端项目" 全中文清洗后 "" 拒绝')
|
|
43
|
+
assertEqual(sanitizeProjectName(''), null, '空字符串拒绝')
|
|
44
|
+
assertEqual(sanitizeProjectName(' '), null, '纯空白拒绝')
|
|
45
|
+
assertEqual(sanitizeProjectName(null), null, 'null 拒绝')
|
|
46
|
+
assertEqual(sanitizeProjectName(undefined), null, 'undefined 拒绝')
|
|
47
|
+
|
|
48
|
+
console.log(`\n${'='.repeat(50)}`)
|
|
49
|
+
console.log(`✅ 通过: ${passed} ❌ 失败: ${failed}`)
|
|
50
|
+
console.log(`${'='.repeat(50)}`)
|
|
51
|
+
if (failed > 0) process.exit(1)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* task-06: scan post-check 失败补 return + completed 标记推迟 + 平台模式 exit(1)
|
|
3
|
+
*
|
|
4
|
+
* 验证点:
|
|
5
|
+
* - AC-1: run.js:2433-2438 失败分支末尾补 return { stageCompleted:false, currentIdx, nextPendingIdx: currentIdx }
|
|
6
|
+
* - AC-2: 平台模式 (platformOpts.specRoot || platformOpts.runtimeRoot) 时 process.exit(1)
|
|
7
|
+
* - AC-3: 返回结构与 plan contract (run.js:2551 附近) 完全一致
|
|
8
|
+
* - AC-4: 非平台模式不 exit(只有 if platformOpts 守卫内的 exit)
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync } from 'fs'
|
|
11
|
+
import { join, dirname } from 'path'
|
|
12
|
+
import { fileURLToPath } from 'url'
|
|
13
|
+
|
|
14
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
15
|
+
const runPath = join(__dirname, '..', 'src', 'run.js')
|
|
16
|
+
const src = readFileSync(runPath, 'utf8')
|
|
17
|
+
|
|
18
|
+
let passed = 0
|
|
19
|
+
let failed = 0
|
|
20
|
+
function check (cond, msg) {
|
|
21
|
+
if (cond) { console.log(`✅ PASS: ${msg}`); passed++ }
|
|
22
|
+
else { console.error(`❌ FAIL: ${msg}`); failed++ }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 定位 scan post-check failed_post_check 分支
|
|
26
|
+
// 已知关键锚:stageData.status = SCAN_STATUS.FAILED_POST_CHECK
|
|
27
|
+
const failedAnchor = src.indexOf("postResult.status === 'failed_post_check'")
|
|
28
|
+
check(failedAnchor > 0, "源码含 'failed_post_check' 失败分支锚点")
|
|
29
|
+
|
|
30
|
+
// 从锚点向后 800 字符内应该有 return { stageCompleted: false ... }
|
|
31
|
+
const tail = src.slice(failedAnchor, failedAnchor + 1200)
|
|
32
|
+
const hasReturnFalse = /return\s*\{\s*stageCompleted:\s*false/.test(tail)
|
|
33
|
+
check(hasReturnFalse, '失败分支末尾补 return { stageCompleted: false, ... }')
|
|
34
|
+
|
|
35
|
+
// nextPendingIdx: currentIdx 字面出现(与 plan contract 一致)
|
|
36
|
+
const hasNextPendingCurrent = /nextPendingIdx:\s*currentIdx/.test(tail)
|
|
37
|
+
check(hasNextPendingCurrent, '失败分支返回 nextPendingIdx: currentIdx(plan contract 对齐)')
|
|
38
|
+
|
|
39
|
+
// 平台模式 process.exit(1)
|
|
40
|
+
const hasExitGuard = /platformOpts\.specRoot\s*\|\|\s*platformOpts\.runtimeRoot/.test(tail)
|
|
41
|
+
const hasExitOne = /process\.exit\(1\)/.test(tail)
|
|
42
|
+
check(hasExitGuard && hasExitOne, '失败分支含平台模式守卫 + process.exit(1)')
|
|
43
|
+
|
|
44
|
+
// 验证 exit(1) 在 if 守卫内(不是裸调)—— 取 exit(1) 位置向前回溯到最近的 } 或 {
|
|
45
|
+
const exitPos = tail.indexOf('process.exit(1)')
|
|
46
|
+
const segmentBeforeExit = tail.slice(0, exitPos)
|
|
47
|
+
const lastOpenBrace = segmentBeforeExit.lastIndexOf('{')
|
|
48
|
+
const lastCloseBrace = segmentBeforeExit.lastIndexOf('}')
|
|
49
|
+
check(lastOpenBrace > lastCloseBrace, 'process.exit(1) 在某个 if 块的 { ... } 内(受守卫保护)')
|
|
50
|
+
|
|
51
|
+
// 验证 exit 守卫表达式在 exit 之前的同一块内
|
|
52
|
+
const guardSegment = tail.slice(Math.max(0, exitPos - 300), exitPos)
|
|
53
|
+
check(/platformOpts\.(specRoot|runtimeRoot)/.test(guardSegment),
|
|
54
|
+
'process.exit(1) 前的代码块内含平台模式守卫条件')
|
|
55
|
+
|
|
56
|
+
// 对照 plan contract(run.js 内 plan 失败分支 return 结构)
|
|
57
|
+
// 定位 plan 阶段失败分支的 stageCompleted:false return
|
|
58
|
+
const planFailMatch = src.match(/plan[\s\S]{0,2000}?return\s*\{\s*stageCompleted:\s*false[\s\S]{0,200}?\}/)
|
|
59
|
+
check(!!planFailMatch, 'plan contract 存在 stageCompleted:false return(对照基准)')
|
|
60
|
+
|
|
61
|
+
console.log(`\n${'='.repeat(50)}`)
|
|
62
|
+
console.log(`✅ 通过: ${passed} ❌ 失败: ${failed}`)
|
|
63
|
+
console.log(`${'='.repeat(50)}`)
|
|
64
|
+
if (failed > 0) process.exit(1)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* task-09 集成:编号正则收紧 + sanitize 联动
|
|
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 为空 → 走兜底)
|
|
9
|
+
*/
|
|
10
|
+
import { sanitizeProjectName } from '../src/run.js'
|
|
11
|
+
|
|
12
|
+
let passed = 0
|
|
13
|
+
let failed = 0
|
|
14
|
+
function assertDeepEqual (actual, expected, msg) {
|
|
15
|
+
const a = JSON.stringify(actual)
|
|
16
|
+
const e = JSON.stringify(expected)
|
|
17
|
+
if (a === e) { console.log(`✅ PASS: ${msg}`); passed++ }
|
|
18
|
+
else { console.error(`❌ FAIL: ${msg}\n expected: ${e}\n actual: ${a}`); failed++ }
|
|
19
|
+
}
|
|
20
|
+
|
|
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)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 用例 1:脏数据列表(task-09 核心场景)+ ASCII 连字符保留(task-05 B2 延伸修正)
|
|
33
|
+
assertDeepEqual(
|
|
34
|
+
parseNumberedList('扫描项目列表:\n1. frontend\n2. 0\n3. 7\n4. order-service\n'),
|
|
35
|
+
['frontend', 'order-service'],
|
|
36
|
+
'脏数据列表:纯数字被拒(task-09);order-service 完整保留(ASCII - 不再被误切)'
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
// 用例 2:步骤说明干扰(方案 A 边界)
|
|
40
|
+
assertDeepEqual(
|
|
41
|
+
parseNumberedList('以下是步骤:\n1. 执行 init\n2. 启动 scan\n3. frontend\n'),
|
|
42
|
+
['frontend'],
|
|
43
|
+
'步骤说明中"1. 执行 init"中文不匹配,仅"3. frontend"入选'
|
|
44
|
+
)
|
|
45
|
+
|
|
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')
|
|
51
|
+
assertDeepEqual(
|
|
52
|
+
r3,
|
|
53
|
+
['Run', 'backend'],
|
|
54
|
+
'英文步骤会误匹配(方案 A 已知边界,纯数字目标已达成)'
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
// 用例 4:空 outputText
|
|
58
|
+
assertDeepEqual(parseNumberedList(''), [], '空 outputText 返回空列表')
|
|
59
|
+
|
|
60
|
+
// 用例 5:含下划线/点的项目名(避开既有 -replace bug,专测 sanitize)
|
|
61
|
+
assertDeepEqual(
|
|
62
|
+
parseNumberedList('1. app.v2\n2. web_api\n'),
|
|
63
|
+
['app.v2', 'web_api'],
|
|
64
|
+
'合法项目名(点/下划线)保留'
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
// 用例 6:0/7 这种斜杠分隔的会被单条编号捕获再 sanitize 拒绝
|
|
68
|
+
assertDeepEqual(
|
|
69
|
+
parseNumberedList('1. 0/7\n2. frontend\n'),
|
|
70
|
+
['frontend'],
|
|
71
|
+
'"0/7" 单条:正则匹配失败(首字符 0 非字母)→ 整条丢弃'
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
console.log(`\n${'='.repeat(50)}`)
|
|
75
|
+
console.log(`✅ 通过: ${passed} ❌ 失败: ${failed}`)
|
|
76
|
+
console.log(`${'='.repeat(50)}`)
|
|
77
|
+
if (failed > 0) process.exit(1)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* task-05: scan-docs.yaml 占位符 {SPEC_ROOT} + 项目名优先级
|
|
3
|
+
*
|
|
4
|
+
* 覆盖:
|
|
5
|
+
* - AC-01: 8 处 outputs.path 均为 {SPEC_ROOT}/docs/<project>/scan/*.md
|
|
6
|
+
* - AC-02: write_scope 含 {SPEC_ROOT}/docs/<project>/scan/
|
|
7
|
+
* - AC-07: 旧 yaml(无 {SPEC_ROOT})兼容,replace 不命中也不报错
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from 'fs'
|
|
10
|
+
import { join, dirname } from 'path'
|
|
11
|
+
import { fileURLToPath } from 'url'
|
|
12
|
+
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
14
|
+
const yamlPath = join(__dirname, '..', 'templates', 'workflows', 'scan-docs.yaml')
|
|
15
|
+
const yaml = readFileSync(yamlPath, 'utf8')
|
|
16
|
+
|
|
17
|
+
let passed = 0
|
|
18
|
+
let failed = 0
|
|
19
|
+
function assert (cond, msg) {
|
|
20
|
+
if (cond) { console.log(`✅ PASS: ${msg}`); passed++ }
|
|
21
|
+
else { console.error(`❌ FAIL: ${msg}`); failed++ }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// AC-01: 8 处 outputs.path 改占位符
|
|
25
|
+
const expectedDocs = [
|
|
26
|
+
'ARCHITECTURE.md',
|
|
27
|
+
'CONVENTIONS.md',
|
|
28
|
+
'STRUCTURE.md',
|
|
29
|
+
'INTEGRATIONS.md',
|
|
30
|
+
'TESTING.md',
|
|
31
|
+
'CONCERNS.md',
|
|
32
|
+
'PROJECT.md',
|
|
33
|
+
]
|
|
34
|
+
console.log('=== AC-01: outputs.path 改占位符 {SPEC_ROOT} ===')
|
|
35
|
+
for (const doc of expectedDocs) {
|
|
36
|
+
const expectedLine = `path: "{SPEC_ROOT}/docs/<project>/scan/${doc}"`
|
|
37
|
+
assert(yaml.includes(expectedLine), `outputs.path 含 {SPEC_ROOT} 占位符 → ${doc}`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// AC-01 反向:不应再有硬编码 .sillyspec/docs/<project>/scan/X.md
|
|
41
|
+
console.log('\n=== AC-01 反向:不再有硬编码 .sillyspec/docs/<project>/scan/ ===')
|
|
42
|
+
const hardcodedMatches = yaml.match(/\.sillyspec\/docs\/<project>\/scan\//g) || []
|
|
43
|
+
assert(hardcodedMatches.length === 0,
|
|
44
|
+
`outputs.path/write_scope 不再含 ".sillyspec/docs/<project>/scan/"(找到 ${hardcodedMatches.length} 处)`)
|
|
45
|
+
|
|
46
|
+
// AC-02: write_scope 含 {SPEC_ROOT}
|
|
47
|
+
console.log('\n=== AC-02: write_scope 含 {SPEC_ROOT} ===')
|
|
48
|
+
assert(yaml.includes('- "{SPEC_ROOT}/docs/<project>/scan/"'),
|
|
49
|
+
'write_scope 含 {SPEC_ROOT}/docs/<project>/scan/')
|
|
50
|
+
|
|
51
|
+
// AC-07 兼容:旧 yaml(无 {SPEC_ROOT})replace 不命中(用代码模拟)
|
|
52
|
+
console.log('\n=== AC-07: 旧 yaml 占位符兼容性 ===')
|
|
53
|
+
{
|
|
54
|
+
const legacyPrompt = '写文件到 .sillyspec/docs/<project>/scan/X.md'
|
|
55
|
+
const replaced = legacyPrompt.replace(/\{SPEC_ROOT\}/g, '/tmp/spec')
|
|
56
|
+
assert(!replaced.includes('{SPEC_ROOT}'), '旧 yaml 无 {SPEC_ROOT} 字面:replace 后无残留')
|
|
57
|
+
assert(replaced.includes('.sillyspec/docs/'), '旧 yaml 行为:保留原 .sillyspec/docs/ 路径(向后兼容)')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// AC-03: 新 yaml 替换后路径正确
|
|
61
|
+
console.log('\n=== AC-03: 新 yaml 占位符替换 ===')
|
|
62
|
+
{
|
|
63
|
+
const newPrompt = '写文件到 {SPEC_ROOT}/docs/<project>/scan/X.md'
|
|
64
|
+
const step1 = newPrompt.replace(/\{SPEC_ROOT\}/g, '/tmp/spec')
|
|
65
|
+
const step2 = step1.replace(/<project>/g, 'myaaa')
|
|
66
|
+
assert(step2 === '写文件到 /tmp/spec/docs/myaaa/scan/X.md',
|
|
67
|
+
`占位符替换正确:${step2}`)
|
|
68
|
+
assert(!step2.includes('{SPEC_ROOT}'), '替换后无 {SPEC_ROOT} 残留')
|
|
69
|
+
assert(!step2.includes('<project>'), '替换后无 <project> 残留')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 结构完整性:其他字段未动
|
|
73
|
+
console.log('\n=== 结构完整性:其他字段未变 ===')
|
|
74
|
+
assert(yaml.includes('name: scan-docs'), 'yaml name 字段保留')
|
|
75
|
+
assert(yaml.includes('checks:'), 'checks 段保留')
|
|
76
|
+
assert(yaml.includes('path: "scan/"'), 'workflow_level.path "scan/" 相对子路径保留(不动)')
|
|
77
|
+
assert(yaml.includes('retry:'), 'retry 段保留')
|
|
78
|
+
assert(yaml.includes('on_check_failure: prompt_retry'), 'on_check_failure 保留')
|
|
79
|
+
assert(yaml.includes('allow_shell: true'), 'permissions.allow_shell 保留')
|
|
80
|
+
|
|
81
|
+
console.log(`\n${'='.repeat(50)}`)
|
|
82
|
+
console.log(`✅ 通过: ${passed} ❌ 失败: ${failed}`)
|
|
83
|
+
console.log(`${'='.repeat(50)}`)
|
|
84
|
+
if (failed > 0) process.exit(1)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* task-05: run.js post-check 项目名优先级链
|
|
3
|
+
*
|
|
4
|
+
* 覆盖 AC-04/05/06/10: currentProjectName 优先级 =
|
|
5
|
+
* progress.project (dbProjectName) > change.project > steps[idx].project > name 正则 > null
|
|
6
|
+
*
|
|
7
|
+
* 由于 runStage 是大函数,这里用源码字符串校验关键优先级链顺序,
|
|
8
|
+
* 并通过控制台 fixture 模拟实际行为。
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync } from 'fs'
|
|
11
|
+
import { join, dirname } from 'path'
|
|
12
|
+
import { fileURLToPath } from 'url'
|
|
13
|
+
|
|
14
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
15
|
+
const runPath = join(__dirname, '..', 'src', 'run.js')
|
|
16
|
+
const src = readFileSync(runPath, 'utf8')
|
|
17
|
+
|
|
18
|
+
let passed = 0
|
|
19
|
+
let failed = 0
|
|
20
|
+
function assert (cond, msg) {
|
|
21
|
+
if (cond) { console.log(`✅ PASS: ${msg}`); passed++ }
|
|
22
|
+
else { console.error(`❌ FAIL: ${msg}`); failed++ }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// AC-10: 定位 workflow post_check 段的 currentProjectName 赋值
|
|
26
|
+
console.log('=== AC-10: currentProjectName 优先级链 ===')
|
|
27
|
+
|
|
28
|
+
// 锚点:workflow post_check 段特征字符串
|
|
29
|
+
const anchor = src.indexOf("Workflow post_check:scan 深度扫描完成后自动检查产物")
|
|
30
|
+
assert(anchor > 0, '找到 workflow post_check scan 锚点')
|
|
31
|
+
|
|
32
|
+
// 取该段后 2000 字符内的 currentProjectName 赋值块
|
|
33
|
+
const tail = src.slice(anchor, anchor + 3000)
|
|
34
|
+
const assignStart = tail.indexOf('const currentProjectName')
|
|
35
|
+
assert(assignStart > 0, 'post_check 段含 currentProjectName 赋值')
|
|
36
|
+
|
|
37
|
+
// 赋值块(到下一行 const 或 let 前)
|
|
38
|
+
const assignBlock = tail.slice(assignStart, assignStart + 600)
|
|
39
|
+
|
|
40
|
+
// 验证优先级链:progress.project 必须在 steps[idx].project 之前出现
|
|
41
|
+
const ppPos = assignBlock.indexOf('progress.project')
|
|
42
|
+
const stepsPos = assignBlock.indexOf('steps[currentIdx].project')
|
|
43
|
+
assert(ppPos > 0 && stepsPos > 0, 'currentProjectName 链含 progress.project + steps[currentIdx].project')
|
|
44
|
+
assert(ppPos < stepsPos,
|
|
45
|
+
`优先级正确:progress.project (pos=${ppPos}) 在 steps[idx].project (pos=${stepsPos}) 之前`)
|
|
46
|
+
|
|
47
|
+
// 验证保留兜底:name 正则提取
|
|
48
|
+
assert(/\[([^\]]+)\]\s*\$\//.test(assignBlock) || /\\\[/i.test(assignBlock),
|
|
49
|
+
'保留兜底:steps[idx].name 正则提取仍存在')
|
|
50
|
+
|
|
51
|
+
// AC-05/06: 行为模拟 — 优先级链实际执行结果
|
|
52
|
+
console.log('\n=== AC-05/06: 优先级链行为模拟 ===')
|
|
53
|
+
// 复刻 run.js:2650 修正后的优先级链
|
|
54
|
+
function pickProjectName (progressProject, changeProject, stepProject, stepName) {
|
|
55
|
+
return progressProject
|
|
56
|
+
|| changeProject
|
|
57
|
+
|| stepProject
|
|
58
|
+
|| (stepName && (stepName.match(/\[([^\]]+)\]\s*$/) || [])[1])
|
|
59
|
+
|| null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// AC-05: progress.project='myaaa' 优先于 steps[idx].project='frontend'
|
|
63
|
+
const r1 = pickProjectName('myaaa', undefined, 'frontend', '深度扫描 [frontend]')
|
|
64
|
+
assert(r1 === 'myaaa', `AC-05: progress.project='myaaa' 优先 → ${r1}`)
|
|
65
|
+
|
|
66
|
+
// AC-06: progress.project 缺失 → 回退 steps[idx].project
|
|
67
|
+
const r2 = pickProjectName(undefined, undefined, 'frontend', '深度扫描 [frontend]')
|
|
68
|
+
assert(r2 === 'frontend', `AC-06: 兜底 steps[idx].project='frontend' → ${r2}`)
|
|
69
|
+
|
|
70
|
+
// 兜底 2: progress/change/step.project 均缺 → name 正则
|
|
71
|
+
const r3 = pickProjectName(undefined, undefined, undefined, '深度扫描 [backend]')
|
|
72
|
+
assert(r3 === 'backend', `兜底 name 正则提取 → ${r3}`)
|
|
73
|
+
|
|
74
|
+
// 全 null
|
|
75
|
+
const r4 = pickProjectName(undefined, undefined, undefined, '深度扫描')
|
|
76
|
+
assert(r4 === null, `全缺 → null(检查所有项目分支)`)
|
|
77
|
+
|
|
78
|
+
// change.project 优先于 steps[idx].project(progress.project 缺失时)
|
|
79
|
+
const r5 = pickProjectName(undefined, 'myaaa', 'frontend', '深度扫描 [frontend]')
|
|
80
|
+
assert(r5 === 'myaaa', `change.project='myaaa' 优先于 steps[idx].project='frontend' → ${r5}`)
|
|
81
|
+
|
|
82
|
+
console.log(`\n${'='.repeat(50)}`)
|
|
83
|
+
console.log(`✅ 通过: ${passed} ❌ 失败: ${failed}`)
|
|
84
|
+
console.log(`${'='.repeat(50)}`)
|
|
85
|
+
if (failed > 0) process.exit(1)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* task-07: run.js workflow post_check anyFailed 阻断
|
|
3
|
+
*
|
|
4
|
+
* 覆盖:
|
|
5
|
+
* - AC: workflow post_check anyFailed 时返回 { stageCompleted:false, currentIdx, nextPendingIdx: currentIdx }
|
|
6
|
+
* (与 task-06 平台模式 scan-postcheck 失败分支 return 结构对齐)
|
|
7
|
+
*
|
|
8
|
+
* 用源码字符串匹配 + 行为模拟。
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync } from 'fs'
|
|
11
|
+
import { join, dirname } from 'path'
|
|
12
|
+
import { fileURLToPath } from 'url'
|
|
13
|
+
|
|
14
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
15
|
+
const runPath = join(__dirname, '..', 'src', 'run.js')
|
|
16
|
+
const src = readFileSync(runPath, 'utf8')
|
|
17
|
+
|
|
18
|
+
let passed = 0
|
|
19
|
+
let failed = 0
|
|
20
|
+
function assert (cond, msg) {
|
|
21
|
+
if (cond) { console.log(`✅ PASS: ${msg}`); passed++ }
|
|
22
|
+
else { console.error(`❌ FAIL: ${msg}`); failed++ }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
console.log('=== workflow post_check anyFailed 阻断 ===')
|
|
26
|
+
|
|
27
|
+
// 锚点:scan 深度扫描 workflow post_check 段
|
|
28
|
+
const anchor = src.indexOf('if (anyFailed)')
|
|
29
|
+
assert(anchor > 0, '找到 if (anyFailed) 锚点')
|
|
30
|
+
|
|
31
|
+
// 取该 if 块前后 500 字符(向前找变量声明,向后找 return)
|
|
32
|
+
const tail = src.slice(anchor, anchor + 500)
|
|
33
|
+
|
|
34
|
+
// 必须有 return { stageCompleted: false ... }
|
|
35
|
+
assert(/return\s*\{\s*stageCompleted:\s*false/.test(tail),
|
|
36
|
+
'anyFailed 分支含 return { stageCompleted: false }')
|
|
37
|
+
|
|
38
|
+
// 必须有 nextPendingIdx: currentIdx
|
|
39
|
+
assert(/nextPendingIdx:\s*currentIdx/.test(tail),
|
|
40
|
+
'anyFailed 分支返回 nextPendingIdx: currentIdx')
|
|
41
|
+
|
|
42
|
+
// 必须有 currentIdx(保持当前 step 不推进)
|
|
43
|
+
assert(/currentIdx/.test(tail), 'anyFailed 分支保留 currentIdx 字段')
|
|
44
|
+
|
|
45
|
+
// 必须有 console.log 警告(保留用户可见提示)
|
|
46
|
+
assert(/console\.log/.test(tail) && /存在检查失败项|重试提示/.test(src.slice(anchor - 200, anchor + 300)),
|
|
47
|
+
'anyFailed 分支保留 console.log 用户提示')
|
|
48
|
+
|
|
49
|
+
console.log(`\n${'='.repeat(50)}`)
|
|
50
|
+
console.log(`✅ 通过: ${passed} ❌ 失败: ${failed}`)
|
|
51
|
+
console.log(`${'='.repeat(50)}`)
|
|
52
|
+
if (failed > 0) process.exit(1)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* task-07: checkTransition failed_post_check 门控
|
|
3
|
+
*
|
|
4
|
+
* 覆盖:
|
|
5
|
+
* - AC-1: scan → brainstorm (failed_post_check) → allowed=false, reason 含"scan post-check 未通过"
|
|
6
|
+
* - AC-2: scan → scan (重跑修复) → allowed=true
|
|
7
|
+
* - AC-3: scan → doctor/status (辅助阶段) → allowed=true
|
|
8
|
+
* - AC-4: 旧数据无 status 字段 → 行为同旧版(按 allowedFrom)
|
|
9
|
+
* - AC-5: status='completed' 不被拦
|
|
10
|
+
*/
|
|
11
|
+
import { checkTransition } from '../src/stage-contract.js'
|
|
12
|
+
|
|
13
|
+
let passed = 0
|
|
14
|
+
let failed = 0
|
|
15
|
+
function assertEqual (actual, expected, msg) {
|
|
16
|
+
const ok = actual === expected
|
|
17
|
+
if (ok) { console.log(`✅ PASS: ${msg}`); passed++ }
|
|
18
|
+
else { console.error(`❌ FAIL: ${msg}\n expected: ${JSON.stringify(expected)}\n actual: ${JSON.stringify(actual)}`); failed++ }
|
|
19
|
+
}
|
|
20
|
+
function assertMatch (actual, regex, msg) {
|
|
21
|
+
if (regex.test(actual)) { console.log(`✅ PASS: ${msg}`); passed++ }
|
|
22
|
+
else { console.error(`❌ FAIL: ${msg}\n actual: ${JSON.stringify(actual)} 不匹配 ${regex}`); failed++ }
|
|
23
|
+
}
|
|
24
|
+
function assert (cond, msg) {
|
|
25
|
+
if (cond) { console.log(`✅ PASS: ${msg}`); passed++ }
|
|
26
|
+
else { console.error(`❌ FAIL: ${msg}`); failed++ }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
console.log('=== AC-1: failed_post_check 状态下进 brainstorm/plan/execute 被拦 ===')
|
|
30
|
+
|
|
31
|
+
// scan → brainstorm, failed_post_check
|
|
32
|
+
{
|
|
33
|
+
const r = checkTransition('scan', 'brainstorm', { fromStageData: { status: 'failed_post_check' } })
|
|
34
|
+
assertEqual(r.allowed, false, "scan→brainstorm failed_post_check: allowed=false")
|
|
35
|
+
assertMatch(r.reason || '', /scan post-check 未通过/, 'reason 含 "scan post-check 未通过"')
|
|
36
|
+
assertMatch(r.reason || '', /重跑\s*scan|重跑\s*scan/, 'reason 含 "重跑 scan" 提示')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// scan → plan / execute 同样被拦
|
|
40
|
+
{
|
|
41
|
+
const r1 = checkTransition('scan', 'plan', { fromStageData: { status: 'failed_post_check' } })
|
|
42
|
+
assertEqual(r1.allowed, false, "scan→plan failed_post_check: allowed=false")
|
|
43
|
+
const r2 = checkTransition('scan', 'execute', { fromStageData: { status: 'failed_post_check' } })
|
|
44
|
+
assertEqual(r2.allowed, false, "scan→execute failed_post_check: allowed=false")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
console.log('\n=== AC-2: 允许 scan → scan 重跑修复(fromStage===toStage) ===')
|
|
48
|
+
{
|
|
49
|
+
const r = checkTransition('scan', 'scan', { fromStageData: { status: 'failed_post_check' } })
|
|
50
|
+
assertEqual(r.allowed, true, "scan→scan failed_post_check: allowed=true(允许重跑修复)")
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
console.log('\n=== AC-3: failed_post_check 下辅助阶段(doctor/status)仍可执行 ===')
|
|
54
|
+
{
|
|
55
|
+
const r1 = checkTransition('scan', 'doctor', { fromStageData: { status: 'failed_post_check' } })
|
|
56
|
+
assertEqual(r1.allowed, true, "scan→doctor failed_post_check: allowed=true(辅助阶段)")
|
|
57
|
+
const r2 = checkTransition('scan', 'status', { fromStageData: { status: 'failed_post_check' } })
|
|
58
|
+
assertEqual(r2.allowed, true, "scan→status failed_post_check: allowed=true(辅助阶段)")
|
|
59
|
+
const r3 = checkTransition('scan', 'quick', { fromStageData: { status: 'failed_post_check' } })
|
|
60
|
+
assertEqual(r3.allowed, true, "scan→quick failed_post_check: allowed=true(辅助阶段)")
|
|
61
|
+
const r4 = checkTransition('scan', 'explore', { fromStageData: { status: 'failed_post_check' } })
|
|
62
|
+
assertEqual(r4.allowed, true, "scan→explore failed_post_check: allowed=true(辅助阶段)")
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
console.log('\n=== AC-4: 旧数据兼容(无 options 或 status 缺失,行为同旧版) ===')
|
|
66
|
+
{
|
|
67
|
+
// 无 options(旧调用)— scan→brainstorm 按 allowedFrom 规则允许(scan 是辅助阶段,可进主流程)
|
|
68
|
+
const r1 = checkTransition('scan', 'brainstorm')
|
|
69
|
+
assertEqual(r1.allowed, true, "scan→brainstorm 无 options: 行为同旧版(allowed=true)")
|
|
70
|
+
|
|
71
|
+
// options 提供 fromStageData 但 status 为 undefined
|
|
72
|
+
const r2 = checkTransition('scan', 'brainstorm', { fromStageData: { /* 无 status */ } })
|
|
73
|
+
assertEqual(r2.allowed, true, "scan→brainstorm status=undefined: 行为同旧版(allowed=true)")
|
|
74
|
+
|
|
75
|
+
// options 为空对象
|
|
76
|
+
const r3 = checkTransition('scan', 'brainstorm', {})
|
|
77
|
+
assertEqual(r3.allowed, true, "scan→brainstorm options={}: 行为同旧版(allowed=true)")
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
console.log('\n=== AC-5: status="completed" 不被门控拦截 ===')
|
|
81
|
+
{
|
|
82
|
+
const r = checkTransition('scan', 'brainstorm', { fromStageData: { status: 'completed' } })
|
|
83
|
+
assertEqual(r.allowed, true, "scan→brainstorm status=completed: allowed=true")
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
console.log('\n=== AC-6: fromStage 非 scan 时门控不触发(门控只针对 scan 源) ===')
|
|
87
|
+
{
|
|
88
|
+
const r = checkTransition('brainstorm', 'plan', { fromStageData: { status: 'failed_post_check' } })
|
|
89
|
+
assertEqual(r.allowed, true, "brainstorm→plan failed_post_check: 门控不触发(非 scan 源)")
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.log('\n=== 接口向后兼容:options 第 3 位可选 ===')
|
|
93
|
+
{
|
|
94
|
+
// 仅 2 参调用(最常见旧用法)
|
|
95
|
+
const r = checkTransition('brainstorm', 'plan')
|
|
96
|
+
assert(r && typeof r.allowed === 'boolean', '2 参调用返回 { allowed: boolean }')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
console.log(`\n${'='.repeat(50)}`)
|
|
100
|
+
console.log(`✅ 通过: ${passed} ❌ 失败: ${failed}`)
|
|
101
|
+
console.log(`${'='.repeat(50)}`)
|
|
102
|
+
if (failed > 0) process.exit(1)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* task-04: workflow.js checkOutput / _checkWorkflow / runPostCheck 支持 specBase
|
|
3
|
+
*
|
|
4
|
+
* 覆盖:
|
|
5
|
+
* - AC-01/AC-03: 平台模式 specBase 透传,scanDir = join(specBase, 'docs', project, 'scan/')
|
|
6
|
+
* - AC-02/AC-04: 非平台模式 specBase 缺省,回退 join(cwd, '.sillyspec')(旧行为)
|
|
7
|
+
* - AC-05: runPostCheck(wf, cwd, name, {}, specBase) 显式传 specBase 透传到 _checkWorkflow/checkOutput
|
|
8
|
+
* - AC-06: archive 同样支持
|
|
9
|
+
*/
|
|
10
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'
|
|
11
|
+
import { tmpdir } from 'os'
|
|
12
|
+
import { join, resolve } from 'node:path'
|
|
13
|
+
import { runPostCheck } from '../src/workflow.js'
|
|
14
|
+
|
|
15
|
+
let passed = 0
|
|
16
|
+
let failed = 0
|
|
17
|
+
function assert (cond, msg) {
|
|
18
|
+
if (cond) { console.log(`✅ PASS: ${msg}`); passed++ }
|
|
19
|
+
else { console.error(`❌ FAIL: ${msg}`); failed++ }
|
|
20
|
+
}
|
|
21
|
+
function assertEqual (actual, expected, msg) {
|
|
22
|
+
const ok = actual === expected
|
|
23
|
+
if (ok) { console.log(`✅ PASS: ${msg}`); passed++ }
|
|
24
|
+
else { console.error(`❌ FAIL: ${msg}\n expected: ${JSON.stringify(expected)}\n actual: ${JSON.stringify(actual)}`); failed++ }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ── 用例 1: 平台模式 specBase — workflow_level file_count 检查 join(specBase, 'docs', project, 'scan/') ──
|
|
28
|
+
{
|
|
29
|
+
const specBase = mkdtempSync(join(tmpdir(), 'spec-base-platform-'))
|
|
30
|
+
const projectName = 'frontend'
|
|
31
|
+
const scanDir = join(specBase, 'docs', projectName, 'scan')
|
|
32
|
+
mkdirSync(scanDir, { recursive: true })
|
|
33
|
+
writeFileSync(join(scanDir, 'a.md'), '# A\ncontent\n')
|
|
34
|
+
|
|
35
|
+
const wf = {
|
|
36
|
+
name: 'scan-docs',
|
|
37
|
+
checks: { workflow_level: [ { type: 'file_count', path: 'scan/', min: 1 } ] },
|
|
38
|
+
roles: [],
|
|
39
|
+
}
|
|
40
|
+
const result = runPostCheck(wf, '/fake/cwd', projectName, {}, specBase)
|
|
41
|
+
const fc = (result.workflow_checks || []).find(c => c.type === 'file_count')
|
|
42
|
+
assert(!!fc, '平台模式 file_count 检查被执行')
|
|
43
|
+
assertEqual(fc && fc.status, 'pass', '平台模式 specBase 下 file_count 通过(找到 1 个 md)')
|
|
44
|
+
assert(!(fc && fc.detail && fc.detail.includes('/fake/cwd')),
|
|
45
|
+
'平台模式 detail 不含 cwd(说明用 specBase 而非裸 cwd)')
|
|
46
|
+
rmSync(specBase, { recursive: true, force: true })
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── 用例 2: 平台模式 specBase — 目录不存在时失败且 detail 含 specBase 路径 ──
|
|
50
|
+
{
|
|
51
|
+
const specBase = mkdtempSync(join(tmpdir(), 'spec-base-empty-'))
|
|
52
|
+
const projectName = 'backend'
|
|
53
|
+
const wf = {
|
|
54
|
+
name: 'scan-docs',
|
|
55
|
+
checks: { workflow_level: [ { type: 'file_count', path: 'scan/', min: 1 } ] },
|
|
56
|
+
roles: [],
|
|
57
|
+
}
|
|
58
|
+
const result = runPostCheck(wf, '/fake/cwd', projectName, {}, specBase)
|
|
59
|
+
const fc = (result.workflow_checks || []).find(c => c.type === 'file_count')
|
|
60
|
+
assertEqual(fc && fc.status, 'fail', '平台模式 specBase 下目录不存在时 file_count 失败')
|
|
61
|
+
assert(!!(fc && fc.detail && fc.detail.includes(specBase.replace(/\\/g, '/'))) ||
|
|
62
|
+
!!(fc && fc.detail && fc.detail.includes(specBase)),
|
|
63
|
+
'平台模式 detail 含 specBase 路径(说明用 specBase)')
|
|
64
|
+
assert(!(fc && fc.detail && fc.detail.includes('/fake/cwd/.sillyspec')),
|
|
65
|
+
'detail 不含 fake/cwd/.sillyspec(未回退裸 cwd)')
|
|
66
|
+
rmSync(specBase, { recursive: true, force: true })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── 用例 3: 非平台模式 specBase 缺省 — 回退 join(cwd, '.sillyspec')(旧行为) ──
|
|
70
|
+
{
|
|
71
|
+
const cwd = mkdtempSync(join(tmpdir(), 'legacy-cwd-'))
|
|
72
|
+
const sillyspecDir = join(cwd, '.sillyspec')
|
|
73
|
+
const projectName = 'sillyspec'
|
|
74
|
+
const scanDir = join(sillyspecDir, 'docs', projectName, 'scan')
|
|
75
|
+
mkdirSync(scanDir, { recursive: true })
|
|
76
|
+
writeFileSync(join(scanDir, 'a.md'), '# A\n')
|
|
77
|
+
|
|
78
|
+
const wf = {
|
|
79
|
+
name: 'scan-docs',
|
|
80
|
+
checks: { workflow_level: [ { type: 'file_count', path: 'scan/', min: 1 } ] },
|
|
81
|
+
roles: [],
|
|
82
|
+
}
|
|
83
|
+
// 不传 specBase(第 5 位留空)
|
|
84
|
+
const result = runPostCheck(wf, cwd, projectName)
|
|
85
|
+
const fc = (result.workflow_checks || []).find(c => c.type === 'file_count')
|
|
86
|
+
assertEqual(fc && fc.status, 'pass', '非平台模式 specBase 缺省时回退 cwd/.sillyspec 通过')
|
|
87
|
+
rmSync(cwd, { recursive: true, force: true })
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── 用例 4: 平台模式 role-level checkOutput — specBase 下产出文件存在 ──
|
|
91
|
+
{
|
|
92
|
+
const specBase = mkdtempSync(join(tmpdir(), 'spec-base-role-'))
|
|
93
|
+
const projectName = 'myproj'
|
|
94
|
+
const docAbsPath = join(specBase, 'docs', projectName, 'scan', 'ARCHITECTURE.md')
|
|
95
|
+
mkdirSync(join(specBase, 'docs', projectName, 'scan'), { recursive: true })
|
|
96
|
+
writeFileSync(docAbsPath, '# Arch\nline1\nline2\nline3\nline4\nline5\n')
|
|
97
|
+
|
|
98
|
+
// outputDef.path 模拟 run.js:645 把 {SPEC_ROOT} 替换为 specBase 后的绝对路径
|
|
99
|
+
const wf = {
|
|
100
|
+
name: 'scan-docs',
|
|
101
|
+
roles: [
|
|
102
|
+
{
|
|
103
|
+
id: 'doc-writer',
|
|
104
|
+
name: 'Doc Writer',
|
|
105
|
+
outputs: [
|
|
106
|
+
{ path: docAbsPath.replace(/\\/g, '/'), checks: [ { type: 'file_exists' }, { type: 'min_lines', min: 3 } ] },
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
}
|
|
111
|
+
const result = runPostCheck(wf, '/fake/cwd', projectName, {}, specBase)
|
|
112
|
+
const role = (result.roles || [])[0]
|
|
113
|
+
assertEqual(role && role.status, 'pass', '平台模式 role-level checkOutput 用 specBase 解析绝对路径通过')
|
|
114
|
+
rmSync(specBase, { recursive: true, force: true })
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── 用例 5: 接口向后兼容 — 不传 specBase 时 placeholders 仍工作 ──
|
|
118
|
+
{
|
|
119
|
+
const cwd = mkdtempSync(join(tmpdir(), 'ph-cwd-'))
|
|
120
|
+
const projectName = 'demo'
|
|
121
|
+
const scanDir = join(cwd, '.sillyspec', 'docs', projectName, 'scan')
|
|
122
|
+
mkdirSync(scanDir, { recursive: true })
|
|
123
|
+
writeFileSync(join(scanDir, 'a.md'), '# A\n')
|
|
124
|
+
|
|
125
|
+
const wf = {
|
|
126
|
+
name: 'scan-docs',
|
|
127
|
+
checks: { workflow_level: [ { type: 'file_count', path: 'scan/', min: 1 } ] },
|
|
128
|
+
roles: [],
|
|
129
|
+
}
|
|
130
|
+
// 旧调用方式:runPostCheck(wf, cwd, name, placeholders)
|
|
131
|
+
const result = runPostCheck(wf, cwd, projectName, { SOME_KEY: 'value' })
|
|
132
|
+
const fc = (result.workflow_checks || []).find(c => c.type === 'file_count')
|
|
133
|
+
assertEqual(fc && fc.status, 'pass', '向后兼容:旧 4 参调用(带 placeholders)仍工作')
|
|
134
|
+
rmSync(cwd, { recursive: true, force: true })
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
console.log(`\n${'='.repeat(50)}`)
|
|
138
|
+
console.log(`✅ 通过: ${passed} ❌ 失败: ${failed}`)
|
|
139
|
+
console.log(`${'='.repeat(50)}`)
|
|
140
|
+
if (failed > 0) {
|
|
141
|
+
process.exit(1)
|
|
142
|
+
}
|