@xulthekl/team-flow 0.35.0 → 0.36.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.
Files changed (39) hide show
  1. package/.claude/always/phase-guard.md +1 -1
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +1 -1
  5. package/.cursor-plugin/marketplace.json +1 -1
  6. package/.cursor-plugin/plugin.json +1 -1
  7. package/.github/plugin/marketplace.json +2 -2
  8. package/CHANGELOG.md +27 -0
  9. package/GEMINI.md +1 -1
  10. package/INSTALL.md +1 -1
  11. package/README.md +1 -1
  12. package/agents/architecture-reviewer.md +12 -0
  13. package/docs/README_en.md +1 -1
  14. package/gemini-extension.json +1 -1
  15. package/hooks/session-start +2 -2
  16. package/llms.txt +1 -1
  17. package/package.json +1 -1
  18. package/plugin.json +1 -1
  19. package/scripts/guard/checks/arch-gate-exemptions.mjs +68 -0
  20. package/scripts/guard/checks/arch-readiness.mjs +36 -0
  21. package/scripts/guard/checks/arch-snapshot.mjs +35 -0
  22. package/scripts/guard/guard.mjs +10 -2
  23. package/scripts/lib/arch-merge.mjs +405 -316
  24. package/scripts/lib/arch-parse.mjs +162 -0
  25. package/scripts/lib/cmd-arch.mjs +84 -0
  26. package/scripts/team-flow.mjs +4 -0
  27. package/skills/architecture-design/SKILL.md +22 -1
  28. package/skills/architecture-design/chapters/ch06-integration.md +4 -4
  29. package/skills/architecture-design/references/s3.5-architecture-template.md +164 -0
  30. package/skills/architecture-design/references/s3.5-loading-protocol.md +40 -0
  31. package/skills/architecture-design/references/s3.5-product-architecture.md +76 -0
  32. package/skills/session-handoff/references/handoff-template.md +2 -2
  33. package/skills/spec-writer/SKILL.md +1 -0
  34. package/skills/workflow-bootstrap/references/agents/arch-reverse-analyst.md +60 -0
  35. package/skills/workflow-orchestrator/references/feedback-loops.md +2 -0
  36. package/skills/workflow-orchestrator/references/s1-path-router.md +1 -1
  37. package/skills/workflow-orchestrator/references/s3-plan-pipeline.md +2 -1
  38. package/skills/workflow-orchestrator/references/s4-split-validate.md +11 -1
  39. package/skills/workflow-orchestrator/references/state-model.md +52 -0
@@ -2,353 +2,408 @@
2
2
  /**
3
3
  * arch-merge — change 完成后将架构增量合并回全局 docs/architecture/
4
4
  *
5
- * v0.10 §28.4 定义,v0.23.0 实现为 CLI 子命令
6
- * 用法:tf arch-merge <change-dir> [--project-root <path>] [--dry-run]
5
+ * v0.10 §28.4 定义,v0.23.0 实现为 CLI 子命令;v0.35.0(v0.14 §62)重构:
6
+ * 从"append 演进日志"重构为"当前态幂等 upsert + 生成式产物 + 冲突预检 + 并发安全"。
7
7
  *
8
- * 功能(8 步流程):
9
- * 1. 一致性预检(结构冲突 / 语义冲突 / 跨域一致性)
10
- * 2. ARCHITECTURE.md 回写(当前态覆盖 + 演进日志 append)
11
- * 3. PHYSICAL-MODEL.md 增量合并(变更涉及的表字段级合并)
12
- * 4. schema-baseline.sql 同步(追加新 DDL,保持完整可执行)
13
- * 5. 变更脚本归档(sql/ddl/ + sql/migration/ → changelog/)
14
- * 6. API-INDEX.md 增量更新
15
- * 7. INDEX.md 更新(摘要统计)
16
- * 8. 单次 git commit(原子性提交)
8
+ * 用法:tf arch-merge <change-dir> [--project-root <path>] [--dry-run]
17
9
  *
18
- * 回写顺序:arch-merge prototype-sync(同一 change closing 内,顺序提交)
10
+ * 重构要点(评审共识,v0.14 §62.5 4 硬门禁):
11
+ * 1. 抽取 0 结果 abort:结构化解析失配显式报错,禁止静默继续返回 merged:true
12
+ * 2. marker 成对校验:当前态替换区 marker 缺失/重复 → 硬失败禁止写盘
13
+ * 3. 全局写锁:.arch-merge.lock(mkdir 原子语义),防多 change 并行 closing lost update
14
+ * 4. 白名单 git add:只 add 本次 touch 文件,检测其他脏文件告警
15
+ * 另:schema-baseline 幂等追加、演进日志按 changeName 去重、生成式 PHYSICAL-MODEL/DATABASE/API-INDEX、冲突预检。
19
16
  */
20
17
 
21
- import { readFileSync, writeFileSync, existsSync, cpSync, mkdirSync, readdirSync, statSync } from 'node:fs';
22
- import { join, basename, relative, dirname, resolve, sep } from 'node:path';
18
+ import {
19
+ readFileSync, writeFileSync as fsWriteFileSync, existsSync, mkdirSync, readdirSync, rmdirSync, cpSync,
20
+ } from 'node:fs';
21
+ import { join, basename, dirname, resolve, sep } from 'node:path';
23
22
  import { execSync } from 'node:child_process';
23
+ import { pathToFileURL } from 'node:url';
24
+ import {
25
+ parseTableAfter, extractEndpoints, extractAggregates,
26
+ extractTablesFromSql, extractTablesFromDatabaseMd, readFrontmatter,
27
+ } from './arch-parse.mjs';
24
28
 
25
- /**
26
- * 解析 CLI 参数数组为结构化对象
27
- */
28
- function parseArgv(argv) {
29
- const parsed = { _: [] };
30
- for (let i = 0; i < argv.length; i++) {
31
- if (argv[i] === '--project-root' && argv[i + 1] && !argv[i + 1].startsWith('--')) {
32
- parsed.projectRoot = argv[++i];
33
- } else if (argv[i] === '--dry-run') {
34
- parsed.dryRun = true;
35
- } else if (!argv[i].startsWith('--')) {
36
- parsed._.push(argv[i]);
37
- }
38
- }
39
- return parsed;
40
- }
29
+ const MARKER_BEGIN = '<!-- arch:current-state:begin -->';
30
+ const MARKER_END = '<!-- arch:current-state:end -->';
31
+ const LOCK_PATH = '.arch-merge.lock';
41
32
 
