specline 1.2.2 → 1.3.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/cli.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { existsSync, mkdirSync, readdirSync, copyFileSync, writeFileSync, readFileSync } from 'fs';
4
- import { join, dirname, resolve, relative } from 'path';
4
+ import { join, dirname, resolve, relative, basename } from 'path';
5
5
  import { fileURLToPath } from 'url';
6
6
  import { createHash } from 'crypto';
7
7
  import { get } from 'https';
@@ -193,6 +193,149 @@ function copyDirRecursive(src, dest) {
193
193
  }
194
194
  }
195
195
 
196
+ // ============================================================
197
+ // 智能合并函数 — hooks.json / config.yaml / CONFLICT 备份
198
+ // ============================================================
199
+
200
+ /**
201
+ * hooks.json 语义合并:清理所有 specline-* 条目,注入模板最新官方 hook
202
+ */
203
+ function mergeHooksJson(existingContent, templateContent) {
204
+ let existingObj, templateObj;
205
+ try {
206
+ existingObj = JSON.parse(existingContent);
207
+ } catch {
208
+ warn('hooks.json 解析失败,将使用模板完整替换');
209
+ return templateContent;
210
+ }
211
+ try {
212
+ templateObj = JSON.parse(templateContent);
213
+ } catch {
214
+ warn('模板 hooks.json 解析失败,保留现有文件');
215
+ return existingContent;
216
+ }
217
+
218
+ for (const eventName of Object.keys(templateObj.hooks || {})) {
219
+ if (!existingObj.hooks) {
220
+ existingObj.hooks = {};
221
+ }
222
+ if (!existingObj.hooks[eventName]) {
223
+ existingObj.hooks[eventName] = [];
224
+ }
225
+ existingObj.hooks[eventName] = existingObj.hooks[eventName].filter(
226
+ (entry) => !(entry.command || '').includes('specline-')
227
+ );
228
+ existingObj.hooks[eventName] = [
229
+ ...templateObj.hooks[eventName],
230
+ ...existingObj.hooks[eventName],
231
+ ];
232
+ }
233
+ return JSON.stringify(existingObj, null, 2) + '\n';
234
+ }
235
+
236
+ function countCustomHooks(hooksObj) {
237
+ let count = 0;
238
+ for (const eventName of Object.keys(hooksObj.hooks || {})) {
239
+ for (const entry of (hooksObj.hooks[eventName] || [])) {
240
+ if (!(entry.command || '').includes('specline-')) count++;
241
+ }
242
+ }
243
+ return count;
244
+ }
245
+
246
+ /**
247
+ * YAML 段落结构
248
+ */
249
+ function parseYamlSections(content) {
250
+ const lines = content.split('\n');
251
+ const sections = [];
252
+ let currentComments = [];
253
+ let currentKey = null;
254
+ let currentBodyLines = [];
255
+ let inBody = false;
256
+
257
+ function flushSection() {
258
+ if (currentComments.length > 0 || currentBodyLines.length > 0 || currentKey) {
259
+ const bodyStr = currentBodyLines.join('\n');
260
+ // 判定 isEmpty:body 为空、纯注释、或仅声明 key 但无实际值
261
+ const bodyTrimmed = bodyStr.trim();
262
+ const onlyKeyDeclaration = currentKey !== null &&
263
+ currentBodyLines.length === 1 &&
264
+ bodyTrimmed.match(/^\w[\w_-]*\s*:\s*$/) !== null;
265
+ const isEmpty = bodyTrimmed === '' ||
266
+ bodyTrimmed.startsWith('#') ||
267
+ onlyKeyDeclaration;
268
+ sections.push({ key: currentKey, headerComments: [...currentComments], body: bodyStr, isEmpty });
269
+ }
270
+ currentComments = [];
271
+ currentKey = null;
272
+ currentBodyLines = [];
273
+ inBody = false;
274
+ }
275
+
276
+ for (const line of lines) {
277
+ const trimmed = line.trim();
278
+ if (trimmed === '') { if (inBody) currentBodyLines.push(line); continue; }
279
+ if (trimmed.startsWith('#')) { if (inBody) currentBodyLines.push(line); else currentComments.push(line); continue; }
280
+ const topKeyMatch = line.match(/^(\w[\w_-]*)\s*:(.*)/);
281
+ if (topKeyMatch && !line.startsWith(' ') && !line.startsWith('\t')) {
282
+ flushSection();
283
+ currentKey = topKeyMatch[1];
284
+ currentBodyLines = [line];
285
+ inBody = true;
286
+ continue;
287
+ }
288
+ if (inBody) currentBodyLines.push(line);
289
+ }
290
+ flushSection();
291
+ return sections;
292
+ }
293
+
294
+ function findSection(sections, key) {
295
+ return sections.find((s) => s.key === key) || null;
296
+ }
297
+
298
+ function mergeConfigYaml(existingContent, templateContent) {
299
+ const existingSections = parseYamlSections(existingContent);
300
+ const templateSections = parseYamlSections(templateContent);
301
+ const resultLines = [];
302
+
303
+ for (const tmplSec of templateSections) {
304
+ const existSec = findSection(existingSections, tmplSec.key);
305
+ if (existSec) {
306
+ if (!existSec.isEmpty && existSec.body.trim() !== tmplSec.body.trim()) {
307
+ resultLines.push(...tmplSec.headerComments);
308
+ resultLines.push(existSec.body);
309
+ } else {
310
+ resultLines.push(...tmplSec.headerComments);
311
+ resultLines.push(tmplSec.body);
312
+ }
313
+ resultLines.push('');
314
+ } else if (tmplSec.key !== null) {
315
+ resultLines.push('# 🆕 新增配置段 (specline sync)');
316
+ resultLines.push(...tmplSec.headerComments);
317
+ resultLines.push(tmplSec.body);
318
+ resultLines.push('');
319
+ }
320
+ }
321
+
322
+ for (const existSec of existingSections) {
323
+ if (existSec.key === null) continue;
324
+ if (!findSection(templateSections, existSec.key)) {
325
+ resultLines.push(...existSec.headerComments);
326
+ resultLines.push(existSec.body);
327
+ resultLines.push('');
328
+ }
329
+ }
330
+ return resultLines.join('\n');
331
+ }
332
+
333
+ function backupBeforeOverwrite(destPath) {
334
+ const backupPath = destPath + '.orig';
335
+ copyFileSync(destPath, backupPath);
336
+ return backupPath;
337
+ }
338
+
196
339
  function cmd_init(targetPath) {
197
340
  const cwd = process.cwd();
198
341
  const target = resolve(cwd, targetPath || '.');
@@ -430,9 +573,52 @@ function cmd_sync({ dryRun, targetPath }) {
430
573
 
431
574
  // 8. dryRun 模式只预览
432
575
  if (dryRun) {
576
+ const HOOKS_JSON = '.cursor/hooks.json';
577
+ const CONFIG_YAML = 'specline/config.yaml';
578
+ let hooksPlan = null;
579
+
433
580
  for (const r of results) {
434
- if (r.type === 'UNCHANGED' || r.type === 'MODIFIED_ONLY') continue;
435
- const labels = { NEW: '➕ 新增', WILL_UPDATE: '🔄 更新', CONFLICT: '⚠️ 冲突', NO_LOCK_CONFLICT: '⚠️ 无锁记录', UPSTREAM_REMOVED: '🗑️ 上游移除' };
581
+ if (r.type === 'UNCHANGED' || r.type === 'MODIFIED_ONLY') {
582
+ if (r.path === HOOKS_JSON) {
583
+ const projPath = join(target, r.path);
584
+ if (existsSync(projPath)) {
585
+ try {
586
+ const existingObj = JSON.parse(readFileSync(projPath, 'utf-8'));
587
+ hooksPlan = { customCount: countCustomHooks(existingObj) };
588
+ } catch {}
589
+ }
590
+ }
591
+ if (r.path === CONFIG_YAML) {
592
+ log('💡 config.yaml: 用户已修改,保留现有配置不变');
593
+ }
594
+ continue;
595
+ }
596
+
597
+ if (r.path === HOOKS_JSON) {
598
+ hooksPlan = hooksPlan || { customCount: 0 };
599
+ // 读取用户现有 hooks.json,计算自定义 hook 数量
600
+ const projPath = join(target, r.path);
601
+ if (existsSync(projPath) && !hooksPlan.readFromUser) {
602
+ try {
603
+ const existingObj = JSON.parse(readFileSync(projPath, 'utf-8'));
604
+ hooksPlan = { customCount: countCustomHooks(existingObj), readFromUser: true };
605
+ } catch {}
606
+ }
607
+ let tplCount = 0;
608
+ try {
609
+ const tpl = JSON.parse(readFileSync(join(TEMPLATES_DIR, r.path), 'utf-8'));
610
+ for (const ev of Object.keys(tpl.hooks || {})) tplCount += (tpl.hooks[ev] || []).length;
611
+ } catch {}
612
+ log(`🔄 hooks.json 语义合并: 保留 ${hooksPlan.customCount >= 0 ? hooksPlan.customCount : '?'} 个自定义 hook, 更新 ${tplCount} 个官方 hook`);
613
+ continue;
614
+ }
615
+
616
+ if (r.path === CONFIG_YAML) {
617
+ log('💡 config.yaml: 保留用户配置不变,更新文档注释');
618
+ continue;
619
+ }
620
+
621
+ const labels = { NEW: '➕ 新增', WILL_UPDATE: '🔄 更新', CONFLICT: '⚠️ 冲突(将备份后覆盖)', NO_LOCK_CONFLICT: '⚠️ 无锁记录', UPSTREAM_REMOVED: '🗑️ 上游移除' };
436
622
  log(labels[r.type] + ' ' + r.path);
437
623
  }
438
624
  if (stats.newCount === 0 && stats.updated === 0 && stats.conflicted === 0 && stats.upstreamRemoved === 0) {
@@ -445,6 +631,9 @@ function cmd_sync({ dryRun, targetPath }) {
445
631
 
446
632
  // 9. 执行写入
447
633
  const newFiles = new Map();
634
+ const HOOKS_JSON = '.cursor/hooks.json';
635
+ const CONFIG_YAML = 'specline/config.yaml';
636
+ const mergeStats = { hooksMerged: false, configUpdated: false, backupsCreated: 0 };
448
637
 
449
638
  for (const r of results) {
450
639
  if (r.type === 'UNCHANGED' || r.type === 'MODIFIED_ONLY') {
@@ -460,7 +649,6 @@ function cmd_sync({ dryRun, targetPath }) {
460
649
  continue;
461
650
  }
462
651
 
463
- // NEW/WILL_UPDATE/CONFLICT/NO_LOCK_CONFLICT: 复制模板文件
464
652
  const srcPath = join(TEMPLATES_DIR, r.path);
465
653
  const destPath = join(target, r.path);
466
654
  const destDir = dirname(destPath);
@@ -469,13 +657,50 @@ function cmd_sync({ dryRun, targetPath }) {
469
657
  }
470
658
 
471
659
  try {
472
- copyFileSync(srcPath, destPath);
473
- newFiles.set(r.path, computeFileHash(destPath));
660
+ // 特殊文件:hooks.json 语义合并
661
+ if (r.path === HOOKS_JSON) {
662
+ const existingContent = existsSync(destPath) ? readFileSync(destPath, 'utf-8') : '{}';
663
+ const templateContent = readFileSync(srcPath, 'utf-8');
664
+ try {
665
+ const merged = mergeHooksJson(existingContent, templateContent);
666
+ writeFileSync(destPath, merged, 'utf-8');
667
+ newFiles.set(r.path, sha256(merged));
668
+ mergeStats.hooksMerged = true;
669
+ } catch {
670
+ warn('hooks.json 合并失败,将保留现有文件');
671
+ newFiles.set(r.path, computeFileHash(destPath));
672
+ }
673
+ continue;
674
+ }
675
+
676
+ // 特殊文件:config.yaml 注释合并
677
+ if (r.path === CONFIG_YAML) {
678
+ const existingContent = existsSync(destPath) ? readFileSync(destPath, 'utf-8') : '';
679
+ const templateContent = readFileSync(srcPath, 'utf-8');
680
+ try {
681
+ const merged = mergeConfigYaml(existingContent, templateContent);
682
+ writeFileSync(destPath, merged, 'utf-8');
683
+ newFiles.set(r.path, sha256(merged));
684
+ mergeStats.configUpdated = true;
685
+ } catch {
686
+ warn('config.yaml 合并失败,将保留现有文件');
687
+ newFiles.set(r.path, computeFileHash(destPath));
688
+ }
689
+ continue;
690
+ }
474
691
 
475
- if (r.type === 'CONFLICT') {
476
- warn('已覆盖(冲突): ' + r.path);
477
- } else if (r.type === 'NO_LOCK_CONFLICT') {
478
- warn('已覆盖(无锁文件记录): ' + r.path);
692
+ // CONFLICT:备份后覆盖
693
+ if (r.type === 'CONFLICT' || r.type === 'NO_LOCK_CONFLICT') {
694
+ if (existsSync(destPath)) {
695
+ const backupPath = backupBeforeOverwrite(destPath);
696
+ mergeStats.backupsCreated++;
697
+ warn('已覆盖(冲突,备份: ' + basename(backupPath) + '): ' + r.path);
698
+ }
699
+ copyFileSync(srcPath, destPath);
700
+ newFiles.set(r.path, computeFileHash(destPath));
701
+ } else {
702
+ copyFileSync(srcPath, destPath);
703
+ newFiles.set(r.path, computeFileHash(destPath));
479
704
  }
480
705
  } catch (err) {
481
706
  warn(r.path + ' 写入失败:' + err.message);
@@ -493,7 +718,8 @@ function cmd_sync({ dryRun, targetPath }) {
493
718
  });
494
719
 
495
720
  // 11. 输出摘要
496
- if (stats.newCount === 0 && stats.updated === 0 && stats.conflicted === 0 && stats.upstreamRemoved === 0) {
721
+ if (stats.newCount === 0 && stats.updated === 0 && stats.conflicted === 0 && stats.upstreamRemoved === 0
722
+ && !mergeStats.hooksMerged && !mergeStats.configUpdated) {
497
723
  log('所有模板文件已是最新,无需同步');
498
724
  } else {
499
725
  log('📊 同步摘要:');
@@ -503,6 +729,9 @@ function cmd_sync({ dryRun, targetPath }) {
503
729
  log(' ⚠️ 已覆盖(冲突): ' + stats.conflicted);
504
730
  log(' ⏭️ 已跳过(本地修改): ' + stats.skippedModified);
505
731
  log(' 🗑️ 上游已移除: ' + stats.upstreamRemoved);
732
+ if (mergeStats.hooksMerged) log(' 🔧 hooks.json: 语义合并完成');
733
+ if (mergeStats.configUpdated) log(' 📝 config.yaml: 注释已更新');
734
+ if (mergeStats.backupsCreated > 0) log(' 💾 创建备份: ' + mergeStats.backupsCreated + ' 个 .orig 文件');
506
735
  log(' ✨ 锁文件已更新至 v' + VERSION);
507
736
  }
508
737
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specline",
3
- "version": "1.2.2",
3
+ "version": "1.3.0",
4
4
  "description": "Spec-driven AI coding pipeline with deterministic quality gates for Cursor IDE",
5
5
  "bin": {
6
6
  "specline": "./cli.mjs"
@@ -19,6 +19,7 @@ description: >-
19
19
  - 功能名称 → kebab-case change name(如 "添加用户登录" → `add-user-login`)
20
20
  - 核心功能点列表
21
21
  - 技术栈上下文(如果有)
22
+ - 语言上下文(由编排者从项目检测结果注入,用于确定测试路径约定)
22
23
 
23
24
  #### Step 2: 创建目录结构
24
25
 
@@ -211,16 +212,33 @@ specline-pipeline-gate.sh new --change "<change-name>"
211
212
  ````markdown
212
213
  ### 测试文件归属
213
214
 
215
+ 根据编排者注入的项目语言上下文选择对应的测试路径约定:
216
+
217
+ | 语言 | 单元测试路径 | 集成/E2E 测试路径 | 命名约定 |
218
+ |------|------------|-----------------|---------|
219
+ | Go | `<package>/<name>_test.go`(与源码同目录) | `tests/integration/` 或内联 | `TestXxx` 函数 |
220
+ | Python | `tests/unit/<module>/test_<name>.py` | `tests/integration/test_<cap>.py` | `test_xxx` 函数 |
221
+ | TypeScript/JavaScript | `__tests__/<name>.test.ts` 或 `<name>.spec.ts` | `tests/integration/<cap>.test.ts` | `describe/it` 块 |
222
+ | Rust | `src/<mod>/tests.rs` 或 `#[cfg(test)]` 模块 | `tests/<name>.rs` | `#[test] fn xxx()` |
223
+
224
+ **生成规则**:
225
+ - 如果语言上下文为 Go:单元测试路径使用模块相对路径 + `_test.go` 后缀,不生成 `tests/unit/` 引用
226
+ - 如果语言上下文为 Python:保持原有 `tests/unit/<module>/` 约定
227
+ - 如果语言上下文为 TypeScript:使用 `__tests__/` 或与源码同级的 `.test.ts`
228
+ - 如果无语言上下文(向后兼容):使用 Python 约定作为默认
229
+
214
230
  | 测试文件(目录) | 测试类型 | 负责者 |
215
231
  |-----------------|---------|-------|
216
- | tests/unit/<module>/ | 单元测试 | Coding Agent (Task N) |
217
- | tests/integration/test_<capability>.py | 集成测试 | specline-test-writer |
218
- | tests/e2e/test_<capability>_flow.py | E2E 测试 | specline-test-writer |
232
+ | [根据语言约定的单元测试路径] | 单元测试 | Coding Agent (Task N) |
233
+ | [根据语言约定的集成测试路径] | 集成测试 | specline-test-writer |
234
+ | [根据语言约定的 E2E 测试路径] | E2E 测试 | specline-test-writer |
219
235
  ````
220
236
 
221
237
  **生成规则**:
222
- - 对每个 `Testable: true` 的任务,从其任务描述和 Files 字段推导模块名,生成单元测试目录行(`tests/unit/<module>/` 或 `tests/models/<module>/`),负责者标注为「Coding Agent (Task N)」
223
- - `specs/` 下每个 capability 目录,生成集成测试文件行(`tests/integration/test_<capability>.py`)和 E2E 测试文件行(`tests/e2e/test_<capability>_flow.py`),负责者标注为「specline-test-writer
238
+ - 根据编排者注入的语言上下文,从上方语言映射表选择对应的测试路径约定(无语言上下文时默认 Python)
239
+ - 对每个 `Testable: true` 的任务,从其任务描述和 Files 字段推导模块名,按语言约定生成单元测试路径行,负责者标注为「Coding Agent (Task N)
240
+ - 对 `specs/` 下每个 capability 目录,按语言约定生成集成测试和 E2E 测试路径行,负责者标注为「specline-test-writer」
241
+ - Go 项目禁止生成 `tests/unit/` 引用;单元测试路径使用 `_test.go` 后缀与源码同目录
224
242
  - 表格按 capability 分组,单元测试行在前、集成/E2E 测试行在后
225
243
  - 如果无 Testable: true 的任务,跳过 Coding Agent 的单元测试行,仅保留集成/E2E 行
226
244
  - **测试文件归属** 节放在所有 `## N. [ ]` 任务节之后、tasks.md 文件末尾
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # a1-covers-ref.sh — A1: Covers 引用存在性验证
4
+ #
5
+ # 验证 tasks.md 中每个任务的 Covers 字段引用的 Requirement 名称和 Scenario
6
+ # 名称在 spec.md 中实际存在。
7
+ #
8
+ # 兼容 bash 3.2+(macOS 默认版本),不使用关联数组(declare -A)。
9
+ #
10
+ # 依赖 common.sh 中定义的:
11
+ # - semantic_error(code, msg)
12
+ # - semantic_warn(code, msg)
13
+ # - semantic_info(code, msg)
14
+ # - SEMANTIC_ERRORS / SEMANTIC_WARNINGS / SEMANTIC_INFOS 全局计数器
15
+ #
16
+ # 环境变量:
17
+ # SPEC_FILE — spec.md 的路径
18
+ # TASKS_FILE — tasks.md 的路径
19
+
20
+ # 确保正确处理多字节 UTF-8 字符(中文 Scenario/Requirement 名称)
21
+ export LC_ALL="${LC_ALL:-zh_CN.UTF-8}"
22
+
23
+ run_a1_covers_ref() {
24
+ # ==== 输入校验 ====
25
+ if [ ! -f "${SPEC_FILE:-}" ]; then
26
+ semantic_error "A1" "spec.md 不存在: ${SPEC_FILE:-未设置}"
27
+ return
28
+ fi
29
+
30
+ if [ ! -f "${TASKS_FILE:-}" ]; then
31
+ semantic_error "A1" "tasks.md 不存在: ${TASKS_FILE:-未设置}"
32
+ return
33
+ fi
34
+
35
+ # ==== 临时文件(存储 Requirement 和 Scenario 名称集合) ====
36
+ # 兼容 bash 3.2,不使用 declare -A 关联数组
37
+ local _req_file _scen_file
38
+ _req_file=$(mktemp) || { semantic_error "A1" "无法创建临时文件"; return; }
39
+ _scen_file=$(mktemp) || { rm -f "$_req_file"; semantic_error "A1" "无法创建临时文件"; return; }
40
+
41
+ # ==== 1. 从 spec.md 提取 Requirement 和 Scenario 名称 ====
42
+ local current_req="" scen_name=""
43
+
44
+ while IFS= read -r line; do
45
+ if [[ "$line" =~ ^###[[:space:]]+Requirement:[[:space:]]+(.+)$ ]]; then
46
+ current_req="${BASH_REMATCH[1]}"
47
+ current_req=$(echo "$current_req" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')
48
+ echo "$current_req" >> "$_req_file"
49
+ elif [[ "$line" =~ ^####[[:space:]]+Scenario:[[:space:]]+(.+)$ ]]; then
50
+ scen_name="${BASH_REMATCH[1]}"
51
+ scen_name=$(echo "$scen_name" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')
52
+ if [ -n "$current_req" ]; then
53
+ echo "${current_req}|${scen_name}" >> "$_scen_file"
54
+ fi
55
+ fi
56
+ done < "$SPEC_FILE"
57
+
58
+ # ==== 2. 从 tasks.md 解析 Covers 引用 ====
59
+ local task_num=0
60
+ local covers_content req_name scenarios_str split_list
61
+ covers_content=""; req_name=""; scenarios_str=""; split_list=""
62
+
63
+ while IFS= read -r line; do
64
+ # 追踪任务编号(从 "## N." 标题行)
65
+ if [[ "$line" =~ ^##[[:space:]]+([0-9]+)\. ]]; then
66
+ task_num="${BASH_REMATCH[1]}"
67
+ continue
68
+ fi
69
+
70
+ # 跳过非 Covers 行
71
+ if [[ "$line" != *"**Covers**:"* ]]; then
72
+ continue
73
+ fi
74
+
75
+ # 提取 Covers 内容
76
+ covers_content=$(echo "$line" \
77
+ | sed 's/.*\*\*Covers\*\*:[[:space:]]*//' \
78
+ | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')
79
+
80
+ # 格式检查:必须有 "Requirement:" 前缀
81
+ if [[ ! "$covers_content" =~ Requirement: ]]; then
82
+ semantic_warn "A1" "任务 $task_num 的 Covers 行格式不规范,跳过该任务的引用验证"
83
+ continue
84
+ fi
85
+
86
+ # 提取 Requirement 名称(Requirement: 之后到第一个分隔符之前)
87
+ req_name=$(echo "$covers_content" \
88
+ | sed -n 's/.*Requirement:[[:space:]]*//p' \
89
+ | sed 's/[[:space:]]*[,,、].*//' \
90
+ | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')
91
+
92
+ if [ -z "$req_name" ]; then
93
+ semantic_warn "A1" "任务 $task_num 的 Covers 行缺少 Requirement 名称,跳过该任务的引用验证"
94
+ continue
95
+ fi
96
+
97
+ # 校验 Requirement 存在性
98
+ if ! grep -qxF "$req_name" "$_req_file" 2>/dev/null; then
99
+ semantic_error "A1" "Covers 引用不存在: 任务 $task_num 引用了不存在的 Requirement \"$req_name\""
100
+ fi
101
+
102
+ # 提取并校验 Scenario 名称列表
103
+ if [[ "$covers_content" =~ Scenario:[[:space:]]*(.+)$ ]]; then
104
+ scenarios_str="${BASH_REMATCH[1]}"
105
+ scenarios_str=$(echo "$scenarios_str" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')
106
+
107
+ # 拆分 Scenario 名称(分隔符:、 , ,)
108
+ split_list=$(echo "$scenarios_str" \
109
+ | sed 's/[、,]/\'$'\n''/g' \
110
+ | sed 's/,[[:space:]]*/\'$'\n''/g')
111
+
112
+ while IFS= read -r scen_name; do
113
+ scen_name=$(echo "$scen_name" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')
114
+ [ -z "$scen_name" ] && continue
115
+
116
+ if ! grep -qxF "${req_name}|${scen_name}" "$_scen_file" 2>/dev/null; then
117
+ semantic_error "A1" "Covers 引用不存在: 任务 $task_num 引用了不存在的 Scenario \"$scen_name\"(在 Requirement \"$req_name\" 下)"
118
+ fi
119
+ done <<< "$split_list"
120
+ fi
121
+ done < "$TASKS_FILE"
122
+
123
+ # ==== 清理临时文件 ====
124
+ rm -f "$_req_file" "$_scen_file"
125
+ }
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # a2-a3-reverse.sh — A2/A3 反向覆盖验证
4
+ #
5
+ # 从 spec.md 提取所有 Requirement 和 Scenario 名称,
6
+ # 与 tasks.md 中 Covers 字段引用的名称做交叉比对,
7
+ # 输出未被任何任务覆盖的 Requirement 和 Scenario(INFO 级别)。
8
+ #
9
+ # 依赖环境变量:
10
+ # SPEC_FILE — spec.md 文件路径
11
+ # TASKS_FILE — tasks.md 文件路径
12
+ # SPEC_REVIEW_FILE — 可选,spec-review.json 路径(交叉验证)
13
+ #
14
+ # 使用方式:
15
+ # source a2-a3-reverse.sh
16
+ # SPEC_FILE=... TASKS_FILE=... run_a2_a3_reverse
17
+
18
+ run_a2_a3_reverse() {
19
+ local spec_file="${SPEC_FILE:-}"
20
+ local tasks_file="${TASKS_FILE:-}"
21
+ local review_file="${SPEC_REVIEW_FILE:-}"
22
+
23
+ # 验证输入文件
24
+ if [ -z "$spec_file" ] || [ ! -f "$spec_file" ]; then
25
+ echo "ERROR: spec.md 文件不存在或未通过 SPEC_FILE 指定: ${spec_file:-未设置}" >&2
26
+ return 1
27
+ fi
28
+ if [ -z "$tasks_file" ] || [ ! -f "$tasks_file" ]; then
29
+ echo "ERROR: tasks.md 文件不存在或未通过 TASKS_FILE 指定: ${tasks_file:-未设置}" >&2
30
+ return 1
31
+ fi
32
+
33
+ # =========================================
34
+ # Step 1: 从 spec.md 提取所有 Requirement 和 Scenario 名称
35
+ # =========================================
36
+
37
+ local tmp_spec_reqs
38
+ tmp_spec_reqs=$(mktemp) || return 1
39
+ local tmp_spec_scens
40
+ tmp_spec_scens=$(mktemp) || return 1
41
+ local tmp_tasks_reqs
42
+ tmp_tasks_reqs=$(mktemp) || return 1
43
+ local tmp_tasks_scens
44
+ tmp_tasks_scens=$(mktemp) || return 1
45
+
46
+ _a2a3_cleanup() {
47
+ rm -f "$tmp_spec_reqs" "$tmp_spec_scens" "$tmp_tasks_reqs" "$tmp_tasks_scens"
48
+ }
49
+ trap _a2a3_cleanup RETURN
50
+
51
+ # 从 spec.md 解析 Requirement 和 Scenario
52
+ local current_req=""
53
+ while IFS= read -r line; do
54
+ if [[ "$line" =~ ^###[[:space:]]+Requirement:[[:space:]]+(.+)$ ]]; then
55
+ current_req="${BASH_REMATCH[1]}"
56
+ current_req="${current_req%"${current_req##*[![:space:]]}"}"
57
+ echo "$current_req" >> "$tmp_spec_reqs"
58
+ elif [[ "$line" =~ ^####[[:space:]]+Scenario:[[:space:]]+(.+)$ ]]; then
59
+ local scen="${BASH_REMATCH[1]}"
60
+ scen="${scen%"${scen##*[![:space:]]}"}"
61
+ if [ -n "$current_req" ]; then
62
+ printf '%s\t%s\n' "$current_req" "$scen" >> "$tmp_spec_scens"
63
+ fi
64
+ fi
65
+ done < "$spec_file"
66
+
67
+ # =========================================
68
+ # Step 2: 从 tasks.md 提取 Covers 引用的 Requirement 和 Scenario
69
+ # =========================================
70
+
71
+ while IFS= read -r line; do
72
+ if [[ "$line" =~ \*\*Covers\*\*[[:space:]]*:[[:space:]]*(.+) ]]; then
73
+ local covers_content="${BASH_REMATCH[1]}"
74
+
75
+ # 提取 Requirement 名称
76
+ if [[ "$covers_content" =~ Requirement:[[:space:]]*([^,,]+) ]]; then
77
+ local task_req="${BASH_REMATCH[1]}"
78
+ task_req="${task_req%"${task_req##*[![:space:]]}"}"
79
+ echo "$task_req" >> "$tmp_tasks_reqs"
80
+ fi
81
+
82
+ # 提取 Scenario 名称
83
+ if [[ "$covers_content" =~ Scenario:[[:space:]]*(.+)$ ]]; then
84
+ local scenarios_str="${BASH_REMATCH[1]}"
85
+ scenarios_str="${scenarios_str%"${scenarios_str##*[![:space:]]}"}"
86
+
87
+ # 用 、或 , 或 ,分割 Scenario 名称
88
+ local cleaned="${scenarios_str//、/ }"
89
+ cleaned="${cleaned//,/,}"
90
+ cleaned="${cleaned//,/ }"
91
+ cleaned="${cleaned//\// }"
92
+
93
+ for item in $cleaned; do
94
+ item="${item#"${item%%[![:space:]]*}"}"
95
+ item="${item%"${item##*[![:space:]]}"}"
96
+ if [ -n "$item" ]; then
97
+ echo "$item" >> "$tmp_tasks_scens"
98
+ fi
99
+ done
100
+ fi
101
+ fi
102
+ done < "$tasks_file"
103
+
104
+ # =========================================
105
+ # Step 3: 计算差集 — 未被覆盖的 Requirement 和 Scenario
106
+ # =========================================
107
+
108
+ sort -u "$tmp_spec_reqs" -o "$tmp_spec_reqs" 2>/dev/null || true
109
+ sort -u "$tmp_spec_scens" -o "$tmp_spec_scens" 2>/dev/null || true
110
+ sort -u "$tmp_tasks_reqs" -o "$tmp_tasks_reqs" 2>/dev/null || true
111
+ sort -u "$tmp_tasks_scens" -o "$tmp_tasks_scens" 2>/dev/null || true
112
+
113
+ local uncovered_req_count=0
114
+ local uncovered_scen_count=0
115
+
116
+ # 查找未被覆盖的 Requirement
117
+ if [ -s "$tmp_spec_reqs" ]; then
118
+ while IFS= read -r req; do
119
+ if ! grep -qxF "$req" "$tmp_tasks_reqs" 2>/dev/null; then
120
+ semantic_info "A2/A3" "Requirement \"${req}\" 不被任何任务覆盖"
121
+ uncovered_req_count=$((uncovered_req_count + 1))
122
+ fi
123
+ done < "$tmp_spec_reqs"
124
+ fi
125
+
126
+ # 查找未被覆盖的 Scenario
127
+ if [ -s "$tmp_spec_scens" ]; then
128
+ while IFS=$'\t' read -r req scen; do
129
+ if ! grep -qxF "$scen" "$tmp_tasks_scens" 2>/dev/null; then
130
+ semantic_info "A2/A3" "Scenario \"${scen}\"(Requirement: \"${req}\")不被任何任务覆盖"
131
+ uncovered_scen_count=$((uncovered_scen_count + 1))
132
+ fi
133
+ done < "$tmp_spec_scens"
134
+ fi
135
+
136
+ # =========================================
137
+ # Step 4: 全部覆盖时的汇总信息
138
+ # =========================================
139
+ if [ "$uncovered_req_count" -eq 0 ] && [ "$uncovered_scen_count" -eq 0 ]; then
140
+ semantic_info "A2/A3" "所有 Requirement 和 Scenario 均被 Covers 覆盖"
141
+ fi
142
+
143
+ # =========================================
144
+ # Step 5: 与 spec-review.json 交叉验证(如果存在)
145
+ # =========================================
146
+ if [ -z "$review_file" ]; then
147
+ local spec_dir
148
+ spec_dir=$(dirname "$spec_file")
149
+ if [ -f "${spec_dir}/spec-review.json" ]; then
150
+ review_file="${spec_dir}/spec-review.json"
151
+ fi
152
+ fi
153
+
154
+ if [ -n "$review_file" ] && [ -f "$review_file" ]; then
155
+ local review_reqs_covered
156
+ local review_reqs_total
157
+ review_reqs_covered=$(jq -r '.coverage.requirements_covered // "N/A"' "$review_file" 2>/dev/null || echo "N/A")
158
+ review_reqs_total=$(jq -r '.coverage.requirements_total // "N/A"' "$review_file" 2>/dev/null || echo "N/A")
159
+
160
+ if [ "$review_reqs_covered" != "N/A" ] && [ "$review_reqs_total" != "N/A" ]; then
161
+ local spec_total_reqs
162
+ local spec_covered_reqs
163
+ spec_total_reqs=$(wc -l < "$tmp_spec_reqs" | tr -d '[:space:]')
164
+ spec_covered_reqs=$((spec_total_reqs - uncovered_req_count))
165
+
166
+ if [ "$spec_covered_reqs" -ne "$review_reqs_covered" ] || [ "$spec_total_reqs" -ne "$review_reqs_total" ]; then
167
+ semantic_info "A2/A3" "与 spec-review.json 差异: A2 发现 ${spec_total_reqs} 个 Requirement(${spec_covered_reqs} 被覆盖),spec-review.json 报告 ${review_reqs_total} 个(${review_reqs_covered} 被覆盖)"
168
+ fi
169
+ fi
170
+ fi
171
+ }