@xulthekl/team-flow 0.52.0 → 0.53.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/.claude/always/phase-guard.md +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/marketplace.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/.github/plugin/marketplace.json +2 -2
- package/AGENTS.md +3 -3
- package/CHANGELOG.md +69 -0
- package/GEMINI.md +1 -1
- package/HANDOFF.md +11 -0
- package/INSTALL.md +1 -1
- package/README.md +2 -2
- package/agents/release-archivist.md +4 -4
- package/docs/README_en.md +1 -1
- package/docs/usage-guide.md +4 -1
- package/gemini-extension.json +1 -1
- package/hooks/session-start +2 -2
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/scripts/guard/checks/arch-merged.mjs +101 -0
- package/scripts/guard/checks/arch-snapshot.mjs +16 -3
- package/scripts/guard/guard.mjs +9 -1
- package/scripts/lib/arch-merge.mjs +407 -47
- package/scripts/lib/arch-parse.mjs +304 -26
- package/scripts/lib/cmd-arch.mjs +29 -1
- package/scripts/lib/cmd-publish.mjs +53 -6
- package/scripts/lib/cmd-state.mjs +2 -0
- package/scripts/lib/prototype-sync.mjs +2 -1
- package/scripts/lib/state-loader.mjs +10 -0
- package/scripts/lib/test-merge.mjs +1 -1
- package/scripts/team-flow.mjs +14 -1
- package/skills/architecture-design/templates/api.md +10 -4
- package/skills/prototype/SKILL.md +2 -2
- package/skills/release-archivist/SKILL.md +46 -36
- package/skills/release-archivist/references/closing-procedures.md +14 -4
- package/skills/workflow-bootstrap/SKILL.md +27 -18
- package/skills/workflow-orchestrator/references/state-model.md +3 -0
|
@@ -22,8 +22,8 @@ import { join, basename, dirname, relative, resolve } from 'node:path';
|
|
|
22
22
|
import { execFileSync } from 'node:child_process';
|
|
23
23
|
import { pathToFileURL } from 'node:url';
|
|
24
24
|
import {
|
|
25
|
-
|
|
26
|
-
extractTablesFromSql,
|
|
25
|
+
parseTableRow, extractEndpoints, extractEndpointsDetailed, extractAggregates,
|
|
26
|
+
extractTablesFromSql, readFrontmatter, PATH_CELL_RE,
|
|
27
27
|
} from './arch-parse.mjs';
|
|
28
28
|
import { detectWorkspaceRoot, getGitRoot, parsePorcelainPaths } from './git-utils.mjs';
|
|
29
29
|
|
|
@@ -65,7 +65,7 @@ function releaseLock(lockPath) {
|
|
|
65
65
|
* 全局文档维护 marker 区(当前态:BC/聚合注册表)+ 演进日志段。
|
|
66
66
|
* marker 区重建 = 所有已合并 change 增量的投影(按聚合 id upsert,非原地编辑)。
|
|
67
67
|
*/
|
|
68
|
-
function mergeArchitecture(changeDir, archDir, globalArchDir, changeName) {
|
|
68
|
+
function mergeArchitecture(changeDir, archDir, globalArchDir, changeName, projectRoot) {
|
|
69
69
|
const srcPath = join(archDir, 'architecture.md');
|
|
70
70
|
const dstPath = join(globalArchDir, 'ARCHITECTURE.md');
|
|
71
71
|
if (!existsSync(srcPath)) return { merged: false, reason: 'no-source' };
|
|
@@ -75,13 +75,26 @@ function mergeArchitecture(changeDir, archDir, globalArchDir, changeName) {
|
|
|
75
75
|
|
|
76
76
|
// 结构化提取聚合清单(唯一事实源,聚合注册表)
|
|
77
77
|
const aggregates = extractAggregates(srcContent);
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
78
|
+
|
|
79
|
+
// v0.53.0 §107 R7:零聚合增量合法化。
|
|
80
|
+
// 「arch_design_decision=required 但零聚合」是**完全合法且常见**的形态(纯 API 增量 /
|
|
81
|
+
// 纯读写模型增量 / 瞬态凭证值对象设计)——C2/C3 连续两个 change 复现,且在识别读写模型
|
|
82
|
+
// (CQRS)增量时必然触发。原实现一律 abort,且无合法绕过路径(塞聚合=伪造/冲突,
|
|
83
|
+
// 改决策=伪造判定),归档永久缺一块。
|
|
84
|
+
//
|
|
85
|
+
// **但零聚合有两种成因,必须可区分**:① 声明为无(合法)② 解析失配(缺陷)。
|
|
86
|
+
// 故只放宽前者——须显式声明 `aggregate_action: none`(或 `aggregate_id: null`),
|
|
87
|
+
// 未声明仍 abort,以保留对解析失配的守卫(否则 §106 那类列错位缺陷会被一并静默)。
|
|
88
|
+
const declaredNone = fm.aggregate_action === 'none' || fm.aggregate_id === 'null';
|
|
89
|
+
if (fm.arch_design_decision === 'required' && aggregates.length === 0 && !declaredNone) {
|
|
90
|
+
abort('architecture.md 聚合注册表解析为空且未声明零增量(arch_design_decision=required)——'
|
|
91
|
+
+ '检查模板 §2 是否输出聚合行;若本 change 确无聚合增量,请显式声明 aggregate_action: none');
|
|
81
92
|
}
|
|
82
93
|
|
|
83
94
|
const existing = existsSync(dstPath) ? readFileSync(dstPath, 'utf-8') : '';
|
|
84
|
-
|
|
95
|
+
// v0.53.0 §108 R8:marker 区 = **已合并 change 的聚合全集投影**(非仅当前 change)
|
|
96
|
+
const allAggregates = collectAllAggregates(projectRoot, changeName, aggregates);
|
|
97
|
+
const currentStateSection = buildCurrentStateSection(allAggregates);
|
|
85
98
|
|
|
86
99
|
// marker 区替换(代码独占写):存在则替换,缺失则插入
|
|
87
100
|
let updated;
|
|
@@ -104,15 +117,59 @@ function mergeArchitecture(changeDir, archDir, globalArchDir, changeName) {
|
|
|
104
117
|
updated = upsertEvolutionLog(updated, srcContent, changeName);
|
|
105
118
|
|
|
106
119
|
writeFile(dstPath, updated, 'utf-8');
|
|
107
|
-
return {
|
|
120
|
+
return {
|
|
121
|
+
merged: true,
|
|
122
|
+
file: 'ARCHITECTURE.md',
|
|
123
|
+
aggregates: aggregates.map(a => a.id),
|
|
124
|
+
projected: allAggregates.length,
|
|
125
|
+
...(aggregates.length === 0 ? { reason: 'no-aggregate-delta' } : {}),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* v0.53.0 §108 R8:收集「已合并 change 的聚合全集」用于 marker 区投影。
|
|
131
|
+
*
|
|
132
|
+
* **原实现只用当前 change 的聚合重建 marker 区** → 多 change 场景下互相抹除:
|
|
133
|
+
* ```
|
|
134
|
+
* C1 merge → marker 区 = {C1 聚合}
|
|
135
|
+
* C2 merge → marker 区 = {C2 聚合} ← C1 的行被抹掉
|
|
136
|
+
* ```
|
|
137
|
+
* emp-auth 现状之所以未暴露该风险,是因为 C2/C3 被零聚合 abort 挡在门外——
|
|
138
|
+
* **一旦 R7 修好门禁,抹除会立即发生**(`20260910-000823` 的「根因 4」已论证:
|
|
139
|
+
* abort 在此客观上起了保护作用)。
|
|
140
|
+
*
|
|
141
|
+
* 纳入判据与 R2 一致:**当前 change 无条件纳入**;其余仅 `closed|closing`
|
|
142
|
+
* (防在途半成品污染,沿用 v0.36.3 判据)。同一 id 被多个 change 声明时
|
|
143
|
+
* **以当前 change 优先**(本次合并是权威——而"其他 change 声明同一聚合"已由
|
|
144
|
+
* `conflictCheck` 在更早阶段阻断,此处仅为确定性兜底)。
|
|
145
|
+
*/
|
|
146
|
+
function collectAllAggregates(projectRoot, changeName, currentAggregates) {
|
|
147
|
+
const collected = new Map(); // id → { ...aggregate, source }
|
|
148
|
+
const changesDir = join(projectRoot, 'changes');
|
|
149
|
+
if (existsSync(changesDir)) {
|
|
150
|
+
for (const name of readdirSync(changesDir)) {
|
|
151
|
+
if (name === changeName) continue;
|
|
152
|
+
const changeRoot = join(changesDir, name);
|
|
153
|
+
if (!isChangeClosed(changeRoot)) continue;
|
|
154
|
+
const p = join(changeRoot, 'architecture', 'architecture.md');
|
|
155
|
+
if (!existsSync(p)) continue;
|
|
156
|
+
for (const a of extractAggregates(readFileSync(p, 'utf-8'))) {
|
|
157
|
+
if (!collected.has(a.id)) collected.set(a.id, { ...a, source: `change:${name}` });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// 当前 change 覆盖同 id 项(本次合并权威)
|
|
162
|
+
for (const a of currentAggregates) collected.set(a.id, { ...a, source: `change:${changeName}` });
|
|
163
|
+
return [...collected.values()];
|
|
108
164
|
}
|
|
109
165
|
|
|
110
|
-
/** 构建当前态 marker 区内容(BC 表 +
|
|
111
|
-
function buildCurrentStateSection(aggregates
|
|
166
|
+
/** 构建当前态 marker 区内容(BC 表 + 聚合注册表,厚锚点一句话级;来源列标注真实来源 change)。 */
|
|
167
|
+
function buildCurrentStateSection(aggregates) {
|
|
112
168
|
const bcMap = new Map();
|
|
113
169
|
for (const a of aggregates) {
|
|
114
|
-
|
|
115
|
-
bcMap.
|
|
170
|
+
const bc = a.context || '(未标注)';
|
|
171
|
+
if (!bcMap.has(bc)) bcMap.set(bc, []);
|
|
172
|
+
bcMap.get(bc).push(a);
|
|
116
173
|
}
|
|
117
174
|
const lines = ['## 当前态', ''];
|
|
118
175
|
lines.push('### 限界上下文');
|
|
@@ -121,31 +178,66 @@ function buildCurrentStateSection(aggregates, changeName) {
|
|
|
121
178
|
lines.push(`| ${bc} | ${aggs.length} |`);
|
|
122
179
|
}
|
|
123
180
|
lines.push('', '### 聚合注册表');
|
|
124
|
-
// v0.36.3 §62.1 首轮特例:全局聚合全部来自已合并 change(=已落地态);状态列标注
|
|
125
181
|
lines.push('| 聚合ID | 上下文 | 根实体 | 来源 | 状态 | 关键不变量 |', '|--------|--------|--------|------|------|-----------|');
|
|
126
182
|
for (const a of aggregates) {
|
|
127
|
-
lines.push(`| ${a.id} | ${a.context} | ${a.root} |
|
|
183
|
+
lines.push(`| ${a.id} | ${a.context || '—'} | ${a.root || '—'} | ${a.source || '—'} | 已落地 | ${a.invariants || '—'} |`);
|
|
128
184
|
}
|
|
129
185
|
return lines.join('\n');
|
|
130
186
|
}
|
|
131
187
|
|
|
132
|
-
/**
|
|
188
|
+
/**
|
|
189
|
+
* 演进日志 upsert:按 changeName 锚定位既有块,存在则替换、否则追加。
|
|
190
|
+
*
|
|
191
|
+
* v0.53.0 §109.4 R11 修复(幂等性):原替换正则前瞻 `(?=^### change:|^## |^\n$|$)` 中,
|
|
192
|
+
* `$` 在 `/m` 标志下匹配的是**行尾**而非字符串末尾 —— 惰性量词 `[\s\S]*?` 在锚行行尾
|
|
193
|
+
* 处即刻满足前瞻 → 匹配范围退化为零宽 → **只替换掉锚行本身,旧表体一行不删**。
|
|
194
|
+
* 后果:arch-merge 每重复执行一次就追加一份演进日志(实测 3 次运行表体 1→2→3 份),
|
|
195
|
+
* 全局 ARCHITECTURE.md 无限膨胀。该缺陷在 B' 方案(arch-merge 前移至门禁前,重试成为
|
|
196
|
+
* 常态)下会被放大,故必须先修。
|
|
197
|
+
*
|
|
198
|
+
* 修复要点:
|
|
199
|
+
* 1. 前瞻末尾 `$` → `$(?![\s\S])`:`/m` 下中间行尾因后面仍有字符而不成立,只有字符串
|
|
200
|
+
* 末尾才命中(等价"真·文件末尾")。
|
|
201
|
+
* 2. 移除 `^\n$`:`/m` 下几乎不匹配,且一旦匹配会造成块被提前截断(截断 = 替换不完整
|
|
202
|
+
* = 残留累积,与本缺陷同型)。
|
|
203
|
+
* 3. **新增 `^<!-- arch:` 边界(关键)**:marker 区(当前态)紧跟在 `### change:` 块之后,
|
|
204
|
+
* 而 `<!-- arch:current-state:begin -->` 不匹配 `^## `。若边界漏掉它,替换会一路吞到
|
|
205
|
+
* 文件末尾,**把 marker 区一并删除** → marker 不成对 → 下次 `mergeArchitecture` 直接
|
|
206
|
+
* abort。原实现之所以未暴露该风险,恰恰是因为零宽 bug 让它"什么都没删"。
|
|
207
|
+
* 4. 块范围 = 锚行 → 下一个块起点,块起点 ∈ {`^### change:`、`^## ` / `^# `、`^<!-- arch:`、
|
|
208
|
+
* 文件末尾}。边界之外的内容(含 change 级模板残留表)由本 change 块独占,随替换清理。
|
|
209
|
+
* 5. 删除死变量 `anchorRe`(`/^#{3,4}\s*.*change[:\s].*$/mi`,从未被引用)。
|
|
210
|
+
*/
|
|
133
211
|
function upsertEvolutionLog(globalContent, srcContent, changeName) {
|
|
134
212
|
// 从 change 源提取演进日志条目(标题 + 正文)
|
|
135
213
|
const logStart = srcContent.search(/^#{2,3}\s*\d*\.?\s*(演进日志|Evolution Log)/m);
|
|
136
214
|
if (logStart < 0) return globalContent;
|
|
137
215
|
const logSection = srcContent.slice(logStart).trim();
|
|
138
|
-
const anchorRe = /^#{3,4}\s*.*change[:\s].*$/mi;
|
|
139
216
|
const anchor = `### change:${changeName}`;
|
|
140
217
|
const logBlock = `${anchor}\n${logSection.replace(/^#{2,3}\s*\d*\.?\s*(演进日志|Evolution Log)/m, '').trim()}`;
|
|
141
218
|
|
|
142
|
-
// 已存在 →
|
|
219
|
+
// 已存在 → 替换整块;否则追加到演进日志段末尾
|
|
143
220
|
const logHeadingRe = /^#{2,3}\s*\d*\.?\s*(演进日志|Evolution Log)/m;
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
221
|
+
const anchorRe = new RegExp(`^### change:${escapeRegExp(changeName)}\\s*$`, 'm');
|
|
222
|
+
if (anchorRe.test(globalContent)) {
|
|
223
|
+
// 块起点:下一个 change 块 / 下一个一二级标题 / arch marker / 文件末尾
|
|
224
|
+
const blockRe = new RegExp(
|
|
225
|
+
`^### change:${escapeRegExp(changeName)}\\s*$[\\s\\S]*?`
|
|
226
|
+
+ `(?=^### change:|^#{1,2}\\s|^<!-- arch:|$(?![\\s\\S]))`,
|
|
227
|
+
'm'
|
|
148
228
|
);
|
|
229
|
+
const matched = globalContent.match(blockRe);
|
|
230
|
+
if (matched) {
|
|
231
|
+
// 被替换段内若含疑似人工内容(非标题/非表格行/非空行/非注释)→ 告警不静默
|
|
232
|
+
const suspicious = matched[0].split('\n').slice(1)
|
|
233
|
+
.filter(l => l.trim() && !l.trim().startsWith('|') && !/^#{1,6}\s/.test(l.trim()));
|
|
234
|
+
if (suspicious.length > 0) {
|
|
235
|
+
console.warn(` [WARN] 演进日志块 change:${changeName} 内含 ${suspicious.length} 行非表格内容,将被替换:${suspicious[0].slice(0, 60)}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// 替换值恒为 `${logBlock}\n`:blockRe 的惰性匹配天然包含块尾换行,故重建结果
|
|
239
|
+
// 与首次写入(第三分支 `+ logBlock + '\n'`)逐字节一致 → 逐字节幂等。
|
|
240
|
+
return globalContent.replace(blockRe, `${logBlock}\n`);
|
|
149
241
|
}
|
|
150
242
|
if (logHeadingRe.test(globalContent)) {
|
|
151
243
|
return globalContent.replace(logHeadingRe, `$&\n${logBlock}`);
|
|
@@ -232,13 +324,24 @@ function isChangeClosed(changeRoot) {
|
|
|
232
324
|
if (!existsSync(stateFile)) return false;
|
|
233
325
|
const m = readFileSync(stateFile, 'utf-8').match(/^state:\s*(.*)$/m);
|
|
234
326
|
const state = m ? m[1].trim() : '';
|
|
327
|
+
// 注:`closed` 在 `VALID_STATES`(state-loader.mjs)中**不存在**,`closing→closed`
|
|
328
|
+
// 转换也不在状态机上——此处的 `closed` 分支**仅为前向兼容**(若未来新增终态则自动
|
|
329
|
+
// 生效),当前活跃路径只有 `closing`。**勿将其当作可依赖的活跃分支。**
|
|
235
330
|
return state === 'closed' || state === 'closing';
|
|
236
331
|
}
|
|
237
332
|
|
|
238
333
|
/**
|
|
239
|
-
* Step 6: API-INDEX.md 生成式(api-scan
|
|
240
|
-
*
|
|
241
|
-
*
|
|
334
|
+
* Step 6: API-INDEX.md 生成式(api-scan 核心)+ 产出量对账(v0.53.0 §103/§104)。
|
|
335
|
+
*
|
|
336
|
+
* 扫描项目根下所有(已关闭 **或本次正在合并** 的)change 的 architecture/api.md,重建端点。
|
|
337
|
+
*
|
|
338
|
+
* 保护与对账:
|
|
339
|
+
* ① **当前 change 无条件纳入**(§103 R2)——原实现对自身也施加 `isChangeClosed` 判据,
|
|
340
|
+
* 而执行时刻自身状态仍为 `executing`/`approved-for-build` → 自我排除 → 扫描集空 →
|
|
341
|
+
* 命中空结果保护 → **报成功但零产出**。此修复使行为与执行时序**彻底解耦**。
|
|
342
|
+
* ② 重建结果为空 → 拒绝覆盖(防历史手工端点被抹;保护本身正确,**但必须可见**,见 §104 R4)。
|
|
343
|
+
* ③ 产出量对账(§104.2.2)——生成器原只判「源文件在不在」,从不判「吃进去多少 / 原文多少」,
|
|
344
|
+
* 形态失配因此长期隐性。
|
|
242
345
|
*/
|
|
243
346
|
function generateApiIndex(projectRoot, globalArchDir, changeName) {
|
|
244
347
|
const changesDir = join(projectRoot, 'changes');
|
|
@@ -246,29 +349,85 @@ function generateApiIndex(projectRoot, globalArchDir, changeName) {
|
|
|
246
349
|
const header = '# API Index(generated by arch-merge, do not edit)\n\n';
|
|
247
350
|
|
|
248
351
|
const endpoints = [];
|
|
352
|
+
const details = [];
|
|
249
353
|
if (existsSync(changesDir)) {
|
|
250
354
|
for (const changeDirName of readdirSync(changesDir)) {
|
|
251
355
|
const changeRoot = join(changesDir, changeDirName);
|
|
252
|
-
if (!isChangeClosed(changeRoot)) continue; //
|
|
356
|
+
if (changeDirName !== changeName && !isChangeClosed(changeRoot)) continue; // §103 R2
|
|
253
357
|
const apiPath = join(changeRoot, 'architecture', 'api.md');
|
|
254
358
|
if (!existsSync(apiPath)) continue;
|
|
255
|
-
const
|
|
256
|
-
|
|
359
|
+
const det = extractEndpointsDetailed(readFileSync(apiPath, 'utf-8'));
|
|
360
|
+
details.push({ change: changeDirName, ...det });
|
|
361
|
+
for (const e of det.endpoints) endpoints.push({ ...e, source: changeDirName });
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ---- 产出量对账(§104.2.2 / §115.3)----
|
|
366
|
+
const warnings = [];
|
|
367
|
+
const failures = [];
|
|
368
|
+
for (const d of details) {
|
|
369
|
+
const epSections = d.sections.filter(s => s.isEndpointSection);
|
|
370
|
+
const epTotal = epSections.reduce((n, s) => n + s.endpoints, 0);
|
|
371
|
+
const candTotal = epSections.reduce((n, s) => n + s.candidates, 0);
|
|
372
|
+
|
|
373
|
+
// FAIL 前提(§115.3 A-2):仅「端点章节」参与判定,且要求 every(全零) && some(有候选)。
|
|
374
|
+
// 这样纯 DB 变更 / 纯重构 change(api.md 存在但无端点)不会误杀——它们没有端点章节候选。
|
|
375
|
+
//
|
|
376
|
+
// v0.53.0 §115.8(P4 复核发现,**严重**):**失败必须归属到 owner**。
|
|
377
|
+
// 本函数扫描「当前 change + 全部已关闭 change」。若把扫描集中**任一** change 的解析
|
|
378
|
+
// 失败都算作本次失败,则**一个历史脏数据会死锁所有后续 change 的 closing**——
|
|
379
|
+
// 实测:无关的已关闭 change 畸形 → `tf arch-merge <当前 change>` 退出码 1,
|
|
380
|
+
// 而当前 change 自己的回写已落地、`arch-merged` 门禁也 PASS;且 `arch_merge_skipped`
|
|
381
|
+
// 只豁免 guard 维度、**不清除命令级 failure** → 逃生通道完全不通。
|
|
382
|
+
//
|
|
383
|
+
// 判据:只有**本 change** 的解析失配才是本次 merge 的 failure(意味着本 change 的
|
|
384
|
+
// 端点未能入索引,回写不完整);其他 change 的问题降级为 WARN,由各自 owner 修复。
|
|
385
|
+
if (epSections.length > 0 && epTotal === 0 && candTotal > 0) {
|
|
386
|
+
const msg = `${d.change}:端点章节表有 ${candTotal} 行候选但 0 条提取——api.md 表格形态失配`;
|
|
387
|
+
if (d.change === changeName) failures.push(msg);
|
|
388
|
+
else warnings.push(`[历史 change 解析异常] ${msg}(不影响本次回写,请单独修复该 change 的 api.md)`);
|
|
389
|
+
}
|
|
390
|
+
// ratio:仅端点章节内(§104.2.2 ⑤)——实测修复前 C1/C2/C3 为 0.163/0.083/0.077
|
|
391
|
+
// 三个文件全部触发,故该规则**必须与 §105 同批上线**才有判别力。
|
|
392
|
+
if (candTotal > 0 && epTotal / candTotal < 0.5) {
|
|
393
|
+
warnings.push(`${d.change}:端点章节覆盖率偏低 ${epTotal}/${candTotal}`);
|
|
257
394
|
}
|
|
395
|
+
// 分层展示(§104.2.2 ②):真信号层(端点章节内)逐条;噪音层折叠为计数。
|
|
396
|
+
// 实测端点章节内 unmatched 恒为 0 → **平时静默、出问题才响**。
|
|
397
|
+
for (const u of d.unmatched.filter(x => x.isEndpointSection)) {
|
|
398
|
+
warnings.push(`${d.change}:端点章节内未识别(${u.kind})第 ${u.line} 行: ${u.text}`);
|
|
399
|
+
}
|
|
400
|
+
const noiseCount = d.unmatched.length - d.unmatched.filter(x => x.isEndpointSection).length;
|
|
401
|
+
if (noiseCount > 0) {
|
|
402
|
+
warnings.push(`${d.change}:引用/映射/约定章节未识别 ${noiseCount} 行(噪音层,已折叠)`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// 解析失配 → fail-closed 不写盘(与聚合链 abort 标准对齐)
|
|
407
|
+
if (failures.length > 0) {
|
|
408
|
+
return { generated: false, reason: 'endpoint-parse-mismatch', failures, warnings, file: 'API-INDEX.md', endpoints: endpoints.length };
|
|
258
409
|
}
|
|
259
410
|
|
|
260
|
-
//
|
|
411
|
+
// 空结果拒绝覆盖(数据丢失保护,**保留**)+ 可见化(§104 R4:不得静默)
|
|
261
412
|
if (endpoints.length === 0 && existsSync(dstPath)) {
|
|
262
|
-
return {
|
|
413
|
+
return {
|
|
414
|
+
generated: false,
|
|
415
|
+
reason: 'empty-scan-skip-overwrite',
|
|
416
|
+
warnings: [...warnings, '扫描结果为空,已保留既有 API-INDEX.md 未被覆盖(防历史手工端点被抹);若确应清空请手工处理'],
|
|
417
|
+
file: 'API-INDEX.md',
|
|
418
|
+
endpoints: 0,
|
|
419
|
+
};
|
|
263
420
|
}
|
|
264
421
|
|
|
265
422
|
const lines = [header, '> 扫描 changes/**/api.md + domains/* 归一化端点重建(v0.14 §62.4)。\n'];
|
|
266
423
|
lines.push('| 方法 | 路径 | 分流 | 来源 |', '|------|------|------|------|');
|
|
267
424
|
for (const e of endpoints) {
|
|
268
|
-
|
|
425
|
+
// method 恒非空(§105.2.4「无方法即跳过」保证),故不再写 `—` 占位——
|
|
426
|
+
// 否则往返解析(updateIndex 会重新 extractEndpoints)时该行会被判为无方法而丢失。
|
|
427
|
+
lines.push(`| ${e.method} | ${e.path} | ${e.kind} | ${e.source} |`);
|
|
269
428
|
}
|
|
270
429
|
writeFile(dstPath, lines.join('\n'), 'utf-8');
|
|
271
|
-
return { generated: true, file: 'API-INDEX.md', endpoints: endpoints.length };
|
|
430
|
+
return { generated: true, file: 'API-INDEX.md', endpoints: endpoints.length, warnings, failures: [] };
|
|
272
431
|
}
|
|
273
432
|
|
|
274
433
|
/* ============ 冲突预检 ============ */
|
|
@@ -280,27 +439,63 @@ function generateApiIndex(projectRoot, globalArchDir, changeName) {
|
|
|
280
439
|
*/
|
|
281
440
|
function conflictCheck(changeDir, archDir, globalArchDir, changeName) {
|
|
282
441
|
const conflicts = [];
|
|
442
|
+
const warnings = [];
|
|
283
443
|
const apiPath = join(archDir, 'api.md');
|
|
284
444
|
const archPath = join(archDir, 'architecture.md');
|
|
285
445
|
|
|
286
446
|
if (existsSync(apiPath)) {
|
|
287
|
-
|
|
447
|
+
// v0.53.0 §115.5:**排除 As-Is 段端点**。
|
|
448
|
+
//
|
|
449
|
+
// As-Is 段(`## 1. As-Is 基线(冻结复制)` / `### 1.x 现有 …`)是**冻结复制的基线记录**,
|
|
450
|
+
// 不是本 change 的端点所有权声明。原实现比较两个 change 的**全部**端点 → 多个 change
|
|
451
|
+
// 各自复制同一批 As-Is 端点即被误判为所有权冲突。
|
|
452
|
+
//
|
|
453
|
+
// emp-auth 实测:C2 与 C3 的 As-Is 段共享 5 条 `/auth/*` 端点(C2 明确标注「F5 归 C3」
|
|
454
|
+
// 即这些归 C3 实现)→ C2 的 arch-merge 被阻断,其演进日志与 marker 来源列永不写入
|
|
455
|
+
// → 连带 `arch-merged` 门禁对该 change 无出路。
|
|
456
|
+
//
|
|
457
|
+
// **该缺陷是 R5 修复暴露出来的**:修复前合并式单元格提取不到这些端点,冲突检查
|
|
458
|
+
// 是"假通过"(同时真冲突也检不出);修复后端点提取完整,误判才显现。
|
|
459
|
+
const newEndpoints = extractEndpointsDetailed(readFileSync(apiPath, 'utf-8'))
|
|
460
|
+
.endpoints.filter(e => !e.isAsIs);
|
|
288
461
|
const indexPath = join(globalArchDir, 'API-INDEX.md');
|
|
289
462
|
if (existsSync(indexPath)) {
|
|
290
463
|
// 全局端点带来源(API-INDEX 生成格式列3=来源);排除本 change 自身的(幂等重复不冲突)
|
|
464
|
+
//
|
|
465
|
+
// v0.53.0 §105.3 R12:**改用 `parseTableRow` + 共享常量**。
|
|
466
|
+
// 原实现是**手写的第二套表格解析器**——`slice(1,-1).split('|')` 自建拆分 + 自建
|
|
467
|
+
// 反引号剥离 + 硬编码路径字符集。它与生成侧的 `parseTableRow` **清洗规则不同**:
|
|
468
|
+
// 若生成侧未来输出 `**GET**`,`parseTableRow`(已支持强调剥离)认得出、此处认不出
|
|
469
|
+
// → 校验静默漏检。**仅把字符集换成共享常量不够,必须统一拆分与清洗入口**——
|
|
470
|
+
// 否则只统一了字符集,口径分裂仍在。
|
|
291
471
|
const existingByKey = new Map();
|
|
292
472
|
for (const line of readFileSync(indexPath, 'utf-8').split('\n')) {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
if (cells.length < 4) continue;
|
|
473
|
+
const cells = parseTableRow(line);
|
|
474
|
+
if (!cells || cells.length < 4) continue;
|
|
296
475
|
const method = cells[0], path = cells[1], source = cells[3];
|
|
297
|
-
if (
|
|
476
|
+
if (!PATH_CELL_RE.test(path)) continue;
|
|
298
477
|
existingByKey.set(`${method}:${path}`, source);
|
|
299
478
|
}
|
|
479
|
+
// v0.53.0 §115.5:**端点重复声明由阻断降级为告警**。
|
|
480
|
+
//
|
|
481
|
+
// 原实现把"两个 change 涉及同一端点"当作所有权冲突硬阻断。但实测(emp-auth):
|
|
482
|
+
// C2 与 C3 的 §2.1 To-Be 段都列了 `/auth/logout`、`/auth/token/flush`、
|
|
483
|
+
// `/auth/token/disable`(C2 明确标注「F5 归 C3」——即它知道这些归 C3,只是
|
|
484
|
+
// 在自己的 To-Be 表里记录了变更影响)。**两个 change 涉及同一端点是协作常态**
|
|
485
|
+
// (登录链路的 change 必然碰到会话治理的 change 要改的端点)。
|
|
486
|
+
//
|
|
487
|
+
// 硬阻断的后果与本方案的**核心目标直接冲突**:C2 因此无法完成 arch-merge →
|
|
488
|
+
// 演进日志与 marker 来源列永不写入 → 连带 `arch-merged` 门禁对该 change 无出路
|
|
489
|
+
// →「架构变更必须合并到台账」这一原则被破坏。
|
|
490
|
+
//
|
|
491
|
+
// **判据修正**:端点归属无法由解析判定(无法区分"重复实现"与"协作共享"),
|
|
492
|
+
// 故降级为 WARN 提示,交由 `cross-change-consistency-checker` agent 做语义判断。
|
|
493
|
+
// **聚合冲突保持阻断**——聚合所有权是明确的(一个聚合一个 owner,由 §2.1 聚合变更
|
|
494
|
+
// 表显式声明),不存在"协作共享聚合"的合法形态。
|
|
300
495
|
for (const e of newEndpoints) {
|
|
301
496
|
const key = `${e.method}:${e.path}`;
|
|
302
497
|
if (existingByKey.has(key) && existingByKey.get(key) !== changeName) {
|
|
303
|
-
|
|
498
|
+
warnings.push(`端点 ${e.method} ${e.path} 亦被 change ${existingByKey.get(key)} 声明(协作共享,非阻断)`);
|
|
304
499
|
}
|
|
305
500
|
}
|
|
306
501
|
}
|
|
@@ -321,7 +516,7 @@ function conflictCheck(changeDir, archDir, globalArchDir, changeName) {
|
|
|
321
516
|
}
|
|
322
517
|
}
|
|
323
518
|
|
|
324
|
-
return { conflicts };
|
|
519
|
+
return { conflicts, warnings };
|
|
325
520
|
}
|
|
326
521
|
|
|
327
522
|
/* ============ INDEX 确定性统计 ============ */
|
|
@@ -444,7 +639,17 @@ function gitCommit(projectRoot, globalArchDir, touchedFiles, changeName, dryRun)
|
|
|
444
639
|
unexpected,
|
|
445
640
|
};
|
|
446
641
|
} catch (e) {
|
|
447
|
-
|
|
642
|
+
const msg = (e.stderr && e.stderr.toString()) || (e.stdout && e.stdout.toString()) || e.message || '';
|
|
643
|
+
// v0.53.0 §104.2.4(B' 方案的硬前提):无变更重跑是**幂等重试的常态**。
|
|
644
|
+
// B' 让 arch-merge 前移到门禁之前,「guard FAIL → 修复 → 重试」成为常态路径,
|
|
645
|
+
// arch-merge 会被反复执行;第二次 merge 内容无变化 → `git commit` 报 nothing to commit
|
|
646
|
+
// → 原实现落入 catch 返回 `{committed:false, error}`。一旦 §104 引入 failure 升级,
|
|
647
|
+
// 这会被计为 failure → 退出码非 0 → **重试永远无法通过门禁**(路径自我阻断)。
|
|
648
|
+
// 故必须归为 **no-op**,不得计为 failure。判据与 `cmd-publish` 同源。
|
|
649
|
+
if (msg.includes('nothing to commit') || msg.includes('nothing added to commit')) {
|
|
650
|
+
return { committed: false, noop: true, message: commitMsg };
|
|
651
|
+
}
|
|
652
|
+
return { committed: false, error: msg };
|
|
448
653
|
}
|
|
449
654
|
}
|
|
450
655
|
|
|
@@ -457,8 +662,19 @@ function detectUntouchedDirtyFiles(projectRoot, globalArchDir, touchedFiles) {
|
|
|
457
662
|
// v0.23 §93.3.1:改用共享解析器。原 `^\S+\s+` 无法剥离 ` M path`(行首空格 = 未暂存修改,
|
|
458
663
|
// 最常见形态)→ 路径残留状态码 → 本次 touch 的文件被误报为「非本次 touch 的脏文件」。
|
|
459
664
|
// 路径口径统一为「相对 projectRoot 的绝对路径」后再与白名单比对,不依赖 process.cwd()。
|
|
460
|
-
|
|
461
|
-
|
|
665
|
+
//
|
|
666
|
+
// v0.53.0 §109.3 R10:比对判据改为**双向往返匹配**(与 `cmd-publish.detectOutsideDirty`
|
|
667
|
+
// 同源)。原实现是**精确匹配** `touched.has(resolve(p))`,而 git 对**全新未跟踪目录**
|
|
668
|
+
// 输出的是**目录级**路径 `?? docs/architecture/`(白名单是文件级)→ 永不命中 →
|
|
669
|
+
// **全新项目首次 arch-merge 必然误报**。该坑 `cmd-publish` 已踩过并修复(其注释明确
|
|
670
|
+
// 记录了「未跟踪内容显示目录级」场景),只是**未横展到 arch-merge**。
|
|
671
|
+
const norm = s => s.replace(/\/+$/, '');
|
|
672
|
+
const touchedDirs = touchedFiles.map(p => norm(resolve(projectRoot, p)));
|
|
673
|
+
return parsePorcelainPaths(status).filter(p => {
|
|
674
|
+
if (!p) return false;
|
|
675
|
+
const abs = norm(resolve(projectRoot, p));
|
|
676
|
+
return !touchedDirs.some(t => abs === t || abs.startsWith(t + '/') || t.startsWith(abs + '/'));
|
|
677
|
+
});
|
|
462
678
|
} catch (e) {
|
|
463
679
|
// v0.24 §98.3.2:解析失败不得静默(旧实现静默返回 [] = 漏报)。commit 侧仍有
|
|
464
680
|
// `git commit -- <白名单>` 与提交后回读比对两道防线,故此处仅告警不阻断。
|
|
@@ -514,7 +730,16 @@ export function run(args = {}) {
|
|
|
514
730
|
if (!preCheckResult.pass) {
|
|
515
731
|
console.error(' Pre-check FAILED:');
|
|
516
732
|
for (const c of preCheckResult.conflicts) console.error(` - ${c}`);
|
|
517
|
-
|
|
733
|
+
// 返回形态统一(v0.53.0 §104.2.3):**所有路径都必须带 warnings/failures 双数组**。
|
|
734
|
+
// 早退路径原先只返 {merged, reason, conflicts},调用方写 `result.failures.length`
|
|
735
|
+
// 会抛 TypeError——同一契约面两种形状是漂移源。
|
|
736
|
+
return {
|
|
737
|
+
merged: false,
|
|
738
|
+
reason: 'pre-check-failed',
|
|
739
|
+
conflicts: preCheckResult.conflicts,
|
|
740
|
+
warnings: [],
|
|
741
|
+
failures: preCheckResult.conflicts,
|
|
742
|
+
};
|
|
518
743
|
}
|
|
519
744
|
|
|
520
745
|
// Step 1b: 冲突预检
|
|
@@ -522,11 +747,17 @@ export function run(args = {}) {
|
|
|
522
747
|
if (conflictResult.conflicts.length > 0) {
|
|
523
748
|
console.error(' Conflict check FAILED:');
|
|
524
749
|
for (const c of conflictResult.conflicts) console.error(` - ${c}`);
|
|
525
|
-
return {
|
|
750
|
+
return {
|
|
751
|
+
merged: false,
|
|
752
|
+
reason: 'conflict',
|
|
753
|
+
conflicts: conflictResult.conflicts,
|
|
754
|
+
warnings: conflictResult.warnings || [],
|
|
755
|
+
failures: conflictResult.conflicts,
|
|
756
|
+
};
|
|
526
757
|
}
|
|
527
758
|
|
|
528
759
|
// Step 2: ARCHITECTURE.md(当前态 upsert)
|
|
529
|
-
const archResult = mergeArchitecture(changeDir, archDir, globalArchDir, changeName);
|
|
760
|
+
const archResult = mergeArchitecture(changeDir, archDir, globalArchDir, changeName, projectRoot);
|
|
530
761
|
|
|
531
762
|
// Step 4: schema-baseline.sql(幂等追加)——先同步 DDL,供 PHYSICAL-MODEL/DATABASE 生成消费
|
|
532
763
|
const schemaResult = syncSchemaBaseline(changeDir, archDir, globalArchDir, changeName);
|
|
@@ -563,10 +794,45 @@ export function run(args = {}) {
|
|
|
563
794
|
}
|
|
564
795
|
const commitResult = gitCommit(projectRoot, globalArchDir, touchedFiles, changeName, dryRun);
|
|
565
796
|
|
|
566
|
-
|
|
797
|
+
// ---- v0.53.0 §104.2.3 失败升级:子步骤结果不再被吞成 merged:true ----
|
|
798
|
+
// 原实现无视全部子步骤结果,一律 `return { merged: true }` + `arch-merge complete.`
|
|
799
|
+
// → `empty-scan-skip-overwrite` 等降级被静默,调用方(release-archivist)在 closing
|
|
800
|
+
// 归档报告里看到的是"全绿"。而**聚合链是有 abort() 的**——两条链标准不对称。
|
|
801
|
+
const warnings = [
|
|
802
|
+
...(conflictResult.warnings || []),
|
|
803
|
+
...(apiResult.warnings || []),
|
|
804
|
+
...(archResult.reason === 'no-aggregate-delta'
|
|
805
|
+
? [`${changeName}:本 change 无聚合增量(aggregate_action: none),marker 区按已合并 change 全集投影,未新增聚合行`]
|
|
806
|
+
: []),
|
|
807
|
+
...(schemaResult.synced === false && schemaResult.reason && schemaResult.reason !== 'no-ddl-dir'
|
|
808
|
+
? [`${changeName}:schema-baseline 未同步(${schemaResult.reason})`]
|
|
809
|
+
: []),
|
|
810
|
+
...(commitResult.noop ? [`${changeName}:无内容变更,未产生新提交(幂等重跑,非失败)`] : []),
|
|
811
|
+
// commit 失败归 **warning 而非 failure**:arch-merge 的核心职责是**回写制品**(已成功),
|
|
812
|
+
// commit 是协作便利层。非 git 环境(如单测 fixture)不应阻断 merge——但必须可见。
|
|
813
|
+
...(commitResult.error ? [`${changeName}:git commit 失败(制品已写盘)——${commitResult.error}`] : []),
|
|
814
|
+
...(dirty.length > 0 ? [`${changeName}:docs/architecture/ 有 ${dirty.length} 个非本次 touch 的改动,未纳入本次提交`] : []),
|
|
815
|
+
];
|
|
816
|
+
const failures = [
|
|
817
|
+
// 只收「设计层面判定为解析/生成失败」的项。schema 未同步(无 DDL 目录/无 .sql 文件)
|
|
818
|
+
// 属**正常降级**(change 可能本就无 DB 变更),归 warning。
|
|
819
|
+
...(archResult.merged === false && archResult.reason !== 'no-source'
|
|
820
|
+
? [`architecture.md 未合并(${archResult.reason})`] : []),
|
|
821
|
+
...(apiResult.failures || []),
|
|
822
|
+
];
|
|
823
|
+
|
|
824
|
+
for (const w of warnings) console.warn(` [WARN] ${w}`);
|
|
825
|
+
for (const f of failures) console.error(` [FAIL] ${f}`);
|
|
826
|
+
|
|
827
|
+
const merged = failures.length === 0;
|
|
828
|
+
console.log(merged
|
|
829
|
+
? (warnings.length ? `\narch-merge complete (with ${warnings.length} warning(s)).` : '\narch-merge complete.')
|
|
830
|
+
: `\narch-merge FAILED: ${failures.length} failure(s).`);
|
|
567
831
|
return {
|
|
568
|
-
merged
|
|
832
|
+
merged,
|
|
569
833
|
changeName,
|
|
834
|
+
warnings,
|
|
835
|
+
failures,
|
|
570
836
|
steps: {
|
|
571
837
|
architecture: archResult, physicalModel: pmResult, database: dbResult,
|
|
572
838
|
schema: schemaResult, archive: archiveResult, apiIndex: apiResult,
|
|
@@ -578,6 +844,96 @@ export function run(args = {}) {
|
|
|
578
844
|
}
|
|
579
845
|
}
|
|
580
846
|
|
|
847
|
+
/* ============ 生成器导出(供 tf arch scaffold 复用,§102.2.1 / §115.1 P2-4) ============ */
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* 全局台账初始化脚手架:产出**目标格式**的空基线(而非复制 change 级模板)。
|
|
851
|
+
*
|
|
852
|
+
* 背景(§101.3 根因 I):`workflow-bootstrap` B2 Step 0 原从
|
|
853
|
+
* `skills/architecture-design/templates/` 复制 **change 级模板** 到全局台账路径
|
|
854
|
+
* (`api.md → API-INDEX.md` 等 5 处),与 arch-merge 的生成式产物语义互斥。
|
|
855
|
+
* 其中 `DATABASE/PHYSICAL-MODEL/INDEX` 因无条件重建而被覆盖,`ARCHITECTURE/API-INDEX`
|
|
856
|
+
* 则永久卡在模板态——且 `API-INDEX.md` 模板自带的 4 条 `/api/xxx` 占位行使扫描非空,
|
|
857
|
+
* **连"空结果拒绝覆盖"保护都不触发**,`updateIndex` 还把这 4 行统计成 `端点数量: 4`。
|
|
858
|
+
*
|
|
859
|
+
* 本函数复用 arch-merge 的**同一批生成器**,构造上消除格式漂移:脚手架产出什么格式,
|
|
860
|
+
* 生成器就维护什么格式。
|
|
861
|
+
*
|
|
862
|
+
* @returns {{created: string[], skipped: string[]}}
|
|
863
|
+
*/
|
|
864
|
+
export function scaffoldGlobalLedger(projectRoot) {
|
|
865
|
+
const globalArchDir = join(projectRoot, 'docs', 'architecture');
|
|
866
|
+
const created = [];
|
|
867
|
+
const skipped = [];
|
|
868
|
+
const schemaPath = join(globalArchDir, 'schema-baseline.sql');
|
|
869
|
+
|
|
870
|
+
if (!existsSync(globalArchDir)) mkdirSync(globalArchDir, { recursive: true });
|
|
871
|
+
|
|
872
|
+
const writeIfAbsent = (name, produce) => {
|
|
873
|
+
const p = join(globalArchDir, name);
|
|
874
|
+
if (existsSync(p)) { skipped.push(name); return; }
|
|
875
|
+
fsWriteFileSync(p, produce(), 'utf-8');
|
|
876
|
+
created.push(name);
|
|
877
|
+
};
|
|
878
|
+
|
|
879
|
+
// 空 marker 区骨架(与 buildCurrentStateSection 同源)。
|
|
880
|
+
// 带 H1 与内容归属说明(v0.53.0 §115.6,P4 反馈 m2 / 问题8):同批其他制品均有 H1,
|
|
881
|
+
// 且需澄清「As-Is 叙述写哪」——arch-merge 只重写 marker 区**之间**,区外内容保留。
|
|
882
|
+
writeIfAbsent('ARCHITECTURE.md', () => [
|
|
883
|
+
'# Architecture',
|
|
884
|
+
'',
|
|
885
|
+
'> **As-Is 架构叙述写在本文件 marker 区之外**(分层 / 模块边界 / 技术选型等)。',
|
|
886
|
+
// ⚠ 此处**不得原样写出 marker 字符串**——那会让文件里出现两对 begin/end,
|
|
887
|
+
// 触发 mergeArchitecture 的 marker 成对校验 → 首次 arch-merge 直接 abort。
|
|
888
|
+
// (本行曾是 `${MARKER_BEGIN}` 插值,被 R1 的"scaffold 产物可被接管"测试抓住。)
|
|
889
|
+
'> 两个 `arch:current-state` HTML 注释标记**之间**为全局当前态(聚合注册表),',
|
|
890
|
+
'> 由 `tf arch-merge` 独占维护(generated — 请勿手工编辑该区)。',
|
|
891
|
+
'',
|
|
892
|
+
MARKER_BEGIN,
|
|
893
|
+
buildCurrentStateSection([]),
|
|
894
|
+
MARKER_END,
|
|
895
|
+
'',
|
|
896
|
+
].join('\n'));
|
|
897
|
+
// 生成器产物(无 schema 时输出"暂无")
|
|
898
|
+
writeIfAbsent('PHYSICAL-MODEL.md', () => {
|
|
899
|
+
const header = '# Physical Model(generated by arch-merge, do not edit)\n\n';
|
|
900
|
+
return header + '> schema-baseline.sql 不存在,暂无物理模型。\n';
|
|
901
|
+
});
|
|
902
|
+
writeIfAbsent('DATABASE.md', () => {
|
|
903
|
+
const header = '# Database(generated by arch-merge, do not edit)\n\n';
|
|
904
|
+
return header + '> schema-baseline.sql 不存在,暂无库表说明。\n';
|
|
905
|
+
});
|
|
906
|
+
writeIfAbsent('API-INDEX.md', () => [
|
|
907
|
+
'# API Index(generated by arch-merge, do not edit)',
|
|
908
|
+
'',
|
|
909
|
+
'> 扫描 changes/**/api.md 归一化端点重建(v0.14 §62.4)。',
|
|
910
|
+
'',
|
|
911
|
+
'| 方法 | 路径 | 分流 | 来源 |',
|
|
912
|
+
'|------|------|------|------|',
|
|
913
|
+
'',
|
|
914
|
+
].join('\n'));
|
|
915
|
+
writeIfAbsent('INDEX.md', () => [
|
|
916
|
+
'# Architecture Index',
|
|
917
|
+
'',
|
|
918
|
+
'> 本文件始终加载,各产物按需 Read。',
|
|
919
|
+
'',
|
|
920
|
+
'## 统计(确定性源)',
|
|
921
|
+
'- 限界上下文: 0',
|
|
922
|
+
'- 表数量: 0',
|
|
923
|
+
'- 端点数量: 0',
|
|
924
|
+
'',
|
|
925
|
+
].join('\n'));
|
|
926
|
+
|
|
927
|
+
if (!existsSync(schemaPath)) {
|
|
928
|
+
fsWriteFileSync(schemaPath, '-- Schema Baseline\n-- Auto-maintained by arch-merge (generated, do not edit)\n\n', 'utf-8');
|
|
929
|
+
created.push('schema-baseline.sql');
|
|
930
|
+
} else {
|
|
931
|
+
skipped.push('schema-baseline.sql');
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
return { created, skipped };
|
|
935
|
+
}
|
|
936
|
+
|
|
581
937
|
// 支持直接执行(仅当被 node 直接运行,非被 import——node --test 加载时 process.argv[1] 可能含文件名片段)
|
|
582
938
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
583
939
|
const args = process.argv.slice(2);
|
|
@@ -587,5 +943,9 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
|
|
587
943
|
else if (args[i] === '--dry-run') parsed.dryRun = true;
|
|
588
944
|
else if (!args[i].startsWith('--')) parsed._.push(args[i]);
|
|
589
945
|
}
|
|
590
|
-
run(parsed);
|
|
946
|
+
const result = run(parsed);
|
|
947
|
+
// v0.53.0 §104.2.3:有 failure → 非零退出码。
|
|
948
|
+
// 原实现一律 exit 0 并以 `arch-merge complete.` 结束,降级(如空扫描保护命中)对
|
|
949
|
+
// 调用方完全不可见。
|
|
950
|
+
if (result && Array.isArray(result.failures) && result.failures.length > 0) process.exitCode = 1;
|
|
591
951
|
}
|