specline 1.4.0 → 2.0.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.
Files changed (75) hide show
  1. package/README.md +132 -125
  2. package/adapters/claude/deploy.json +12 -0
  3. package/adapters/claude/hooks/hooks.json +12 -0
  4. package/adapters/claude/hooks.json +12 -0
  5. package/adapters/claude/orchestration.md +17 -0
  6. package/adapters/codex/agent.toml.hbs +7 -0
  7. package/adapters/codex/deploy.json +12 -0
  8. package/adapters/codex/hooks.json +12 -0
  9. package/adapters/codex/orchestration.md +18 -0
  10. package/adapters/cursor/deploy.json +12 -0
  11. package/adapters/cursor/hooks.json +9 -0
  12. package/adapters/cursor/orchestration.md +17 -0
  13. package/adapters/opencode/deploy.json +12 -0
  14. package/adapters/opencode/orchestration.md +18 -0
  15. package/adapters/opencode/plugin.js +10 -0
  16. package/cli.mjs +161 -558
  17. package/core/agents/specline-backend-dev.yaml +45 -0
  18. package/core/agents/specline-code-reviewer.yaml +67 -0
  19. package/core/agents/specline-config-dev.yaml +50 -0
  20. package/core/agents/specline-config-reviewer.yaml +70 -0
  21. package/core/agents/specline-explore-assistant.yaml +79 -0
  22. package/core/agents/specline-frontend-dev.yaml +45 -0
  23. package/core/agents/specline-spec-creator.yaml +58 -0
  24. package/core/agents/specline-spec-reviewer.yaml +58 -0
  25. package/core/agents/specline-test-runner.yaml +62 -0
  26. package/core/agents/specline-test-writer.yaml +67 -0
  27. package/core/bootstrap/using-specline.md +14 -0
  28. package/core/gates/pipeline-gate-checks/a1-covers-ref.sh +125 -0
  29. package/core/gates/pipeline-gate-checks/a2-a3-reverse.sh +171 -0
  30. package/core/gates/pipeline-gate-checks/c1-exception.sh +71 -0
  31. package/core/gates/pipeline-gate-checks/c2-vague.sh +60 -0
  32. package/core/gates/pipeline-gate-checks/common.sh +68 -0
  33. package/core/gates/pipeline-gate-checks/d1-cycle.sh +149 -0
  34. package/core/gates/pipeline-gate-checks/d3-type-file.sh +260 -0
  35. package/core/gates/pipeline-gate.sh +1456 -0
  36. package/core/hooks/session-start.sh +259 -0
  37. package/core/skills/specline-apply-change/SKILL.md +197 -0
  38. package/core/skills/specline-archive-change/SKILL.md +173 -0
  39. package/core/skills/specline-explore/SKILL.md +504 -0
  40. package/core/skills/specline-knowledge/SKILL.md +539 -0
  41. package/core/skills/specline-pipeline/SKILL.md +604 -0
  42. package/core/skills/specline-pipeline/references/error-recovery-details.md +49 -0
  43. package/core/skills/specline-pipeline/references/event-log-spec.md +59 -0
  44. package/core/skills/specline-pipeline/references/pipeline-state-schema.md +87 -0
  45. package/core/skills/specline-pipeline/templates/subagent-prompts.md +397 -0
  46. package/core/skills/specline-propose/SKILL.md +186 -0
  47. package/core/skills/specline-quickfix/SKILL.md +289 -0
  48. package/core/templates/AGENTS.md.hbs +5 -0
  49. package/core/templates/specline/config.yaml +15 -0
  50. package/lib/deploy-claude.mjs +80 -0
  51. package/lib/deploy-codex.mjs +77 -0
  52. package/lib/deploy-opencode.mjs +93 -0
  53. package/lib/deploy.mjs +668 -0
  54. package/lib/gate.mjs +103 -0
  55. package/lib/hash.mjs +13 -0
  56. package/lib/hook.mjs +105 -0
  57. package/lib/init.mjs +122 -0
  58. package/lib/lock.mjs +99 -0
  59. package/lib/merge.mjs +188 -0
  60. package/lib/paths.mjs +40 -0
  61. package/lib/platforms.mjs +74 -0
  62. package/lib/render-agents.mjs +88 -0
  63. package/lib/render.mjs +126 -0
  64. package/lib/sync.mjs +253 -0
  65. package/lib/tty-select.mjs +89 -0
  66. package/package.json +4 -1
  67. package/templates/.cursor/README.md +18 -0
  68. package/templates/.cursor/agents/specline-code-reviewer.md +18 -2
  69. package/templates/.cursor/agents/specline-spec-creator.md +51 -2
  70. package/templates/.cursor/agents/specline-test-runner.md +10 -1
  71. package/templates/.cursor/agents/specline-test-writer.md +58 -7
  72. package/templates/.cursor/hooks/specline-pipeline-gate-checks/a2-a3-reverse.sh +1 -1
  73. package/templates/.cursor/hooks/specline-pipeline-gate.sh +118 -0
  74. package/templates/.cursor/skills/specline-pipeline/SKILL.md +10 -4
  75. package/templates/.cursor/skills/specline-propose/SKILL.md +3 -3
