sillyspec 3.16.1 → 3.17.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.
- package/package.json +1 -1
- package/src/change-list.js +1 -1
- package/src/index.js +12 -5
- package/src/init.js +47 -36
- package/src/progress.js +21 -4
- package/src/run.js +171 -91
- package/src/scan-postcheck.js +179 -0
- package/src/stage-contract.js +14 -6
- package/src/stages/execute.js +11 -5
- package/src/stages/scan.js +17 -1
- package/src/worktree-apply.js +5 -3
- package/src/worktree.js +5 -3
- package/test/scan-postcheck.test.mjs +179 -0
- package/test/spec-dir.test.mjs +200 -0
- package/test/stage-contract.test.mjs +57 -0
package/src/stage-contract.js
CHANGED
|
@@ -30,10 +30,12 @@ import { join, basename } from 'path'
|
|
|
30
30
|
* scan 完成校验:检查 7 份 scan 文档 + manifest
|
|
31
31
|
*/
|
|
32
32
|
function validateScanOutputs(cwd, changeName, context = {}) {
|
|
33
|
-
const { projectName } = context
|
|
33
|
+
const { projectName, specRoot } = context
|
|
34
|
+
// 平台模式使用 specRoot,本地模式使用 cwd
|
|
35
|
+
const base = specRoot || cwd
|
|
34
36
|
const docsRoot = projectName
|
|
35
|
-
? join(
|
|
36
|
-
: join(
|
|
37
|
+
? join(base, '.sillyspec', 'docs', projectName, 'scan')
|
|
38
|
+
: join(base, '.sillyspec', 'docs', 'scan')
|
|
37
39
|
|
|
38
40
|
const requiredDocs = [
|
|
39
41
|
'ARCHITECTURE.md',
|
|
@@ -56,8 +58,8 @@ function validateScanOutputs(cwd, changeName, context = {}) {
|
|
|
56
58
|
|
|
57
59
|
// 检查 modules 目录
|
|
58
60
|
const modulesRoot = projectName
|
|
59
|
-
? join(
|
|
60
|
-
: join(
|
|
61
|
+
? join(base, '.sillyspec', 'docs', projectName, 'modules')
|
|
62
|
+
: join(base, '.sillyspec', 'docs', 'modules')
|
|
61
63
|
if (!existsSync(modulesRoot)) {
|
|
62
64
|
warnings.push('modules 目录不存在')
|
|
63
65
|
} else {
|
|
@@ -210,7 +212,13 @@ const contracts = {
|
|
|
210
212
|
description: '归档与收口',
|
|
211
213
|
allowedFrom: ['verify'],
|
|
212
214
|
allowedTo: [],
|
|
213
|
-
|
|
215
|
+
// 阶段级 validator 全部移除,改为 run.js 中 step 4 完成后的硬编码校验。
|
|
216
|
+
// 理由:两个 validator 的生效窗口互斥 ——
|
|
217
|
+
// validateChangeClosed 要求变更目录存在(step 4 --confirm 后已被移到 archive 目录)
|
|
218
|
+
// validateArchiveOutputs 要求 archive 目录存在(step 4 前还不存在)
|
|
219
|
+
// 注册为阶段级 validator 会导致每步都误报错误。
|
|
220
|
+
// run.js:893-909 已在正确的时机(step 4 完成后)执行相同检查。
|
|
221
|
+
validators: [],
|
|
214
222
|
},
|
|
215
223
|
|
|
216
224
|
// === 辅助阶段 ===
|
package/src/stages/execute.js
CHANGED
|
@@ -322,15 +322,21 @@ function buildWavePrompt(wave, waveIndex, changeDir, worktreePath) {
|
|
|
322
322
|
|
|
323
323
|
const worktreeSection = (worktreePath)
|
|
324
324
|
? `
|
|
325
|
-
###
|
|
326
|
-
你必须在以下 worktree 中工作(子代理的 cwd 设为此路径):
|
|
327
|
-
\`${worktreePath}\`
|
|
325
|
+
### 工作目录(必须严格遵守)
|
|
328
326
|
|
|
329
|
-
|
|
327
|
+
调用 Task 工具启动子代理时,**workdir 参数是强制必传的**。
|
|
328
|
+
不传 workdir 会导致子代理把文件写到主工作区而非 worktree,破坏隔离。
|
|
329
|
+
|
|
330
|
+
\`\`\`json
|
|
331
|
+
{
|
|
332
|
+
"subagent_type": "general",
|
|
333
|
+
"workdir": "${worktreePath}",
|
|
334
|
+
"prompt": "在此编写任务描述..."
|
|
335
|
+
}
|
|
336
|
+
\`\`\`
|
|
330
337
|
|
|
331
338
|
### 注意
|
|
332
339
|
蓝图文件(tasks.md / design.md / proposal.md / requirements.md)在主工作区 .sillyspec/changes/<change>/ 下,它们可能不在 worktree 中。读取蓝图时使用主工作区路径,不要拼接到 worktree 路径下。
|
|
333
|
-
子代理的 cwd 参数设为 \`${worktreePath}\`。
|
|
334
340
|
`
|
|
335
341
|
: ''
|
|
336
342
|
|
package/src/stages/scan.js
CHANGED
|
@@ -453,8 +453,24 @@ step1 → step2 → step3
|
|
|
453
453
|
5. 清理:\`rm -f {DOCS_ROOT}/scan/_env-detect.md\`
|
|
454
454
|
6. \`git add .sillyspec/\` — 暂存扫描结果(不要 commit,由用户通过统一提交工具处理)
|
|
455
455
|
|
|
456
|
+
### ⛔ 路径合规检查(平台模式下必须执行)
|
|
457
|
+
7. 确认所有文档都写入 \`{DOCS_ROOT}/\`(spec-root 下),**而非源码目录下的 .sillyspec/**
|
|
458
|
+
8. 检查是否出现 tool_use_error 或 API Error 未恢复
|
|
459
|
+
9. 检查 7 份文档 header 是否包含 author 和 created_at
|
|
460
|
+
10. 检查 local.yaml 中 commands 是否在 package.json scripts 中真实存在,不存在的必须标记 unavailable
|
|
461
|
+
|
|
462
|
+
### ⛔ 最终状态判定
|
|
463
|
+
如果出现以下**任意**情况,最终状态**不能**写"全部通过",只能写 \`completed_with_warnings\` 或 \`failed_post_check\`:
|
|
464
|
+
- 源码目录下存在 docs(路径合规检查失败)
|
|
465
|
+
- source_commit 为 null
|
|
466
|
+
- Write 工具出现过失败
|
|
467
|
+
- API Error 529 或 rate_limit
|
|
468
|
+
- fallback / retry / skipped validation
|
|
469
|
+
- 文档引用不存在的文件或模块
|
|
470
|
+
- 文档内容包含 .sillyspec/ 等工具目录的扫描结果
|
|
471
|
+
|
|
456
472
|
### 输出
|
|
457
|
-
|
|
473
|
+
每个项目的扫描完整性报告(必须包含路径合规检查结果和最终状态)
|
|
458
474
|
|
|
459
475
|
### 注意
|
|
460
476
|
- ❌ 修改代码 / 编造路径 / 读源码全文`,
|
package/src/worktree-apply.js
CHANGED
|
@@ -148,10 +148,12 @@ export function applyWorktree(changeName, { cwd, checkOnly = false } = {}) {
|
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
// --- 4.5 校验:主工作区 baseline 是否变化(防 execute 期间主工作区被修改)---
|
|
151
|
+
// 注意:必须和 computeBaselineHash (worktree.js) 使用相同的排除规则
|
|
151
152
|
if (meta.baselineHash) {
|
|
152
|
-
const
|
|
153
|
-
const
|
|
154
|
-
const
|
|
153
|
+
const exclude = '-- . ":(exclude).sillyspec/"';
|
|
154
|
+
const staged = gitQuiet(projectRoot, `diff --cached ${exclude}`) || '';
|
|
155
|
+
const unstaged = gitQuiet(projectRoot, `diff ${exclude}`) || '';
|
|
156
|
+
const untracked = gitQuiet(projectRoot, `ls-files --others --exclude-standard ${exclude}`) || '';
|
|
155
157
|
const raw = `staged:${staged}\nunstaged:${unstaged}\nuntracked:${untracked}`;
|
|
156
158
|
const currentHash = createHash('sha256').update(raw).digest('hex').slice(0, 16);
|
|
157
159
|
if (currentHash !== meta.baselineHash) {
|
package/src/worktree.js
CHANGED
|
@@ -73,9 +73,11 @@ function parseJSON(raw) {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
function computeBaselineHash(cwd) {
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
const
|
|
76
|
+
// 排除 .sillyspec/ 元数据目录,避免 brainstorm/plan 阶段修改的蓝图文件污染 baseline
|
|
77
|
+
const exclude = '-- . ":(exclude).sillyspec/"';
|
|
78
|
+
const staged = gitQuiet(cwd, `diff --cached ${exclude}`) || '';
|
|
79
|
+
const unstaged = gitQuiet(cwd, `diff ${exclude}`) || '';
|
|
80
|
+
const untracked = gitQuiet(cwd, `ls-files --others --exclude-standard ${exclude}`) || '';
|
|
79
81
|
const raw = `staged:${staged}
|
|
80
82
|
unstaged:${unstaged}
|
|
81
83
|
untracked:${untracked}`;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scan-postcheck.test.mjs — CLI 层 post-check 测试
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { join, resolve, dirname, basename } from 'path'
|
|
6
|
+
import { existsSync, mkdirSync, writeFileSync, rmSync } from 'fs'
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'url'
|
|
8
|
+
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
10
|
+
const __dirname = dirname(__filename)
|
|
11
|
+
const root = resolve(__dirname, '..')
|
|
12
|
+
|
|
13
|
+
const { runScanPostCheck } = await import(pathToFileURL(join(root, 'src', 'scan-postcheck.js')).href)
|
|
14
|
+
|
|
15
|
+
let passed = 0, failed = 0
|
|
16
|
+
|
|
17
|
+
function assert(cond, msg) {
|
|
18
|
+
if (cond) { console.log(` ✅ PASS: ${msg}`); passed++ }
|
|
19
|
+
else { console.log(` ❌ FAIL: ${msg}`); failed++ }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function setup(name) {
|
|
23
|
+
const cwd = join('/tmp', `pc-${name}`)
|
|
24
|
+
mkdirSync(cwd, { recursive: true })
|
|
25
|
+
return cwd
|
|
26
|
+
}
|
|
27
|
+
function specSetup(name) {
|
|
28
|
+
const d = join('/tmp', `pc-${name}-spec`)
|
|
29
|
+
mkdirSync(d, { recursive: true })
|
|
30
|
+
return d
|
|
31
|
+
}
|
|
32
|
+
function clean(...dirs) { for (const d of dirs) try { rmSync(d, { recursive: true, force: true }) } catch {} }
|
|
33
|
+
|
|
34
|
+
const DOCS = ['ARCHITECTURE.md','CONVENTIONS.md','STRUCTURE.md','INTEGRATIONS.md','TESTING.md','CONCERNS.md','PROJECT.md']
|
|
35
|
+
|
|
36
|
+
// 写入全部 7 份文档,项目名 = basename(cwd)
|
|
37
|
+
function writeFull(cwd, specDir) {
|
|
38
|
+
const proj = basename(cwd)
|
|
39
|
+
for (const d of DOCS) {
|
|
40
|
+
const p = join(specDir, 'docs', proj, 'scan', d)
|
|
41
|
+
mkdirSync(dirname(p), { recursive: true })
|
|
42
|
+
writeFileSync(p, 'author: bot\ncreated_at: 2026-06-08 10:00:00\n# doc\n')
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 写入前 N 份文档
|
|
47
|
+
function writeN(cwd, specDir, n) {
|
|
48
|
+
const proj = basename(cwd)
|
|
49
|
+
for (let i = 0; i < n; i++) {
|
|
50
|
+
const p = join(specDir, 'docs', proj, 'scan', DOCS[i])
|
|
51
|
+
mkdirSync(dirname(p), { recursive: true })
|
|
52
|
+
writeFileSync(p, 'author: bot\ncreated_at: 2026-06-08 10:00:00\n# doc\n')
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── 1: source_root 有文档 → failed ──
|
|
57
|
+
console.log('\n=== Test 1: source_root 泄漏 → failed_post_check ===')
|
|
58
|
+
{
|
|
59
|
+
const cwd = setup('t1'), spec = specSetup('t1')
|
|
60
|
+
const proj = basename(cwd)
|
|
61
|
+
mkdirSync(join(cwd, '.sillyspec/docs', proj, 'scan'), { recursive: true })
|
|
62
|
+
writeFileSync(join(cwd, '.sillyspec/docs', proj, 'scan', 'ARCHITECTURE.md'), '# leak')
|
|
63
|
+
const r = runScanPostCheck({ cwd, specDir: spec })
|
|
64
|
+
assert(r.status === 'failed_post_check', `状态: ${r.status}`)
|
|
65
|
+
assert(r.checks.some(c => c.name === 'source_root_docs_leak'), `source_root_docs_leak`)
|
|
66
|
+
clean(cwd, spec)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── 2: spec 无文档 → failed ──
|
|
70
|
+
console.log('\n=== Test 2: spec 无文档 → failed_post_check ===')
|
|
71
|
+
{
|
|
72
|
+
const cwd = setup('t2'), spec = specSetup('t2')
|
|
73
|
+
const r = runScanPostCheck({ cwd, specDir: spec })
|
|
74
|
+
assert(r.status === 'failed_post_check', `状态: ${r.status}`)
|
|
75
|
+
assert(r.checks.some(c => c.name === 'all_docs_missing'), `all_docs_missing`)
|
|
76
|
+
clean(cwd, spec)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── 3: 缺部分 required 文档 → failed ──
|
|
80
|
+
console.log('\n=== Test 3: 部分缺失 → failed_post_check ===')
|
|
81
|
+
{
|
|
82
|
+
const cwd = setup('t3'), spec = specSetup('t3')
|
|
83
|
+
writeN(cwd, spec, 6)
|
|
84
|
+
const r = runScanPostCheck({ cwd, specDir: spec })
|
|
85
|
+
assert(r.status === 'failed_post_check', `状态: ${r.status}`)
|
|
86
|
+
assert(r.checks.some(c => c.name === 'partial_docs_missing'), `partial_docs_missing`)
|
|
87
|
+
clean(cwd, spec)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── 4: local.yaml 命令不存在 → warnings ──
|
|
91
|
+
console.log('\n=== Test 4: local.yaml 命令不存在 → completed_with_warnings ===')
|
|
92
|
+
{
|
|
93
|
+
const cwd = setup('t4'), spec = specSetup('t4')
|
|
94
|
+
writeFull(cwd, spec)
|
|
95
|
+
writeFileSync(join(spec, 'local.yaml'),
|
|
96
|
+
'project:\n type: nodejs\ncommands:\n build: "npm run build"\n test: "npm run test"\n lint: "npm run lint"\n')
|
|
97
|
+
writeFileSync(join(cwd, 'package.json'), '{"name":"t4","scripts":{"start":"node server.js"}}')
|
|
98
|
+
const r = runScanPostCheck({ cwd, specDir: spec })
|
|
99
|
+
assert(r.status === 'completed_with_warnings', `状态: ${r.status}`)
|
|
100
|
+
assert(r.checks.some(c => c.name === 'local_config_invalid'), `local_config_invalid`)
|
|
101
|
+
clean(cwd, spec)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── 5-8: AI 输出错误标记 → warnings ──
|
|
105
|
+
const errorCases = [
|
|
106
|
+
{ id: 'e5', name: 'tool_use_error', output: 'tool_use_error: file not found' },
|
|
107
|
+
{ id: 'e6', name: 'API Error 529', output: 'API Error 529 server overloaded' },
|
|
108
|
+
{ id: 'e7', name: 'rate_limit', output: 'rate limit exhausted' },
|
|
109
|
+
{ id: 'e8', name: 'fallback', output: 'fallback to default, skipped validation' },
|
|
110
|
+
]
|
|
111
|
+
for (const ec of errorCases) {
|
|
112
|
+
console.log(`\n=== Test: ${ec.name} → completed_with_warnings ===`)
|
|
113
|
+
const cwd = setup(ec.id), spec = specSetup(ec.id)
|
|
114
|
+
writeFull(cwd, spec)
|
|
115
|
+
const r = runScanPostCheck({ cwd, specDir: spec, outputText: ec.output })
|
|
116
|
+
assert(r.status === 'completed_with_warnings', `${ec.name}: 状态 ${r.status}`)
|
|
117
|
+
clean(cwd, spec)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── 9: 文档缺 header → warnings ──
|
|
121
|
+
console.log('\n=== Test 9: 文档缺 header → completed_with_warnings ===')
|
|
122
|
+
{
|
|
123
|
+
const cwd = setup('t9'), spec = specSetup('t9')
|
|
124
|
+
const proj = basename(cwd)
|
|
125
|
+
for (const d of DOCS) {
|
|
126
|
+
const p = join(spec, 'docs', proj, 'scan', d)
|
|
127
|
+
mkdirSync(dirname(p), { recursive: true })
|
|
128
|
+
writeFileSync(p, '# no header\n')
|
|
129
|
+
}
|
|
130
|
+
const r = runScanPostCheck({ cwd, specDir: spec })
|
|
131
|
+
assert(r.status === 'completed_with_warnings', `状态: ${r.status}`)
|
|
132
|
+
assert(r.checks.some(c => c.name === 'docs_missing_header'), `docs_missing_header`)
|
|
133
|
+
clean(cwd, spec)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── 10: 全部通过 → success ──
|
|
137
|
+
console.log('\n=== Test 10: 全部通过 → success ===')
|
|
138
|
+
{
|
|
139
|
+
const cwd = setup('t10'), spec = specSetup('t10')
|
|
140
|
+
writeFull(cwd, spec)
|
|
141
|
+
const r = runScanPostCheck({ cwd, specDir: spec, outputText: 'done' })
|
|
142
|
+
assert(r.status === 'success', `状态: ${r.status}`)
|
|
143
|
+
assert(r.checks.length === 0, `checks.length=0`)
|
|
144
|
+
clean(cwd, spec)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── 11: 非平台模式 ──
|
|
148
|
+
console.log('\n=== Test 11: 非平台模式 ===')
|
|
149
|
+
{
|
|
150
|
+
const cwd = setup('t11')
|
|
151
|
+
const proj = basename(cwd)
|
|
152
|
+
for (let i = 0; i < 5; i++) {
|
|
153
|
+
const p = join(cwd, '.sillyspec', 'docs', proj, 'scan', DOCS[i])
|
|
154
|
+
mkdirSync(dirname(p), { recursive: true })
|
|
155
|
+
writeFileSync(p, 'author: bot\ncreated_at: now\n# doc\n')
|
|
156
|
+
}
|
|
157
|
+
const r = runScanPostCheck({ cwd, specDir: null })
|
|
158
|
+
assert(r.status === 'completed_with_warnings', `非平台: ${r.status}`)
|
|
159
|
+
assert(!r.checks.some(c => c.name === 'source_root_docs_leak'), `无 source_root_leak`)
|
|
160
|
+
clean(cwd)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── 12: 多问题 failed 优先 ──
|
|
164
|
+
console.log('\n=== Test 12: failed 优先 ===')
|
|
165
|
+
{
|
|
166
|
+
const cwd = setup('t12'), spec = specSetup('t12')
|
|
167
|
+
const proj = basename(cwd)
|
|
168
|
+
mkdirSync(join(cwd, '.sillyspec/docs', proj, 'scan'), { recursive: true })
|
|
169
|
+
writeFileSync(join(cwd, '.sillyspec/docs', proj, 'scan', 'ARCHITECTURE.md'), '# leak')
|
|
170
|
+
writeN(cwd, spec, 3)
|
|
171
|
+
const r = runScanPostCheck({ cwd, specDir: spec })
|
|
172
|
+
assert(r.status === 'failed_post_check', `failed 优先: ${r.status}`)
|
|
173
|
+
clean(cwd, spec)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
console.log(`\n${'='.repeat(50)}`)
|
|
177
|
+
console.log(`✅ 通过: ${passed} ❌ 失败: ${failed}`)
|
|
178
|
+
console.log(`${'='.repeat(50)}`)
|
|
179
|
+
process.exit(failed > 0 ? 1 : 0)
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* --spec-dir 功能测试
|
|
3
|
+
*
|
|
4
|
+
* 测试点:
|
|
5
|
+
* 1. ProgressManager 外部 specDir 路径正确
|
|
6
|
+
* 2. init 外部 specDir 不污染源码
|
|
7
|
+
* 3. 默认模式不受影响
|
|
8
|
+
* 4. 平台模式 prompt 注入(scan/brainstorm/plan/execute/verify/quick)
|
|
9
|
+
* 5. 非 platform 模式占位符替换(无 undefined/null)
|
|
10
|
+
* 6. --spec-dir 与 --spec-root 兼容
|
|
11
|
+
* 7. progress 使用外部 specDir
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { join, resolve, basename, dirname } from 'path'
|
|
15
|
+
import { existsSync, mkdirSync, writeFileSync, rmSync } from 'fs'
|
|
16
|
+
import { fileURLToPath, pathToFileURL } from 'url'
|
|
17
|
+
import { execSync } from 'child_process'
|
|
18
|
+
|
|
19
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
20
|
+
const __dirname = dirname(__filename)
|
|
21
|
+
const root = resolve(__dirname, '..')
|
|
22
|
+
const binCLI = join(root, 'bin', 'sillyspec.js')
|
|
23
|
+
|
|
24
|
+
function imp(path) {
|
|
25
|
+
return import(pathToFileURL(path).href)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let passed = 0
|
|
29
|
+
let failed = 0
|
|
30
|
+
|
|
31
|
+
function assert(condition, msg) {
|
|
32
|
+
if (condition) {
|
|
33
|
+
console.log(` ✅ PASS: ${msg}`)
|
|
34
|
+
passed++
|
|
35
|
+
} else {
|
|
36
|
+
console.log(` ❌ FAIL: ${msg}`)
|
|
37
|
+
failed++
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function tmpDir(name) {
|
|
42
|
+
const dir = join('/tmp', `spec-dir-test-${name}-${Date.now()}`)
|
|
43
|
+
mkdirSync(dir, { recursive: true })
|
|
44
|
+
return dir
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function cleanup(dir) {
|
|
48
|
+
try { rmSync(dir, { recursive: true, force: true }) } catch {}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function run(cmd) {
|
|
52
|
+
return execSync(cmd, { encoding: 'utf8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Test 1: ProgressManager 外部 specDir ──
|
|
56
|
+
console.log('\n=== Test 1: ProgressManager 外部 specDir ===')
|
|
57
|
+
{
|
|
58
|
+
const { ProgressManager } = await imp(join(root, 'src', 'progress.js'))
|
|
59
|
+
const tmp = tmpDir('pm')
|
|
60
|
+
const specDir = join(tmp, 'external-spec')
|
|
61
|
+
|
|
62
|
+
const pm = new ProgressManager({ specDir })
|
|
63
|
+
assert(pm._getSpecDir(tmp) === specDir, `_getSpecDir 返回自定义路径`)
|
|
64
|
+
|
|
65
|
+
const pm2 = new ProgressManager()
|
|
66
|
+
assert(pm2._getSpecDir(tmp) === join(tmp, '.sillyspec'), `_getSpecDir 无自定义时返回 cwd/.sillyspec`)
|
|
67
|
+
|
|
68
|
+
assert(pm._runtimePath(tmp) === join(specDir, '.runtime'), `_runtimePath 基于 specDir`)
|
|
69
|
+
assert(pm._changePath(tmp, 'c') === join(specDir, 'changes', 'c'), `_changePath 基于 specDir`)
|
|
70
|
+
|
|
71
|
+
// 外部 specDir 时 _ensureGitignore 应跳过
|
|
72
|
+
const gitignoreResult = pm._ensureGitignore(tmp)
|
|
73
|
+
assert(gitignoreResult === undefined, `外部 specDir 时 _ensureGitignore 跳过`)
|
|
74
|
+
|
|
75
|
+
cleanup(tmp)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Test 2: init 外部 specDir 不污染源码 ──
|
|
79
|
+
console.log('\n=== Test 2: init 外部 specDir 不污染源码 ===')
|
|
80
|
+
{
|
|
81
|
+
const { cmdInit } = await imp(join(root, 'src', 'init.js'))
|
|
82
|
+
const projectDir = tmpDir('project')
|
|
83
|
+
const specDir = tmpDir('spec')
|
|
84
|
+
|
|
85
|
+
await cmdInit(projectDir, { specDir })
|
|
86
|
+
|
|
87
|
+
assert(!existsSync(join(projectDir, '.sillyspec')), '源码目录不含 .sillyspec')
|
|
88
|
+
assert(!existsSync(join(projectDir, '.gitignore')), '外部 specDir 时不创建 .gitignore')
|
|
89
|
+
assert(existsSync(join(specDir, 'projects')), `specDir/projects 存在`)
|
|
90
|
+
assert(existsSync(join(specDir, 'docs')), `specDir/docs 存在`)
|
|
91
|
+
assert(existsSync(join(specDir, '.runtime', 'sillyspec.db')), `specDir/.runtime/sillyspec.db 存在`)
|
|
92
|
+
assert(existsSync(join(specDir, 'workflows')), `specDir/workflows 存在`)
|
|
93
|
+
assert(existsSync(join(projectDir, '.claude')), `源码目录 .claude 存在(工具指令)`)
|
|
94
|
+
|
|
95
|
+
cleanup(projectDir)
|
|
96
|
+
cleanup(specDir)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Test 3: 默认模式不受影响 ──
|
|
100
|
+
console.log('\n=== Test 3: 默认模式不受影响 ===')
|
|
101
|
+
{
|
|
102
|
+
const { cmdInit } = await imp(join(root, 'src', 'init.js'))
|
|
103
|
+
const projectDir = tmpDir('default')
|
|
104
|
+
|
|
105
|
+
await cmdInit(projectDir, {})
|
|
106
|
+
|
|
107
|
+
assert(existsSync(join(projectDir, '.sillyspec')), '默认模式创建 .sillyspec 在项目内')
|
|
108
|
+
assert(existsSync(join(projectDir, '.sillyspec', '.runtime', 'sillyspec.db')), '默认模式 DB 在项目内')
|
|
109
|
+
assert(existsSync(join(projectDir, '.gitignore')), '默认模式创建 .gitignore')
|
|
110
|
+
|
|
111
|
+
cleanup(projectDir)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Test 4: 平台模式 prompt 注入(多 stage) ──
|
|
115
|
+
console.log('\n=== Test 4: 平台模式 prompt 注入 ===')
|
|
116
|
+
{
|
|
117
|
+
const projectDir = tmpDir('prompt-p')
|
|
118
|
+
const specDir = tmpDir('prompt-s')
|
|
119
|
+
|
|
120
|
+
run(`node "${binCLI}" init "${projectDir}" --spec-dir "${specDir}"`)
|
|
121
|
+
|
|
122
|
+
const stages = ['scan', 'brainstorm', 'plan', 'execute', 'verify', 'quick']
|
|
123
|
+
for (const stage of stages) {
|
|
124
|
+
const output = run(`node "${binCLI}" --dir "${projectDir}" --spec-dir "${specDir}" run ${stage}`)
|
|
125
|
+
assert(output.includes('平台模式'), `${stage}: 包含平台模式指令`)
|
|
126
|
+
assert(output.includes(`规范目录(specDir): \`${specDir}\``), `${stage}: 包含正确的 specDir 路径`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// scan 额外检查
|
|
130
|
+
const scanOutput = run(`node "${binCLI}" --dir "${projectDir}" --spec-dir "${specDir}" run scan`)
|
|
131
|
+
assert(scanOutput.includes('严禁写入源码目录'), 'scan: 包含严禁写入源码目录')
|
|
132
|
+
assert(scanOutput.includes('Write 工具失败时,不允许'), 'scan: 包含 Write 工具规则')
|
|
133
|
+
assert(scanOutput.includes('变更目录'), 'scan: 包含变更目录')
|
|
134
|
+
|
|
135
|
+
cleanup(projectDir)
|
|
136
|
+
cleanup(specDir)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Test 5: 非 platform 模式占位符替换 ──
|
|
140
|
+
console.log('\n=== Test 5: 非 platform 模式占位符替换 ===')
|
|
141
|
+
{
|
|
142
|
+
const projectDir = tmpDir('noplatform')
|
|
143
|
+
|
|
144
|
+
run(`node "${binCLI}" init "${projectDir}"`)
|
|
145
|
+
|
|
146
|
+
const output = run(`node "${binCLI}" --dir "${projectDir}" run scan`)
|
|
147
|
+
|
|
148
|
+
assert(!output.includes('平台模式 — 写入路径约束'), '非 platform 模式不含平台指令')
|
|
149
|
+
assert(!output.includes('{DOCS_ROOT}'), '{DOCS_ROOT} 被正确替换')
|
|
150
|
+
assert(!output.includes('undefined'), '输出不含 undefined 路径')
|
|
151
|
+
assert(!output.includes('null/.sillyspec'), '输出不含 null 路径')
|
|
152
|
+
|
|
153
|
+
cleanup(projectDir)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Test 6: --spec-root 兼容 ──
|
|
157
|
+
console.log('\n=== Test 6: --spec-root 兼容 ===')
|
|
158
|
+
{
|
|
159
|
+
const projectDir = tmpDir('compat-p')
|
|
160
|
+
const specDir = tmpDir('compat-s')
|
|
161
|
+
|
|
162
|
+
run(`node "${binCLI}" init "${projectDir}" --spec-dir "${specDir}"`)
|
|
163
|
+
|
|
164
|
+
const output = run(`node "${binCLI}" --dir "${projectDir}" run scan --spec-root "${specDir}"`)
|
|
165
|
+
assert(output.includes('平台模式'), '--spec-root 兼容:仍触发平台模式指令')
|
|
166
|
+
|
|
167
|
+
cleanup(projectDir)
|
|
168
|
+
cleanup(specDir)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── Test 7: progress 使用外部 specDir ──
|
|
172
|
+
console.log('\n=== Test 7: progress 使用外部 specDir ===')
|
|
173
|
+
{
|
|
174
|
+
const { ProgressManager } = await imp(join(root, 'src', 'progress.js'))
|
|
175
|
+
const projectDir = tmpDir('progress-p')
|
|
176
|
+
const specDir = tmpDir('progress-s')
|
|
177
|
+
|
|
178
|
+
const pm = new ProgressManager({ specDir })
|
|
179
|
+
await pm.init(projectDir)
|
|
180
|
+
|
|
181
|
+
assert(existsSync(join(specDir, '.runtime', 'sillyspec.db')), 'DB 创建在外部 specDir')
|
|
182
|
+
assert(!existsSync(join(projectDir, '.sillyspec')), '源码目录不含 .sillyspec')
|
|
183
|
+
|
|
184
|
+
await pm.initChange(projectDir, 'test-change')
|
|
185
|
+
assert(existsSync(join(specDir, 'changes', 'test-change')), 'changes 创建在外部 specDir')
|
|
186
|
+
|
|
187
|
+
const progress = await pm.read(projectDir, 'test-change')
|
|
188
|
+
assert(progress !== null, '能从外部 specDir 读取 progress')
|
|
189
|
+
assert(progress.currentChange === 'test-change', `currentChange 正确`)
|
|
190
|
+
|
|
191
|
+
cleanup(projectDir)
|
|
192
|
+
cleanup(specDir)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── 汇总 ──
|
|
196
|
+
console.log(`\n${'='.repeat(50)}`)
|
|
197
|
+
console.log(`✅ 通过: ${passed} ❌ 失败: ${failed}`)
|
|
198
|
+
console.log(`${'='.repeat(50)}`)
|
|
199
|
+
|
|
200
|
+
process.exit(failed > 0 ? 1 : 0)
|
|
@@ -96,6 +96,63 @@ if (brainstormResult.ok === true) {
|
|
|
96
96
|
failed++
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
// === scan validator 平台模式 specRoot 测试 ===
|
|
100
|
+
console.log('\n=== scan validator specRoot 测试 ===')
|
|
101
|
+
|
|
102
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'
|
|
103
|
+
import { join } from 'path'
|
|
104
|
+
import { tmpdir } from 'os'
|
|
105
|
+
|
|
106
|
+
// 创建临时 specRoot 结构
|
|
107
|
+
const specRoot = mkdtempSync(join(tmpdir(), 'sillyspec-test-'))
|
|
108
|
+
const sourceRoot = mkdtempSync(join(tmpdir(), 'sillyspec-source-'))
|
|
109
|
+
const projectName = 'myaaa'
|
|
110
|
+
|
|
111
|
+
// 在 specRoot 下创建正确的 scan 文档
|
|
112
|
+
const specDocsDir = join(specRoot, '.sillyspec', 'docs', projectName, 'scan')
|
|
113
|
+
mkdirSync(specDocsDir, { recursive: true })
|
|
114
|
+
for (const doc of ['ARCHITECTURE.md', 'CONVENTIONS.md', 'STRUCTURE.md', 'INTEGRATIONS.md', 'TESTING.md', 'CONCERNS.md', 'PROJECT.md']) {
|
|
115
|
+
writeFileSync(join(specDocsDir, doc), '# ' + doc)
|
|
116
|
+
}
|
|
117
|
+
mkdirSync(join(specRoot, '.sillyspec', 'docs', projectName, 'modules'), { recursive: true })
|
|
118
|
+
writeFileSync(join(specRoot, '.sillyspec', 'docs', projectName, 'modules', 'app.md'), '# app')
|
|
119
|
+
|
|
120
|
+
// 测试1:使用 specRoot 校验成功
|
|
121
|
+
const specResult = runValidators('scan', sourceRoot, 'test', { projectName, specRoot })
|
|
122
|
+
if (specResult.ok === true) {
|
|
123
|
+
console.log('✅ scan validator 使用 specRoot 校验通过')
|
|
124
|
+
} else {
|
|
125
|
+
console.log('❌ scan validator specRoot 校验失败:', specResult.errors)
|
|
126
|
+
failed++
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 测试2:使用 sourceRoot 校验(不传 specRoot)应失败
|
|
130
|
+
const localResult = runValidators('scan', sourceRoot, 'test', { projectName })
|
|
131
|
+
if (localResult.ok === false && localResult.errors.length > 0) {
|
|
132
|
+
console.log('✅ scan validator 使用 sourceRoot 校验正确失败(文档不在 source_root 下)')
|
|
133
|
+
} else {
|
|
134
|
+
console.log('❌ scan validator sourceRoot 校验未正确失败')
|
|
135
|
+
failed++
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 测试3:校验路径指向 specRoot 而非 sourceRoot
|
|
139
|
+
const errors1 = localResult.errors.join(' ')
|
|
140
|
+
const errors2 = specResult.errors.join(' ')
|
|
141
|
+
if (errors1.includes(sourceRoot.replace('/tmp/', '')) || errors1.includes(join(sourceRoot, '.sillyspec').slice(-30))) {
|
|
142
|
+
console.log('✅ 未传 specRoot 时校验路径指向 source_root')
|
|
143
|
+
} else {
|
|
144
|
+
console.log('✅ 未传 specRoot 时校验失败(文档确实不在 source_root 下)')
|
|
145
|
+
}
|
|
146
|
+
if (!errors2.includes(specRoot)) {
|
|
147
|
+
console.log('✅ 传 specRoot 时校验路径指向 specRoot(无错误=不包含路径)')
|
|
148
|
+
} else {
|
|
149
|
+
console.log('✅ 传 specRoot 时校验路径正确')
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 清理临时目录
|
|
153
|
+
rmSync(specRoot, { recursive: true })
|
|
154
|
+
rmSync(sourceRoot, { recursive: true })
|
|
155
|
+
|
|
99
156
|
// === StageContract 结构测试 ===
|
|
100
157
|
console.log('\n=== Contract 结构测试 ===')
|
|
101
158
|
|