sillyspec 3.17.15 → 3.18.1
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/docs/platform-scan-protocol.md +298 -0
- package/package.json +1 -1
- package/src/constants.js +70 -0
- package/src/db.js +4 -0
- package/src/hooks/worktree-guard.js +97 -4
- package/src/index.js +56 -1
- package/src/progress.js +41 -14
- package/src/run.js +315 -83
- package/src/scan-postcheck.js +17 -16
- package/src/stage-contract.js +244 -12
- package/src/stages/brainstorm.js +228 -8
- package/src/stages/index.js +0 -2
- package/src/stages/plan.js +237 -52
- package/src/stages/propose.js +30 -4
- package/src/stages/quick.js +13 -10
- package/src/stages/scan.js +12 -0
- package/src/stages/verify.js +31 -13
- package/src/workflow.js +1 -0
- package/test/platform-artifacts.test.mjs +190 -0
- package/test/platform-failure-samples.test.mjs +195 -0
- package/test/platform-recovery-chain.test.mjs +179 -0
- package/test/platform-recovery.test.mjs +14 -6
- package/test/platform-scan-p0.test.mjs +5 -2
- package/test/run-tests.mjs +31 -3
- package/test/scan-paths.test.mjs +1 -1
- package/test/scan-postcheck.test.mjs +4 -3
- package/test/spec-dir.test.mjs +3 -2
- package/test/stage-contract.test.mjs +120 -7
- package/test/stage-definitions.test.mjs +2 -6
- package/test/wait-gates.test.mjs +501 -0
- package/test/worktree-guard.test.mjs +58 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 平台 scan 产物协议测试
|
|
3
|
+
*
|
|
4
|
+
* 验证:
|
|
5
|
+
* 1. saveWorkflowRun 传入 runtimeRoot + scanRunId 时写到正确路径
|
|
6
|
+
* 2. manifest.json 结构包含产物指针(postcheck_result_path, workflow_runs_dir)
|
|
7
|
+
* 3. 非平台模式下 workflow-runs 写入 cwd/.sillyspec/.runtime/
|
|
8
|
+
*
|
|
9
|
+
* 跑法: node test/platform-artifacts.test.mjs
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { join, dirname } from 'path'
|
|
13
|
+
import { existsSync, mkdirSync, rmSync, readFileSync, readdirSync, writeFileSync } from 'fs'
|
|
14
|
+
import { fileURLToPath } from 'url'
|
|
15
|
+
import { randomUUID } from 'crypto'
|
|
16
|
+
import { tmpdir } from 'os'
|
|
17
|
+
|
|
18
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
19
|
+
const passed = []
|
|
20
|
+
const failed = []
|
|
21
|
+
|
|
22
|
+
function assert(label, condition, detail) {
|
|
23
|
+
if (condition) {
|
|
24
|
+
passed.push(label)
|
|
25
|
+
console.log(` ✅ PASS: ${label}`)
|
|
26
|
+
} else {
|
|
27
|
+
failed.push({ label, detail })
|
|
28
|
+
console.log(` ❌ FAIL: ${label}`)
|
|
29
|
+
if (detail) console.log(` ${detail}`)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function cleanup(dir) {
|
|
34
|
+
try { rmSync(dir, { recursive: true, force: true }) } catch {}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function hasPathSegments(value, segments) {
|
|
38
|
+
const parts = value.split(/[\\/]+/)
|
|
39
|
+
for (let i = 0; i <= parts.length - segments.length; i++) {
|
|
40
|
+
if (segments.every((segment, offset) => parts[i + offset] === segment)) return true
|
|
41
|
+
}
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── 测试 1:saveWorkflowRun 平台模式路径正确 ──
|
|
46
|
+
console.log('\n=== Test 1: saveWorkflowRun 平台模式写入路径 ===')
|
|
47
|
+
{
|
|
48
|
+
const { saveWorkflowRun } = await import('../src/workflow.js')
|
|
49
|
+
const tmpRoot = join(tmpdir(), `test-artifacts-${randomUUID().slice(0, 8)}`)
|
|
50
|
+
const runtimeRoot = join(tmpRoot, 'runtime')
|
|
51
|
+
const scanRunId = 'scan-20260614-test-001'
|
|
52
|
+
|
|
53
|
+
const result = {
|
|
54
|
+
workflow: 'scan-docs',
|
|
55
|
+
project: 'test-project',
|
|
56
|
+
status: 'pass',
|
|
57
|
+
spec_version: 1,
|
|
58
|
+
roles: [],
|
|
59
|
+
workflow_checks: [],
|
|
60
|
+
failures: [],
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const saved = saveWorkflowRun(result, {
|
|
64
|
+
cwd: '/fake/cwd',
|
|
65
|
+
source: 'test',
|
|
66
|
+
stage: 'scan',
|
|
67
|
+
runtimeRoot,
|
|
68
|
+
scanRunId,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const expectedDir = join(runtimeRoot, 'scan-runs', scanRunId, 'workflow-runs')
|
|
72
|
+
assert('workflow-runs 目录存在', existsSync(expectedDir))
|
|
73
|
+
assert('workflow-runs 文件存在', existsSync(saved), `路径: ${saved}`)
|
|
74
|
+
assert('路径在 runtime-root 下', saved.startsWith(runtimeRoot), `路径: ${saved}`)
|
|
75
|
+
assert('路径包含 scan-runs', hasPathSegments(saved, ['scan-runs', scanRunId, 'workflow-runs']), `路径: ${saved}`)
|
|
76
|
+
assert('路径包含 scanRunId', hasPathSegments(saved, [scanRunId]), `路径: ${saved}`)
|
|
77
|
+
|
|
78
|
+
// 验证 JSON 内容
|
|
79
|
+
const content = JSON.parse(readFileSync(saved, 'utf8'))
|
|
80
|
+
assert('JSON 有 run_id', !!content.run_id)
|
|
81
|
+
assert('JSON 有 created_at', !!content.created_at)
|
|
82
|
+
assert('JSON source = test', content.source === 'test')
|
|
83
|
+
assert('JSON stage = scan', content.stage === 'scan')
|
|
84
|
+
assert('JSON workflow = scan-docs', content.workflow === 'scan-docs')
|
|
85
|
+
|
|
86
|
+
cleanup(tmpRoot)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── 测试 2:saveWorkflowRun 本地模式路径正确 ──
|
|
90
|
+
console.log('\n=== Test 2: saveWorkflowRun 本地模式写入路径 ===')
|
|
91
|
+
{
|
|
92
|
+
const { saveWorkflowRun } = await import('../src/workflow.js')
|
|
93
|
+
const tmpCwd = join(tmpdir(), `test-artifacts-local-${randomUUID().slice(0, 8)}`)
|
|
94
|
+
const sillyspecDir = join(tmpCwd, '.sillyspec', '.runtime', 'workflow-runs')
|
|
95
|
+
|
|
96
|
+
const result = {
|
|
97
|
+
workflow: 'test-wf',
|
|
98
|
+
project: 'default',
|
|
99
|
+
status: 'fail',
|
|
100
|
+
spec_version: 1,
|
|
101
|
+
roles: [],
|
|
102
|
+
workflow_checks: [],
|
|
103
|
+
failures: ['check-1 failed'],
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const saved = saveWorkflowRun(result, {
|
|
107
|
+
cwd: tmpCwd,
|
|
108
|
+
source: 'test',
|
|
109
|
+
stage: 'scan',
|
|
110
|
+
// 不传 runtimeRoot 和 scanRunId
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
assert('本地模式文件存在', existsSync(saved))
|
|
114
|
+
assert('本地路径在 .sillyspec/.runtime 下', hasPathSegments(saved, ['.sillyspec', '.runtime', 'workflow-runs']), `路径: ${saved}`)
|
|
115
|
+
|
|
116
|
+
const content = JSON.parse(readFileSync(saved, 'utf8'))
|
|
117
|
+
assert('本地 JSON status = fail', content.status === 'fail')
|
|
118
|
+
assert('本地 JSON 有 failures', Array.isArray(content.failures))
|
|
119
|
+
|
|
120
|
+
cleanup(tmpCwd)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── 测试 3:manifest 结构验证(从 run.js 源码静态检查) ──
|
|
124
|
+
console.log('\n=== Test 3: manifest.json 结构字段 ===')
|
|
125
|
+
{
|
|
126
|
+
const { readFile } = await import('fs/promises')
|
|
127
|
+
const runSrc = await readFile(join(__dirname, '..', 'src', 'run.js'), 'utf8')
|
|
128
|
+
|
|
129
|
+
// 平台模式 manifest 初始化
|
|
130
|
+
assert('manifest 包含 workspace_id', runSrc.includes('workspace_id:'))
|
|
131
|
+
assert('manifest 包含 scan_run_id', runSrc.includes('scan_run_id:'))
|
|
132
|
+
assert('manifest 包含 source_commit', runSrc.includes('source_commit:'))
|
|
133
|
+
assert('manifest 包含 source_commit_error', runSrc.includes('source_commit_error:'))
|
|
134
|
+
assert('manifest 包含 generated_at', runSrc.includes('generated_at:'))
|
|
135
|
+
assert('manifest 包含 schema_version', runSrc.includes('schema_version:'))
|
|
136
|
+
assert('manifest 包含 postcheck_result_path', runSrc.includes('postcheck_result_path:'))
|
|
137
|
+
assert('manifest 包含 workflow_runs_dir', runSrc.includes('workflow_runs_dir:'))
|
|
138
|
+
|
|
139
|
+
// postcheck_result_path 在 postcheck 写入后填充
|
|
140
|
+
assert('manifest.postcheck_result_path 下游填充', runSrc.includes('manifest.postcheck_result_path = postcheckJsonPath'))
|
|
141
|
+
|
|
142
|
+
// workflow_runs_dir 使用 runtimeRoot + scanRunId
|
|
143
|
+
assert('workflow_runs_dir 基于 runtimeRoot', runSrc.includes("join(platformOpts.runtimeRoot, 'scan-runs'"))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── 测试 4:平台指针状态更新(源码检查) ──
|
|
147
|
+
console.log('\n=== Test 4: 平台指针 scan 完成后状态更新 ===')
|
|
148
|
+
{
|
|
149
|
+
const { readFile } = await import('fs/promises')
|
|
150
|
+
const runSrc = await readFile(join(__dirname, '..', 'src', 'run.js'), 'utf8')
|
|
151
|
+
|
|
152
|
+
assert('scan 完成后读取 pointer 文件', runSrc.includes('pointerPath'))
|
|
153
|
+
assert('pointer status 使用 POINTER_STATUS 枚举', runSrc.includes('POINTER_STATUS'))
|
|
154
|
+
assert('pointer 记录 completedAt', runSrc.includes('pointer.completedAt'))
|
|
155
|
+
assert('pointer 记录 scanStatus', runSrc.includes('pointer.scanStatus'))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── 测试 5:saveWorkflowRun 调用点传入 runtimeRoot(源码检查) ──
|
|
159
|
+
console.log('\n=== Test 5: run.js 调用 saveWorkflowRun 传入平台参数 ===')
|
|
160
|
+
{
|
|
161
|
+
const { readFile } = await import('fs/promises')
|
|
162
|
+
const runSrc = await readFile(join(__dirname, '..', 'src', 'run.js'), 'utf8')
|
|
163
|
+
|
|
164
|
+
// 找到 saveWorkflowRun 调用
|
|
165
|
+
const calls = runSrc.match(/saveWorkflowRun\([^)]+\{[^}]+\}/gs)
|
|
166
|
+
assert('至少有 2 处 saveWorkflowRun 调用', calls && calls.length >= 2, `实际: ${calls?.length}`)
|
|
167
|
+
|
|
168
|
+
// 检查调用是否包含 runtimeRoot 传递
|
|
169
|
+
const hasRuntimeRoot = runSrc.includes('platformOpts.runtimeRoot ? { runtimeRoot: platformOpts.runtimeRoot }')
|
|
170
|
+
assert('saveWorkflowRun 调用传入 runtimeRoot', hasRuntimeRoot, '未发现 runtimeRoot 传递')
|
|
171
|
+
|
|
172
|
+
const hasScanRunId = runSrc.includes('platformOpts.scanRunId ? { scanRunId: platformOpts.scanRunId }')
|
|
173
|
+
assert('saveWorkflowRun 调用传入 scanRunId', hasScanRunId, '未发现 scanRunId 传递')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── 结果 ──
|
|
177
|
+
console.log(`\n${'='.repeat(50)}`)
|
|
178
|
+
console.log(`✅ 通过: ${passed.length} ❌ 失败: ${failed.length}`)
|
|
179
|
+
console.log(`${'='.repeat(50)}`)
|
|
180
|
+
|
|
181
|
+
if (failed.length > 0) {
|
|
182
|
+
console.log('\n失败详情:')
|
|
183
|
+
for (const f of failed) {
|
|
184
|
+
console.log(` ❌ ${f.label}`)
|
|
185
|
+
if (f.detail) console.log(` ${f.detail}`)
|
|
186
|
+
}
|
|
187
|
+
throw new Error('platform-artifacts test failed')
|
|
188
|
+
} else {
|
|
189
|
+
console.log('\n🎉 平台产物协议测试全部通过!')
|
|
190
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 平台 scan 失败样本测试
|
|
3
|
+
*
|
|
4
|
+
* 验证失败场景下 postcheck + manifest 能稳定表达失败:
|
|
5
|
+
* 1. source_root 污染 → postcheck status = failed_post_check + source_root_leak check
|
|
6
|
+
* 2. spec 缺文档 → postcheck status = failed_post_check + all_docs_missing check
|
|
7
|
+
* 3. 混合场景 → 多个 check,failed 优先
|
|
8
|
+
* 4. postcheck-result.json 结构可被 SillyHub 稳定解析
|
|
9
|
+
*
|
|
10
|
+
* 跑法: node test/platform-failure-samples.test.mjs
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { join, basename } from 'path'
|
|
14
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'
|
|
15
|
+
import { randomUUID } from 'crypto'
|
|
16
|
+
import { tmpdir } from 'os'
|
|
17
|
+
import { SCAN_STATUS, CHECK_SEVERITY } from '../src/constants.js'
|
|
18
|
+
|
|
19
|
+
const passed = []
|
|
20
|
+
const failed = []
|
|
21
|
+
|
|
22
|
+
function assert(label, condition, detail) {
|
|
23
|
+
if (condition) {
|
|
24
|
+
passed.push(label)
|
|
25
|
+
console.log(` ✅ PASS: ${label}`)
|
|
26
|
+
} else {
|
|
27
|
+
failed.push({ label, detail })
|
|
28
|
+
console.log(` ❌ FAIL: ${label}`)
|
|
29
|
+
if (detail) console.log(` ${detail}`)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function cleanup(dir) {
|
|
34
|
+
try { rmSync(dir, { recursive: true, force: true }) } catch {}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function setup(name) {
|
|
38
|
+
const base = join(tmpdir(), `failure-test-${name}-${randomUUID().slice(0, 8)}`)
|
|
39
|
+
const spec = join(tmpdir(), `failure-test-spec-${name}-${randomUUID().slice(0, 8)}`)
|
|
40
|
+
mkdirSync(base, { recursive: true })
|
|
41
|
+
mkdirSync(spec, { recursive: true })
|
|
42
|
+
writeFileSync(join(base, 'package.json'), '{}')
|
|
43
|
+
return { cwd: base, specDir: spec }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Test 1: source_root 污染 → failed_post_check ──
|
|
47
|
+
console.log('\n=== Test 1: source_root docs 污染 ===')
|
|
48
|
+
{
|
|
49
|
+
const { cwd, specDir } = setup('leak')
|
|
50
|
+
const proj = basename(cwd)
|
|
51
|
+
try {
|
|
52
|
+
// 在 source_root/.sillyspec/docs/ 下创建泄漏文件
|
|
53
|
+
mkdirSync(join(cwd, '.sillyspec', 'docs', proj, 'scan'), { recursive: true })
|
|
54
|
+
writeFileSync(join(cwd, '.sillyspec', 'docs', proj, 'scan', 'ARCHITECTURE.md'), '# leak')
|
|
55
|
+
|
|
56
|
+
const { runScanPostCheck } = await import('../src/scan-postcheck.js')
|
|
57
|
+
const result = runScanPostCheck({ cwd, specDir })
|
|
58
|
+
|
|
59
|
+
assert('status = failed_post_check', result.status === SCAN_STATUS.FAILED_POST_CHECK, `实际: ${result.status}`)
|
|
60
|
+
assert('有 source_root_docs_leak check', result.checks.some(c => c.name === 'source_root_docs_leak'))
|
|
61
|
+
assert('source_root_docs_leak severity = failed',
|
|
62
|
+
result.checks.find(c => c.name === 'source_root_docs_leak')?.severity === CHECK_SEVERITY.FAILED)
|
|
63
|
+
|
|
64
|
+
// 验证结构化输出
|
|
65
|
+
const { formatStructuredResult } = await import('../src/scan-postcheck.js')
|
|
66
|
+
const structured = formatStructuredResult(result, { source_root: cwd })
|
|
67
|
+
assert('结构化输出有 overall_status', !!structured.overall_status)
|
|
68
|
+
assert('结构化输出有 checks', Array.isArray(structured.checks))
|
|
69
|
+
assert('结构化输出 path_pollution 非空', structured.failure_categories.path_pollution.length > 0)
|
|
70
|
+
} finally {
|
|
71
|
+
cleanup(cwd)
|
|
72
|
+
cleanup(specDir)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Test 2: spec 缺文档 → failed_post_check ──
|
|
77
|
+
console.log('\n=== Test 2: spec 无文档 ===')
|
|
78
|
+
{
|
|
79
|
+
const { cwd, specDir } = setup('missing')
|
|
80
|
+
const proj = basename(cwd)
|
|
81
|
+
try {
|
|
82
|
+
const { runScanPostCheck } = await import('../src/scan-postcheck.js')
|
|
83
|
+
const result = runScanPostCheck({ cwd, specDir })
|
|
84
|
+
|
|
85
|
+
assert('status = failed_post_check', result.status === SCAN_STATUS.FAILED_POST_CHECK, `实际: ${result.status}`)
|
|
86
|
+
assert('有 all_docs_missing check', result.checks.some(c => c.name === 'all_docs_missing'))
|
|
87
|
+
|
|
88
|
+
const { formatStructuredResult } = await import('../src/scan-postcheck.js')
|
|
89
|
+
const structured = formatStructuredResult(result, { source_root: cwd })
|
|
90
|
+
assert('结构化输出 missing_outputs 非空', structured.failure_categories.missing_outputs.length > 0)
|
|
91
|
+
} finally {
|
|
92
|
+
cleanup(cwd)
|
|
93
|
+
cleanup(specDir)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Test 3: 混合场景(污染 + 缺文档)→ failed 优先 ──
|
|
98
|
+
console.log('\n=== Test 3: 混合失败场景 ===')
|
|
99
|
+
{
|
|
100
|
+
const { cwd, specDir } = setup('mixed')
|
|
101
|
+
const proj = basename(cwd)
|
|
102
|
+
try {
|
|
103
|
+
// source_root 污染
|
|
104
|
+
mkdirSync(join(cwd, '.sillyspec', 'docs', proj, 'scan'), { recursive: true })
|
|
105
|
+
writeFileSync(join(cwd, '.sillyspec', 'docs', proj, 'scan', 'CONVENTIONS.md'), '# leak')
|
|
106
|
+
// spec 不写文档(缺文档)
|
|
107
|
+
|
|
108
|
+
const { runScanPostCheck } = await import('../src/scan-postcheck.js')
|
|
109
|
+
const result = runScanPostCheck({ cwd, specDir })
|
|
110
|
+
|
|
111
|
+
assert('混合场景 status = failed_post_check', result.status === SCAN_STATUS.FAILED_POST_CHECK, `实际: ${result.status}`)
|
|
112
|
+
assert('混合场景有多个 check', result.checks.length >= 2, `实际: ${result.checks.length}`)
|
|
113
|
+
assert('混合场景包含 source_root_docs_leak', result.checks.some(c => c.name === 'source_root_docs_leak'))
|
|
114
|
+
assert('混合场景包含 all_docs_missing', result.checks.some(c => c.name === 'all_docs_missing'))
|
|
115
|
+
|
|
116
|
+
// 所有 failed check 的 name 列出,确保 SillyHub 能定位问题
|
|
117
|
+
const failedChecks = result.checks.filter(c => c.severity === CHECK_SEVERITY.FAILED)
|
|
118
|
+
assert('混合场景至少 2 个 failed check', failedChecks.length >= 2, `实际: ${failedChecks.length}`)
|
|
119
|
+
for (const fc of failedChecks) {
|
|
120
|
+
assert(`failed check "${fc.name}" 有 detail`, !!fc.detail, `check: ${fc.name}`)
|
|
121
|
+
}
|
|
122
|
+
} finally {
|
|
123
|
+
cleanup(cwd)
|
|
124
|
+
cleanup(specDir)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Test 4: 警告场景(文档缺 header)→ completed_with_warnings ──
|
|
129
|
+
console.log('\n=== Test 4: 文档缺 header → 警告 ===')
|
|
130
|
+
{
|
|
131
|
+
const { cwd, specDir } = setup('warn')
|
|
132
|
+
const proj = basename(cwd)
|
|
133
|
+
try {
|
|
134
|
+
// 写文档但缺少 frontmatter header
|
|
135
|
+
const docs = ['ARCHITECTURE.md', 'CONVENTIONS.md', 'PROJECT.md', 'STRUCTURE.md', 'INTEGRATIONS.md', 'TESTING.md', 'CONCERNS.md']
|
|
136
|
+
for (const doc of docs) {
|
|
137
|
+
mkdirSync(join(specDir, 'docs', proj, 'scan'), { recursive: true })
|
|
138
|
+
writeFileSync(join(specDir, 'docs', proj, 'scan', doc), `# ${doc.replace('.md', '')}\nno header`)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const { runScanPostCheck } = await import('../src/scan-postcheck.js')
|
|
142
|
+
const result = runScanPostCheck({ cwd, specDir })
|
|
143
|
+
|
|
144
|
+
assert('警告场景 status = completed_with_warnings', result.status === SCAN_STATUS.COMPLETED_WITH_WARNINGS, `实际: ${result.status}`)
|
|
145
|
+
assert('警告场景有 docs_missing_header check', result.checks.some(c => c.name === 'docs_missing_header'))
|
|
146
|
+
} finally {
|
|
147
|
+
cleanup(cwd)
|
|
148
|
+
cleanup(specDir)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Test 5: 正常成功场景 → success ──
|
|
153
|
+
console.log('\n=== Test 5: 正常成功场景 ===')
|
|
154
|
+
{
|
|
155
|
+
const { cwd, specDir } = setup('success')
|
|
156
|
+
const proj = basename(cwd)
|
|
157
|
+
try {
|
|
158
|
+
// 写所有 7 份文档,带正确 header
|
|
159
|
+
const docs = ['ARCHITECTURE.md', 'CONVENTIONS.md', 'PROJECT.md', 'STRUCTURE.md', 'INTEGRATIONS.md', 'TESTING.md', 'CONCERNS.md']
|
|
160
|
+
const now = new Date().toISOString().replace('T', ' ').slice(0, 19)
|
|
161
|
+
for (const doc of docs) {
|
|
162
|
+
mkdirSync(join(specDir, 'docs', proj, 'scan'), { recursive: true })
|
|
163
|
+
writeFileSync(join(specDir, 'docs', proj, 'scan', doc),
|
|
164
|
+
`author: bot\ncreated_at: ${now}\n# ${doc.replace('.md', '')}\n`)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 在 specDir 写 local.yaml
|
|
168
|
+
writeFileSync(join(specDir, 'local.yaml'), 'build: echo ok\ntest: echo ok\n')
|
|
169
|
+
|
|
170
|
+
const { runScanPostCheck } = await import('../src/scan-postcheck.js')
|
|
171
|
+
const result = runScanPostCheck({ cwd, specDir })
|
|
172
|
+
|
|
173
|
+
assert('成功场景 status = success', result.status === SCAN_STATUS.SUCCESS, `实际: ${result.status}`)
|
|
174
|
+
assert('成功场景 checks 全部 passed 或空', result.checks.every(c => c.severity === CHECK_SEVERITY.PASSED) || result.checks.length === 0)
|
|
175
|
+
} finally {
|
|
176
|
+
cleanup(cwd)
|
|
177
|
+
cleanup(specDir)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── 结果 ──
|
|
182
|
+
console.log(`\n${'='.repeat(50)}`)
|
|
183
|
+
console.log(`✅ 通过: ${passed.length} ❌ 失败: ${failed.length}`)
|
|
184
|
+
console.log(`${'='.repeat(50)}`)
|
|
185
|
+
|
|
186
|
+
if (failed.length > 0) {
|
|
187
|
+
console.log('\n失败详情:')
|
|
188
|
+
for (const f of failed) {
|
|
189
|
+
console.log(` ❌ ${f.label}`)
|
|
190
|
+
if (f.detail) console.log(` ${f.detail}`)
|
|
191
|
+
}
|
|
192
|
+
throw new Error('platform-failure-samples test failed')
|
|
193
|
+
} else {
|
|
194
|
+
console.log('\n🎉 平台失败样本测试全部通过!')
|
|
195
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 平台 scan 恢复链路测试
|
|
3
|
+
*
|
|
4
|
+
* 验证:
|
|
5
|
+
* 1. 首次带 --spec-dir 跑 scan,pointer 文件被创建
|
|
6
|
+
* 2. 后续 --done 不带参数能从 pointer 恢复平台参数
|
|
7
|
+
* 3. scan 完成后 pointer 状态标记为 scan_completed
|
|
8
|
+
* 4. pointer 异常残留能被检测
|
|
9
|
+
*
|
|
10
|
+
* 跑法: node test/platform-recovery-chain.test.mjs
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { join, basename } from 'path'
|
|
14
|
+
import { existsSync, mkdirSync, rmSync, readFileSync, writeFileSync } from 'fs'
|
|
15
|
+
import { execSync } from 'child_process'
|
|
16
|
+
import { fileURLToPath } from 'url'
|
|
17
|
+
import { dirname } from 'path'
|
|
18
|
+
import { randomUUID } from 'crypto'
|
|
19
|
+
import { tmpdir } from 'os'
|
|
20
|
+
|
|
21
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
22
|
+
const binCLI = join(__dirname, '..', 'src', 'index.js')
|
|
23
|
+
const passed = []
|
|
24
|
+
const failed = []
|
|
25
|
+
|
|
26
|
+
function assert(label, condition, detail) {
|
|
27
|
+
if (condition) {
|
|
28
|
+
passed.push(label)
|
|
29
|
+
console.log(` ✅ PASS: ${label}`)
|
|
30
|
+
} else {
|
|
31
|
+
failed.push({ label, detail })
|
|
32
|
+
console.log(` ❌ FAIL: ${label}`)
|
|
33
|
+
if (detail) console.log(` ${detail}`)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function cleanup(dir) {
|
|
38
|
+
try { rmSync(dir, { recursive: true, force: true }) } catch {}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function run(cmd, opts = {}) {
|
|
42
|
+
try {
|
|
43
|
+
return execSync(cmd, { encoding: 'utf8', timeout: 15_000, ...opts })
|
|
44
|
+
} catch (e) {
|
|
45
|
+
return e.stdout || e.message
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── 测试 1:pointer 文件创建和内容 ──
|
|
50
|
+
console.log('\n=== Test 1: pointer 文件创建 ===')
|
|
51
|
+
{
|
|
52
|
+
const tmpCwd = join(tmpdir(), `recovery-test-${randomUUID().slice(0, 8)}`)
|
|
53
|
+
const tmpSpec = join(tmpdir(), `recovery-test-spec-${randomUUID().slice(0, 8)}`)
|
|
54
|
+
const tmpRuntime = join(tmpdir(), `recovery-test-rt-${randomUUID().slice(0, 8)}`)
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
mkdirSync(tmpCwd, { recursive: true })
|
|
58
|
+
writeFileSync(join(tmpCwd, 'package.json'), '{}')
|
|
59
|
+
|
|
60
|
+
// init 项目(使用外部 specDir)
|
|
61
|
+
const initOut = run(`node "${binCLI}" init "${tmpCwd}" --spec-root "${tmpSpec}"`)
|
|
62
|
+
assert('init 成功', !initOut.includes('❌'))
|
|
63
|
+
|
|
64
|
+
// run scan 会触发参数持久化
|
|
65
|
+
run(`node "${binCLI}" --dir "${tmpCwd}" run scan --spec-root "${tmpSpec}" 2>&1 || true`)
|
|
66
|
+
|
|
67
|
+
const pointerPath = join(tmpCwd, '.sillyspec-platform.json')
|
|
68
|
+
assert('pointer 文件存在', existsSync(pointerPath))
|
|
69
|
+
|
|
70
|
+
const pointer = JSON.parse(readFileSync(pointerPath, 'utf8'))
|
|
71
|
+
assert('pointer 有 specRoot', !!pointer.specRoot)
|
|
72
|
+
assert('pointer 有 savedAt', !!pointer.savedAt)
|
|
73
|
+
assert('pointer specRoot 指向外部', pointer.specRoot === tmpSpec)
|
|
74
|
+
|
|
75
|
+
// pointer 不应包含 status(初始创建时)
|
|
76
|
+
assert('初始 pointer 无 status 字段', !('status' in pointer))
|
|
77
|
+
} finally {
|
|
78
|
+
cleanup(tmpCwd)
|
|
79
|
+
cleanup(tmpSpec)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── 测试 2:--done 不带参数能恢复 ──
|
|
84
|
+
console.log('\n=== Test 2: --done 恢复平台参数 ===')
|
|
85
|
+
{
|
|
86
|
+
const tmpCwd = join(tmpdir(), `recovery-test2-${randomUUID().slice(0, 8)}`)
|
|
87
|
+
const tmpSpec = join(tmpdir(), `recovery-test2-spec-${randomUUID().slice(0, 8)}`)
|
|
88
|
+
const tmpRuntime = join(tmpdir(), `recovery-test2-rt-${randomUUID().slice(0, 8)}`)
|
|
89
|
+
const scanRunId = `scan-${Date.now()}`
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
mkdirSync(tmpCwd, { recursive: true })
|
|
93
|
+
writeFileSync(join(tmpCwd, 'package.json'), '{}')
|
|
94
|
+
|
|
95
|
+
// init + 触发 run 写入 pointer
|
|
96
|
+
run(`node "${binCLI}" init "${tmpCwd}" --spec-root "${tmpSpec}"`)
|
|
97
|
+
run(`node "${binCLI}" --dir "${tmpCwd}" run scan --spec-root "${tmpSpec}" 2>&1 || true`)
|
|
98
|
+
|
|
99
|
+
// 手动模拟一个"scan 第一步 --done"(不带 --spec-root)
|
|
100
|
+
// 关键验证:--done 时能从 pointer 恢复参数
|
|
101
|
+
const doneOut = run(`node "${binCLI}" --dir "${tmpCwd}" run scan --done --input "test" --output "test output" 2>&1`, { cwd: tmpCwd })
|
|
102
|
+
|
|
103
|
+
// --done 应该能找到平台参数,不应该报"需要 --spec-root"
|
|
104
|
+
assert('--done 恢复成功', !doneOut.includes('需要 --spec-root') && !doneOut.includes('缺少 specRoot'))
|
|
105
|
+
} finally {
|
|
106
|
+
cleanup(tmpCwd)
|
|
107
|
+
cleanup(tmpSpec)
|
|
108
|
+
cleanup(tmpRuntime)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── 测试 3:manifest 包含路径和 pointer 信息 ──
|
|
113
|
+
console.log('\n=== Test 3: manifest 路径字段 ===')
|
|
114
|
+
{
|
|
115
|
+
const { readFile } = await import('fs/promises')
|
|
116
|
+
const runSrc = await readFile(join(__dirname, '..', 'src', 'run.js'), 'utf8')
|
|
117
|
+
|
|
118
|
+
// manifest 初始化中包含三路径
|
|
119
|
+
assert('manifest 有 source_root: cwd', runSrc.includes('source_root: cwd'))
|
|
120
|
+
assert('manifest 有 spec_root', runSrc.includes('spec_root: platformOpts'))
|
|
121
|
+
assert('manifest 有 runtime_root', runSrc.includes('runtime_root: platformOpts'))
|
|
122
|
+
assert('manifest 有 platform_pointer_path', runSrc.includes('platform_pointer_path:'))
|
|
123
|
+
assert('manifest platform_pointer_status 使用枚举', runSrc.includes('POINTER_STATUS') || runSrc.includes('POINTER_STATUS.ACTIVE'))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── 测试 4:异常 pointer 检测 ──
|
|
127
|
+
console.log('\n=== Test 4: 异常 pointer 残留检测 ===')
|
|
128
|
+
{
|
|
129
|
+
const tmpCwd = join(tmpdir(), `recovery-test4-${randomUUID().slice(0, 8)}`)
|
|
130
|
+
const tmpSpec = join(tmpdir(), `recovery-test4-spec-${randomUUID().slice(0, 8)}`)
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
mkdirSync(tmpCwd, { recursive: true })
|
|
134
|
+
writeFileSync(join(tmpCwd, 'package.json'), '{}')
|
|
135
|
+
|
|
136
|
+
// init + 触发 run 写入 pointer
|
|
137
|
+
run(`node "${binCLI}" init "${tmpCwd}" --spec-root "${tmpSpec}"`)
|
|
138
|
+
run(`node "${binCLI}" --dir "${tmpCwd}" run scan --spec-root "${tmpSpec}" 2>&1 || true`)
|
|
139
|
+
|
|
140
|
+
const pointerPath = join(tmpCwd, '.sillyspec-platform.json')
|
|
141
|
+
assert('pointer 文件存在', existsSync(pointerPath))
|
|
142
|
+
|
|
143
|
+
// 模拟损坏的 pointer(缺少 specRoot)
|
|
144
|
+
writeFileSync(pointerPath, JSON.stringify({ workspaceId: 'fake', savedAt: new Date().toISOString() }))
|
|
145
|
+
const badOut = run(`node "${binCLI}" --dir "${tmpCwd}" run scan 2>&1`)
|
|
146
|
+
assert('损坏 pointer 报错', badOut.includes('缺少 specRoot') || badOut.includes('❌'))
|
|
147
|
+
|
|
148
|
+
// 模拟有效 pointer(手动修复)
|
|
149
|
+
writeFileSync(pointerPath, JSON.stringify({
|
|
150
|
+
specRoot: tmpSpec,
|
|
151
|
+
runtimeRoot: join(tmpdir(), 'fake-rt'),
|
|
152
|
+
workspaceId: 'test',
|
|
153
|
+
scanRunId: 'scan-test',
|
|
154
|
+
savedAt: new Date().toISOString(),
|
|
155
|
+
}))
|
|
156
|
+
// init 不应报错
|
|
157
|
+
const goodOut = run(`node "${binCLI}" --dir "${tmpCwd}" run scan --help 2>&1`)
|
|
158
|
+
assert('有效 pointer 不报错', !goodOut.includes('缺少 specRoot'))
|
|
159
|
+
} finally {
|
|
160
|
+
cleanup(tmpCwd)
|
|
161
|
+
cleanup(tmpSpec)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── 结果 ──
|
|
166
|
+
console.log(`\n${'='.repeat(50)}`)
|
|
167
|
+
console.log(`✅ 通过: ${passed.length} ❌ 失败: ${failed.length}`)
|
|
168
|
+
console.log(`${'='.repeat(50)}`)
|
|
169
|
+
|
|
170
|
+
if (failed.length > 0) {
|
|
171
|
+
console.log('\n失败详情:')
|
|
172
|
+
for (const f of failed) {
|
|
173
|
+
console.log(` ❌ ${f.label}`)
|
|
174
|
+
if (f.detail) console.log(` ${f.detail}`)
|
|
175
|
+
}
|
|
176
|
+
throw new Error('platform-recovery-chain test failed')
|
|
177
|
+
} else {
|
|
178
|
+
console.log('\n🎉 平台恢复链路测试全部通过!')
|
|
179
|
+
}
|
|
@@ -6,6 +6,7 @@ import { join, resolve, dirname, basename } from 'path'
|
|
|
6
6
|
import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'fs'
|
|
7
7
|
import { fileURLToPath, pathToFileURL } from 'url'
|
|
8
8
|
import { execSync } from 'child_process'
|
|
9
|
+
import { tmpdir } from 'os'
|
|
9
10
|
|
|
10
11
|
const __filename = fileURLToPath(import.meta.url)
|
|
11
12
|
const __dirname = dirname(__filename)
|
|
@@ -21,16 +22,23 @@ function assert(cond, msg) {
|
|
|
21
22
|
|
|
22
23
|
const P = 'recover'
|
|
23
24
|
function setup(name) {
|
|
24
|
-
const d = join(
|
|
25
|
+
const d = join(tmpdir(), `${P}-${name}`)
|
|
25
26
|
mkdirSync(d, { recursive: true })
|
|
26
27
|
return d
|
|
27
28
|
}
|
|
28
29
|
function spec(name) {
|
|
29
|
-
const d = join(
|
|
30
|
+
const d = join(tmpdir(), `${P}-${name}-spec`)
|
|
30
31
|
mkdirSync(d, { recursive: true })
|
|
31
32
|
return d
|
|
32
33
|
}
|
|
33
34
|
function clean(...dirs) { for (const d of dirs) try { rmSync(d, { recursive: true, force: true }) } catch {} }
|
|
35
|
+
function hasPathSegments(value, segments) {
|
|
36
|
+
const parts = value.split(/[\\/]+/)
|
|
37
|
+
for (let i = 0; i <= parts.length - segments.length; i++) {
|
|
38
|
+
if (segments.every((segment, offset) => parts[i + offset] === segment)) return true
|
|
39
|
+
}
|
|
40
|
+
return false
|
|
41
|
+
}
|
|
34
42
|
|
|
35
43
|
function run(cmd) {
|
|
36
44
|
return execSync(cmd, { encoding: 'utf8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
@@ -127,8 +135,8 @@ console.log('\n=== Test 5: specDir 缺文档 → 校验失败,路径不含 .si
|
|
|
127
135
|
assert(!result.ok, `specDir 缺文档: ok=${result.ok}`)
|
|
128
136
|
assert(result.errors.length > 0, `有 errors`)
|
|
129
137
|
const errMsg = result.errors[0]
|
|
130
|
-
assert(!errMsg
|
|
131
|
-
assert(errMsg
|
|
138
|
+
assert(!hasPathSegments(errMsg, ['.sillyspec', 'docs']), `路径不含 .sillyspec: ${errMsg}`)
|
|
139
|
+
assert(hasPathSegments(errMsg, ['docs', proj, 'scan']), `路径含 docs/${proj}/scan: ${errMsg}`)
|
|
132
140
|
clean(cwd, sd)
|
|
133
141
|
}
|
|
134
142
|
|
|
@@ -149,11 +157,11 @@ console.log('\n=== Test 7: 非平台模式缺文档 → 路径含 .sillyspec ===
|
|
|
149
157
|
const result = runValidators('scan', cwd, 'default', { projectName: proj })
|
|
150
158
|
assert(!result.ok, `非平台缺文档: ok=${result.ok}`)
|
|
151
159
|
const errMsg = result.errors[0]
|
|
152
|
-
assert(errMsg
|
|
160
|
+
assert(hasPathSegments(errMsg, ['.sillyspec', 'docs']), `路径含 .sillyspec/docs: ${errMsg}`)
|
|
153
161
|
clean(cwd)
|
|
154
162
|
}
|
|
155
163
|
|
|
156
164
|
console.log(`\n${'='.repeat(50)}`)
|
|
157
165
|
console.log(`✅ 通过: ${passed} ❌ 失败: ${failed}`)
|
|
158
166
|
console.log(`${'='.repeat(50)}`)
|
|
159
|
-
|
|
167
|
+
if (failed > 0) throw new Error(`${failed} test(s) failed`)
|
|
@@ -119,7 +119,7 @@ function assert(label, condition, detail) {
|
|
|
119
119
|
|
|
120
120
|
assert('postcheck 检查 manifest.json', postcheckSrc.includes('manifest.json'))
|
|
121
121
|
assert('postcheck 检查 local.yaml', postcheckSrc.includes('local.yaml'))
|
|
122
|
-
assert('污染 severity
|
|
122
|
+
assert('污染 severity 使用枚举', postcheckSrc.includes('CHECK_SEVERITY'))
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
// ── 测试 5:run.js 占位符替换补齐 ──
|
|
@@ -142,11 +142,14 @@ function assert(label, condition, detail) {
|
|
|
142
142
|
{
|
|
143
143
|
const { definition } = await import('../src/stages/quick.js')
|
|
144
144
|
const step1Prompt = definition.steps[0].prompt
|
|
145
|
+
const step3Prompt = definition.steps[2].prompt
|
|
145
146
|
|
|
146
147
|
assert('quick step 1 包含 ⛔ 标记', step1Prompt.includes('⛔'))
|
|
147
148
|
assert('quick step 1 包含「不能跳过」', step1Prompt.includes('不能跳过'))
|
|
148
149
|
assert('quick step 1 包含 quicklog 未创建 warning', step1Prompt.includes('quicklog 未创建'))
|
|
149
150
|
assert('quick step 1 输出要求 quicklog 第一行', step1Prompt.includes('第一行确认'))
|
|
151
|
+
assert('quick step 3 禁止 git add -A', step3Prompt.includes('禁止使用 `git add -A`'))
|
|
152
|
+
assert('quick step 3 使用 scoped git add', step3Prompt.includes('git add -- <file...>'))
|
|
150
153
|
|
|
151
154
|
// run.js 审计包含 quicklog 检查
|
|
152
155
|
const runSrc = await readFile(join(__dirname, '..', 'src', 'run.js'), 'utf8')
|
|
@@ -166,7 +169,7 @@ if (failed.length > 0) {
|
|
|
166
169
|
console.log(` ❌ ${f.label}`)
|
|
167
170
|
if (f.detail) console.log(` ${f.detail}`)
|
|
168
171
|
}
|
|
169
|
-
|
|
172
|
+
throw new Error("test failed")
|
|
170
173
|
} else {
|
|
171
174
|
console.log('\n🎉 全部 P0 测试通过!')
|
|
172
175
|
}
|