@@ -0,0 +1,1456 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # specline-pipeline-gate.sh — 确定性门禁脚本(零 LLM 参与)
4
+ #
5
+ # Usage:
6
+ # specline-pipeline-gate.sh <phase> --change <change-name>
7
+ #
8
+ # Phases:
9
+ # new | list | artifacts | spec | semantic | build | lint | test-unit | test-integration | test-e2e | detect-modules | bind | archive | status
10
+ #
11
+ # Exit codes:
12
+ # 0 = 通过
13
+ # 1 = 失败
14
+ # 2 = 输入参数错误
15
+
16
+ set -euo pipefail
17
+
18
+ PHASE="${1:-}"
19
+ CHANGE=""
20
+ EXECUTE_ARCHIVE=""
21
+ POSITIONAL_ARGS=()
22
+
23
+ # 遍历所有参数,不依赖位置
24
+ shift # 跳过 PHASE
25
+ while [ $# -gt 0 ]; do
26
+ case "$1" in
27
+ --change)
28
+ CHANGE="$2"
29
+ shift 2
30
+ ;;
31
+ --execute)
32
+ EXECUTE_ARCHIVE="--execute"
33
+ shift
34
+ ;;
35
+ *)
36
+ POSITIONAL_ARGS+=("$1")
37
+ shift
38
+ ;;
39
+ esac
40
+ done
41
+ # ===== 项目根目录 =====
42
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
43
+ PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
44
+
45
+ # ===== 状态文件 =====
46
+ if [ -n "$CHANGE" ]; then
47
+ STATE_FILE="$PROJECT_ROOT/specline/changes/$CHANGE/.pipeline-state.json"
48
+ else
49
+ STATE_FILE=""
50
+ fi
51
+
52
+ # ===== 辅助函数 =====
53
+ now_iso8601() {
54
+ date -u +"%Y-%m-%dT%H:%M:%SZ"
55
+ }
56
+
57
+ write_gate_passed() {
58
+ local gate_path="$1" # e.g., "phases.spec.gates.spec_gate"
59
+ if [ -n "$STATE_FILE" ] && [ -f "$STATE_FILE" ]; then
60
+ local time
61
+ time=$(now_iso8601)
62
+ jq --arg time "$time" \
63
+ ".updated_at = \$time | .${gate_path} = { \"passed\": true, \"run_at\": \$time }" \
64
+ "$STATE_FILE" > "$STATE_FILE.tmp" && mv "$STATE_FILE.tmp" "$STATE_FILE"
65
+ fi
66
+ }
67
+
68
+ fail() {
69
+ echo "❌ $1" >&2
70
+ exit 1
71
+ }
72
+
73
+ pass() {
74
+ echo "✅ $1"
75
+ }
76
+
77
+ # ===== 获取 Spec 文件路径 =====
78
+ find_spec_file() {
79
+ if [ -z "$CHANGE" ]; then
80
+ echo ""
81
+ return
82
+ fi
83
+ find "$PROJECT_ROOT/specline/changes/$CHANGE/specs" -name "spec.md" 2>/dev/null | head -1
84
+ }
85
+
86
+ MODULES_JSON=""
87
+
88
+ detect_project_modules() {
89
+ # 扫描 maxdepth 2 的语言标记文件,输出 JSON 数组
90
+ # 格式: [{"path":"backend/","language":"go"},{"path":"frontend/","language":"typescript"}]
91
+ # 排除: node_modules/, .git/, vendor/, dist/
92
+
93
+ local modules='[]'
94
+
95
+ while IFS= read -r marker; do
96
+ [ -z "$marker" ] && continue
97
+ local dir
98
+ dir=$(dirname "$marker")
99
+ local rel_dir="${dir#$PROJECT_ROOT/}"
100
+ [ "$rel_dir" = "$dir" ] && rel_dir="."
101
+ [ "$rel_dir" != "." ] && rel_dir="${rel_dir}/"
102
+
103
+ case "$(basename "$marker")" in
104
+ go.mod)
105
+ modules=$(echo "$modules" | jq --arg p "$rel_dir" '. + [{"path":$p,"language":"go"}]')
106
+ ;;
107
+ Cargo.toml)
108
+ modules=$(echo "$modules" | jq --arg p "$rel_dir" '. + [{"path":$p,"language":"rust"}]')
109
+ ;;
110
+ pyproject.toml|setup.cfg|requirements.txt)
111
+ local exists
112
+ exists=$(echo "$modules" | jq --arg p "$rel_dir" '[.[] | select(.path == $p and .language == "python")] | length')
113
+ if [ "$exists" = "0" ]; then
114
+ modules=$(echo "$modules" | jq --arg p "$rel_dir" '. + [{"path":$p,"language":"python"}]')
115
+ fi
116
+ ;;
117
+ package.json)
118
+ if ! grep -q '"workspaces"' "$marker" 2>/dev/null; then
119
+ local lang="javascript"
120
+ [ -f "${dir}/tsconfig.json" ] && lang="typescript"
121
+ modules=$(echo "$modules" | jq --arg p "$rel_dir" --arg l "$lang" '. + [{"path":$p,"language":$l}]')
122
+ fi
123
+ ;;
124
+ pom.xml)
125
+ modules=$(echo "$modules" | jq --arg p "$rel_dir" '. + [{"path":$p,"language":"java"}]')
126
+ ;;
127
+ build.gradle|build.gradle.kts)
128
+ local exists
129
+ exists=$(echo "$modules" | jq --arg p "$rel_dir" '[.[] | select(.path == $p)] | length')
130
+ if [ "$exists" = "0" ]; then
131
+ modules=$(echo "$modules" | jq --arg p "$rel_dir" '. + [{"path":$p,"language":"kotlin"}]')
132
+ fi
133
+ ;;
134
+ esac
135
+ done < <(find "$PROJECT_ROOT" -maxdepth 2 \
136
+ \( -name "go.mod" -o -name "package.json" -o -name "Cargo.toml" \
137
+ -o -name "pyproject.toml" -o -name "setup.cfg" -o -name "requirements.txt" \
138
+ -o -name "pom.xml" -o -name "build.gradle" -o -name "build.gradle.kts" \) \
139
+ -not -path "*/node_modules/*" -not -path "*/.git/*" \
140
+ -not -path "*/vendor/*" -not -path "*/dist/*" -not -path "*/.tmp*/*" -not -path "*/.tmp-*" 2>/dev/null)
141
+
142
+ echo "$modules"
143
+ }
144
+
145
+ load_project_config() {
146
+ local config_file="$PROJECT_ROOT/specline/config.yaml"
147
+ MODULES_JSON=""
148
+
149
+ if [ -f "$config_file" ] && grep -q '^project:' "$config_file" 2>/dev/null; then
150
+ if grep -q '^ - path:' "$config_file" 2>/dev/null; then
151
+ MODULES_JSON=$(awk '
152
+ /^ modules:/ { in_modules=1; next }
153
+ /^ [a-z]/ && !/^ / { in_modules=0 }
154
+ /^[a-z]/ { in_modules=0 }
155
+ in_modules && /^ - path:/ {
156
+ if (path != "") printf ","
157
+ path=$NF; gsub(/["'\'']/, "", path)
158
+ printf "{\"path\":\"%s\"", path
159
+ }
160
+ in_modules && /^ language:/ {
161
+ lang=$NF; gsub(/["'\'']/, "", lang)
162
+ printf ",\"language\":\"%s\"}", lang
163
+ }
164
+ ' "$config_file")
165
+ if [ -n "$MODULES_JSON" ]; then
166
+ MODULES_JSON="[${MODULES_JSON}]"
167
+ fi
168
+ fi
169
+ fi
170
+
171
+ if [ -z "$MODULES_JSON" ] || [ "$MODULES_JSON" = "[]" ]; then
172
+ MODULES_JSON=$(detect_project_modules)
173
+ fi
174
+ }
175
+
176
+ module_absolute_path() {
177
+ local rel_path="$1"
178
+ if [ "$rel_path" = "." ] || [ "$rel_path" = "./" ]; then
179
+ echo "$PROJECT_ROOT"
180
+ else
181
+ echo "$PROJECT_ROOT/${rel_path%/}"
182
+ fi
183
+ }
184
+
185
+ module_has_eslint_config() {
186
+ local mod_dir="$1"
187
+ local f
188
+ for f in eslint.config.js eslint.config.mjs eslint.config.cjs .eslintrc.js .eslintrc.cjs .eslintrc.json; do
189
+ [ -f "$mod_dir/$f" ] && return 0
190
+ done
191
+ return 1
192
+ }
193
+
194
+ build_module() {
195
+ local rel_path="$1"
196
+ local lang="$2"
197
+ local mod_dir
198
+ mod_dir=$(module_absolute_path "$rel_path")
199
+
200
+ case "$lang" in
201
+ go)
202
+ echo "正在 build Go 模块: $rel_path"
203
+ if ! (cd "$mod_dir" && go build ./...); then
204
+ fail "Go build 失败 ($rel_path)"
205
+ fi
206
+ ;;
207
+ typescript)
208
+ echo "正在 build TypeScript 模块: $rel_path"
209
+ if ! (cd "$mod_dir" && npx tsc --noEmit); then
210
+ fail "TypeScript 编译失败 ($rel_path)"
211
+ fi
212
+ ;;
213
+ javascript)
214
+ echo "ℹ️ JavaScript 模块 $rel_path 无 compile 步骤,跳过 build"
215
+ ;;
216
+ python)
217
+ echo "正在检查 Python 语法: $rel_path"
218
+ if ! (cd "$mod_dir" && python3 -m compileall -q .); then
219
+ fail "Python 语法错误 ($rel_path)"
220
+ fi
221
+ ;;
222
+ rust)
223
+ echo "正在 build Rust 模块: $rel_path"
224
+ if ! (cd "$mod_dir" && cargo build); then
225
+ fail "Rust build 失败 ($rel_path)"
226
+ fi
227
+ ;;
228
+ java)
229
+ echo "正在 build Java 模块: $rel_path"
230
+ if ! (cd "$mod_dir" && mvn compile -q); then
231
+ fail "Java build 失败 ($rel_path)"
232
+ fi
233
+ ;;
234
+ kotlin)
235
+ echo "正在 build Kotlin 模块: $rel_path"
236
+ if [ -f "$mod_dir/build.gradle.kts" ] || [ -f "$mod_dir/build.gradle" ]; then
237
+ if ! (cd "$mod_dir" && ./gradlew compileKotlin -q 2>/dev/null || gradle compileKotlin -q); then
238
+ fail "Kotlin build 失败 ($rel_path)"
239
+ fi
240
+ fi
241
+ ;;
242
+ *)
243
+ echo "⚠️ 未知语言 '$lang' ($rel_path),跳过 build"
244
+ ;;
245
+ esac
246
+ }
247
+
248
+ lint_module() {
249
+ local rel_path="$1"
250
+ local lang="$2"
251
+ local mod_dir
252
+ mod_dir=$(module_absolute_path "$rel_path")
253
+
254
+ case "$lang" in
255
+ go)
256
+ echo "正在 lint Go 模块: $rel_path"
257
+ if command -v go &>/dev/null; then
258
+ if ! (cd "$mod_dir" && go vet ./...); then
259
+ fail "Go vet 失败 ($rel_path)"
260
+ fi
261
+ if command -v golangci-lint &>/dev/null; then
262
+ if ! (cd "$mod_dir" && golangci-lint run ./...); then
263
+ fail "golangci-lint 失败 ($rel_path)"
264
+ fi
265
+ fi
266
+ else
267
+ echo "⚠️ go 未安装,跳过 Go lint ($rel_path)"
268
+ fi
269
+ ;;
270
+ typescript|javascript)
271
+ if module_has_eslint_config "$mod_dir"; then
272
+ echo "正在 lint JS/TS 模块: $rel_path"
273
+ if command -v npx &>/dev/null; then
274
+ if ! (cd "$mod_dir" && npx eslint . --quiet); then
275
+ fail "ESLint 失败 ($rel_path)"
276
+ fi
277
+ fi
278
+ else
279
+ echo "ℹ️ 模块 $rel_path 无 eslint 配置,跳过 JS/TS lint"
280
+ fi
281
+ ;;
282
+ python)
283
+ if command -v ruff &>/dev/null; then
284
+ echo "正在 lint Python 模块: $rel_path"
285
+ if ! (cd "$mod_dir" && ruff check . --quiet); then
286
+ fail "Python lint 失败 ($rel_path)"
287
+ fi
288
+ else
289
+ echo "⚠️ ruff 未安装,跳过 Python lint ($rel_path)"
290
+ fi
291
+ ;;
292
+ rust)
293
+ echo "正在 lint Rust 模块: $rel_path"
294
+ if ! (cd "$mod_dir" && cargo clippy -- -D warnings 2>/dev/null || cargo clippy); then
295
+ fail "Rust clippy 失败 ($rel_path)"
296
+ fi
297
+ ;;
298
+ java|kotlin)
299
+ echo "ℹ️ Java/Kotlin lint 暂未集成 ($rel_path),跳过"
300
+ ;;
301
+ *)
302
+ echo "⚠️ 未知语言 '$lang' ($rel_path),跳过 lint"
303
+ ;;
304
+ esac
305
+ }
306
+
307
+ run_default_unit_test() {
308
+ local rel_path="$1"
309
+ local lang="$2"
310
+ local mod_dir
311
+ mod_dir=$(module_absolute_path "$rel_path")
312
+
313
+ case "$lang" in
314
+ go)
315
+ echo "正在执行 Go 单元测试: $rel_path"
316
+ if ! (cd "$mod_dir" && go test ./...); then
317
+ fail "Go 单元测试失败 ($rel_path)"
318
+ fi
319
+ ;;
320
+ python)
321
+ echo "正在执行 Python 单元测试: $rel_path"
322
+ if ! (cd "$mod_dir" && pytest); then
323
+ fail "Python 单元测试失败 ($rel_path)"
324
+ fi
325
+ ;;
326
+ typescript|javascript)
327
+ if [ -f "$mod_dir/package.json" ]; then
328
+ if grep -q '"vitest"' "$mod_dir/package.json" 2>/dev/null; then
329
+ echo "正在执行 Vitest 单元测试: $rel_path"
330
+ if ! (cd "$mod_dir" && npx vitest run); then
331
+ fail "Vitest 单元测试失败 ($rel_path)"
332
+ fi
333
+ elif grep -q '"jest"' "$mod_dir/package.json" 2>/dev/null; then
334
+ echo "正在执行 Jest 单元测试: $rel_path"
335
+ if ! (cd "$mod_dir" && npx jest); then
336
+ fail "Jest 单元测试失败 ($rel_path)"
337
+ fi
338
+ else
339
+ echo "⚠️ 模块 $rel_path 未检测到 vitest/jest,跳过单元测试"
340
+ fi
341
+ fi
342
+ ;;
343
+ rust)
344
+ echo "正在执行 Rust 单元测试: $rel_path"
345
+ if ! (cd "$mod_dir" && cargo test); then
346
+ fail "Rust 单元测试失败 ($rel_path)"
347
+ fi
348
+ ;;
349
+ java)
350
+ echo "正在执行 Java 单元测试: $rel_path"
351
+ if [ -f "$mod_dir/pom.xml" ]; then
352
+ if ! (cd "$mod_dir" && mvn test -q); then
353
+ fail "Java 单元测试失败 ($rel_path)"
354
+ fi
355
+ fi
356
+ ;;
357
+ kotlin)
358
+ echo "正在执行 Kotlin 单元测试: $rel_path"
359
+ if [ -f "$mod_dir/build.gradle.kts" ] || [ -f "$mod_dir/build.gradle" ]; then
360
+ if ! (cd "$mod_dir" && ./gradlew test -q 2>/dev/null || gradle test -q); then
361
+ fail "Kotlin 单元测试失败 ($rel_path)"
362
+ fi
363
+ fi
364
+ ;;
365
+ *)
366
+ echo "⚠️ 未知语言 '$lang' ($rel_path),跳过单元测试"
367
+ ;;
368
+ esac
369
+ }
370
+
371
+ run_default_integration_test() {
372
+ local rel_path="$1"
373
+ local lang="$2"
374
+ local mod_dir
375
+ mod_dir=$(module_absolute_path "$rel_path")
376
+
377
+ case "$lang" in
378
+ go)
379
+ echo "正在执行 Go 集成测试: $rel_path"
380
+ if ! (cd "$mod_dir" && go test ./...); then
381
+ fail "Go 集成测试失败 ($rel_path)"
382
+ fi
383
+ ;;
384
+ python)
385
+ if [ -d "$mod_dir/tests/integration" ]; then
386
+ echo "正在执行 Python 集成测试: $rel_path"
387
+ if ! (cd "$mod_dir" && pytest tests/integration -v); then
388
+ fail "Python 集成测试失败 ($rel_path)"
389
+ fi
390
+ else
391
+ echo "ℹ️ 模块 $rel_path 无 tests/integration/,跳过集成测试"
392
+ fi
393
+ ;;
394
+ typescript|javascript)
395
+ if [ -d "$mod_dir/tests/integration" ]; then
396
+ if grep -q '"vitest"' "$mod_dir/package.json" 2>/dev/null; then
397
+ if ! (cd "$mod_dir" && npx vitest run tests/integration); then
398
+ fail "Vitest 集成测试失败 ($rel_path)"
399
+ fi
400
+ elif grep -q '"jest"' "$mod_dir/package.json" 2>/dev/null; then
401
+ if ! (cd "$mod_dir" && npx jest tests/integration); then
402
+ fail "Jest 集成测试失败 ($rel_path)"
403
+ fi
404
+ fi
405
+ else
406
+ echo "ℹ️ 模块 $rel_path 无 tests/integration/,跳过集成测试"
407
+ fi
408
+ ;;
409
+ rust)
410
+ if [ -d "$mod_dir/tests" ]; then
411
+ if ! (cd "$mod_dir" && cargo test --test '*' 2>/dev/null || cargo test); then
412
+ fail "Rust 集成测试失败 ($rel_path)"
413
+ fi
414
+ fi
415
+ ;;
416
+ *)
417
+ echo "ℹ️ 语言 '$lang' 集成测试默认命令未定义 ($rel_path),跳过"
418
+ ;;
419
+ esac
420
+ }
421
+
422
+ run_default_e2e_test() {
423
+ local rel_path="$1"
424
+ local lang="$2"
425
+ local mod_dir
426
+ mod_dir=$(module_absolute_path "$rel_path")
427
+
428
+ case "$lang" in
429
+ python)
430
+ if [ -d "$mod_dir/tests/e2e" ]; then
431
+ if ! (cd "$mod_dir" && pytest tests/e2e -v); then
432
+ fail "Python E2E 测试失败 ($rel_path)"
433
+ fi
434
+ else
435
+ echo "ℹ️ 模块 $rel_path 无 tests/e2e/,跳过 E2E 测试"
436
+ fi
437
+ ;;
438
+ typescript|javascript)
439
+ if [ -d "$mod_dir/tests/e2e" ] || [ -d "$mod_dir/e2e" ]; then
440
+ local e2e_dir="tests/e2e"
441
+ [ -d "$mod_dir/e2e" ] && e2e_dir="e2e"
442
+ if grep -q '"vitest"' "$mod_dir/package.json" 2>/dev/null; then
443
+ if ! (cd "$mod_dir" && npx vitest run "$e2e_dir"); then
444
+ fail "Vitest E2E 测试失败 ($rel_path)"
445
+ fi
446
+ elif grep -q '"jest"' "$mod_dir/package.json" 2>/dev/null; then
447
+ if ! (cd "$mod_dir" && npx jest "$e2e_dir"); then
448
+ fail "Jest E2E 测试失败 ($rel_path)"
449
+ fi
450
+ fi
451
+ else
452
+ echo "ℹ️ 模块 $rel_path 无 E2E 测试目录,跳过"
453
+ fi
454
+ ;;
455
+ *)
456
+ echo "ℹ️ 语言 '$lang' E2E 测试默认命令未定义 ($rel_path),跳过"
457
+ ;;
458
+ esac
459
+ }
460
+
461
+ verify_test_result_files() {
462
+ local result_file="$1"
463
+ local files_key="$2"
464
+ local missing=""
465
+
466
+ while IFS= read -r f; do
467
+ [ -z "$f" ] && continue
468
+ if [ ! -f "$PROJECT_ROOT/$f" ]; then
469
+ missing="${missing}
470
+ - $f"
471
+ fi
472
+ done < <(jq -r --arg key "$files_key" '.[$key][]?' "$result_file" 2>/dev/null)
473
+
474
+ if [ -n "$missing" ]; then
475
+ fail "${files_key} 中的测试文件不存在:${missing}"
476
+ fi
477
+ }
478
+
479
+ run_tests_by_modules() {
480
+ local test_kind="$1"
481
+ local count
482
+ count=$(echo "$MODULES_JSON" | jq 'length')
483
+
484
+ if [ "$count" -eq 0 ]; then
485
+ echo "⚠️ 未检测到项目模块,跳过${test_kind}测试"
486
+ return 0
487
+ fi
488
+
489
+ local i=0
490
+ while [ "$i" -lt "$count" ]; do
491
+ local path lang
492
+ path=$(echo "$MODULES_JSON" | jq -r ".[$i].path")
493
+ lang=$(echo "$MODULES_JSON" | jq -r ".[$i].language")
494
+
495
+ case "$test_kind" in
496
+ unit) run_default_unit_test "$path" "$lang" ;;
497
+ integration) run_default_integration_test "$path" "$lang" ;;
498
+ e2e) run_default_e2e_test "$path" "$lang" ;;
499
+ esac
500
+
501
+ i=$((i + 1))
502
+ done
503
+ }
504
+
505
+ # ===== Phase Handlers =====
506
+
507
+ gate_new() {
508
+ if [ -z "$CHANGE" ]; then
509
+ fail "需要 --change <name>"
510
+ fi
511
+
512
+ local change_dir="$PROJECT_ROOT/specline/changes/$CHANGE"
513
+
514
+ if [ -d "$change_dir" ]; then
515
+ echo "⚠️ Change '$CHANGE' 已存在"
516
+ exit 0
517
+ fi
518
+
519
+ mkdir -p "$change_dir/specs"
520
+ mkdir -p "$change_dir/.tmp"
521
+
522
+ # 写入 .specline.yaml
523
+ cat > "$change_dir/.specline.yaml" << YAML
524
+ schema: spec-driven
525
+ created: $(date -u +"%Y-%m-%dT%H:%M:%SZ")
526
+ YAML
527
+
528
+ # 初始化 .pipeline-state.json
529
+ cat > "$change_dir/.pipeline-state.json" << 'JSON'
530
+ {
531
+ "version": 1,
532
+ "change_name": "CHANGE_NAME_PLACEHOLDER",
533
+ "created_at": "CREATED_AT_PLACEHOLDER",
534
+ "updated_at": "CREATED_AT_PLACEHOLDER",
535
+ "current_phase": "spec",
536
+ "current_step": "spec-creator",
537
+ "phases": {
538
+ "spec": { "status": "in_progress", "retry_count": 0, "sub_phases": {}, "gates": { "spec_gate": { "passed": null }, "human_gate_1": { "passed": null } } },
539
+ "coding": { "status": "pending", "tasks": [], "sub_phases": {}, "gates": { "build_gate": { "passed": null } } },
540
+ "code_review": { "status": "pending", "retry_count": 0, "gates": { "lint_gate": { "passed": null }, "human_gate_2": { "passed": null } } },
541
+ "test": { "status": "pending", "framework": null, "sub_phases": { "unit": { "status": "pending", "gates": { "test_unit_gate": { "passed": null } } }, "integration": { "status": "pending", "gates": { "test_integration_gate": { "passed": null } } }, "e2e": { "status": "pending", "gates": { "test_e2e_gate": { "passed": null } } } } },
542
+ "archive": { "status": "pending", "gates": { "human_gate_3": { "passed": null }, "archive_gate": { "passed": null } } }
543
+ }
544
+ }
545
+ JSON
546
+
547
+ # 用实际值替换占位符
548
+ local now
549
+ now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
550
+ # 跨平台 sed(macOS/Linux 兼容)
551
+ if [[ "$OSTYPE" == "darwin"* ]]; then
552
+ sed -i '' "s/CHANGE_NAME_PLACEHOLDER/$CHANGE/g" "$change_dir/.pipeline-state.json"
553
+ sed -i '' "s/CREATED_AT_PLACEHOLDER/$now/g" "$change_dir/.pipeline-state.json"
554
+ else
555
+ sed -i "s/CHANGE_NAME_PLACEHOLDER/$CHANGE/g" "$change_dir/.pipeline-state.json"
556
+ sed -i "s/CREATED_AT_PLACEHOLDER/$now/g" "$change_dir/.pipeline-state.json"
557
+ fi
558
+
559
+ echo "✅ Change '$CHANGE' 已创建: $change_dir"
560
+ echo " .specline.yaml + .pipeline-state.json + specs/"
561
+
562
+ write_gate_passed "phases.spec.gates.spec_gate"
563
+ }
564
+
565
+ gate_list() {
566
+ local changes_dir="$PROJECT_ROOT/specline/changes"
567
+ local json_output=false
568
+
569
+ if [ "${1:-}" = "--json" ]; then
570
+ json_output=true
571
+ fi
572
+
573
+ if [ ! -d "$changes_dir" ]; then
574
+ if $json_output; then
575
+ echo '[]'
576
+ else
577
+ echo "(无活跃 change)"
578
+ fi
579
+ exit 0
580
+ fi
581
+
582
+ if $json_output; then
583
+ echo "["
584
+ local first=true
585
+ for f in "$changes_dir"/*/.pipeline-state.json; do
586
+ [ -f "$f" ] || continue
587
+ # 跳过 archive/
588
+ if echo "$f" | grep -q "/archive/"; then continue; fi
589
+ local dir name phase
590
+ dir=$(dirname "$f")
591
+ name=$(basename "$dir")
592
+ phase=$(jq -r '.current_phase // "unknown"' "$f" 2>/dev/null)
593
+ if [ "$first" = true ]; then first=false; else echo ","; fi
594
+ echo " {\"name\":\"$name\",\"phase\":\"$phase\"}"
595
+ done
596
+ echo "]"
597
+ else
598
+ for f in "$changes_dir"/*/.pipeline-state.json; do
599
+ [ -f "$f" ] || continue
600
+ if echo "$f" | grep -q "/archive/"; then continue; fi
601
+ local dir name phase
602
+ dir=$(dirname "$f")
603
+ name=$(basename "$dir")
604
+ phase=$(jq -r '.current_phase // "unknown"' "$f" 2>/dev/null)
605
+ echo " $name (phase: $phase)"
606
+ done
607
+ fi
608
+ }
609
+
610
+ gate_artifacts() {
611
+ if [ -z "$CHANGE" ]; then
612
+ fail "需要 --change <name>"
613
+ fi
614
+
615
+ local dir="$PROJECT_ROOT/specline/changes/$CHANGE"
616
+ local json_output=false
617
+
618
+ if [ "${1:-}" = "--json" ]; then
619
+ json_output=true
620
+ fi
621
+
622
+ local has_proposal=false has_design=false has_tasks=false has_specs=false
623
+
624
+ [ -f "$dir/proposal.md" ] && has_proposal=true
625
+ [ -f "$dir/design.md" ] && has_design=true
626
+ [ -f "$dir/tasks.md" ] && has_tasks=true
627
+ [ -d "$dir/specs" ] && [ -n "$(find "$dir/specs" -name 'spec.md' 2>/dev/null)" ] && has_specs=true
628
+
629
+ if $json_output; then
630
+ echo "{"
631
+ echo " \"proposal\": $has_proposal,"
632
+ echo " \"design\": $has_design,"
633
+ echo " \"tasks\": $has_tasks,"
634
+ echo " \"specs\": $has_specs"
635
+ echo "}"
636
+ else
637
+ echo "Artifacts for '$CHANGE':"
638
+ echo " proposal.md: $has_proposal"
639
+ echo " design.md: $has_design"
640
+ echo " tasks.md: $has_tasks"
641
+ echo " spec.md: $has_specs"
642
+ fi
643
+ }
644
+
645
+ gate_spec() {
646
+ local spec_file
647
+ spec_file=$(find_spec_file)
648
+
649
+ if [ -z "$spec_file" ] || [ ! -f "$spec_file" ]; then
650
+ fail "spec.md 不存在。请确保 spec-creator 已生成 spec 文件。"
651
+ fi
652
+
653
+ # 1. H1 含 "Specification"
654
+ if ! grep -q "^# .* Specification" "$spec_file"; then
655
+ fail "标题格式错误:H1 必须包含 'Specification' 关键词"
656
+ fi
657
+
658
+ # 2. 含 Purpose 章节
659
+ if ! grep -q "^## Purpose" "$spec_file"; then
660
+ fail "缺少 ## Purpose 章节"
661
+ fi
662
+
663
+ # 3. 含 Requirements 章节
664
+ if ! grep -q "^## Requirements" "$spec_file"; then
665
+ fail "缺少 ## Requirements 章节"
666
+ fi
667
+
668
+ # 4. 至少 1 个 Requirement
669
+ local req_count
670
+ req_count=$(grep -c "^### Requirement:" "$spec_file" || echo "0")
671
+ if [ "$req_count" -lt 1 ]; then
672
+ fail "至少需要 1 个 Requirement,当前: $req_count"
673
+ fi
674
+ pass "Requirements 数量: $req_count"
675
+
676
+ # 5. 每个 Requirement 至少 1 个 Scenario(简化检查:Scenario 总数 >= Requirement 总数)
677
+ local scenario_count
678
+ scenario_count=$(grep -c "^#### Scenario:" "$spec_file" || echo "0")
679
+ if [ "$scenario_count" -lt "$req_count" ]; then
680
+ fail "每个 Requirement 至少需要 1 个 Scenario。Requirement: $req_count, Scenario: $scenario_count"
681
+ fi
682
+ pass "Scenario 数量: $scenario_count"
683
+
684
+ # 6. WHEN/THEN 语义检查(每个 Scenario 至少 1 WHEN + 1 THEN)
685
+ local bad_scenarios=""
686
+ local current_scenario="" has_when=0 has_then=0
687
+
688
+ while IFS= read -r line; do
689
+ if [[ "$line" =~ ^####\ Scenario: ]]; then
690
+ if [ -n "$current_scenario" ] && { [ "$has_when" -eq 0 ] || [ "$has_then" -eq 0 ]; }; then
691
+ bad_scenarios="${bad_scenarios}\n - ${current_scenario} (WHEN=${has_when}, THEN=${has_then})"
692
+ fi
693
+ current_scenario="${line#*Scenario: }"
694
+ has_when=0; has_then=0
695
+ fi
696
+ [[ "$line" == *'**WHEN**'* ]] && ((has_when++)) || true
697
+ [[ "$line" == *'**THEN**'* ]] && ((has_then++)) || true
698
+ done < "$spec_file"
699
+
700
+ if [ -n "$current_scenario" ] && { [ "$has_when" -eq 0 ] || [ "$has_then" -eq 0 ]; }; then
701
+ bad_scenarios="${bad_scenarios}\n - ${current_scenario} (WHEN=${has_when}, THEN=${has_then})"
702
+ fi
703
+
704
+ if [ -n "$bad_scenarios" ]; then
705
+ fail "以下 Scenario 缺少 WHEN 或 THEN:${bad_scenarios}"
706
+ fi
707
+ pass "WHEN/THEN 语义检查通过 (每个 Scenario 至少 1 WHEN + 1 THEN)"
708
+
709
+ # 7. review.json 状态检查(如果存在)
710
+ local review_file
711
+ review_file="$(dirname "$spec_file")/spec-review.json"
712
+ if [ -f "$review_file" ]; then
713
+ local review_status
714
+ review_status=$(jq -r '.status' "$review_file" 2>/dev/null || echo "missing")
715
+ if [ "$review_status" != "approved" ]; then
716
+ fail "spec-review.json 审核未通过 (status: $review_status)"
717
+ fi
718
+ pass "审核状态: approved"
719
+
720
+ # 7b. 检查 coverage(所有 Requirement 和 Scenario 被 task 的 Covers 覆盖)
721
+ local cov_req_total cov_req_covered
722
+ cov_req_total=$(jq -r '.coverage.requirements_total' "$review_file" 2>/dev/null || echo "0")
723
+ cov_req_covered=$(jq -r '.coverage.requirements_covered' "$review_file" 2>/dev/null || echo "0")
724
+ if [ "$cov_req_covered" -lt "$cov_req_total" ]; then
725
+ fail "Requirement 覆盖不全: $cov_req_covered/$cov_req_total"
726
+ fi
727
+ pass "Requirement 覆盖率: $cov_req_covered/$cov_req_total"
728
+ else
729
+ pass "审核状态: 无 spec-review.json(跳过审核检查)"
730
+ fi
731
+
732
+ # 8. 检查 tasks.md 是否存在且含完整的 Type/Depends/Covers/Files 标注
733
+ local tasks_file="$PROJECT_ROOT/specline/changes/$CHANGE/tasks.md"
734
+ if [ ! -f "$tasks_file" ]; then
735
+ fail "tasks.md 不存在"
736
+ fi
737
+ pass "tasks.md 存在"
738
+
739
+ # 9. 检查每个任务标注完整性
740
+ local task_count type_count deps_count covers_count files_count
741
+ task_count=$(grep -c '^## ' "$tasks_file" || echo "0")
742
+ type_count=$(grep -c '\*\*Type\*\*:' "$tasks_file" || echo "0")
743
+ deps_count=$(grep -c '\*\*Depends\*\*:' "$tasks_file" || echo "0")
744
+ covers_count=$(grep -c '\*\*Covers\*\*:' "$tasks_file" || echo "0")
745
+ files_count=$(grep -c '\*\*Files\*\*:' "$tasks_file" || echo "0")
746
+
747
+ if [ "$type_count" -lt "$task_count" ] || [ "$deps_count" -lt "$task_count" ] || \
748
+ [ "$covers_count" -lt "$task_count" ] || [ "$files_count" -lt "$task_count" ]; then
749
+ fail "tasks.md 标注不完整:任务=$task_count, Type=$type_count, Depends=$deps_count, Covers=$covers_count, Files=$files_count"
750
+ fi
751
+ pass "tasks.md 标注完整性检查通过 ($task_count 个任务)"
752
+
753
+ # 10. Testable 字段校验
754
+ local testable_count
755
+ testable_count=$(grep -c '\*\*Testable\*\*:' "$tasks_file" || echo "0")
756
+
757
+ if [ "$testable_count" -eq 0 ]; then
758
+ echo "⚠️ Testable 标注缺失(向后兼容模式:缺失字段的任务将被视为 Testable: false)"
759
+ elif [ "$testable_count" -gt 0 ] && [ "$testable_count" -lt "$task_count" ]; then
760
+ local missing_testable_tasks
761
+ missing_testable_tasks=$(awk '
762
+ /^## / {
763
+ if (in_task && !has_testable) missing = missing (missing ? ", " : "") prev_task
764
+ prev_task = $2; gsub(/\..*/, "", prev_task)
765
+ in_task = 1; has_testable = 0
766
+ }
767
+ /\*\*Testable\*\*:/ { has_testable = 1 }
768
+ END {
769
+ if (in_task && !has_testable) missing = missing (missing ? ", " : "") prev_task
770
+ print missing
771
+ }' "$tasks_file")
772
+ echo "⚠️ Testable 标注不完整:任务=$task_count, Testable=$testable_count(缺失任务: $missing_testable_tasks;缺失字段的任务将被视为 Testable: false)"
773
+ else
774
+ pass "Testable 标注完整性检查通过 ($testable_count/$task_count)"
775
+ fi
776
+
777
+ # 11. 至少 1 个任务无依赖
778
+ local independent_count
779
+ independent_count=$(grep -c '\*\*Depends\*\*: (none)' "$tasks_file" || echo "0")
780
+ if [ "$independent_count" -lt 1 ]; then
781
+ fail "至少需要 1 个无依赖任务 (Depends: none),当前: $independent_count"
782
+ fi
783
+ pass "无依赖任务数: $independent_count"
784
+
785
+ write_gate_passed "phases.spec.gates.spec_gate"
786
+ pass "Spec Gate 全部通过"
787
+ }
788
+
789
+ gate_build() {
790
+ load_project_config
791
+
792
+ local module_count
793
+ module_count=$(echo "$MODULES_JSON" | jq 'length')
794
+
795
+ if [ "$module_count" -eq 0 ]; then
796
+ echo "⚠️ 未检测到项目模块,跳过 build 命令"
797
+ else
798
+ local i=0
799
+ while [ "$i" -lt "$module_count" ]; do
800
+ local path lang
801
+ path=$(echo "$MODULES_JSON" | jq -r ".[$i].path")
802
+ lang=$(echo "$MODULES_JSON" | jq -r ".[$i].language")
803
+ build_module "$path" "$lang"
804
+ i=$((i + 1))
805
+ done
806
+ pass "模块 build 检查通过 ($module_count 个模块)"
807
+ fi
808
+
809
+ # Agent 产出 JSON 验证(task-result.json files_changed / files)
810
+ if [ -n "$CHANGE" ]; then
811
+ local task_result="$PROJECT_ROOT/specline/changes/$CHANGE/.tmp/task-result.json"
812
+ if [ -f "$task_result" ]; then
813
+ echo "正在验证 task-result.json 声明的文件..."
814
+ local missing_files=""
815
+ while IFS= read -r f; do
816
+ [ -z "$f" ] && continue
817
+ if [ ! -f "$PROJECT_ROOT/$f" ]; then
818
+ missing_files="${missing_files}
819
+ - $f"
820
+ fi
821
+ done < <(jq -r '(.files_changed // .files // [])[]?' "$task_result" 2>/dev/null)
822
+
823
+ if [ -n "$missing_files" ]; then
824
+ fail "task-result.json 声明的文件不存在:${missing_files}"
825
+ fi
826
+ pass "task-result.json 文件验证通过"
827
+ fi
828
+ fi
829
+
830
+ # 单元测试文件存在性检查(Testable=true 任务)
831
+ local tasks_file="$PROJECT_ROOT/specline/changes/$CHANGE/tasks.md"
832
+ if [ -f "$tasks_file" ]; then
833
+ local testable_true_count
834
+ testable_true_count=$(grep -c '\*\*Testable\*\*:.*true' "$tasks_file" || echo "0")
835
+
836
+ if [ "$testable_true_count" -gt 0 ]; then
837
+ echo "正在检查 $testable_true_count 个 Testable=true 任务的单元测试文件..."
838
+
839
+ local missing_files=""
840
+ local syntax_errors=""
841
+
842
+ while IFS='|' read -r task_id file_path; do
843
+ if [ -z "$file_path" ]; then
844
+ missing_files="${missing_files}
845
+ 任务 $task_id: 未在 Files 列表中声明测试文件(支持 tests/unit/、tests/models/、*_test.go、*.test.ts 等)"
846
+ continue
847
+ fi
848
+
849
+ if [ ! -f "$PROJECT_ROOT/$file_path" ]; then
850
+ missing_files="${missing_files}
851
+ 任务 $task_id: $file_path"
852
+ continue
853
+ fi
854
+
855
+ case "$file_path" in
856
+ *.py)
857
+ if ! python3 -m py_compile "$PROJECT_ROOT/$file_path" 2>&1; then
858
+ syntax_errors="${syntax_errors}
859
+ 任务 $task_id: $file_path (Python 语法错误)"
860
+ fi
861
+ ;;
862
+ *.ts|*.tsx)
863
+ if ! npx tsc --noEmit "$PROJECT_ROOT/$file_path" 2>&1; then
864
+ syntax_errors="${syntax_errors}
865
+ 任务 $task_id: $file_path (TypeScript 语法错误)"
866
+ fi
867
+ ;;
868
+ esac
869
+ done < <(awk '
870
+ /^## / {
871
+ task_id = $2; gsub(/\..*/, "", task_id)
872
+ testable = ""; files_line = ""
873
+ }
874
+ /\*\*Testable\*\*:.*true/ { testable = "true" }
875
+ /\*\*Files\*\*:/ {
876
+ if (testable == "true") {
877
+ files_line = $0
878
+ gsub(/.*\*\*Files\*\*:[ \t]*/, "", files_line)
879
+ split(files_line, paths, /,[ \t]*/)
880
+ has_test = 0
881
+ for (i in paths) {
882
+ gsub(/^[ \t]+|[ \t]+$/, "", paths[i])
883
+ if (paths[i] ~ /^tests\/(unit|models)\// ||
884
+ paths[i] ~ /_test\.go$/ ||
885
+ paths[i] ~ /\.test\.(ts|tsx|js|jsx)$/ ||
886
+ paths[i] ~ /\.spec\.(ts|tsx|js|jsx)$/ ||
887
+ paths[i] ~ /^src\/.*\/tests\.rs$/) {
888
+ print task_id "|" paths[i]
889
+ has_test = 1
890
+ }
891
+ }
892
+ if (has_test == 0) {
893
+ print task_id "|"
894
+ }
895
+ }
896
+ }
897
+ ' "$tasks_file")
898
+
899
+ if [ -n "$missing_files" ]; then
900
+ fail "单元测试文件缺失:${missing_files}"
901
+ fi
902
+
903
+ if [ -n "$syntax_errors" ]; then
904
+ fail "单元测试文件语法错误:${syntax_errors}"
905
+ fi
906
+
907
+ pass "单元测试文件存在性检查通过"
908
+ else
909
+ echo "ℹ️ 无 Testable=true 任务,跳过单元测试文件检查"
910
+ fi
911
+ fi
912
+
913
+ write_gate_passed "phases.coding.gates.build_gate"
914
+ pass "Build Gate 全部通过"
915
+ }
916
+
917
+ gate_lint() {
918
+ load_project_config
919
+
920
+ local module_count
921
+ module_count=$(echo "$MODULES_JSON" | jq 'length')
922
+
923
+ if [ "$module_count" -eq 0 ]; then
924
+ echo "⚠️ 未检测到项目模块,跳过 lint"
925
+ else
926
+ local i=0
927
+ while [ "$i" -lt "$module_count" ]; do
928
+ local path lang
929
+ path=$(echo "$MODULES_JSON" | jq -r ".[$i].path")
930
+ lang=$(echo "$MODULES_JSON" | jq -r ".[$i].language")
931
+ lint_module "$path" "$lang"
932
+ i=$((i + 1))
933
+ done
934
+ pass "模块 lint 检查通过 ($module_count 个模块)"
935
+ fi
936
+
937
+ # code-review.json error 计数(位于 change 的 .tmp/ 目录下)
938
+ local review_file="$PROJECT_ROOT/specline/changes/$CHANGE/.tmp/code-review.json"
939
+ if [ -f "$review_file" ]; then
940
+ local error_count
941
+ error_count=$(jq '[.findings[] | select(.severity=="error")] | length' "$review_file" 2>/dev/null || echo "0")
942
+ if [ "$error_count" -gt 0 ]; then
943
+ fail "code-review.json 中发现 $error_count 个 error,必须修复"
944
+ fi
945
+ pass "Review errors: 0"
946
+ fi
947
+
948
+ write_gate_passed "phases.code_review.gates.lint_gate"
949
+ pass "Lint Gate 全部通过"
950
+ }
951
+
952
+ # ===== 测试框架自动检测 =====
953
+ # 优先级:.pipeline-state.json > test-code-result.json > MODULES_JSON 推导 > 无兜底
954
+ detect_test_framework() {
955
+ framework="" test_cmd="" coverage_cmd=""
956
+
957
+ # 1. 先尝试从状态文件读取 test-writer 的检测结果
958
+ if [ -f "$STATE_FILE" ]; then
959
+ local recorded
960
+ recorded=$(jq -r '.phases.test.framework // empty' "$STATE_FILE" 2>/dev/null)
961
+ if [ -n "$recorded" ]; then
962
+ framework="$recorded"
963
+ fi
964
+ fi
965
+
966
+ # 2. 从 test-code-result.json 读取
967
+ if [ -z "$framework" ] && [ -n "$CHANGE" ]; then
968
+ local test_result="$PROJECT_ROOT/specline/changes/$CHANGE/.tmp/test-code-result.json"
969
+ if [ -f "$test_result" ]; then
970
+ framework=$(jq -r '.test_framework // empty' "$test_result" 2>/dev/null)
971
+ test_cmd=$(jq -r '.test_cmd // empty' "$test_result" 2>/dev/null)
972
+ fi
973
+ fi
974
+
975
+ # 3. 从 MODULES_JSON 推导
976
+ if [ -z "$framework" ]; then
977
+ load_project_config
978
+ local module_count
979
+ module_count=$(echo "$MODULES_JSON" | jq 'length')
980
+ if [ "$module_count" -gt 0 ]; then
981
+ local lang
982
+ lang=$(echo "$MODULES_JSON" | jq -r '.[0].language')
983
+ case "$lang" in
984
+ go) framework="go-test" ;;
985
+ python) framework="pytest" ;;
986
+ rust) framework="cargo-test" ;;
987
+ java) framework="junit" ;;
988
+ kotlin) framework="junit" ;;
989
+ typescript|javascript)
990
+ local mod_path
991
+ mod_path=$(echo "$MODULES_JSON" | jq -r '.[0].path')
992
+ local mod_dir
993
+ mod_dir=$(module_absolute_path "$mod_path")
994
+ if [ -f "$mod_dir/package.json" ]; then
995
+ if grep -q '"vitest"' "$mod_dir/package.json" 2>/dev/null; then
996
+ framework="vitest"
997
+ elif grep -q '"jest"' "$mod_dir/package.json" 2>/dev/null; then
998
+ framework="jest"
999
+ elif grep -q '"mocha"' "$mod_dir/package.json" 2>/dev/null; then
1000
+ framework="mocha"
1001
+ fi
1002
+ fi
1003
+ ;;
1004
+ esac
1005
+ fi
1006
+ fi
1007
+
1008
+ # 4. 无兜底 pytest — 检测失败时 framework 为空
1009
+ if [ -z "$framework" ]; then
1010
+ echo "⚠️ 未检测到测试框架"
1011
+ return 0
1012
+ fi
1013
+
1014
+ # 根据框架确定命令(test_cmd 可能已由 JSON 提供)
1015
+ if [ -z "$test_cmd" ]; then
1016
+ case "$framework" in
1017
+ jest)
1018
+ test_cmd="npx jest"
1019
+ coverage_cmd="npx jest --coverage"
1020
+ ;;
1021
+ vitest)
1022
+ test_cmd="npx vitest run"
1023
+ coverage_cmd="npx vitest run --coverage"
1024
+ ;;
1025
+ mocha)
1026
+ test_cmd="npx mocha"
1027
+ coverage_cmd="npx nyc mocha"
1028
+ ;;
1029
+ go-test)
1030
+ test_cmd="go test ./..."
1031
+ coverage_cmd="go test -cover ./..."
1032
+ ;;
1033
+ cargo-test)
1034
+ test_cmd="cargo test"
1035
+ coverage_cmd="cargo tarpaulin 2>/dev/null || cargo test"
1036
+ ;;
1037
+ junit)
1038
+ local mod_path mod_dir
1039
+ mod_path=$(echo "$MODULES_JSON" | jq -r '.[0].path // "."')
1040
+ mod_dir=$(module_absolute_path "$mod_path")
1041
+ if [ -f "$mod_dir/pom.xml" ]; then
1042
+ test_cmd="mvn test"
1043
+ coverage_cmd="mvn jacoco:report"
1044
+ else
1045
+ test_cmd="gradle test"
1046
+ coverage_cmd="gradle jacocoTestReport"
1047
+ fi
1048
+ ;;
1049
+ pytest)
1050
+ test_cmd="pytest"
1051
+ coverage_cmd="pytest --cov --cov-fail-under=80"
1052
+ ;;
1053
+ *)
1054
+ test_cmd=""
1055
+ coverage_cmd=""
1056
+ ;;
1057
+ esac
1058
+ fi
1059
+
1060
+ if [ -n "$framework" ] && [ -n "$test_cmd" ]; then
1061
+ echo "检测到测试框架: $framework (命令: $test_cmd)"
1062
+ fi
1063
+ }
1064
+
1065
+ gate_test_unit() {
1066
+ echo "正在执行单元测试..."
1067
+ load_project_config
1068
+
1069
+ local test_result=""
1070
+ if [ -n "$CHANGE" ]; then
1071
+ test_result="$PROJECT_ROOT/specline/changes/$CHANGE/.tmp/test-code-result.json"
1072
+ fi
1073
+
1074
+ if [ -n "$test_result" ] && [ -f "$test_result" ]; then
1075
+ local test_cmd
1076
+ test_cmd=$(jq -r '.test_cmd // empty' "$test_result" 2>/dev/null)
1077
+
1078
+ verify_test_result_files "$test_result" "test_files"
1079
+
1080
+ if [ -n "$test_cmd" ]; then
1081
+ echo "执行 Agent 声明的 test_cmd: $test_cmd"
1082
+ if ! (cd "$PROJECT_ROOT" && eval "$test_cmd"); then
1083
+ fail "单元测试失败"
1084
+ fi
1085
+ else
1086
+ echo "⚠️ test-code-result.json 无 test_cmd,回退到模块默认命令"
1087
+ run_tests_by_modules "unit"
1088
+ fi
1089
+ else
1090
+ local module_count
1091
+ module_count=$(echo "$MODULES_JSON" | jq 'length')
1092
+ if [ "$module_count" -eq 0 ]; then
1093
+ echo "⚠️ 未检测到项目模块且无 test-code-result.json,跳过单元测试"
1094
+ write_gate_passed "phases.test.sub_phases.unit.gates.test_unit_gate"
1095
+ pass "单元测试已跳过"
1096
+ return 0
1097
+ fi
1098
+ run_tests_by_modules "unit"
1099
+ fi
1100
+
1101
+ # 覆盖率检查(非阻塞)
1102
+ detect_test_framework
1103
+ if [ -n "${coverage_cmd:-}" ]; then
1104
+ echo "正在检查覆盖率..."
1105
+ if ! (cd "$PROJECT_ROOT" && eval "$coverage_cmd" 2>&1); then
1106
+ echo "⚠️ 覆盖率检查未通过(不阻塞,由 test-runner agent 深入分析)"
1107
+ fi
1108
+ fi
1109
+
1110
+ write_gate_passed "phases.test.sub_phases.unit.gates.test_unit_gate"
1111
+ pass "单元测试通过"
1112
+ }
1113
+
1114
+ gate_test_integration() {
1115
+ echo "正在执行集成测试..."
1116
+ load_project_config
1117
+
1118
+ local test_result=""
1119
+ if [ -n "$CHANGE" ]; then
1120
+ test_result="$PROJECT_ROOT/specline/changes/$CHANGE/.tmp/test-code-result.json"
1121
+ fi
1122
+
1123
+ if [ -n "$test_result" ] && [ -f "$test_result" ]; then
1124
+ local test_cmd
1125
+ test_cmd=$(jq -r '.integration_test_cmd // empty' "$test_result" 2>/dev/null)
1126
+
1127
+ if jq -e '.integration_test_files | length > 0' "$test_result" &>/dev/null; then
1128
+ verify_test_result_files "$test_result" "integration_test_files"
1129
+ fi
1130
+
1131
+ if [ -n "$test_cmd" ]; then
1132
+ echo "执行 Agent 声明的 integration_test_cmd: $test_cmd"
1133
+ if ! (cd "$PROJECT_ROOT" && eval "$test_cmd"); then
1134
+ fail "集成测试失败"
1135
+ fi
1136
+ else
1137
+ run_tests_by_modules "integration"
1138
+ fi
1139
+ else
1140
+ local module_count
1141
+ module_count=$(echo "$MODULES_JSON" | jq 'length')
1142
+ if [ "$module_count" -eq 0 ]; then
1143
+ echo "⚠️ 未检测到项目模块且无 test-code-result.json,跳过集成测试"
1144
+ else
1145
+ run_tests_by_modules "integration"
1146
+ fi
1147
+ fi
1148
+
1149
+ write_gate_passed "phases.test.sub_phases.integration.gates.test_integration_gate"
1150
+ pass "集成测试通过"
1151
+ }
1152
+
1153
+ gate_test_e2e() {
1154
+ echo "正在执行 E2E 测试..."
1155
+ load_project_config
1156
+
1157
+ local test_result=""
1158
+ if [ -n "$CHANGE" ]; then
1159
+ test_result="$PROJECT_ROOT/specline/changes/$CHANGE/.tmp/test-code-result.json"
1160
+ fi
1161
+
1162
+ if [ -n "$test_result" ] && [ -f "$test_result" ]; then
1163
+ local test_cmd
1164
+ test_cmd=$(jq -r '.e2e_test_cmd // empty' "$test_result" 2>/dev/null)
1165
+
1166
+ if jq -e '.e2e_test_files | length > 0' "$test_result" &>/dev/null; then
1167
+ verify_test_result_files "$test_result" "e2e_test_files"
1168
+ fi
1169
+
1170
+ if [ -n "$test_cmd" ]; then
1171
+ echo "执行 Agent 声明的 e2e_test_cmd: $test_cmd"
1172
+ if ! (cd "$PROJECT_ROOT" && eval "$test_cmd"); then
1173
+ fail "E2E 测试失败"
1174
+ fi
1175
+ else
1176
+ run_tests_by_modules "e2e"
1177
+ fi
1178
+ else
1179
+ local module_count
1180
+ module_count=$(echo "$MODULES_JSON" | jq 'length')
1181
+ if [ "$module_count" -eq 0 ]; then
1182
+ echo "⚠️ 未检测到项目模块且无 test-code-result.json,跳过 E2E 测试"
1183
+ else
1184
+ run_tests_by_modules "e2e"
1185
+ fi
1186
+ fi
1187
+
1188
+ write_gate_passed "phases.test.sub_phases.e2e.gates.test_e2e_gate"
1189
+ pass "E2E 测试通过"
1190
+ }
1191
+
1192
+ gate_archive() {
1193
+ if [ -z "$CHANGE" ]; then
1194
+ fail "需要 --change <name>"
1195
+ fi
1196
+
1197
+ # 如果传了 --execute,执行实际归档动作
1198
+ if [ -n "$EXECUTE_ARCHIVE" ]; then
1199
+ local src_dir="$PROJECT_ROOT/specline/changes/$CHANGE"
1200
+ local archive_dir="$PROJECT_ROOT/specline/changes/archive"
1201
+ local date_prefix
1202
+ date_prefix=$(date -u +"%Y-%m-%d")
1203
+ local dest="$archive_dir/${date_prefix}-${CHANGE}"
1204
+
1205
+ if [ ! -d "$src_dir" ]; then
1206
+ fail "Change '$CHANGE' 不存在: $src_dir"
1207
+ fi
1208
+
1209
+ # 检查基本文件
1210
+ if [ ! -f "$src_dir/proposal.md" ]; then
1211
+ fail "缺少 proposal.md"
1212
+ fi
1213
+ if [ ! -f "$src_dir/tasks.md" ]; then
1214
+ fail "缺少 tasks.md"
1215
+ fi
1216
+
1217
+ # 同步 delta specs 到主 specs
1218
+ if [ -d "$src_dir/specs" ]; then
1219
+ echo "正在同步 delta specs 到 specline/specs/..."
1220
+ cp -r "$src_dir/specs/"* "$PROJECT_ROOT/specline/specs/" 2>/dev/null || true
1221
+ fi
1222
+
1223
+ # 移动到归档
1224
+ mkdir -p "$archive_dir"
1225
+ if [ -d "$dest" ]; then
1226
+ fail "归档目标已存在: $dest"
1227
+ fi
1228
+
1229
+ mv "$src_dir" "$dest"
1230
+ echo "✅ 已归档到: $dest"
1231
+
1232
+ # 临时文件(.tmp/)随 change 目录一起归档,无需单独清理
1233
+
1234
+ # 更新状态文件
1235
+ if [ -f "$dest/.pipeline-state.json" ]; then
1236
+ local now
1237
+ now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
1238
+ # macOS sed 兼容
1239
+ sed -i '' "s/\"current_phase\": \"[^\"]*\"/\"current_phase\": \"archived\"/g" "$dest/.pipeline-state.json" 2>/dev/null || true
1240
+ fi
1241
+
1242
+ # 清理所有绑定到该 change 的 session
1243
+ local bindings_file="$PROJECT_ROOT/specline/.pipeline-sessions.json"
1244
+ if [ -f "$bindings_file" ]; then
1245
+ jq --arg change "$CHANGE" \
1246
+ 'with_entries(select(.value.change != $change))' \
1247
+ "$bindings_file" > "${bindings_file}.tmp" && mv "${bindings_file}.tmp" "$bindings_file"
1248
+ echo "✅ 已清理 pipeline '$CHANGE' 的所有 session 绑定"
1249
+ fi
1250
+
1251
+ exit 0
1252
+ fi
1253
+
1254
+ # 验证模式(原有逻辑,路径改为 specline)
1255
+ local archive_dir="$PROJECT_ROOT/specline/changes/archive"
1256
+ local found
1257
+ found=$(find "$archive_dir" -maxdepth 1 -type d -name "*$CHANGE" 2>/dev/null | head -1)
1258
+
1259
+ if [ -z "$found" ]; then
1260
+ fail "归档目录不存在: $archive_dir/*$CHANGE"
1261
+ fi
1262
+
1263
+ if [ ! -f "$found/proposal.md" ]; then
1264
+ fail "归档目录缺少 proposal.md"
1265
+ fi
1266
+ if [ ! -f "$found/tasks.md" ]; then
1267
+ fail "归档目录缺少 tasks.md"
1268
+ fi
1269
+
1270
+ write_gate_passed "phases.archive.gates.archive_gate"
1271
+ pass "Archive Gate 全部通过"
1272
+ }
1273
+
1274
+ gate_status() {
1275
+ if [ ! -f "$STATE_FILE" ]; then
1276
+ echo '{"status":"no_pipeline","message":"未找到流水线状态文件"}'
1277
+ exit 0
1278
+ fi
1279
+
1280
+ jq '{
1281
+ change: .change_name,
1282
+ phase: .current_phase,
1283
+ step: .current_step,
1284
+ tasks: .phases.coding.tasks | map({id: .id, type: .type, status: .status, batch: .batch}),
1285
+ progress: {
1286
+ spec: .phases.spec.status,
1287
+ coding: .phases.coding.status,
1288
+ code_review: .phases.code_review.status,
1289
+ test: .phases.test.status,
1290
+ archive: .phases.archive.status
1291
+ }
1292
+ }' "$STATE_FILE"
1293
+ }
1294
+
1295
+ gate_bind() {
1296
+ local session_id="$1"
1297
+ local target_change="$2"
1298
+
1299
+ if [ -z "$session_id" ] || [ -z "$target_change" ]; then
1300
+ fail "需要 <session_id> <change_name>"
1301
+ fi
1302
+
1303
+ local state_file="$PROJECT_ROOT/specline/changes/$target_change/.pipeline-state.json"
1304
+ if [ ! -f "$state_file" ]; then
1305
+ fail "Change '$target_change' 不存在"
1306
+ fi
1307
+
1308
+ local bindings_file="$PROJECT_ROOT/specline/.pipeline-sessions.json"
1309
+ [ ! -f "$bindings_file" ] && echo '{}' > "$bindings_file"
1310
+
1311
+ local now
1312
+ now=$(now_iso8601)
1313
+ jq --arg sid "$session_id" --arg change "$target_change" --arg time "$now" \
1314
+ '.[$sid] = {"change": $change, "bound_at": $time}' \
1315
+ "$bindings_file" > "${bindings_file}.tmp" && mv "${bindings_file}.tmp" "$bindings_file"
1316
+
1317
+ echo "✅ 已绑定 session '$session_id' → pipeline '$target_change'"
1318
+ }
1319
+
1320
+ # ===== Semantic Gate — 跨文件语义检查 =====
1321
+ gate_semantic() {
1322
+ if [ -z "$CHANGE" ]; then
1323
+ fail "需要 --change <name>"
1324
+ fi
1325
+
1326
+ # 定位 spec.md 和 tasks.md
1327
+ local spec_file tasks_file
1328
+ spec_file=$(find_spec_file)
1329
+ tasks_file="$PROJECT_ROOT/specline/changes/$CHANGE/tasks.md"
1330
+
1331
+ if [ ! -f "$spec_file" ] || [ ! -f "$tasks_file" ]; then
1332
+ fail "spec.md 或 tasks.md 不存在,无法执行语义检查"
1333
+ fi
1334
+
1335
+ local checks_dir="$SCRIPT_DIR/pipeline-gate-checks"
1336
+ local common_sh="$checks_dir/common.sh"
1337
+
1338
+ if [ ! -f "$common_sh" ]; then
1339
+ fail "common.sh 不存在: $common_sh"
1340
+ fi
1341
+
1342
+ # source common.sh 初始化计数器
1343
+ source "$common_sh"
1344
+
1345
+ # 设定文件路径环境变量,供各检查脚本使用
1346
+ export SPEC_FILE="$spec_file"
1347
+ export TASKS_FILE="$tasks_file"
1348
+
1349
+ # 依次执行 6 项语义检查
1350
+ local check_scripts=(
1351
+ "a1-covers-ref.sh"
1352
+ "d1-cycle.sh"
1353
+ "c1-exception.sh"
1354
+ "c2-vague.sh"
1355
+ "a2-a3-reverse.sh"
1356
+ "d3-type-file.sh"
1357
+ )
1358
+
1359
+ local check_functions=(
1360
+ "run_a1_covers_ref"
1361
+ "run_d1_cycle"
1362
+ "run_c1_exception"
1363
+ "run_c2_vague"
1364
+ "run_a2_a3_reverse"
1365
+ "run_d3_type_file"
1366
+ )
1367
+
1368
+ local i=0
1369
+ for script in "${check_scripts[@]}"; do
1370
+ local script_path="$checks_dir/$script"
1371
+ if [ -f "$script_path" ]; then
1372
+ source "$script_path"
1373
+ if declare -f "${check_functions[$i]}" > /dev/null 2>&1; then
1374
+ "${check_functions[$i]}"
1375
+ fi
1376
+ else
1377
+ echo "⚠️ 检查脚本不存在,跳过: $script"
1378
+ fi
1379
+ i=$((i + 1))
1380
+ done
1381
+
1382
+ # 汇总结果
1383
+ local total_issues=$((SEMANTIC_ERRORS + SEMANTIC_WARNINGS + SEMANTIC_INFOS))
1384
+
1385
+ echo ""
1386
+ echo "========== Semantic Gate 汇总 =========="
1387
+ echo " ❌ 错误: $SEMANTIC_ERRORS"
1388
+ echo " ⚠️ 警告: $SEMANTIC_WARNINGS"
1389
+ echo " ℹ️ 信息: $SEMANTIC_INFOS"
1390
+ echo " 总计: $total_issues"
1391
+ echo "========================================="
1392
+
1393
+ if [ "$SEMANTIC_ERRORS" -gt 0 ]; then
1394
+ echo ""
1395
+ echo "❌ Semantic Gate 未通过:发现 $SEMANTIC_ERRORS 个错误"
1396
+ exit 1
1397
+ fi
1398
+
1399
+ write_gate_passed "phases.spec.gates.semantic_gate"
1400
+ pass "✅ Semantic Gate 全部通过"
1401
+ }
1402
+
1403
+ # ===== 分派 =====
1404
+
1405
+ case "$PHASE" in
1406
+ new)
1407
+ gate_new
1408
+ ;;
1409
+ list)
1410
+ gate_list "$@"
1411
+ ;;
1412
+ artifacts)
1413
+ gate_artifacts "$@"
1414
+ ;;
1415
+ spec)
1416
+ gate_spec
1417
+ ;;
1418
+ semantic)
1419
+ gate_semantic "$@"
1420
+ ;;
1421
+ build)
1422
+ gate_build
1423
+ ;;
1424
+ lint)
1425
+ gate_lint
1426
+ ;;
1427
+ test-unit)
1428
+ gate_test_unit
1429
+ ;;
1430
+ test-integration)
1431
+ gate_test_integration
1432
+ ;;
1433
+ test-e2e)
1434
+ gate_test_e2e
1435
+ ;;
1436
+ bind)
1437
+ gate_bind "${POSITIONAL_ARGS[0]:-}" "${POSITIONAL_ARGS[1]:-}"
1438
+ ;;
1439
+ detect-modules)
1440
+ load_project_config
1441
+ echo "$MODULES_JSON"
1442
+ ;;
1443
+ archive)
1444
+ gate_archive "$@"
1445
+ ;;
1446
+ status)
1447
+ gate_status
1448
+ ;;
1449
+ *)
1450
+ echo "未知 phase: $PHASE"
1451
+ echo "可用: new | list | artifacts | spec | semantic | build | lint | test-unit | test-integration | test-e2e | detect-modules | bind | archive | status"
1452
+ exit 2
1453
+ ;;
1454
+ esac
1455
+
1456
+ exit 0