sillyspec 3.18.5 → 3.19.0

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.
@@ -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
+ }