specline 1.2.2 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,7 +43,7 @@
43
43
  - **确定性门禁**:每个阶段用 Shell 脚本的退出码判定是否通过,不做模糊判断
44
44
  - **黑盒测试**:测试 Agent 只看 Spec 文档,不能读取任何实现源码
45
45
  - **断点续跑**:随时中断,下次从最后一个可信门禁自动恢复(tasks.md 的 `[x]`/`[ ]` 标记进度)
46
- - **人机协作**:3 个人工检查点——Spec 确认、Review 可选复核、归档确认
46
+ - **人机协作**:3 个人工检查点——Spec 确认、Review 可选复核、归档确认,支持 `full`/`minimal`/`none` 三级自动化策略配置(`specline/config.yaml` 中 `pipeline.human_gate_policy`)
47
47
  - **Hook 约束体系**:sessionStart 注入 pipeline 上下文 → preToolUse 违规拦截 → postToolUse 操作后提醒,确保长对话中 Agent 不偏离流水线逻辑
48
48
  - **安全 Hook**:自动拦截危险 Shell 命令(如 `rm -rf`、`curl|bash`)+ 代码变更后自动格式化
49
49
  - **零外部依赖**:不依赖 OpenSpec CLI,全部功能自包含
@@ -77,10 +77,14 @@ my-project/
77
77
  │ ├── agents/ ← 9 个 Specline Agent 定义
78
78
  │ ├── commands/ ← 3 个 Slash 命令入口
79
79
  │ ├── skills/ ← 6 个 Skill 指令
80
+ │ │ └── specline-pipeline/
81
+ │ │ ├── SKILL.md ← 核心编排指令(~500 行)
82
+ │ │ ├── templates/ ← 子 Agent prompt 模板
83
+ │ │ └── references/ ← Schema / 事件日志 / 约束参考文档
80
84
  │ ├── hooks/ ← 7 个 Gate/Hook 脚本
81
85
  │ └── hooks.json ← Cursor Hook 配置
82
86
  ├── specline/ ← 运行时目录
83
- │ ├── config.yaml
87
+ │ ├── config.yaml ← 项目配置(含 pipeline 人机门禁策略)
84
88
  │ ├── changes/ ← 变更目录
85
89
  │ │ └── archive/ ← 归档目录
86
90
  │ └── specs/ ← 主规格目录
@@ -132,8 +136,8 @@ PHASE 1: SPEC(规格)
132
136
  ├── design.md — 技术设计(架构/数据流/决策)
133
137
  └── tasks.md — 任务清单(Type/Depends/Covers/Testable/Files + [ ] 进度标记)
134
138
  → specline-spec-reviewer 审核
135
- → Gate: grep + jq 格式校验
136
- → 🟡 人工确认 Spec 和任务规划
139
+ → Gate: grep + jq 格式校验 + semantic 语义检查(Covers 引用悬空 / 依赖环路 / 异常场景缺失 / 模糊需求检测)
140
+ → 🟡 人工确认 Spec 和任务规划(策略可配:`full` 需确认 / `minimal` `none` 自动通过)
137
141
 
138
142
  PHASE 2: CODING(编码)
139
143
  解析 tasks.md → 按依赖 DAG 分层 → 同批次前后端/config Agent 并发
@@ -152,7 +156,7 @@ PHASE 4: TEST(测试)
152
156
  → 自动重试最多 2 次
153
157
 
154
158
  PHASE 5: ARCHIVE(归档)
155
- → 🟡 人工确认归档
159
+ → 🟡 人工确认归档(策略可配:`full` `minimal` 需确认 / `none` 自动归档)
156
160
  → delta specs 合并到主规格目录
157
161
  → 按日期归档到 specline/changes/archive/
158
162
  ✅ 完成
@@ -205,6 +209,10 @@ PHASE 5: ARCHIVE(归档)
205
209
  ▼ ▼
206
210
  specline-pipeline SKILL ← 编排层 编排者直接执行(无子 Agent)
207
211
  │ Read → Write → ReadLints → Shell → 归档
212
+ ├── SKILL.md 核心编排指令(~500 行)
213
+ ├── templates/ subagent-prompts.md(3 套 prompt 模板)
214
+ └── references/ Schema / 事件日志 / 约束参考文档
215
+
208
216
  ┌───┼──────────────────┬──────────────────────┐
209
217
  ▼ ▼ ▼ ▼
210
218
  9 个子 Agent specline-pipeline- Cursor Hooks
