specline 1.0.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/LICENSE +21 -0
- package/README.md +161 -0
- package/cli.mjs +169 -0
- package/package.json +30 -0
- package/templates/.cursor/agents/specline-backend-dev.md +47 -0
- package/templates/.cursor/agents/specline-code-reviewer.md +51 -0
- package/templates/.cursor/agents/specline-frontend-dev.md +47 -0
- package/templates/.cursor/agents/specline-spec-creator.md +216 -0
- package/templates/.cursor/agents/specline-spec-reviewer.md +115 -0
- package/templates/.cursor/agents/specline-test-runner.md +98 -0
- package/templates/.cursor/agents/specline-test-writer.md +119 -0
- package/templates/.cursor/commands/specline-explore.md +173 -0
- package/templates/.cursor/commands/specline-pipeline.md +22 -0
- package/templates/.cursor/hooks/specline-agent-guard.sh +15 -0
- package/templates/.cursor/hooks/specline-auto-format.sh +12 -0
- package/templates/.cursor/hooks/specline-pipeline-gate.sh +682 -0
- package/templates/.cursor/hooks/specline-shell-guard.sh +18 -0
- package/templates/.cursor/hooks.json +25 -0
- package/templates/.cursor/skills/specline-apply-change/SKILL.md +140 -0
- package/templates/.cursor/skills/specline-archive-change/SKILL.md +114 -0
- package/templates/.cursor/skills/specline-explore/SKILL.md +288 -0
- package/templates/.cursor/skills/specline-pipeline/SKILL.md +674 -0
- package/templates/.cursor/skills/specline-propose/SKILL.md +79 -0
- package/templates/.specline-config.yaml +1 -0
- package/templates/specline/config.yaml +20 -0
|
@@ -0,0 +1,682 @@
|
|
|
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 | build | lint | test-unit | test-integration | test-e2e | archive | status
|
|
10
|
+
#
|
|
11
|
+
# Exit codes:
|
|
12
|
+
# 0 = 通过
|
|
13
|
+
# 1 = 失败
|
|
14
|
+
# 2 = 输入参数错误
|
|
15
|
+
|
|
16
|
+
set -euo pipefail
|
|
17
|
+
|
|
18
|
+
# ===== 参数解析 =====
|
|
19
|
+
PHASE="${1:-}"
|
|
20
|
+
CHANGE=""
|
|
21
|
+
if [ "$#" -ge 3 ] && [ "$2" = "--change" ]; then
|
|
22
|
+
CHANGE="$3"
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
if [ -z "$PHASE" ]; then
|
|
26
|
+
echo "Usage: specline-pipeline-gate.sh <phase> --change <change-name>"
|
|
27
|
+
echo "Phases: new | list | artifacts | spec | build | lint | test-unit | test-integration | test-e2e | archive | status"
|
|
28
|
+
exit 2
|
|
29
|
+
fi
|
|
30
|
+
|
|
31
|
+
# ===== 项目根目录 =====
|
|
32
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
33
|
+
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|
34
|
+
|
|
35
|
+
# ===== 状态文件 =====
|
|
36
|
+
if [ -n "$CHANGE" ]; then
|
|
37
|
+
STATE_FILE="$PROJECT_ROOT/specline/changes/$CHANGE/.pipeline-state.json"
|
|
38
|
+
else
|
|
39
|
+
STATE_FILE=""
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
# ===== 辅助函数 =====
|
|
43
|
+
now_iso8601() {
|
|
44
|
+
date -u +"%Y-%m-%dT%H:%M:%SZ"
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
write_gate_passed() {
|
|
48
|
+
local gate_path="$1" # e.g., "phases.spec.gates.spec_gate"
|
|
49
|
+
if [ -n "$STATE_FILE" ] && [ -f "$STATE_FILE" ]; then
|
|
50
|
+
local time
|
|
51
|
+
time=$(now_iso8601)
|
|
52
|
+
jq --arg time "$time" \
|
|
53
|
+
".updated_at = \$time | .${gate_path} = { \"passed\": true, \"run_at\": \$time }" \
|
|
54
|
+
"$STATE_FILE" > "$STATE_FILE.tmp" && mv "$STATE_FILE.tmp" "$STATE_FILE"
|
|
55
|
+
fi
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
fail() {
|
|
59
|
+
echo "❌ $1" >&2
|
|
60
|
+
exit 1
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
pass() {
|
|
64
|
+
echo "✅ $1"
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# ===== 获取 Spec 文件路径 =====
|
|
68
|
+
find_spec_file() {
|
|
69
|
+
if [ -z "$CHANGE" ]; then
|
|
70
|
+
echo ""
|
|
71
|
+
return
|
|
72
|
+
fi
|
|
73
|
+
find "$PROJECT_ROOT/specline/changes/$CHANGE/specs" -name "spec.md" 2>/dev/null | head -1
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# ===== Phase Handlers =====
|
|
77
|
+
|
|
78
|
+
gate_new() {
|
|
79
|
+
if [ -z "$CHANGE" ]; then
|
|
80
|
+
fail "需要 --change <name>"
|
|
81
|
+
fi
|
|
82
|
+
|
|
83
|
+
local change_dir="$PROJECT_ROOT/specline/changes/$CHANGE"
|
|
84
|
+
|
|
85
|
+
if [ -d "$change_dir" ]; then
|
|
86
|
+
echo "⚠️ Change '$CHANGE' 已存在"
|
|
87
|
+
exit 0
|
|
88
|
+
fi
|
|
89
|
+
|
|
90
|
+
mkdir -p "$change_dir/specs"
|
|
91
|
+
|
|
92
|
+
# 写入 .specline.yaml
|
|
93
|
+
cat > "$change_dir/.specline.yaml" << YAML
|
|
94
|
+
schema: spec-driven
|
|
95
|
+
created: $(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
96
|
+
YAML
|
|
97
|
+
|
|
98
|
+
# 初始化 .pipeline-state.json
|
|
99
|
+
cat > "$change_dir/.pipeline-state.json" << 'JSON'
|
|
100
|
+
{
|
|
101
|
+
"version": 1,
|
|
102
|
+
"change_name": "CHANGE_NAME_PLACEHOLDER",
|
|
103
|
+
"created_at": "CREATED_AT_PLACEHOLDER",
|
|
104
|
+
"updated_at": "CREATED_AT_PLACEHOLDER",
|
|
105
|
+
"current_phase": "spec",
|
|
106
|
+
"current_step": "spec-creator",
|
|
107
|
+
"phases": {
|
|
108
|
+
"spec": { "status": "in_progress", "retry_count": 0, "sub_phases": {}, "gates": { "spec_gate": { "passed": null }, "human_gate_1": { "passed": null } } },
|
|
109
|
+
"coding": { "status": "pending", "tasks": [], "sub_phases": {}, "gates": { "build_gate": { "passed": null } } },
|
|
110
|
+
"code_review": { "status": "pending", "retry_count": 0, "gates": { "lint_gate": { "passed": null }, "human_gate_2": { "passed": null } } },
|
|
111
|
+
"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 } } } } },
|
|
112
|
+
"archive": { "status": "pending", "gates": { "human_gate_3": { "passed": null }, "archive_gate": { "passed": null } } }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
JSON
|
|
116
|
+
|
|
117
|
+
# 用实际值替换占位符
|
|
118
|
+
local now
|
|
119
|
+
now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
120
|
+
# macOS sed 兼容
|
|
121
|
+
sed -i '' "s/CHANGE_NAME_PLACEHOLDER/$CHANGE/g" "$change_dir/.pipeline-state.json"
|
|
122
|
+
sed -i '' "s/CREATED_AT_PLACEHOLDER/$now/g" "$change_dir/.pipeline-state.json"
|
|
123
|
+
|
|
124
|
+
echo "✅ Change '$CHANGE' 已创建: $change_dir"
|
|
125
|
+
echo " .specline.yaml + .pipeline-state.json + specs/"
|
|
126
|
+
|
|
127
|
+
write_gate_passed "phases.spec.gates.spec_gate"
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
gate_list() {
|
|
131
|
+
local changes_dir="$PROJECT_ROOT/specline/changes"
|
|
132
|
+
local json_output=false
|
|
133
|
+
|
|
134
|
+
if [ "${1:-}" = "--json" ]; then
|
|
135
|
+
json_output=true
|
|
136
|
+
fi
|
|
137
|
+
|
|
138
|
+
if [ ! -d "$changes_dir" ]; then
|
|
139
|
+
if $json_output; then
|
|
140
|
+
echo '[]'
|
|
141
|
+
else
|
|
142
|
+
echo "(无活跃 change)"
|
|
143
|
+
fi
|
|
144
|
+
exit 0
|
|
145
|
+
fi
|
|
146
|
+
|
|
147
|
+
if $json_output; then
|
|
148
|
+
echo "["
|
|
149
|
+
local first=true
|
|
150
|
+
for f in "$changes_dir"/*/.pipeline-state.json; do
|
|
151
|
+
[ -f "$f" ] || continue
|
|
152
|
+
# 跳过 archive/
|
|
153
|
+
if echo "$f" | grep -q "/archive/"; then continue; fi
|
|
154
|
+
local dir name phase
|
|
155
|
+
dir=$(dirname "$f")
|
|
156
|
+
name=$(basename "$dir")
|
|
157
|
+
phase=$(jq -r '.current_phase // "unknown"' "$f" 2>/dev/null)
|
|
158
|
+
if [ "$first" = true ]; then first=false; else echo ","; fi
|
|
159
|
+
echo " {\"name\":\"$name\",\"phase\":\"$phase\"}"
|
|
160
|
+
done
|
|
161
|
+
echo "]"
|
|
162
|
+
else
|
|
163
|
+
for f in "$changes_dir"/*/.pipeline-state.json; do
|
|
164
|
+
[ -f "$f" ] || continue
|
|
165
|
+
if echo "$f" | grep -q "/archive/"; then continue; fi
|
|
166
|
+
local dir name phase
|
|
167
|
+
dir=$(dirname "$f")
|
|
168
|
+
name=$(basename "$dir")
|
|
169
|
+
phase=$(jq -r '.current_phase // "unknown"' "$f" 2>/dev/null)
|
|
170
|
+
echo " $name (phase: $phase)"
|
|
171
|
+
done
|
|
172
|
+
fi
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
gate_artifacts() {
|
|
176
|
+
if [ -z "$CHANGE" ]; then
|
|
177
|
+
fail "需要 --change <name>"
|
|
178
|
+
fi
|
|
179
|
+
|
|
180
|
+
local dir="$PROJECT_ROOT/specline/changes/$CHANGE"
|
|
181
|
+
local json_output=false
|
|
182
|
+
|
|
183
|
+
if [ "${1:-}" = "--json" ]; then
|
|
184
|
+
json_output=true
|
|
185
|
+
fi
|
|
186
|
+
|
|
187
|
+
local has_proposal=false has_design=false has_tasks=false has_specs=false
|
|
188
|
+
|
|
189
|
+
[ -f "$dir/proposal.md" ] && has_proposal=true
|
|
190
|
+
[ -f "$dir/design.md" ] && has_design=true
|
|
191
|
+
[ -f "$dir/tasks.md" ] && has_tasks=true
|
|
192
|
+
[ -d "$dir/specs" ] && [ -n "$(find "$dir/specs" -name 'spec.md' 2>/dev/null)" ] && has_specs=true
|
|
193
|
+
|
|
194
|
+
if $json_output; then
|
|
195
|
+
echo "{"
|
|
196
|
+
echo " \"proposal\": $has_proposal,"
|
|
197
|
+
echo " \"design\": $has_design,"
|
|
198
|
+
echo " \"tasks\": $has_tasks,"
|
|
199
|
+
echo " \"specs\": $has_specs"
|
|
200
|
+
echo "}"
|
|
201
|
+
else
|
|
202
|
+
echo "Artifacts for '$CHANGE':"
|
|
203
|
+
echo " proposal.md: $has_proposal"
|
|
204
|
+
echo " design.md: $has_design"
|
|
205
|
+
echo " tasks.md: $has_tasks"
|
|
206
|
+
echo " spec.md: $has_specs"
|
|
207
|
+
fi
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
gate_spec() {
|
|
211
|
+
local spec_file
|
|
212
|
+
spec_file=$(find_spec_file)
|
|
213
|
+
|
|
214
|
+
if [ -z "$spec_file" ] || [ ! -f "$spec_file" ]; then
|
|
215
|
+
fail "spec.md 不存在。请确保 spec-creator 已生成 spec 文件。"
|
|
216
|
+
fi
|
|
217
|
+
|
|
218
|
+
# 1. H1 含 "Specification"
|
|
219
|
+
if ! grep -q "^# .* Specification" "$spec_file"; then
|
|
220
|
+
fail "标题格式错误:H1 必须包含 'Specification' 关键词"
|
|
221
|
+
fi
|
|
222
|
+
|
|
223
|
+
# 2. 含 Purpose 章节
|
|
224
|
+
if ! grep -q "^## Purpose" "$spec_file"; then
|
|
225
|
+
fail "缺少 ## Purpose 章节"
|
|
226
|
+
fi
|
|
227
|
+
|
|
228
|
+
# 3. 含 Requirements 章节
|
|
229
|
+
if ! grep -q "^## Requirements" "$spec_file"; then
|
|
230
|
+
fail "缺少 ## Requirements 章节"
|
|
231
|
+
fi
|
|
232
|
+
|
|
233
|
+
# 4. 至少 1 个 Requirement
|
|
234
|
+
local req_count
|
|
235
|
+
req_count=$(grep -c "^### Requirement:" "$spec_file" || echo "0")
|
|
236
|
+
if [ "$req_count" -lt 1 ]; then
|
|
237
|
+
fail "至少需要 1 个 Requirement,当前: $req_count"
|
|
238
|
+
fi
|
|
239
|
+
pass "Requirements 数量: $req_count"
|
|
240
|
+
|
|
241
|
+
# 5. 每个 Requirement 至少 1 个 Scenario(简化检查:Scenario 总数 >= Requirement 总数)
|
|
242
|
+
local scenario_count
|
|
243
|
+
scenario_count=$(grep -c "^#### Scenario:" "$spec_file" || echo "0")
|
|
244
|
+
if [ "$scenario_count" -lt "$req_count" ]; then
|
|
245
|
+
fail "每个 Requirement 至少需要 1 个 Scenario。Requirement: $req_count, Scenario: $scenario_count"
|
|
246
|
+
fi
|
|
247
|
+
pass "Scenario 数量: $scenario_count"
|
|
248
|
+
|
|
249
|
+
# 6. WHEN/THEN 配对检查
|
|
250
|
+
local when_count then_count
|
|
251
|
+
when_count=$(grep -c "\*\*WHEN\*\*" "$spec_file" || echo "0")
|
|
252
|
+
then_count=$(grep -c "\*\*THEN\*\*" "$spec_file" || echo "0")
|
|
253
|
+
if [ "$when_count" -ne "$then_count" ]; then
|
|
254
|
+
fail "WHEN/THEN 数量不匹配。WHEN: $when_count, THEN: $then_count"
|
|
255
|
+
fi
|
|
256
|
+
pass "WHEN/THEN 配对检查通过 ($when_count 对)"
|
|
257
|
+
|
|
258
|
+
# 7. review.json 状态检查(如果存在)
|
|
259
|
+
local review_file
|
|
260
|
+
review_file="$(dirname "$spec_file")/spec-review.json"
|
|
261
|
+
if [ -f "$review_file" ]; then
|
|
262
|
+
local review_status
|
|
263
|
+
review_status=$(jq -r '.status' "$review_file" 2>/dev/null || echo "missing")
|
|
264
|
+
if [ "$review_status" != "approved" ]; then
|
|
265
|
+
fail "spec-review.json 审核未通过 (status: $review_status)"
|
|
266
|
+
fi
|
|
267
|
+
pass "审核状态: approved"
|
|
268
|
+
|
|
269
|
+
# 7b. 检查 coverage(所有 Requirement 和 Scenario 被 task 的 Covers 覆盖)
|
|
270
|
+
local cov_req_total cov_req_covered
|
|
271
|
+
cov_req_total=$(jq -r '.coverage.requirements_total' "$review_file" 2>/dev/null || echo "0")
|
|
272
|
+
cov_req_covered=$(jq -r '.coverage.requirements_covered' "$review_file" 2>/dev/null || echo "0")
|
|
273
|
+
if [ "$cov_req_covered" -lt "$cov_req_total" ]; then
|
|
274
|
+
fail "Requirement 覆盖不全: $cov_req_covered/$cov_req_total"
|
|
275
|
+
fi
|
|
276
|
+
pass "Requirement 覆盖率: $cov_req_covered/$cov_req_total"
|
|
277
|
+
else
|
|
278
|
+
pass "审核状态: 无 spec-review.json(跳过审核检查)"
|
|
279
|
+
fi
|
|
280
|
+
|
|
281
|
+
# 8. 检查 tasks.md 是否存在且含完整的 Type/Depends/Covers/Files 标注
|
|
282
|
+
local tasks_file="$PROJECT_ROOT/specline/changes/$CHANGE/tasks.md"
|
|
283
|
+
if [ ! -f "$tasks_file" ]; then
|
|
284
|
+
fail "tasks.md 不存在"
|
|
285
|
+
fi
|
|
286
|
+
pass "tasks.md 存在"
|
|
287
|
+
|
|
288
|
+
# 9. 检查每个任务标注完整性
|
|
289
|
+
local task_count type_count deps_count covers_count files_count
|
|
290
|
+
task_count=$(grep -c '^## ' "$tasks_file" || echo "0")
|
|
291
|
+
type_count=$(grep -c '\*\*Type\*\*:' "$tasks_file" || echo "0")
|
|
292
|
+
deps_count=$(grep -c '\*\*Depends\*\*:' "$tasks_file" || echo "0")
|
|
293
|
+
covers_count=$(grep -c '\*\*Covers\*\*:' "$tasks_file" || echo "0")
|
|
294
|
+
files_count=$(grep -c '\*\*Files\*\*:' "$tasks_file" || echo "0")
|
|
295
|
+
|
|
296
|
+
if [ "$type_count" -lt "$task_count" ] || [ "$deps_count" -lt "$task_count" ] || \
|
|
297
|
+
[ "$covers_count" -lt "$task_count" ] || [ "$files_count" -lt "$task_count" ]; then
|
|
298
|
+
fail "tasks.md 标注不完整:任务=$task_count, Type=$type_count, Depends=$deps_count, Covers=$covers_count, Files=$files_count"
|
|
299
|
+
fi
|
|
300
|
+
pass "tasks.md 标注完整性检查通过 ($task_count 个任务)"
|
|
301
|
+
|
|
302
|
+
# 10. 至少 1 个任务无依赖
|
|
303
|
+
local independent_count
|
|
304
|
+
independent_count=$(grep -c '\*\*Depends\*\*: (none)' "$tasks_file" || echo "0")
|
|
305
|
+
if [ "$independent_count" -lt 1 ]; then
|
|
306
|
+
fail "至少需要 1 个无依赖任务 (Depends: none),当前: $independent_count"
|
|
307
|
+
fi
|
|
308
|
+
pass "无依赖任务数: $independent_count"
|
|
309
|
+
|
|
310
|
+
write_gate_passed "phases.spec.gates.spec_gate"
|
|
311
|
+
pass "Spec Gate 全部通过"
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
gate_build() {
|
|
315
|
+
# TypeScript 编译检查(如果存在 tsconfig.json)
|
|
316
|
+
if [ -f "$PROJECT_ROOT/tsconfig.json" ]; then
|
|
317
|
+
echo "正在检查 TypeScript 编译..."
|
|
318
|
+
if ! npx tsc --noEmit 2>&1; then
|
|
319
|
+
fail "TypeScript 编译失败"
|
|
320
|
+
fi
|
|
321
|
+
pass "TypeScript 编译通过"
|
|
322
|
+
fi
|
|
323
|
+
|
|
324
|
+
# Python 语法检查
|
|
325
|
+
echo "正在检查 Python 语法..."
|
|
326
|
+
local py_dirs=""
|
|
327
|
+
for d in agent server scripts; do
|
|
328
|
+
if [ -d "$PROJECT_ROOT/$d" ]; then
|
|
329
|
+
py_dirs="$py_dirs $d"
|
|
330
|
+
fi
|
|
331
|
+
done
|
|
332
|
+
if [ -n "$py_dirs" ]; then
|
|
333
|
+
if ! python -m compileall -q $py_dirs 2>&1; then
|
|
334
|
+
fail "Python 语法错误"
|
|
335
|
+
fi
|
|
336
|
+
pass "Python 语法检查通过"
|
|
337
|
+
fi
|
|
338
|
+
|
|
339
|
+
write_gate_passed "phases.coding.gates.build_gate"
|
|
340
|
+
pass "Build Gate 全部通过"
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
gate_lint() {
|
|
344
|
+
# Python lint (ruff)
|
|
345
|
+
if command -v ruff &>/dev/null; then
|
|
346
|
+
echo "正在检查 Python 代码规范..."
|
|
347
|
+
if ! ruff check "$PROJECT_ROOT" --quiet 2>&1; then
|
|
348
|
+
fail "Python lint 失败"
|
|
349
|
+
fi
|
|
350
|
+
pass "Python lint 通过"
|
|
351
|
+
else
|
|
352
|
+
echo "⚠️ ruff 未安装,跳过 Python lint"
|
|
353
|
+
fi
|
|
354
|
+
|
|
355
|
+
# JS/TS lint (eslint)
|
|
356
|
+
if [ -f "$PROJECT_ROOT/package.json" ]; then
|
|
357
|
+
if command -v npx &>/dev/null; then
|
|
358
|
+
echo "正在检查 JS/TS 代码规范..."
|
|
359
|
+
if ! npx eslint "$PROJECT_ROOT" --max-warnings 0 --quiet 2>&1; then
|
|
360
|
+
fail "JS/TS lint 失败"
|
|
361
|
+
fi
|
|
362
|
+
pass "JS/TS lint 通过"
|
|
363
|
+
fi
|
|
364
|
+
fi
|
|
365
|
+
|
|
366
|
+
# code-review.json error 计数
|
|
367
|
+
local review_file="$PROJECT_ROOT/code-review.json"
|
|
368
|
+
if [ -f "$review_file" ]; then
|
|
369
|
+
local error_count
|
|
370
|
+
error_count=$(jq '[.findings[] | select(.severity=="error")] | length' "$review_file" 2>/dev/null || echo "0")
|
|
371
|
+
if [ "$error_count" -gt 0 ]; then
|
|
372
|
+
fail "code-review.json 中发现 $error_count 个 error,必须修复"
|
|
373
|
+
fi
|
|
374
|
+
pass "Review errors: 0"
|
|
375
|
+
fi
|
|
376
|
+
|
|
377
|
+
write_gate_passed "phases.code_review.gates.lint_gate"
|
|
378
|
+
pass "Lint Gate 全部通过"
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
# ===== 测试框架自动检测 =====
|
|
382
|
+
# 优先级:.pipeline-state.json 中的 test_framework > 项目配置文件检测 > 默认 pytest
|
|
383
|
+
detect_test_framework() {
|
|
384
|
+
framework="" test_cmd="" coverage_cmd=""
|
|
385
|
+
|
|
386
|
+
# 1. 先尝试从状态文件读取 test-writer 的检测结果
|
|
387
|
+
if [ -f "$STATE_FILE" ]; then
|
|
388
|
+
local recorded
|
|
389
|
+
recorded=$(jq -r '.phases.test.framework // empty' "$STATE_FILE" 2>/dev/null)
|
|
390
|
+
if [ -n "$recorded" ]; then
|
|
391
|
+
framework="$recorded"
|
|
392
|
+
fi
|
|
393
|
+
fi
|
|
394
|
+
|
|
395
|
+
# 2. 如果状态文件没有,从项目配置文件检测
|
|
396
|
+
if [ -z "$framework" ]; then
|
|
397
|
+
if [ -f "$PROJECT_ROOT/package.json" ]; then
|
|
398
|
+
if grep -q '"jest"' "$PROJECT_ROOT/package.json" 2>/dev/null; then
|
|
399
|
+
framework="jest"
|
|
400
|
+
elif grep -q '"vitest"' "$PROJECT_ROOT/package.json" 2>/dev/null; then
|
|
401
|
+
framework="vitest"
|
|
402
|
+
elif grep -q '"mocha"' "$PROJECT_ROOT/package.json" 2>/dev/null; then
|
|
403
|
+
framework="mocha"
|
|
404
|
+
fi
|
|
405
|
+
elif [ -f "$PROJECT_ROOT/go.mod" ]; then
|
|
406
|
+
framework="go-test"
|
|
407
|
+
elif [ -f "$PROJECT_ROOT/Cargo.toml" ]; then
|
|
408
|
+
framework="cargo-test"
|
|
409
|
+
elif [ -f "$PROJECT_ROOT/pom.xml" ] || [ -f "$PROJECT_ROOT/build.gradle" ]; then
|
|
410
|
+
framework="junit"
|
|
411
|
+
fi
|
|
412
|
+
fi
|
|
413
|
+
|
|
414
|
+
# 3. 默认兜底
|
|
415
|
+
if [ -z "$framework" ]; then
|
|
416
|
+
framework="pytest"
|
|
417
|
+
fi
|
|
418
|
+
|
|
419
|
+
# 根据框架确定命令
|
|
420
|
+
case "$framework" in
|
|
421
|
+
jest)
|
|
422
|
+
test_cmd="npx jest"
|
|
423
|
+
coverage_cmd="npx jest --coverage"
|
|
424
|
+
;;
|
|
425
|
+
vitest)
|
|
426
|
+
test_cmd="npx vitest run"
|
|
427
|
+
coverage_cmd="npx vitest run --coverage"
|
|
428
|
+
;;
|
|
429
|
+
mocha)
|
|
430
|
+
test_cmd="npx mocha"
|
|
431
|
+
coverage_cmd="npx nyc mocha"
|
|
432
|
+
;;
|
|
433
|
+
go-test)
|
|
434
|
+
test_cmd="go test"
|
|
435
|
+
coverage_cmd="go test -cover"
|
|
436
|
+
;;
|
|
437
|
+
cargo-test)
|
|
438
|
+
test_cmd="cargo test"
|
|
439
|
+
coverage_cmd="cargo tarpaulin 2>/dev/null || cargo test" # tarpaulin 可能未安装
|
|
440
|
+
;;
|
|
441
|
+
junit)
|
|
442
|
+
if [ -f "$PROJECT_ROOT/pom.xml" ]; then
|
|
443
|
+
test_cmd="mvn test"
|
|
444
|
+
coverage_cmd="mvn jacoco:report"
|
|
445
|
+
else
|
|
446
|
+
test_cmd="gradle test"
|
|
447
|
+
coverage_cmd="gradle jacocoTestReport"
|
|
448
|
+
fi
|
|
449
|
+
;;
|
|
450
|
+
pytest|*)
|
|
451
|
+
test_cmd="pytest"
|
|
452
|
+
coverage_cmd="pytest --cov --cov-fail-under=80"
|
|
453
|
+
;;
|
|
454
|
+
esac
|
|
455
|
+
|
|
456
|
+
echo "检测到测试框架: $framework (命令: $test_cmd)"
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
gate_test_unit() {
|
|
460
|
+
echo "正在执行单元测试..."
|
|
461
|
+
detect_test_framework
|
|
462
|
+
|
|
463
|
+
# 确定测试目录
|
|
464
|
+
local test_dir=""
|
|
465
|
+
for d in "$PROJECT_ROOT/tests/unit" "$PROJECT_ROOT/tests" "$PROJECT_ROOT/__tests__" "$PROJECT_ROOT/test"; do
|
|
466
|
+
if [ -d "$d" ]; then
|
|
467
|
+
test_dir="$d"
|
|
468
|
+
break
|
|
469
|
+
fi
|
|
470
|
+
done
|
|
471
|
+
|
|
472
|
+
if [ -z "$test_dir" ]; then
|
|
473
|
+
echo "⚠️ 未找到测试目录,尝试用框架默认命令运行..."
|
|
474
|
+
if ! eval "$test_cmd" 2>&1; then
|
|
475
|
+
fail "单元测试失败(无测试目录且框架命令执行失败)"
|
|
476
|
+
fi
|
|
477
|
+
else
|
|
478
|
+
echo "测试目录: $test_dir"
|
|
479
|
+
if ! eval "$test_cmd \"$test_dir\" -v 2>&1"; then
|
|
480
|
+
fail "单元测试失败"
|
|
481
|
+
fi
|
|
482
|
+
fi
|
|
483
|
+
|
|
484
|
+
# 覆盖率检查(非阻塞,警告即可——覆盖率的深入分析由 test-runner agent 负责)
|
|
485
|
+
echo "正在检查覆盖率..."
|
|
486
|
+
if ! eval "$coverage_cmd \"$test_dir\" 2>&1"; then
|
|
487
|
+
echo "⚠️ 覆盖率检查未通过(不阻塞,由 test-runner agent 深入分析)"
|
|
488
|
+
fi
|
|
489
|
+
|
|
490
|
+
write_gate_passed "phases.test.sub_phases.unit.gates.test_unit_gate"
|
|
491
|
+
pass "单元测试通过"
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
gate_test_integration() {
|
|
495
|
+
echo "正在执行集成测试..."
|
|
496
|
+
detect_test_framework
|
|
497
|
+
|
|
498
|
+
local test_dir=""
|
|
499
|
+
for d in "$PROJECT_ROOT/tests/integration" "$PROJECT_ROOT/__tests__/integration"; do
|
|
500
|
+
if [ -d "$d" ]; then
|
|
501
|
+
test_dir="$d"
|
|
502
|
+
break
|
|
503
|
+
fi
|
|
504
|
+
done
|
|
505
|
+
|
|
506
|
+
if [ -z "$test_dir" ]; then
|
|
507
|
+
echo "⚠️ 无集成测试目录,跳过"
|
|
508
|
+
else
|
|
509
|
+
echo "测试目录: $test_dir"
|
|
510
|
+
if ! eval "$test_cmd \"$test_dir\" -v 2>&1"; then
|
|
511
|
+
fail "集成测试失败"
|
|
512
|
+
fi
|
|
513
|
+
# 覆盖率
|
|
514
|
+
if ! eval "$coverage_cmd \"$test_dir\" 2>&1"; then
|
|
515
|
+
echo "⚠️ 覆盖率检查未通过(不阻塞)"
|
|
516
|
+
fi
|
|
517
|
+
fi
|
|
518
|
+
write_gate_passed "phases.test.sub_phases.integration.gates.test_integration_gate"
|
|
519
|
+
pass "集成测试通过"
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
gate_test_e2e() {
|
|
523
|
+
echo "正在执行 E2E 测试..."
|
|
524
|
+
detect_test_framework
|
|
525
|
+
|
|
526
|
+
local test_dir=""
|
|
527
|
+
for d in "$PROJECT_ROOT/tests/e2e" "$PROJECT_ROOT/__tests__/e2e" "$PROJECT_ROOT/e2e"; do
|
|
528
|
+
if [ -d "$d" ]; then
|
|
529
|
+
test_dir="$d"
|
|
530
|
+
break
|
|
531
|
+
fi
|
|
532
|
+
done
|
|
533
|
+
|
|
534
|
+
if [ -z "$test_dir" ]; then
|
|
535
|
+
echo "⚠️ 无 E2E 测试目录,跳过"
|
|
536
|
+
else
|
|
537
|
+
echo "测试目录: $test_dir"
|
|
538
|
+
if ! eval "$test_cmd \"$test_dir\" -v 2>&1"; then
|
|
539
|
+
fail "E2E 测试失败"
|
|
540
|
+
fi
|
|
541
|
+
# E2E 通常不需要覆盖率检查
|
|
542
|
+
fi
|
|
543
|
+
write_gate_passed "phases.test.sub_phases.e2e.gates.test_e2e_gate"
|
|
544
|
+
pass "E2E 测试通过"
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
gate_archive() {
|
|
548
|
+
if [ -z "$CHANGE" ]; then
|
|
549
|
+
fail "需要 --change <name>"
|
|
550
|
+
fi
|
|
551
|
+
|
|
552
|
+
# 如果传了 --execute,执行实际归档动作
|
|
553
|
+
if [ "${1:-}" = "--execute" ]; then
|
|
554
|
+
local src_dir="$PROJECT_ROOT/specline/changes/$CHANGE"
|
|
555
|
+
local archive_dir="$PROJECT_ROOT/specline/changes/archive"
|
|
556
|
+
local date_prefix
|
|
557
|
+
date_prefix=$(date -u +"%Y-%m-%d")
|
|
558
|
+
local dest="$archive_dir/${date_prefix}-${CHANGE}"
|
|
559
|
+
|
|
560
|
+
if [ ! -d "$src_dir" ]; then
|
|
561
|
+
fail "Change '$CHANGE' 不存在: $src_dir"
|
|
562
|
+
fi
|
|
563
|
+
|
|
564
|
+
# 检查基本文件
|
|
565
|
+
if [ ! -f "$src_dir/proposal.md" ]; then
|
|
566
|
+
fail "缺少 proposal.md"
|
|
567
|
+
fi
|
|
568
|
+
if [ ! -f "$src_dir/tasks.md" ]; then
|
|
569
|
+
fail "缺少 tasks.md"
|
|
570
|
+
fi
|
|
571
|
+
|
|
572
|
+
# 同步 delta specs 到主 specs
|
|
573
|
+
if [ -d "$src_dir/specs" ]; then
|
|
574
|
+
echo "正在同步 delta specs 到 specline/specs/..."
|
|
575
|
+
cp -r "$src_dir/specs/"* "$PROJECT_ROOT/specline/specs/" 2>/dev/null || true
|
|
576
|
+
fi
|
|
577
|
+
|
|
578
|
+
# 移动到归档
|
|
579
|
+
mkdir -p "$archive_dir"
|
|
580
|
+
if [ -d "$dest" ]; then
|
|
581
|
+
fail "归档目标已存在: $dest"
|
|
582
|
+
fi
|
|
583
|
+
|
|
584
|
+
mv "$src_dir" "$dest"
|
|
585
|
+
echo "✅ 已归档到: $dest"
|
|
586
|
+
|
|
587
|
+
# 更新状态文件
|
|
588
|
+
if [ -f "$dest/.pipeline-state.json" ]; then
|
|
589
|
+
local now
|
|
590
|
+
now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
591
|
+
# macOS sed 兼容
|
|
592
|
+
sed -i '' "s/\"current_phase\": \"[^\"]*\"/\"current_phase\": \"archived\"/g" "$dest/.pipeline-state.json" 2>/dev/null || true
|
|
593
|
+
fi
|
|
594
|
+
|
|
595
|
+
exit 0
|
|
596
|
+
fi
|
|
597
|
+
|
|
598
|
+
# 验证模式(原有逻辑,路径改为 specline)
|
|
599
|
+
local archive_dir="$PROJECT_ROOT/specline/changes/archive"
|
|
600
|
+
local found
|
|
601
|
+
found=$(find "$archive_dir" -maxdepth 1 -type d -name "*$CHANGE" 2>/dev/null | head -1)
|
|
602
|
+
|
|
603
|
+
if [ -z "$found" ]; then
|
|
604
|
+
fail "归档目录不存在: $archive_dir/*$CHANGE"
|
|
605
|
+
fi
|
|
606
|
+
|
|
607
|
+
if [ ! -f "$found/proposal.md" ]; then
|
|
608
|
+
fail "归档目录缺少 proposal.md"
|
|
609
|
+
fi
|
|
610
|
+
if [ ! -f "$found/tasks.md" ]; then
|
|
611
|
+
fail "归档目录缺少 tasks.md"
|
|
612
|
+
fi
|
|
613
|
+
|
|
614
|
+
write_gate_passed "phases.archive.gates.archive_gate"
|
|
615
|
+
pass "Archive Gate 全部通过"
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
gate_status() {
|
|
619
|
+
if [ ! -f "$STATE_FILE" ]; then
|
|
620
|
+
echo '{"status":"no_pipeline","message":"未找到流水线状态文件"}'
|
|
621
|
+
exit 0
|
|
622
|
+
fi
|
|
623
|
+
|
|
624
|
+
jq '{
|
|
625
|
+
change: .change_name,
|
|
626
|
+
phase: .current_phase,
|
|
627
|
+
step: .current_step,
|
|
628
|
+
tasks: .phases.coding.tasks | map({id: .id, type: .type, status: .status, batch: .batch}),
|
|
629
|
+
progress: {
|
|
630
|
+
spec: .phases.spec.status,
|
|
631
|
+
coding: .phases.coding.status,
|
|
632
|
+
code_review: .phases.code_review.status,
|
|
633
|
+
test: .phases.test.status,
|
|
634
|
+
archive: .phases.archive.status
|
|
635
|
+
}
|
|
636
|
+
}' "$STATE_FILE"
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
# ===== 分派 =====
|
|
640
|
+
|
|
641
|
+
case "$PHASE" in
|
|
642
|
+
new)
|
|
643
|
+
gate_new
|
|
644
|
+
;;
|
|
645
|
+
list)
|
|
646
|
+
gate_list "$@"
|
|
647
|
+
;;
|
|
648
|
+
artifacts)
|
|
649
|
+
gate_artifacts "$@"
|
|
650
|
+
;;
|
|
651
|
+
spec)
|
|
652
|
+
gate_spec
|
|
653
|
+
;;
|
|
654
|
+
build)
|
|
655
|
+
gate_build
|
|
656
|
+
;;
|
|
657
|
+
lint)
|
|
658
|
+
gate_lint
|
|
659
|
+
;;
|
|
660
|
+
test-unit)
|
|
661
|
+
gate_test_unit
|
|
662
|
+
;;
|
|
663
|
+
test-integration)
|
|
664
|
+
gate_test_integration
|
|
665
|
+
;;
|
|
666
|
+
test-e2e)
|
|
667
|
+
gate_test_e2e
|
|
668
|
+
;;
|
|
669
|
+
archive)
|
|
670
|
+
gate_archive "$@"
|
|
671
|
+
;;
|
|
672
|
+
status)
|
|
673
|
+
gate_status
|
|
674
|
+
;;
|
|
675
|
+
*)
|
|
676
|
+
echo "未知 phase: $PHASE"
|
|
677
|
+
echo "可用: new | list | artifacts | spec | build | lint | test-unit | test-integration | test-e2e | archive | status"
|
|
678
|
+
exit 2
|
|
679
|
+
;;
|
|
680
|
+
esac
|
|
681
|
+
|
|
682
|
+
exit 0
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# shell-guard.sh — beforeShellExecution Hook: 拦截危险命令
|
|
3
|
+
input=$(cat)
|
|
4
|
+
command=$(echo "$input" | jq -r '.command // empty')
|
|
5
|
+
if echo "$command" | grep -qE "rm -rf|rm -r "; then
|
|
6
|
+
echo '{"permission": "deny", "user_message": "危险命令被拦截: rm -rf"}'
|
|
7
|
+
exit 0
|
|
8
|
+
fi
|
|
9
|
+
if echo "$command" | grep -qE "curl.*\|.*bash|wget.*\|.*sh"; then
|
|
10
|
+
echo '{"permission": "deny", "user_message": "危险命令被拦截: 管道执行远程脚本"}'
|
|
11
|
+
exit 0
|
|
12
|
+
fi
|
|
13
|
+
if echo "$command" | grep -qE "^sudo "; then
|
|
14
|
+
echo '{"permission": "deny", "user_message": "sudo 需要人工审批"}'
|
|
15
|
+
exit 0
|
|
16
|
+
fi
|
|
17
|
+
echo '{"permission": "allow"}'
|
|
18
|
+
exit 0
|