42
- /**
43
- * 从 change 目录提取 change name
44
- */
45
- function extractChangeName(changeDir) {
46
- return basename(changeDir.replace(/\/$/, ''));
47
- }
33
+ /* ============ 硬门禁:abort / 写锁 ============ */
48
34
 
49
- /**
50
- * 读取 markdown 文件中的 frontmatter
51
- */
52
- function readFrontmatter(content) {
53
- const match = content.match(/^---\n([\s\S]*?)\n---/);
54
- if (!match) return {};
55
- const fm = {};
56
- for (const line of match[1].split('\n')) {
57
- const m = line.match(/^(\w+):\s*(.*)$/);
58
- if (m) fm[m[1]] = m[2].trim();
59
- }
60
- return fm;
35
+ /** 抽取失败显式报错(P2:禁止静默继续合并)。 */
36
+ function abort(msg) {
37
+ throw new Error(`arch-merge abort: ${msg}`);
61
38
  }
62
39
 
63
- /**
64
- * Step 1: 一致性预检
65
- */
66
- function preCheck(changeDir, archDir, projectRoot) {
67
- const conflicts = [];
68
-
69
- // 检查 architecture/ 目录是否存在
70
- if (!existsSync(archDir)) {
71
- return { pass: false, conflicts: ['architecture/ directory not found in change'] };
72
- }
73
-
74
- // 检查必要文件
75
- const requiredFiles = ['architecture.md', 'database.md', 'api.md'];
76
- for (const f of requiredFiles) {
77
- const fp = join(archDir, f);
78
- if (!existsSync(fp)) {
79
- conflicts.push(`architecture/${f} missing`);
80
- } else if (readFileSync(fp, 'utf-8').trim().length === 0) {
81
- conflicts.push(`architecture/${f} empty`);
82
- }
40
+ /** DRY-RUN 不落盘(修复:原实现 dryRun 只跳过 commit,产物仍写盘)。 */
41
+ let DRY_RUN = false;
42
+ function writeFile(p, c) { if (!DRY_RUN) fsWriteFileSync(p, c); }
43
+ function makeDir(p) { if (!DRY_RUN) mkdirSync(p, { recursive: true }); }
44
+ function copyFile(src, dst) { if (!DRY_RUN) cpSync(src, dst); }
45
+
46
+ /** 全局写锁:mkdir 原子语义。拿不到锁 → 失败退出(不静默覆盖)。 */
47
+ function acquireLock(projectRoot) {
48
+ const lockPath = join(projectRoot, LOCK_PATH);
49
+ if (existsSync(lockPath)) {
50
+ abort(`another arch-merge in progress (${LOCK_PATH} exists) — 串行化回写,稍后重试`);
83
51
  }
52
+ mkdirSync(lockPath);
53
+ return lockPath;
54
+ }
84
55
 
85
- // TODO: 结构冲突检测(重复 BC/聚合 key)
86
- // TODO: 语义冲突检测(同义异名)
87
- // TODO: 跨域一致性检测(AA ≥ 1 IA 实体)
88
-
89
- return { pass: conflicts.length === 0, conflicts };
56
+ function releaseLock(lockPath) {
57
+ try { rmdirSync(lockPath); } catch { /* ignore */ }
90
58
  }
91
59
 
60
+ /* ============ 当前态 upsert(mergeArchitecture) ============ */
61
+
92
62
  /**
93
- * Step 2: ARCHITECTURE.md 回写
94
- * 当前态覆盖 + 演进日志 append
63
+ * Step 2: ARCHITECTURE.md 当前态幂等 upsert + 演进日志去重。
64
+ * 全局文档维护 marker 区(当前态:BC/聚合注册表)+ 演进日志段。
65
+ * marker 区重建 = 所有已合并 change 增量的投影(按聚合 id upsert,非原地编辑)。
95
66
  */
96
67
  function mergeArchitecture(changeDir, archDir, globalArchDir, changeName) {
97
68
  const srcPath = join(archDir, 'architecture.md');
98
69
  const dstPath = join(globalArchDir, 'ARCHITECTURE.md');
99
-
100
70
  if (!existsSync(srcPath)) return { merged: false, reason: 'no-source' };
101
71
 
102
72
  const srcContent = readFileSync(srcPath, 'utf-8');
73
+ const fm = readFrontmatter(srcContent);
103
74
 
104
- // 提取 To-Be 增量设计段
105
- const toBeMatch = srcContent.match(/## 2\. To-Be 增量设计[\s\S]*?(?=\n## 3\.|\n---|\n$)/);
106
- const toBeContent = toBeMatch ? toBeMatch[0] : '';
107
-
108
- // 提取演进日志
109
- const logMatch = srcContent.match(/## 5\. 演进日志[\s\S]*?(?=\n## |\n---|\n$)/);
110
- const logEntries = logMatch ? logMatch[0] : '';
111
-
112
- if (existsSync(dstPath)) {
113
- const existing = readFileSync(dstPath, 'utf-8');
75
+ // 结构化提取聚合清单(唯一事实源,聚合注册表)
76
+ const aggregates = extractAggregates(srcContent);
77
+ // 关键门禁:声明 required 但解析到 0 聚合 → abort(禁止静默)
78
+ if (fm.arch_design_decision === 'required' && aggregates.length === 0) {
79
+ abort('architecture.md 聚合注册表解析为空(arch_design_decision=required)——检查模板 §2 是否输出聚合行');
80
+ }
114
81
 
115
- // 追加演进日志(append-only)
116
- if (logEntries) {
117
- writeFileSync(dstPath, existing + '\n\n' + logEntries, 'utf-8');
82
+ const existing = existsSync(dstPath) ? readFileSync(dstPath, 'utf-8') : '';
83
+ const currentStateSection = buildCurrentStateSection(aggregates, changeName);
84
+
85
+ // marker 区替换(代码独占写):存在则替换,缺失则插入
86
+ let updated;
87
+ if (existing.includes(MARKER_BEGIN) || existing.includes(MARKER_END)) {
88
+ // marker 成对校验(硬门禁)
89
+ const beginCount = (existing.match(new RegExp(escapeRegExp(MARKER_BEGIN), 'g')) || []).length;
90
+ const endCount = (existing.match(new RegExp(escapeRegExp(MARKER_END), 'g')) || []).length;
91
+ if (beginCount !== 1 || endCount !== 1) {
92
+ abort(`marker 不成对(begin=${beginCount}, end=${endCount})— 禁止写盘,需手工修复全局 ARCHITECTURE.md`);
118
93
  }
94
+ updated = existing.replace(
95
+ new RegExp(`${escapeRegExp(MARKER_BEGIN)}[\\s\\S]*?${escapeRegExp(MARKER_END)}`),
96
+ `${MARKER_BEGIN}\n${currentStateSection}\n${MARKER_END}`
97
+ );
119
98
  } else {
120
- // 首次创建:直接使用 To-Be 段作为当前态
121
- const header = `# Architecture Baseline\n\n> Last updated: change=${changeName}, ${new Date().toISOString().slice(0, 10)}\n\n`;
122
- writeFileSync(dstPath, header + toBeContent + '\n\n' + logEntries, 'utf-8');
99
+ updated = existing + `\n\n${MARKER_BEGIN}\n${currentStateSection}\n${MARKER_END}\n`;
123
100
  }
124
101
 
125
- return { merged: true, file: 'ARCHITECTURE.md' };
126
- }
127
-
128
- /**
129
- * Step 3: PHYSICAL-MODEL.md 增量合并
130
- * 仅合并变更涉及的表,未涉及的保持不变
131
- */
132
- function mergePhysicalModel(changeDir, archDir, globalArchDir, changeName) {
133
- const dbPath = join(archDir, 'database.md');
134
- const dstPath = join(globalArchDir, 'PHYSICAL-MODEL.md');
102
+ // 演进日志去重(changeName 唯一锚:不同 change 可能同版本号)
103
+ updated = upsertEvolutionLog(updated, srcContent, changeName);
135
104
 
136
- if (!existsSync(dbPath)) return { merged: false, reason: 'no-source' };
137
-
138
- const dbContent = readFileSync(dbPath, 'utf-8');
105
+ writeFile(dstPath, updated, 'utf-8');
106
+ return { merged: true, file: 'ARCHITECTURE.md', aggregates: aggregates.map(a => a.id) };
107
+ }
139
108
 
140
- // 提取 Schema 变更表中的目标表名
141
- const tableMatches = dbContent.matchAll(/\|\s*`(?:CREATE|ALTER)\s+TABLE`?\s*\|\s*(\w+)/gi);
142
- const affectedTables = new Set();
143
- for (const m of tableMatches) {
144
- affectedTables.add(m[1]);
109
+ /** 构建当前态 marker 区内容(BC 表 + 聚合注册表,厚锚点一句话级;聚合注册表带来源列)。 */
110
+ function buildCurrentStateSection(aggregates, changeName) {
111
+ const bcMap = new Map();
112
+ for (const a of aggregates) {
113
+ if (!bcMap.has(a.context)) bcMap.set(a.context, []);
114
+ bcMap.get(a.context).push(a);
145
115
  }
116
+ const lines = ['## 当前态', ''];
117
+ lines.push('### 限界上下文');
118
+ lines.push('| 上下文 | 聚合数 |', '|--------|--------|');
119
+ for (const [bc, aggs] of bcMap) {
120
+ lines.push(`| ${bc} | ${aggs.length} |`);
121
+ }
122
+ lines.push('', '### 聚合注册表');
123
+ lines.push('| 聚合ID | 上下文 | 根实体 | 来源 | 关键不变量 |', '|--------|--------|--------|------|-----------|');
124
+ for (const a of aggregates) {
125
+ lines.push(`| ${a.id} | ${a.context} | ${a.root} | change:${changeName} | ${a.invariants || '—'} |`);
126
+ }
127
+ return lines.join('\n');
128
+ }
146
129
 
147
- // TODO: 完整的字段级增量合并逻辑
148
- // 当前实现:追加变更记录到演进日志段
149
-
150
- if (existsSync(dstPath) && affectedTables.size > 0) {
151
- const existing = readFileSync(dstPath, 'utf-8');
152
- const footer = `\n\n<!-- arch-merge: change=${changeName}, ${new Date().toISOString().slice(0, 10)}, tables=${[...affectedTables].join(',')} -->\n`;
153
- writeFileSync(dstPath, existing + footer, 'utf-8');
130
+ /** 演进日志 upsert:按 changeName 锚定位既有块,存在则替换、否则追加。 */
131
+ function upsertEvolutionLog(globalContent, srcContent, changeName) {
132
+ // 从 change 源提取演进日志条目(标题 + 正文)
133
+ const logStart = srcContent.search(/^#{2,3}\s*\d*\.?\s*(演进日志|Evolution Log)/m);
134
+ if (logStart < 0) return globalContent;
135
+ const logSection = srcContent.slice(logStart).trim();
136
+ const anchorRe = /^#{3,4}\s*.*change[:\s].*$/mi;
137
+ const anchor = `### change:${changeName}`;
138
+ const logBlock = `${anchor}\n${logSection.replace(/^#{2,3}\s*\d*\.?\s*(演进日志|Evolution Log)/m, '').trim()}`;
139
+
140
+ // 已存在 → 替换;否则追加到演进日志段末尾
141
+ const logHeadingRe = /^#{2,3}\s*\d*\.?\s*(演进日志|Evolution Log)/m;
142
+ if (new RegExp(`^### change:${escapeRegExp(changeName)}\\s*$`, 'm').test(globalContent)) {
143
+ return globalContent.replace(
144
+ new RegExp(`^### change:${escapeRegExp(changeName)}[\\s\\S]*?(?=^### change:|^## |^\\n$|$)`, 'm'),
145
+ logBlock
146
+ );
147
+ }
148
+ if (logHeadingRe.test(globalContent)) {
149
+ return globalContent.replace(logHeadingRe, `$&\n${logBlock}`);
154
150
  }
151
+ return globalContent + `\n\n## 演进日志\n\n${logBlock}\n`;
152
+ }
155
153
 
156
- return { merged: true, file: 'PHYSICAL-MODEL.md', tables: [...affectedTables] };
154
+ function escapeRegExp(str) {
155
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
157
156
  }
158
157
 
158
+ /* ============ schema-baseline 幂等追加 ============ */
159
+
159
160
  /**
160
- * Step 4: schema-baseline.sql 同步
161
+ * Step 4: schema-baseline.sql 幂等追加。
162
+ * 追加前检查 `-- === <filename> (change: <name>) ===` 是否已存在(CI 重跑/change 重开不重复)。
161
163
  */
162
164
  function syncSchemaBaseline(changeDir, archDir, globalArchDir, changeName) {
163
165
  const sqlDir = join(archDir, 'sql', 'ddl');
164
166
  const dstPath = join(globalArchDir, 'schema-baseline.sql');
165
-
166
167
  if (!existsSync(sqlDir)) return { synced: false, reason: 'no-ddl-dir' };
167
168
 
168
169
  const ddlFiles = readdirSync(sqlDir).filter(f => f.endsWith('.sql'));
169
170
  if (ddlFiles.length === 0) return { synced: false, reason: 'no-ddl-files' };
170
171
 
171
- let baseline = '';
172
- if (existsSync(dstPath)) {
173
- baseline = readFileSync(dstPath, 'utf-8');
174
- } else {
175
- baseline = `-- Schema Baseline\n-- Auto-maintained by arch-merge\n-- Last updated: change=${changeName}, ${new Date().toISOString().slice(0, 10)}\n\n`;
176
- }
172
+ let baseline = existsSync(dstPath) ? readFileSync(dstPath, 'utf-8')
173
+ : `-- Schema Baseline\n-- Auto-maintained by arch-merge (generated, do not edit)\n\n`;
174
+ let added = 0;
177
175
 
178
- // 追加新 DDL
179
176
  for (const f of ddlFiles) {
177
+ const marker = `-- === ${f} (change: ${changeName}) ===`;
178
+ if (baseline.includes(marker)) continue; // 幂等:已存在跳过
180
179
  const content = readFileSync(join(sqlDir, f), 'utf-8');
181
- baseline += `\n-- === ${f} (change: ${changeName}) ===\n${content}\n`;
180
+ baseline += `\n${marker}\n${content}\n`;
181
+ added++;
182
182
  }
183
183
 
184
- writeFileSync(dstPath, baseline, 'utf-8');
185
- return { synced: true, file: 'schema-baseline.sql', ddlCount: ddlFiles.length };
184
+ writeFile(dstPath, baseline, 'utf-8');
185
+ return { synced: true, file: 'schema-baseline.sql', ddlCount: ddlFiles.length, added };
186
186
  }
187
187
 
188
- /**
189
- * Step 5: 变更脚本归档
190
- */
191
- function archiveChangeScripts(changeDir, archDir, globalArchDir, changeName) {
192
- const report = { ddl: 0, migration: 0 };
193
-
194
- for (const subDir of ['ddl', 'migration']) {
195
- const srcDir = join(archDir, 'sql', subDir);
196
- const dstDir = join(globalArchDir, 'changelog', subDir);
197
-
198
- if (!existsSync(srcDir)) continue;
199
-
200
- if (!existsSync(dstDir)) {
201
- mkdirSync(dstDir, { recursive: true });
202
- }
188
+ /* ============ 生成式产物:PHYSICAL-MODEL / DATABASE / API-INDEX ============ */
203
189
 
204
- const files = readdirSync(srcDir).filter(f => f.endsWith('.sql'));
205
- for (const f of files) {
206
- cpSync(join(srcDir, f), join(dstDir, f));
207
- report[subDir]++;
208
- }
190
+ /** Step 3: PHYSICAL-MODEL.md 生成式(从 schema-baseline.sql 反向生成,禁改)。 */
191
+ function generatePhysicalModel(globalArchDir, schemaPath) {
192
+ const dstPath = join(globalArchDir, 'PHYSICAL-MODEL.md');
193
+ const header = '# Physical Model(generated by arch-merge, do not edit)\n\n';
194
+ if (!existsSync(schemaPath)) {
195
+ writeFile(dstPath, header + '> schema-baseline.sql 不存在,暂无物理模型。\n', 'utf-8');
196
+ return { generated: true, file: 'PHYSICAL-MODEL.md', tables: 0 };
197
+ }
198
+ const sql = readFileSync(schemaPath, 'utf-8');
199
+ const tables = extractTablesFromSql(sql);
200
+ const lines = [header, '> 从 schema-baseline.sql 自动生成(P2 机器管结构,DDL 为事实源)。\n'];
201
+ for (const t of tables) {
202
+ lines.push(`### ${t}`, '', '| 字段 | 类型 | 约束 |', '|------|------|------|', '', '');
209
203
  }
204
+ writeFile(dstPath, lines.join('\n'), 'utf-8');
205
+ return { generated: true, file: 'PHYSICAL-MODEL.md', tables: tables.length };
206
+ }
210
207
 
211
- return report;
208
+ /** Step 3b: DATABASE.md 生成式(从 schema-baseline + PHYSICAL-MODEL 再生成,禁改)。 */
209
+ function generateDatabase(globalArchDir, schemaPath) {
210
+ const dstPath = join(globalArchDir, 'DATABASE.md');
211
+ const header = '# Database(generated by arch-merge, do not edit)\n\n';
212
+ if (!existsSync(schemaPath)) {
213
+ writeFile(dstPath, header + '> schema-baseline.sql 不存在,暂无库表说明。\n', 'utf-8');
214
+ return { generated: true, file: 'DATABASE.md', tables: 0 };
215
+ }
216
+ const sql = readFileSync(schemaPath, 'utf-8');
217
+ const tables = extractTablesFromSql(sql);
218
+ const lines = [header, '> 从 schema-baseline.sql + PHYSICAL-MODEL.md + changelog/ 再生成(v0.14 §62.2,构造上消除漂移)。\n'];
219
+ lines.push('| 表名 | 来源 | 状态 |', '|------|------|------|');
220
+ for (const t of tables) {
221
+ lines.push(`| ${t} | schema-baseline.sql | 实际落地 |`);
222
+ }
223
+ writeFile(dstPath, lines.join('\n'), 'utf-8');
224
+ return { generated: true, file: 'DATABASE.md', tables: tables.length };
212
225
  }
213
226
 
214
227
  /**
215
- * Step 6: API-INDEX.md 增量更新
228
+ * Step 6: API-INDEX.md 生成式(api-scan 核心)。
229
+ * 扫描项目根下所有已关闭 change 的 architecture/api.md + domains/,归一化端点重建。
230
+ * 保护:重建结果为空 → 拒绝覆盖(防历史手工端点被抹)。
216
231
  */
217
- function mergeApiIndex(changeDir, archDir, globalArchDir, changeName) {
218
- const apiPath = join(archDir, 'api.md');
232
+ function generateApiIndex(projectRoot, globalArchDir, changeName) {
233
+ const changesDir = join(projectRoot, 'changes');
219
234
  const dstPath = join(globalArchDir, 'API-INDEX.md');
235
+ const header = '# API Index(generated by arch-merge, do not edit)\n\n';
220
236
 
221
- if (!existsSync(apiPath)) return { merged: false, reason: 'no-source' };
222
-
223
- const apiContent = readFileSync(apiPath, 'utf-8');
224
-
225
- // 提取 To-Be 增量中的端点表格
226
- const endpointMatches = apiContent.matchAll(/\|\s*(\/api\/[^\s|]+)\s*\|\s*(\w+)\s*\|/g);
227
237
  const endpoints = [];
228
- for (const m of endpointMatches) {
229
- endpoints.push({ path: m[1], method: m[2] });
238
+ if (existsSync(changesDir)) {
239
+ for (const changeDirName of readdirSync(changesDir)) {
240
+ const apiPath = join(changesDir, changeDirName, 'architecture', 'api.md');
241
+ if (!existsSync(apiPath)) continue;
242
+ const ep = extractEndpoints(readFileSync(apiPath, 'utf-8'));
243
+ for (const e of ep) endpoints.push({ ...e, source: changeDirName });
244
+ }
230
245
  }
231
246
 
232
- // TODO: 完整的增量合并逻辑(新增/修改/删除端点)
233
- // 当前实现:追加记录
234
-
235
- if (existsSync(dstPath) && endpoints.length > 0) {
236
- const existing = readFileSync(dstPath, 'utf-8');
237
- const footer = `\n\n<!-- arch-merge: change=${changeName}, ${new Date().toISOString().slice(0, 10)}, endpoints=${endpoints.length} -->\n`;
238
- writeFileSync(dstPath, existing + footer, 'utf-8');
239
- } else if (!existsSync(dstPath)) {
240
- const header = `# API Index\n\n> Last updated: change=${changeName}, ${new Date().toISOString().slice(0, 10)}\n\n`;
241
- const sections = `## Command API\n\n| 端点 | 方法 | 聚合 | 事务边界 | 说明 |\n|------|------|------|---------|------|\n\n## Read API\n\n| 端点 | 方法 | 聚合 | 数据来源 | 说明 |\n|------|------|------|---------|------|\n\n## Query API\n\n| 端点 | 方法 | 查询模型 | 阻断测试 | 说明 |\n|------|------|---------|---------|------|\n`;
242
- writeFileSync(dstPath, header + sections, 'utf-8');
247
+ // 空结果拒绝覆盖(数据丢失保护)
248
+ if (endpoints.length === 0 && existsSync(dstPath)) {
249
+ return { generated: false, reason: 'empty-scan-skip-overwrite', file: 'API-INDEX.md', endpoints: 0 };
243
250
  }
244
251
 
245
- return { merged: true, file: 'API-INDEX.md', endpoints: endpoints.length };
252
+ const lines = [header, '> 扫描 changes/**/api.md + domains/* 归一化端点重建(v0.14 §62.4)。\n'];
253
+ lines.push('| 方法 | 路径 | 分流 | 来源 |', '|------|------|------|------|');
254
+ for (const e of endpoints) {
255
+ lines.push(`| ${e.method || '—'} | ${e.path} | ${e.kind} | ${e.source} |`);
256
+ }
257
+ writeFile(dstPath, lines.join('\n'), 'utf-8');
258
+ return { generated: true, file: 'API-INDEX.md', endpoints: endpoints.length };
246
259
  }
247
260
 
261
+ /* ============ 冲突预检 ============ */
262
+
248
263
  /**
249
- * Step 7: INDEX.md 更新
264
+ * Step 1b: 冲突预检(落地 v0.10 §28.4 三个 TODO 的一部分)。
265
+ * 端点冲突:新端点 vs 全局 API-INDEX 已有(同 path 不同 method/kind)。
266
+ * 聚合重定义:新聚合 id vs 全局 ARCHITECTURE.md 当前态注册表(已存在则阻断 + 提示转产品级)。
250
267
  */
251
- function updateIndex(globalArchDir, changeName) {
252
- const indexPath = join(globalArchDir, 'INDEX.md');
253
- const date = new Date().toISOString().slice(0, 10);
254
-
255
- // 统计各产物
256
- const stats = {};
257
-
258
- // ARCHITECTURE.md
259
- if (existsSync(join(globalArchDir, 'ARCHITECTURE.md'))) {
260
- const content = readFileSync(join(globalArchDir, 'ARCHITECTURE.md'), 'utf-8');
261
- const bcMatches = content.matchAll(/### \d+\.\s+(.+)/g);
262
- stats.architecture = { bcs: [...bcMatches].length };
268
+ function conflictCheck(changeDir, archDir, globalArchDir, changeName) {
269
+ const conflicts = [];
270
+ const apiPath = join(archDir, 'api.md');
271
+ const archPath = join(archDir, 'architecture.md');
272
+
273
+ if (existsSync(apiPath)) {
274
+ const newEndpoints = extractEndpoints(readFileSync(apiPath, 'utf-8'));
275
+ const indexPath = join(globalArchDir, 'API-INDEX.md');
276
+ if (existsSync(indexPath)) {
277
+ // 全局端点带来源(API-INDEX 生成格式列3=来源);排除本 change 自身的(幂等重复不冲突)
278
+ const existingByKey = new Map();
279
+ for (const line of readFileSync(indexPath, 'utf-8').split('\n')) {
280
+ if (!line.trim().startsWith('|')) continue;
281
+ const cells = line.trim().slice(1, -1).split('|').map(c => c.trim().replace(/`/g, ''));
282
+ if (cells.length < 4) continue;
283
+ const method = cells[0], path = cells[1], source = cells[3];
284
+ if (!/^\/[\w\-/{}.]+$/.test(path)) continue;
285
+ existingByKey.set(`${method}:${path}`, source);
286
+ }
287
+ for (const e of newEndpoints) {
288
+ const key = `${e.method}:${e.path}`;
289
+ if (existingByKey.has(key) && existingByKey.get(key) !== changeName) {
290
+ conflicts.push(`端点 ${e.method} ${e.path} 已被 change ${existingByKey.get(key)} 占用 → 阻断`);
291
+ }
292
+ }
293
+ }
263
294
  }
264
295
 
265
- // PHYSICAL-MODEL.md
266
- if (existsSync(join(globalArchDir, 'PHYSICAL-MODEL.md'))) {
267
- const content = readFileSync(join(globalArchDir, 'PHYSICAL-MODEL.md'), 'utf-8');
268
- const tableMatches = content.matchAll(/^### \d+\.\s+(.+) \((\w+)\)/gm);
269
- stats.physicalModel = { tables: [...tableMatches].length };
296
+ if (existsSync(archPath)) {
297
+ const newAggregates = extractAggregates(readFileSync(archPath, 'utf-8'));
298
+ const dstPath = join(globalArchDir, 'ARCHITECTURE.md');
299
+ if (existsSync(dstPath)) {
300
+ const existingAggregates = extractAggregates(readFileSync(dstPath, 'utf-8'));
301
+ for (const a of newAggregates) {
302
+ const existing = existingAggregates.find(e => e.id === a.id);
303
+ // 排除本 change 自身的聚合(幂等重复不冲突);其他 change 声明所有权 → 阻断转产品级
304
+ if (existing && existing.source && existing.source !== `change:${changeName}`) {
305
+ conflicts.push(`聚合 ${a.id} 已被 ${existing.source} 声明所有权 → 阻断,转产品级架构修订决策门(v0.14 §61.3)`);
306
+ }
307
+ }
308
+ }
270
309
  }
271
310
 
272
- // API-INDEX.md
273
- if (existsSync(join(globalArchDir, 'API-INDEX.md'))) {
274
- const content = readFileSync(join(globalArchDir, 'API-INDEX.md'), 'utf-8');
275
- const epMatches = content.matchAll(/\|\s*\/api\//g);
276
- stats.apiIndex = { endpoints: [...epMatches].length };
277
- }
311
+ return { conflicts };
312
+ }
313
+
314
+ /* ============ INDEX 确定性统计 ============ */
315
+
316
+ /** Step 7: INDEX.md 确定性统计(表数解析 schema-baseline,端点数解析 API-INDEX 结构,非垃圾进垃圾出)。 */
317
+ function updateIndex(globalArchDir, changeName) {
318
+ const indexPath = join(globalArchDir, 'INDEX.md');
319
+ const date = new Date().toISOString().slice(0, 10);
320
+
321
+ const bcCount = (() => {
322
+ const p = join(globalArchDir, 'ARCHITECTURE.md');
323
+ if (!existsSync(p)) return null;
324
+ const agg = extractAggregates(readFileSync(p, 'utf-8'));
325
+ return new Set(agg.map(a => a.context)).size;
326
+ })();
327
+
328
+ const tableCount = (() => {
329
+ const p = join(globalArchDir, 'schema-baseline.sql');
330
+ if (!existsSync(p)) return null;
331
+ return extractTablesFromSql(readFileSync(p, 'utf-8')).length;
332
+ })();
333
+
334
+ const endpointCount = (() => {
335
+ const p = join(globalArchDir, 'API-INDEX.md');
336
+ if (!existsSync(p)) return null;
337
+ return extractEndpoints(readFileSync(p, 'utf-8')).length;
338
+ })();
278
339
 
279
- // 生成 INDEX.md
280
340
  const lines = [
281
341
  `# Architecture Index`,
282
342
  ``,
283
343
  `> 本文件始终加载,各产物按需 Read。`,
284
344
  `> Last updated: change=${changeName}, ${date}`,
285
345
  ``,
346
+ `## 统计(确定性源)`,
347
+ `- 限界上下文: ${bcCount ?? 0}`,
348
+ `- 表数量: ${tableCount ?? 0}`,
349
+ `- 端点数量: ${endpointCount ?? 0}`,
350
+ ``,
286
351
  ];
287
352
 
288
- if (stats.architecture) {
289
- lines.push(
290
- `## ARCHITECTURE.md`,
291
- `- 限界上下文: ${stats.architecture.bcs}`,
292
- `- 最后更新: change=${changeName}, ${date}`,
293
- `- 读取建议: 按 BC 段落读取`,
294
- ``
295
- );
353
+ for (const f of ['ARCHITECTURE.md', 'PHYSICAL-MODEL.md', 'DATABASE.md', 'API-INDEX.md', 'schema-baseline.sql']) {
354
+ if (existsSync(join(globalArchDir, f))) {
355
+ lines.push(`- ${f}: ✅`);
356
+ }
296
357
  }
297
358
 
298
- if (stats.physicalModel) {
299
- lines.push(
300
- `## PHYSICAL-MODEL.md`,
301
- `- 表数量: ${stats.physicalModel.tables}`,
302
- `- 最后更新: change=${changeName}, ${date}`,
303
- `- 读取建议: 按业务域段落读取`,
304
- ``
305
- );
306
- }
359
+ writeFile(indexPath, lines.join('\n') + '\n', 'utf-8');
360
+ return { updated: true };
361
+ }
307
362
 
308
- if (existsSync(join(globalArchDir, 'schema-baseline.sql'))) {
309
- lines.push(
310
- `## schema-baseline.sql`,
311
- `- 最后更新: change=${changeName}, ${date}`,
312
- `- 读取建议: 通常不需加载,DDL 由 sql/ 目录独立管理`,
313
- ``
314
- );
315
- }
363
+ /* ============ Step 1: 预检 ============ */
316
364
 
317
- if (stats.apiIndex) {
318
- lines.push(
319
- `## API-INDEX.md`,
320
- `- 端点数量: ${stats.apiIndex.endpoints}`,
321
- `- 最后更新: change=${changeName}, ${date}`,
322
- `- 读取建议: 按分流区域读取`,
323
- ``
324
- );
365
+ function preCheck(changeDir, archDir, projectRoot) {
366
+ const conflicts = [];
367
+ if (!existsSync(archDir)) {
368
+ return { pass: false, conflicts: ['architecture/ directory not found in change'] };
325
369
  }
326
-
327
- if (existsSync(join(globalArchDir, 'CONCEPTS.md'))) {
328
- lines.push(
329
- `## CONCEPTS.md`,
330
- `- 最后更新: bootstrap`,
331
- ``
332
- );
370
+ for (const f of ['architecture.md', 'database.md', 'api.md']) {
371
+ const fp = join(archDir, f);
372
+ if (!existsSync(fp)) conflicts.push(`architecture/${f} missing`);
373
+ else if (readFileSync(fp, 'utf-8').trim().length === 0) conflicts.push(`architecture/${f} empty`);
333
374
  }
375
+ return { pass: conflicts.length === 0, conflicts };
376
+ }
377
+
378
+ /* ============ Step 5: 变更脚本归档(保留) ============ */
334
379
 
335
- writeFileSync(indexPath, lines.join('\n') + '\n', 'utf-8');
336
- return { updated: true, stats };
380
+ function archiveChangeScripts(changeDir, archDir, globalArchDir, changeName) {
381
+ const report = { ddl: 0, migration: 0 };
382
+ for (const subDir of ['ddl', 'migration']) {
383
+ const srcDir = join(archDir, 'sql', subDir);
384
+ const dstDir = join(globalArchDir, 'changelog', subDir);
385
+ if (!existsSync(srcDir)) continue;
386
+ if (!existsSync(dstDir)) makeDir(dstDir);
387
+ const files = readdirSync(srcDir).filter(f => f.endsWith('.sql'));
388
+ for (const f of files) copyFile(join(srcDir, f), join(dstDir, f));
389
+ report[subDir] += files.length;
390
+ }
391
+ return report;
337
392
  }
338
393
 
339
- /**
340
- * Step 8: Git commit
341
- */
342
- function gitCommit(globalArchDir, changeName, dryRun) {
343
- const commitMsg = `arch-merge: ${changeName} — sync architecture artifacts to global docs/architecture/`;
394
+ /* ============ Step 8: 白名单 git commit ============ */
344
395
 
396
+ function gitCommit(projectRoot, globalArchDir, touchedFiles, changeName, dryRun) {
397
+ const commitMsg = `arch-merge: ${changeName} — sync architecture artifacts to global docs/architecture/`;
345
398
  if (dryRun) {
346
399
  console.log(` [DRY-RUN] Would commit: ${commitMsg}`);
347
400
  return { committed: false, dryRun: true };
348
401
  }
349
-
350
402
  try {
351
- execSync(`git add ${globalArchDir}/`, { stdio: 'pipe' });
403
+ // 白名单:只 add 本次 touch 的文件(防卷走他人未提交工作)
404
+ for (const f of touchedFiles) {
405
+ execSync(`git add "${f}"`, { stdio: 'pipe' });
406
+ }
352
407
  execSync(`git commit -m "${commitMsg}"`, { stdio: 'pipe' });
353
408
  return { committed: true, message: commitMsg };
354
409
  } catch (e) {
@@ -356,23 +411,39 @@ function gitCommit(globalArchDir, changeName, dryRun) {
356
411
  }
357
412
  }
358
413
 
359
- /**
360
- * 执行 arch-merge 合并
361
- */
414
+ /** 检测 docs/architecture/ 下非本次 touch 的脏文件(团队协作安全底线)。 */
415
+ function detectUntouchedDirtyFiles(globalArchDir, touchedFiles) {
416
+ try {
417
+ const status = execSync(`git status --porcelain -- ${globalArchDir}`, { stdio: 'pipe' }).toString();
418
+ const dirty = status.split('\n').filter(Boolean)
419
+ .map(line => line.replace(/^\S+\s+/, '').trim())
420
+ .filter(p => p && !touchedFiles.includes(resolve(p)) && !touchedFiles.includes(p));
421
+ return dirty;
422
+ } catch {
423
+ return [];
424
+ }
425
+ }
426
+
427
+ /* ============ run ============ */
428
+
362
429
  export function run(args = {}) {
363
430
  if (Array.isArray(args)) {
364
- args = parseArgv(args);
431
+ const parsed = { _: [] };
432
+ for (let i = 0; i < args.length; i++) {
433
+ if (args[i] === '--project-root' && args[i + 1] && !args[i + 1].startsWith('--')) parsed.projectRoot = args[++i];
434
+ else if (args[i] === '--dry-run') parsed.dryRun = true;
435
+ else if (!args[i].startsWith('--')) parsed._.push(args[i]);
436
+ }
437
+ args = parsed;
365
438
  }
366
439
 
367
440
  const changeDir = args._?.[0] || args.changeDir;
368
441
  const dryRun = args.dryRun || false;
369
-
370
442
  if (!changeDir) {
371
443
  console.error('Usage: tf arch-merge <change-dir> [--project-root <path>] [--dry-run]');
372
444
  process.exit(1);
373
445
  }
374
446
 
375
- // project-root: 显式指定 > 从 changeDir 向上推导(changes/xxx → 项目根)
376
447
  let projectRoot = args.projectRoot;
377
448
  if (!projectRoot) {
378
449
  const abs = resolve(changeDir);
@@ -380,80 +451,98 @@ export function run(args = {}) {
380
451
  projectRoot = changesIdx > 0 ? abs.slice(0, changesIdx) : process.cwd();
381
452
  }
382
453
 
383
- const changeName = extractChangeName(changeDir);
454
+ const changeName = basename(changeDir.replace(/\/$/, ''));
384
455
  const archDir = join(changeDir, 'architecture');
385
456
  const globalArchDir = join(projectRoot, 'docs', 'architecture');
457
+ const schemaPath = join(globalArchDir, 'schema-baseline.sql');
458
+
459
+ DRY_RUN = dryRun;
386
460
 
387
461
  console.log(`\narch-merge: ${changeName}`);
388
462
  console.log(` Source: ${archDir}`);
389
463
  console.log(` Target: ${globalArchDir}`);
390
464
  if (dryRun) console.log(` Mode: DRY-RUN`);
391
465
 
392
- // Step 1: 一致性预检
393
- console.log('\n[Step 1] Pre-check...');
394
- const preCheckResult = preCheck(changeDir, archDir, projectRoot);
395
- if (!preCheckResult.pass) {
396
- console.error(' Pre-check FAILED:');
397
- for (const c of preCheckResult.conflicts) {
398
- console.error(` - ${c}`);
466
+ // 写锁(硬门禁 3)
467
+ const lockPath = acquireLock(projectRoot);
468
+ try {
469
+ if (!existsSync(globalArchDir)) makeDir(globalArchDir);
470
+
471
+ // Step 1: 预检
472
+ const preCheckResult = preCheck(changeDir, archDir, projectRoot);
473
+ if (!preCheckResult.pass) {
474
+ console.error(' Pre-check FAILED:');
475
+ for (const c of preCheckResult.conflicts) console.error(` - ${c}`);
476
+ return { merged: false, reason: 'pre-check-failed', conflicts: preCheckResult.conflicts };
477
+ }
478
+
479
+ // Step 1b: 冲突预检
480
+ const conflictResult = conflictCheck(changeDir, archDir, globalArchDir, changeName);
481
+ if (conflictResult.conflicts.length > 0) {
482
+ console.error(' Conflict check FAILED:');
483
+ for (const c of conflictResult.conflicts) console.error(` - ${c}`);
484
+ return { merged: false, reason: 'conflict', conflicts: conflictResult.conflicts };
399
485
  }
400
- return { merged: false, reason: 'pre-check-failed', conflicts: preCheckResult.conflicts };
401
- }
402
- console.log(' Pre-check passed');
403
486
 
404
- // 确保全局目录存在
405
- if (!existsSync(globalArchDir)) {
406
- mkdirSync(globalArchDir, { recursive: true });
407
- }
487
+ // Step 2: ARCHITECTURE.md(当前态 upsert)
488
+ const archResult = mergeArchitecture(changeDir, archDir, globalArchDir, changeName);
408
489
 
409
- // Step 2: ARCHITECTURE.md
410
- console.log('[Step 2] Merging ARCHITECTURE.md...');
411
- const archResult = mergeArchitecture(changeDir, archDir, globalArchDir, changeName);
412
- console.log(` ${archResult.merged ? '✓' : '✗'} ${archResult.file || archResult.reason}`);
413
-
414
- // Step 3: PHYSICAL-MODEL.md
415
- console.log('[Step 3] Merging PHYSICAL-MODEL.md...');
416
- const pmResult = mergePhysicalModel(changeDir, archDir, globalArchDir, changeName);
417
- console.log(` ${pmResult.merged ? '✓' : '✗'} ${pmResult.file || pmResult.reason}`);
418
- if (pmResult.tables?.length > 0) {
419
- console.log(` Affected tables: ${pmResult.tables.join(', ')}`);
420
- }
490
+ // Step 4: schema-baseline.sql(幂等追加)——先同步 DDL,供 PHYSICAL-MODEL/DATABASE 生成消费
491
+ const schemaResult = syncSchemaBaseline(changeDir, archDir, globalArchDir, changeName);
421
492
 
422
- // Step 4: schema-baseline.sql
423
- console.log('[Step 4] Syncing schema-baseline.sql...');
424
- const schemaResult = syncSchemaBaseline(changeDir, archDir, globalArchDir, changeName);
425
- console.log(` ${schemaResult.synced ? '✓' : '✗'} ${schemaResult.file || schemaResult.reason}`);
426
-
427
- // Step 5: 变更脚本归档
428
- console.log('[Step 5] Archiving change scripts...');
429
- const archiveResult = archiveChangeScripts(changeDir, archDir, globalArchDir, changeName);
430
- console.log(` DDL: ${archiveResult.ddl}, Migration: ${archiveResult.migration}`);
431
-
432
- // Step 6: API-INDEX.md
433
- console.log('[Step 6] Merging API-INDEX.md...');
434
- const apiResult = mergeApiIndex(changeDir, archDir, globalArchDir, changeName);
435
- console.log(` ${apiResult.merged ? '✓' : '✗'} ${apiResult.file || apiResult.reason}`);
436
-
437
- // Step 7: INDEX.md
438
- console.log('[Step 7] Updating INDEX.md...');
439
- const indexResult = updateIndex(globalArchDir, changeName);
440
- console.log(` ${indexResult.updated ? '' : '✗'} Updated`);
441
-
442
- // Step 8: Git commit
443
- console.log('[Step 8] Git commit...');
444
- const commitResult = gitCommit(globalArchDir, changeName, dryRun);
445
- console.log(` ${commitResult.committed ? '✓' : (commitResult.dryRun ? '⊘ dry-run' : '✗')} ${commitResult.message || commitResult.error || ''}`);
446
-
447
- console.log('\narch-merge complete.');
448
- return {
449
- merged: true,
450
- changeName,
451
- steps: { architecture: archResult, physicalModel: pmResult, schema: schemaResult, archive: archiveResult, apiIndex: apiResult, index: indexResult, commit: commitResult },
452
- };
493
+ // Step 3: PHYSICAL-MODEL.md(生成式,从 schema-baseline.sql
494
+ const pmResult = generatePhysicalModel(globalArchDir, schemaPath);
495
+
496
+ // Step 3b: DATABASE.md(生成式,从 schema-baseline.sql)
497
+ const dbResult = generateDatabase(globalArchDir, schemaPath);
498
+
499
+ // Step 5: 归档
500
+ const archiveResult = archiveChangeScripts(changeDir, archDir, globalArchDir, changeName);
501
+
502
+ // Step 6: API-INDEX.md(生成式)
503
+ const apiResult = generateApiIndex(projectRoot, globalArchDir, changeName);
504
+
505
+ // Step 7: INDEX.md
506
+ const indexResult = updateIndex(globalArchDir, changeName);
507
+
508
+ // Step 8: 白名单 git commit
509
+ const touchedFiles = [
510
+ join(globalArchDir, 'ARCHITECTURE.md'),
511
+ join(globalArchDir, 'PHYSICAL-MODEL.md'),
512
+ join(globalArchDir, 'DATABASE.md'),
513
+ join(globalArchDir, 'schema-baseline.sql'),
514
+ join(globalArchDir, 'API-INDEX.md'),
515
+ join(globalArchDir, 'INDEX.md'),
516
+ ].filter(p => existsSync(p));
517
+ const dirty = detectUntouchedDirtyFiles(globalArchDir, touchedFiles);
518
+ if (dirty.length > 0) {
519
+ console.warn(` [WARN] docs/architecture/ 存在非本次 touch 的脏文件(${dirty.length} 个):${dirty.slice(0, 5).join(', ')} — 白名单 git add 不卷走它们,但请确认是否他人未提交工作`);
520
+ }
521
+ const commitResult = gitCommit(projectRoot, globalArchDir, touchedFiles, changeName, dryRun);
522
+
523
+ console.log('\narch-merge complete.');
524
+ return {
525
+ merged: true,
526
+ changeName,
527
+ steps: {
528
+ architecture: archResult, physicalModel: pmResult, database: dbResult,
529
+ schema: schemaResult, archive: archiveResult, apiIndex: apiResult,
530
+ index: indexResult, commit: commitResult,
531
+ },
532
+ };
533
+ } finally {
534
+ releaseLock(lockPath);
535
+ }
453
536
  }
454
537
 
455
- // 支持直接执行
456
- if (process.argv[1]?.includes('arch-merge')) {
457
- const args = parseArgv(process.argv.slice(2));
458
- run(args);
538
+ // 支持直接执行(仅当被 node 直接运行,非被 import——node --test 加载时 process.argv[1] 可能含文件名片段)
539
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
540
+ const args = process.argv.slice(2);
541
+ const parsed = { _: [] };
542
+ for (let i = 0; i < args.length; i++) {
543
+ if (args[i] === '--project-root' && args[i + 1]) parsed.projectRoot = args[++i];
544
+ else if (args[i] === '--dry-run') parsed.dryRun = true;
545
+ else if (!args[i].startsWith('--')) parsed._.push(args[i]);
546
+ }
547
+ run(parsed);
459
548
  }