@@ -218,7 +226,7 @@ specline-pipeline SKILL ← 编排层 编排者直接执行(
218
226
  |------|------|
219
227
  | `specline init [path]` | 在指定路径(默认当前目录)初始化 Specline 项目,复制模板文件并生成锁文件 |
220
228
  | `specline update` | 检查 CLI 是否有新版本可用(npm registry),输出更新提示 |
221
- | `specline sync [--dry-run] [path]` | 将上游最新模板文件同步到项目,基于 Lock File 智能识别安全更新/冲突/仅本地修改。`--dry-run` 预览变更不实际写入 |
229
+ | `specline sync [--dry-run] [path]` | 将上游最新模板文件同步到项目,基于 Lock File 智能识别安全更新/冲突/仅本地修改。hooks.json 语义合并(保留用户自定义 hook)、config.yaml 注释级更新(保留用户配置值)、CONFLICT 覆盖前自动创建 `.orig` 备份。`--dry-run` 预览变更不实际写入 |
222
230
  | `specline --version` | 显示当前 CLI 版本号 |
223
231
  | `specline --help` | 显示帮助信息 |
224
232
 
@@ -242,7 +250,7 @@ specline-pipeline SKILL ← 编排层 编排者直接执行(
242
250
 
243
251
  | 门禁 | 检查内容 |
244
252
  |------|---------|
245
- | Spec | `grep` 检查 Purpose/Requirements/Scenarios 章节完整性、WHEN/THEN 配对、Testable 字段格式与一致性 |
253
+ | Spec | 结构性检查(`grep` 检查章节完整性、WHEN/THEN 配对、字段格式)+ 语义检查(`semantic` 子命令:Covers 引用悬空、依赖环路、异常场景缺失、模糊需求、反向覆盖、Type-文件一致性,分 ERROR/WARNING/INFO 三级严重度) |
246
254
  | Build | `tsc --noEmit` / `python -m compileall` 编译检查 + Testable 任务单元测试文件存在性与语法检查 |
247
255
  | Lint | `ruff` / `eslint` 退出码 + code-review.json 中 error 数量 |
248
256
  | Test | 测试框架退出码 + 覆盖率阈值 |
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';
@@ -88,11 +88,13 @@ function writeLockFile(projectDir, lockData) {
88
88
  }
89
89
 
90
90
  /**
91
- * 遍历 TEMPLATES_DIR 所有文件,构建锁数据结构
91
+ * 遍历指定目录所有文件,构建锁数据结构
92
+ * rootDir: 要遍历的根目录(必须是目标项目目录,这样 init 后锁哈希与实际文件一致)
92
93
  * 返回 { version, synced_at, files: Map<string, string> }
93
94
  */
94
- function buildLockData(projectDir) {
95
+ function buildLockData(projectDir, rootDir) {
95
96
  const files = new Map();
97
+ const walkRoot = rootDir || TEMPLATES_DIR;
96
98
 
97
99
  function walk(dir, base) {
98
100
  const entries = readdirSync(dir, { withFileTypes: true });
@@ -107,7 +109,7 @@ function buildLockData(projectDir) {
107
109
  }
108
110
  }
109
111
 
110
- walk(TEMPLATES_DIR, '');
112
+ walk(walkRoot, '');
111
113
 
112
114
  return {
113
115
  version: VERSION,
@@ -193,6 +195,149 @@ function copyDirRecursive(src, dest) {
193
195
  }
194
196
  }
195
197
 
198
+ // ============================================================
199
+ // 智能合并函数 — hooks.json / config.yaml / CONFLICT 备份
200
+ // ============================================================
201
+
202
+ /**
203
+ * hooks.json 语义合并:清理所有 specline-* 条目,注入模板最新官方 hook
204
+ */
205
+ function mergeHooksJson(existingContent, templateContent) {
206
+ let existingObj, templateObj;
207
+ try {
208
+ existingObj = JSON.parse(existingContent);
209
+ } catch {
210
+ warn('hooks.json 解析失败,将使用模板完整替换');
211
+ return templateContent;
212
+ }
213
+ try {
214
+ templateObj = JSON.parse(templateContent);
215
+ } catch {
216
+ warn('模板 hooks.json 解析失败,保留现有文件');
217
+ return existingContent;
218
+ }
219
+
220
+ for (const eventName of Object.keys(templateObj.hooks || {})) {
221
+ if (!existingObj.hooks) {
222
+ existingObj.hooks = {};
223
+ }
224
+ if (!existingObj.hooks[eventName]) {
225
+ existingObj.hooks[eventName] = [];
226
+ }
227
+ existingObj.hooks[eventName] = existingObj.hooks[eventName].filter(
228
+ (entry) => !(entry.command || '').includes('specline-')
229
+ );
230
+ existingObj.hooks[eventName] = [
231
+ ...templateObj.hooks[eventName],
232
+ ...existingObj.hooks[eventName],
233
+ ];
234
+ }
235
+ return JSON.stringify(existingObj, null, 2) + '\n';
236
+ }
237
+
238
+ function countCustomHooks(hooksObj) {
239
+ let count = 0;
240
+ for (const eventName of Object.keys(hooksObj.hooks || {})) {
241
+ for (const entry of (hooksObj.hooks[eventName] || [])) {
242
+ if (!(entry.command || '').includes('specline-')) count++;
243
+ }
244
+ }
245
+ return count;
246
+ }
247
+
248
+ /**
249
+ * YAML 段落结构
250
+ */
251
+ function parseYamlSections(content) {
252
+ const lines = content.split('\n');
253
+ const sections = [];
254
+ let currentComments = [];
255
+ let currentKey = null;
256
+ let currentBodyLines = [];
257
+ let inBody = false;
258
+
259
+ function flushSection() {
260
+ if (currentComments.length > 0 || currentBodyLines.length > 0 || currentKey) {
261
+ const bodyStr = currentBodyLines.join('\n');
262
+ // 判定 isEmpty:body 为空、纯注释、或仅声明 key 但无实际值
263
+ const bodyTrimmed = bodyStr.trim();
264
+ const onlyKeyDeclaration = currentKey !== null &&
265
+ currentBodyLines.length === 1 &&
266
+ bodyTrimmed.match(/^\w[\w_-]*\s*:\s*$/) !== null;
267
+ const isEmpty = bodyTrimmed === '' ||
268
+ bodyTrimmed.startsWith('#') ||
269
+ onlyKeyDeclaration;
270
+ sections.push({ key: currentKey, headerComments: [...currentComments], body: bodyStr, isEmpty });
271
+ }
272
+ currentComments = [];
273
+ currentKey = null;
274
+ currentBodyLines = [];
275
+ inBody = false;
276
+ }
277
+
278
+ for (const line of lines) {
279
+ const trimmed = line.trim();
280
+ if (trimmed === '') { if (inBody) currentBodyLines.push(line); continue; }
281
+ if (trimmed.startsWith('#')) { if (inBody) currentBodyLines.push(line); else currentComments.push(line); continue; }
282
+ const topKeyMatch = line.match(/^(\w[\w_-]*)\s*:(.*)/);
283
+ if (topKeyMatch && !line.startsWith(' ') && !line.startsWith('\t')) {
284
+ flushSection();
285
+ currentKey = topKeyMatch[1];
286
+ currentBodyLines = [line];
287
+ inBody = true;
288
+ continue;
289
+ }
290
+ if (inBody) currentBodyLines.push(line);
291
+ }
292
+ flushSection();
293
+ return sections;
294
+ }
295
+
296
+ function findSection(sections, key) {
297
+ return sections.find((s) => s.key === key) || null;
298
+ }
299
+
300
+ function mergeConfigYaml(existingContent, templateContent) {
301
+ const existingSections = parseYamlSections(existingContent);
302
+ const templateSections = parseYamlSections(templateContent);
303
+ const resultLines = [];
304
+
305
+ for (const tmplSec of templateSections) {
306
+ const existSec = findSection(existingSections, tmplSec.key);
307
+ if (existSec) {
308
+ if (!existSec.isEmpty && existSec.body.trim() !== tmplSec.body.trim()) {
309
+ resultLines.push(...tmplSec.headerComments);
310
+ resultLines.push(existSec.body);
311
+ } else {
312
+ resultLines.push(...tmplSec.headerComments);
313
+ resultLines.push(tmplSec.body);
314
+ }
315
+ resultLines.push('');
316
+ } else if (tmplSec.key !== null) {
317
+ resultLines.push('# 🆕 新增配置段 (specline sync)');
318
+ resultLines.push(...tmplSec.headerComments);
319
+ resultLines.push(tmplSec.body);
320
+ resultLines.push('');
321
+ }
322
+ }
323
+
324
+ for (const existSec of existingSections) {
325
+ if (existSec.key === null) continue;
326
+ if (!findSection(templateSections, existSec.key)) {
327
+ resultLines.push(...existSec.headerComments);
328
+ resultLines.push(existSec.body);
329
+ resultLines.push('');
330
+ }
331
+ }
332
+ return resultLines.join('\n');
333
+ }
334
+
335
+ function backupBeforeOverwrite(destPath) {
336
+ const backupPath = destPath + '.orig';
337
+ copyFileSync(destPath, backupPath);
338
+ return backupPath;
339
+ }
340
+
196
341
  function cmd_init(targetPath) {
197
342
  const cwd = process.cwd();
198
343
  const target = resolve(cwd, targetPath || '.');
@@ -280,7 +425,7 @@ initialized_at: "${new Date().toISOString()}"
280
425
  if (existsSync(lockPath) && !forceMode) {
281
426
  warn('锁文件已存在,跳过');
282
427
  } else {
283
- const lockData = buildLockData(target);
428
+ const lockData = buildLockData(target, target);
284
429
  writeLockFile(target, lockData);
285
430
  success('已生成锁文件');
286
431
  }
@@ -378,20 +523,14 @@ function cmd_sync({ dryRun, targetPath }) {
378
523
  const lockData = readLockFile(target);
379
524
 
380
525
  // 4. 版本校验
381
- if (lockData) {
382
- if (lockData.version === VERSION) {
383
- success('项目模板已与 CLI 版本同步 (v' + VERSION + ')');
384
- process.exit(0);
385
- }
386
- if (compareVersions(lockData.version, VERSION) > 0) {
387
- warn('锁文件版本 (v' + lockData.version + ') 高于 CLI 版本 (v' + VERSION + '),继续同步可能导致问题');
388
- if (!process.stdin.isTTY) {
389
- error('非交互式环境,已跳过同步');
390
- process.exit(1);
391
- }
392
- error('锁文件版本高于 CLI,请先更新 CLI');
526
+ if (lockData && compareVersions(lockData.version, VERSION) > 0) {
527
+ warn('锁文件版本 (v' + lockData.version + ') 高于 CLI 版本 (v' + VERSION + '),继续同步可能导致问题');
528
+ if (!process.stdin.isTTY) {
529
+ error('非交互式环境,已跳过同步');
393
530
  process.exit(1);
394
531
  }
532
+ error('锁文件版本高于 CLI,请先更新 CLI');
533
+ process.exit(1);
395
534
  }
396
535
 
397
536
  // 5. 收集所有需要分类的路径
@@ -406,6 +545,10 @@ function cmd_sync({ dryRun, targetPath }) {
406
545
  // 6. 分类
407
546
  const results = [];
408
547
  for (const path of allPaths) {
548
+ if (path === '.specline-config.yaml') {
549
+ // 项目标识文件,由 specline init 生成(含时间戳),sync 不覆盖
550
+ continue;
551
+ }
409
552
  const templateHash = upstreamFiles.get(path) || null;
410
553
  const lockEntry = lockData ? (lockData.files.get(path) || null) : null;
411
554
  const projectPath = join(target, path);
@@ -430,12 +573,56 @@ 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
- if (stats.newCount === 0 && stats.updated === 0 && stats.conflicted === 0 && stats.upstreamRemoved === 0) {
624
+ if (stats.newCount === 0 && stats.updated === 0 && stats.conflicted === 0
625
+ && stats.skippedModified === 0 && stats.upstreamRemoved === 0) {
439
626
  log('所有模板文件已是最新,无需同步');
440
627
  } else {
441
628
  log('\n以上为预览,未实际执行。去掉 --dry-run 以执行同步。');
@@ -445,6 +632,9 @@ function cmd_sync({ dryRun, targetPath }) {
445
632
 
446
633
  // 9. 执行写入
447
634
  const newFiles = new Map();
635
+ const HOOKS_JSON = '.cursor/hooks.json';
636
+ const CONFIG_YAML = 'specline/config.yaml';
637
+ const mergeStats = { hooksMerged: false, configUpdated: false, backupsCreated: 0 };
448
638
 
449
639
  for (const r of results) {
450
640
  if (r.type === 'UNCHANGED' || r.type === 'MODIFIED_ONLY') {
@@ -460,7 +650,6 @@ function cmd_sync({ dryRun, targetPath }) {
460
650
  continue;
461
651
  }
462
652
 
463
- // NEW/WILL_UPDATE/CONFLICT/NO_LOCK_CONFLICT: 复制模板文件
464
653
  const srcPath = join(TEMPLATES_DIR, r.path);
465
654
  const destPath = join(target, r.path);
466
655
  const destDir = dirname(destPath);
@@ -469,13 +658,50 @@ function cmd_sync({ dryRun, targetPath }) {
469
658
  }
470
659
 
471
660
  try {
472
- copyFileSync(srcPath, destPath);
473
- newFiles.set(r.path, computeFileHash(destPath));
661
+ // 特殊文件:hooks.json 语义合并
662
+ if (r.path === HOOKS_JSON) {
663
+ const existingContent = existsSync(destPath) ? readFileSync(destPath, 'utf-8') : '{}';
664
+ const templateContent = readFileSync(srcPath, 'utf-8');
665
+ try {
666
+ const merged = mergeHooksJson(existingContent, templateContent);
667
+ writeFileSync(destPath, merged, 'utf-8');
668
+ newFiles.set(r.path, sha256(merged));
669
+ mergeStats.hooksMerged = true;
670
+ } catch {
671
+ warn('hooks.json 合并失败,将保留现有文件');
672
+ newFiles.set(r.path, computeFileHash(destPath));
673
+ }
674
+ continue;
675
+ }
474
676
 
475
- if (r.type === 'CONFLICT') {
476
- warn('已覆盖(冲突): ' + r.path);
477
- } else if (r.type === 'NO_LOCK_CONFLICT') {
478
- warn('已覆盖(无锁文件记录): ' + r.path);
677
+ // 特殊文件:config.yaml 注释合并
678
+ if (r.path === CONFIG_YAML) {
679
+ const existingContent = existsSync(destPath) ? readFileSync(destPath, 'utf-8') : '';
680
+ const templateContent = readFileSync(srcPath, 'utf-8');
681
+ try {
682
+ const merged = mergeConfigYaml(existingContent, templateContent);
683
+ writeFileSync(destPath, merged, 'utf-8');
684
+ newFiles.set(r.path, sha256(merged));
685
+ mergeStats.configUpdated = true;
686
+ } catch {
687
+ warn('config.yaml 合并失败,将保留现有文件');
688
+ newFiles.set(r.path, computeFileHash(destPath));
689
+ }
690
+ continue;
691
+ }
692
+
693
+ // CONFLICT:备份后覆盖
694
+ if (r.type === 'CONFLICT' || r.type === 'NO_LOCK_CONFLICT') {
695
+ if (existsSync(destPath)) {
696
+ const backupPath = backupBeforeOverwrite(destPath);
697
+ mergeStats.backupsCreated++;
698
+ warn('已覆盖(冲突,备份: ' + basename(backupPath) + '): ' + r.path);
699
+ }
700
+ copyFileSync(srcPath, destPath);
701
+ newFiles.set(r.path, computeFileHash(destPath));
702
+ } else {
703
+ copyFileSync(srcPath, destPath);
704
+ newFiles.set(r.path, computeFileHash(destPath));
479
705
  }
480
706
  } catch (err) {
481
707
  warn(r.path + ' 写入失败:' + err.message);
@@ -493,8 +719,10 @@ function cmd_sync({ dryRun, targetPath }) {
493
719
  });
494
720
 
495
721
  // 11. 输出摘要
496
- if (stats.newCount === 0 && stats.updated === 0 && stats.conflicted === 0 && stats.upstreamRemoved === 0) {
497
- log('所有模板文件已是最新,无需同步');
722
+ if (stats.newCount === 0 && stats.updated === 0 && stats.conflicted === 0
723
+ && stats.skippedModified === 0 && stats.upstreamRemoved === 0
724
+ && !mergeStats.hooksMerged && !mergeStats.configUpdated) {
725
+ success('项目模板已是最新,无需同步 (v' + VERSION + ')');
498
726
  } else {
499
727
  log('📊 同步摘要:');
500
728
  log(' 总模板文件: ' + allPaths.size);
@@ -503,6 +731,9 @@ function cmd_sync({ dryRun, targetPath }) {
503
731
  log(' ⚠️ 已覆盖(冲突): ' + stats.conflicted);
504
732
  log(' ⏭️ 已跳过(本地修改): ' + stats.skippedModified);
505
733
  log(' 🗑️ 上游已移除: ' + stats.upstreamRemoved);
734
+ if (mergeStats.hooksMerged) log(' 🔧 hooks.json: 语义合并完成');
735
+ if (mergeStats.configUpdated) log(' 📝 config.yaml: 注释已更新');
736
+ if (mergeStats.backupsCreated > 0) log(' 💾 创建备份: ' + mergeStats.backupsCreated + ' 个 .orig 文件');
506
737
  log(' ✨ 锁文件已更新至 v' + VERSION);
507
738
  }
508
739
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specline",
3
- "version": "1.2.2",
3
+ "version": "1.3.1",
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
+ }