@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
|
@@ -4,15 +4,81 @@
|
|
|
4
4
|
// 背景(红队评估实证):旧实现正则硬编码(`## 2. To-Be`、`/api/` 前缀、反引号表名)
|
|
5
5
|
// 在 LLM 文档标题漂移/格式变化时静默失败(match 失配返回空串、流程继续、返回 merged:true)。
|
|
6
6
|
// 本模块用通用表格解析(按 `|` split + 去反引号)与格式容错匹配,抽取失配由调用方 abort。
|
|
7
|
+
//
|
|
8
|
+
// v0.53.0 形态健壮化(设计增强方案 v0.25 §105 / §106 / §115):
|
|
9
|
+
// - **共享常量**(§105.3):路径字符集与方法词表原在「提取侧(本文件)」与「校验侧
|
|
10
|
+
// (arch-merge.conflictCheck)」各自硬编码 → 改一处即口径分裂(提取到、校验认不出 →
|
|
11
|
+
// 冲突逃逸)。本文件为唯一来源,`arch-merge` 的手写解析器一律改调 `parseTableRow`。
|
|
12
|
+
// - **extractEndpoints 六种形态**(§105.2):合并式单元格 / 强调包裹 / 逗号枚举 /
|
|
13
|
+
// 括号后缀 / 章节 kind 归属 / 无方法即跳过。并新增 `extractEndpointsDetailed`
|
|
14
|
+
// 承载产出量对账(§104.2.1)。
|
|
15
|
+
// - **extractAggregates 表头定位**(§106.2):原实现用第二列的**值**是否命中
|
|
16
|
+
// `新增|修改|New|Update` 判断表布局;C1 实际写的是 `**extend**`(DDD 术语 + 加粗)
|
|
17
|
+
// → 两重失配 → 列语义整体错位 → 全局台账被写入 `上下文=extend`、
|
|
18
|
+
// `根实体=SysFrontSystem(不变)`。**这是污染而非缺失**。改判据为表头定位。
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* HTTP 方法词表——**三个方法正则的唯一事实源**。
|
|
22
|
+
*
|
|
23
|
+
* 单一来源是刻意的:本文件早期版本把词表在 3 个正则里各写了一遍字面量
|
|
24
|
+
* (`HTTP_METHOD_RE` / `MULTI_METHOD_RE` / `MERGED_CELL_RE`),而 `arch-merge` 的
|
|
25
|
+
* 校验侧又自建第 4 份——这正是根因 III「同一跨文件契约在多处独立实现 → 判据漂移」
|
|
26
|
+
* (§101.5)。新增方法(如 HEAD / OPTIONS)时凡漏改一处即产生口径分裂。
|
|
27
|
+
*/
|
|
28
|
+
export const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'];
|
|
29
|
+
const METHODS_ALT = HTTP_METHODS.join('|');
|
|
7
30
|
|
|
8
|
-
|
|
31
|
+
export const HTTP_METHOD_RE = new RegExp(`^(${METHODS_ALT})$`, 'i');
|
|
32
|
+
/**
|
|
33
|
+
* 多方法单元格:官方根模板 `templates/api.md` 授权 `POST / PUT / DELETE` 写法
|
|
34
|
+
* (§115.3 D-3)。若只认单方法,未来该类行会被静默丢弃。
|
|
35
|
+
*/
|
|
36
|
+
export const MULTI_METHOD_RE = new RegExp(`^(${METHODS_ALT})(\\s*/\\s*(?:${METHODS_ALT}))+$`, 'i');
|
|
37
|
+
/** 路径单元格(含逗号枚举,如 `/check/{add,update}/{a,b}/unique`)。 */
|
|
38
|
+
export const PATH_CELL_RE = /^\/[\w\-/{},.]+$/;
|
|
39
|
+
/** 合并式单元格:方法与路径同格(捕获组 1=方法,2=路径)。 */
|
|
40
|
+
export const MERGED_CELL_RE = new RegExp(`^(${METHODS_ALT})\\s+(\\S+)`, 'i');
|
|
41
|
+
/** 路径合法字符前缀——用于合并式捕获后的**截断 + 复验**,防全角括号/尾部描述混入路径。 */
|
|
42
|
+
const PATH_PREFIX_RE = /^\/[\w\-/{},.]*/;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 端点章节的**表级**判定:首列表头**精确等于**其中之一。
|
|
46
|
+
*
|
|
47
|
+
* ⚠ **禁止 `includes` / `startsWith`**(§115.3 A-1,全量死锁风险):
|
|
48
|
+
* `API 端点` 是引用/对齐表的固定表头,来自**两个官方模板**——
|
|
49
|
+
* `templates/api.md`(`| API 端点 | 支撑的数据实体 | 一致性 |`)与
|
|
50
|
+
* `skills/architecture-design/templates/api.md`(`| API 端点 | 对应数据实体 | 对齐状态 |`)。
|
|
51
|
+
* **每一份按模板产出的 api.md 都带此表**,且模板行本身是无斜杠简写(candidates>0 / endpoints=0)。
|
|
52
|
+
* 若用 contains 匹配,该表被判为端点章节 → 触发 FAIL 判据 → 配合 arch-merged 阻断语义
|
|
53
|
+
* → **全量 change 死锁**(严重度高于零聚合 abort:后者只影响纯 DB change)。
|
|
54
|
+
*/
|
|
55
|
+
export const ENDPOINT_HEADERS = new Set(['端点', 'Endpoint', 'API', '接口']);
|
|
56
|
+
|
|
57
|
+
/** 强调标记剥离:`**x**` / `__x__` / `*x*` / `_x_` → `x`(首尾成对才剥离;长的优先)。 */
|
|
58
|
+
function stripEmphasis(s) {
|
|
59
|
+
let out = s;
|
|
60
|
+
for (const [open, close] of [['**', '**'], ['__', '__'], ['*', '*'], ['_', '_']]) {
|
|
61
|
+
if (out.length > open.length + close.length && out.startsWith(open) && out.endsWith(close)) {
|
|
62
|
+
out = out.slice(open.length, -close.length).trim();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 解析一行 markdown 表格为单元格数组(去反引号 + 剥离强调标记 + trim)。非表格行返回 null。
|
|
70
|
+
*
|
|
71
|
+
* v0.53.0 §105.2.2:新增强调标记剥离。原实现只去反引号,`**\`/path\`**` 清理后残留 `**`
|
|
72
|
+
* → 首字符非 `/` → 整行漏提取。实测 C1 的 `update/status`、`reset/secret` 两条**新增**
|
|
73
|
+
* 端点因此丢失——即**最重要的增量端点最容易被漏掉**。
|
|
74
|
+
*/
|
|
9
75
|
export function parseTableRow(line) {
|
|
10
76
|
const trimmed = line.trim();
|
|
11
77
|
if (!trimmed.startsWith('|') || !trimmed.endsWith('|')) return null;
|
|
12
78
|
return trimmed
|
|
13
79
|
.slice(1, -1)
|
|
14
80
|
.split('|')
|
|
15
|
-
.map(cell => cell.trim().replace(/`/g, '').trim());
|
|
81
|
+
.map(cell => stripEmphasis(cell.trim().replace(/`/g, '').trim()).trim());
|
|
16
82
|
}
|
|
17
83
|
|
|
18
84
|
/**
|
|
@@ -43,46 +109,259 @@ export function parseTableAfter(content, headingRe) {
|
|
|
43
109
|
}
|
|
44
110
|
|
|
45
111
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
112
|
+
* 章节标题分类。**未识别的标题返回 `'unknown'` 而非 `null`——即显式重置**
|
|
113
|
+
* (§105.2.6):原实现只在匹配 Command/Read/Query 时更新 currentKind 且**从不重置**,
|
|
114
|
+
* 「前端路由契约」类标题不匹配任何模式 → 沿用更早章节(如 `### 1.3 现有 Query API`)
|
|
115
|
+
* 留下的 `Query` → C2/C3 的前端路由误报被标注为 Query 分流(**误报不仅内容错、分类也错**)。
|
|
116
|
+
* 非标题行返回 null(不动状态)。
|
|
49
117
|
*/
|
|
50
|
-
|
|
118
|
+
function classifyHeading(line) {
|
|
119
|
+
if (!/^#{2,3}\s/.test(line)) return null;
|
|
120
|
+
if (/(Command|命令)/i.test(line)) return 'Command';
|
|
121
|
+
if (/(Read|读取)/i.test(line)) return 'Read';
|
|
122
|
+
if (/(Query|查询)/i.test(line)) return 'Query';
|
|
123
|
+
return 'unknown';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 端点提取 + 产出量对账(§104.2.1)。
|
|
128
|
+
*
|
|
129
|
+
* 返回 `{ endpoints, candidates, unmatched, sections }`;`extractEndpoints` 为兼容包装。
|
|
130
|
+
*
|
|
131
|
+
* 形态支持(§105.2 六种):
|
|
132
|
+
* 1. 合并式单元格 `GET /path`(含尾部描述/全角括号 → **截断 + PATH_CELL_RE 复验**)
|
|
133
|
+
* 2. 强调包裹 `**\`/path\`**`(由 `parseTableRow` 统一剥离)
|
|
134
|
+
* 3. 逗号枚举 `/x/{a,b}`(`PATH_CELL_RE` 含逗号)
|
|
135
|
+
* 4. 括号后缀 `/x(svc)`(截断)
|
|
136
|
+
* 5. 章节 kind 归属(含未识别标题的显式重置)
|
|
137
|
+
* 6. 无方法即跳过 + 按 `method+path` 去重
|
|
138
|
+
*/
|
|
139
|
+
export function extractEndpointsDetailed(apiMd) {
|
|
51
140
|
const endpoints = [];
|
|
141
|
+
const unmatched = [];
|
|
142
|
+
const sections = new Map();
|
|
143
|
+
const seen = new Set();
|
|
52
144
|
const lines = apiMd.split('\n');
|
|
145
|
+
|
|
53
146
|
let currentKind = 'unknown';
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
147
|
+
let headingText = '(top)';
|
|
148
|
+
let currentIsAsIs = false;
|
|
149
|
+
let inTable = false;
|
|
150
|
+
let tableHeader = null;
|
|
151
|
+
let sectionIsEndpoint = false;
|
|
152
|
+
let sectionCandidates = 0;
|
|
153
|
+
let sectionEndpoints = 0;
|
|
154
|
+
|
|
155
|
+
const flushSection = () => {
|
|
156
|
+
const key = `${headingText}::${sectionIsEndpoint ? 'EP' : 'ref'}`;
|
|
157
|
+
const prev = sections.get(key) || { heading: headingText, isEndpointSection: sectionIsEndpoint, candidates: 0, endpoints: 0 };
|
|
158
|
+
prev.candidates += sectionCandidates;
|
|
159
|
+
prev.endpoints += sectionEndpoints;
|
|
160
|
+
sections.set(key, prev);
|
|
161
|
+
sectionCandidates = 0;
|
|
162
|
+
sectionEndpoints = 0;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
for (let i = 0; i < lines.length; i++) {
|
|
166
|
+
const line = lines[i];
|
|
167
|
+
|
|
168
|
+
// ---- 标题:切换 kind 与章节,并**显式重置**章节级统计 ----
|
|
169
|
+
const headingClass = classifyHeading(line);
|
|
170
|
+
if (headingClass !== null) {
|
|
171
|
+
if (inTable) flushSection();
|
|
172
|
+
currentKind = headingClass;
|
|
173
|
+
headingText = line.trim().slice(0, 60);
|
|
174
|
+
// As-Is 段(`## 1. As-Is 基线(冻结复制)` / `### 1.x 现有 …`):这些表是
|
|
175
|
+
// **冻结复制的基线记录**,不代表本 change 的端点所有权。`conflictCheck` 必须
|
|
176
|
+
// 排除它们,否则多个 change 各自复制同一批 As-Is 端点会被误判为所有权冲突
|
|
177
|
+
// (emp-auth 实测:C2/C3 的 As-Is 段共享 5 条 `/auth/*` 端点 → C2 被阻断)。
|
|
178
|
+
currentIsAsIs = /as-?is|现有/i.test(line);
|
|
179
|
+
sectionIsEndpoint = headingClass !== 'unknown';
|
|
180
|
+
inTable = false;
|
|
181
|
+
tableHeader = null;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const isTableLine = line.trim().startsWith('|');
|
|
186
|
+
if (!isTableLine) {
|
|
187
|
+
if (inTable) { flushSection(); inTable = false; tableHeader = null; }
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---- 表格首行 = 表头;表头行不参与提取,但参与「端点章节」的表级判定 ----
|
|
192
|
+
if (!inTable) {
|
|
193
|
+
inTable = true;
|
|
194
|
+
tableHeader = parseTableRow(line);
|
|
195
|
+
// 表级端点章节判定:首列表头**精确等于**(禁 includes/startsWith,§115.3 A-1)
|
|
196
|
+
if (tableHeader && ENDPOINT_HEADERS.has((tableHeader[0] || '').trim())) sectionIsEndpoint = true;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (/^\s*\|[\s:|-]+\|\s*$/.test(line)) continue; // 分隔行
|
|
200
|
+
|
|
59
201
|
const cells = parseTableRow(line);
|
|
60
202
|
if (!cells || cells.length === 0) continue;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
203
|
+
|
|
204
|
+
// ---- candidates 判定(§104.2.1)----
|
|
205
|
+
const mergedCell = cells.map(c => c.match(MERGED_CELL_RE)).find(Boolean);
|
|
206
|
+
const hasMethodWord = cells.some(c => HTTP_METHOD_RE.test(c) || MULTI_METHOD_RE.test(c)) || !!mergedCell;
|
|
207
|
+
const hasPathWord = cells.some(c => PATH_CELL_RE.test(c));
|
|
208
|
+
if (!hasMethodWord && !hasPathWord) continue;
|
|
209
|
+
sectionCandidates++;
|
|
210
|
+
|
|
211
|
+
// ---- 取 path / method(合并式优先,统一走「截断 + PATH_CELL_RE 复验」收口)----
|
|
212
|
+
// MERGED_CELL_RE 捕获组:1=方法,2=路径(`GET /auth/token/flush` → ['GET', '/auth/token/flush'])
|
|
213
|
+
let path = '';
|
|
214
|
+
let method = '';
|
|
215
|
+
if (mergedCell) {
|
|
216
|
+
const truncated = (mergedCell[2].match(PATH_PREFIX_RE) || [''])[0];
|
|
217
|
+
if (PATH_CELL_RE.test(truncated)) { path = truncated; method = mergedCell[1].toUpperCase(); }
|
|
218
|
+
}
|
|
219
|
+
if (!path) {
|
|
220
|
+
const pathCell = cells.find(c => PATH_CELL_RE.test(c));
|
|
221
|
+
if (pathCell) path = pathCell;
|
|
222
|
+
}
|
|
223
|
+
if (!path) {
|
|
224
|
+
if (hasMethodWord) {
|
|
225
|
+
unmatched.push({ line: i + 1, text: line.trim().slice(0, 120), section: headingText, isEndpointSection: sectionIsEndpoint, kind: 'method-no-path' });
|
|
226
|
+
}
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (!method) {
|
|
230
|
+
const methodCell = cells.find(c => HTTP_METHOD_RE.test(c) || MULTI_METHOD_RE.test(c));
|
|
231
|
+
if (methodCell) method = methodCell.split('/')[0].trim().toUpperCase();
|
|
232
|
+
}
|
|
233
|
+
// 无方法即跳过(§105.2.4)——消除前端路由误报与「半成品端点」两类噪音
|
|
234
|
+
if (!method) {
|
|
235
|
+
unmatched.push({ line: i + 1, text: line.trim().slice(0, 120), section: headingText, isEndpointSection: sectionIsEndpoint, kind: 'path-no-method' });
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const key = `${method} ${path}`;
|
|
240
|
+
if (seen.has(key)) continue; // 去重(§105.2.6)
|
|
241
|
+
seen.add(key);
|
|
242
|
+
|
|
65
243
|
const kindCell = cells.find(c => /^(Command|Read|Query)$/i.test(c));
|
|
66
244
|
const kind = currentKind !== 'unknown' ? currentKind : (kindCell || 'unknown');
|
|
67
|
-
endpoints.push({
|
|
245
|
+
endpoints.push({
|
|
246
|
+
path, method, kind,
|
|
247
|
+
section: headingText,
|
|
248
|
+
isEndpointSection: sectionIsEndpoint,
|
|
249
|
+
isAsIs: currentIsAsIs,
|
|
250
|
+
});
|
|
251
|
+
sectionEndpoints++;
|
|
68
252
|
}
|
|
69
|
-
|
|
253
|
+
if (inTable) flushSection();
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
endpoints,
|
|
257
|
+
candidates: [...sections.values()].reduce((n, s) => n + s.candidates, 0),
|
|
258
|
+
unmatched,
|
|
259
|
+
sections: [...sections.values()],
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** 兼容包装:沿用原签名(返回数组),三个既有消费方零改动。 */
|
|
264
|
+
export function extractEndpoints(apiMd) {
|
|
265
|
+
return extractEndpointsDetailed(apiMd).endpoints;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* 聚合表表头关键词 → 字段名。**精确匹配**(normalize 后),长的语义优先靠 Map 顺序保证。
|
|
270
|
+
* 见 §106.2:由表头决定列语义,不再由「第二列的值」决定。
|
|
271
|
+
*/
|
|
272
|
+
const AGG_HEADER_MAP = [
|
|
273
|
+
[['聚合id', 'aggregateid', 'aggregate', '聚合名称', '聚合'], 'id'],
|
|
274
|
+
[['聚合根', '根实体', 'root', '聚合根实体'], 'root'],
|
|
275
|
+
[['所属bc', '所属限界上下文', '所属上下文', '上下文', 'context', 'bc'], 'context'],
|
|
276
|
+
[['不变量', '关键不变量', 'invariant', 'invariants'], 'invariants'],
|
|
277
|
+
[['来源', 'source'], 'source'],
|
|
278
|
+
];
|
|
279
|
+
|
|
280
|
+
/** 表头归一:去空白/下划线/反引号/强调标记,转小写(中文不受影响)。 */
|
|
281
|
+
function normalizeHeader(h) {
|
|
282
|
+
return (h || '').trim().replace(/[\s_*`]/g, '').toLowerCase();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** 表头 → 列索引映射。未识别的字段不写入(调用方按 undefined 处理)。 */
|
|
286
|
+
function mapAggregateColumns(header) {
|
|
287
|
+
const col = {};
|
|
288
|
+
header.forEach((h, i) => {
|
|
289
|
+
const n = normalizeHeader(h);
|
|
290
|
+
for (const [keys, field] of AGG_HEADER_MAP) {
|
|
291
|
+
if (col[field] !== undefined) continue;
|
|
292
|
+
if (keys.includes(n)) { col[field] = i; break; }
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
return col;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* 定位聚合表(三处容错,见 §106.1 的 R2 修正)。
|
|
300
|
+
*
|
|
301
|
+
* v0.53.0 修正:R2 原为 `/^#{3,4}\s*\d*\.?\s*(聚合变更|…)/`,其中 `\d*\.?` **无法消费
|
|
302
|
+
* 子编号 `2.1` 中的 `.1`** → 实测对真实标题 `### 2.1 聚合变更` 返回 false →
|
|
303
|
+
* **R2 从未被任何实际标题命中**,所有 change 级聚合表一直靠 R3(To-Be)兜底命中。
|
|
304
|
+
* 而 R3 取的是「To-Be」标题下的**第一个表格**,若该标题下首个表不是聚合表即取错表。
|
|
305
|
+
* 修正为 `\d+(?:\.\d+)*\.?` 支持任意层级子编号。
|
|
306
|
+
*/
|
|
307
|
+
function locateAggregateTable(archMd) {
|
|
308
|
+
let rows = parseTableAfter(archMd, /^#{2,3}\s*\d*\.?\s*(聚合注册表|Aggregate Registry)/i);
|
|
309
|
+
if (rows.length) return rows;
|
|
310
|
+
rows = parseTableAfter(archMd, /^#{2,4}\s*(?:\d+(?:\.\d+)*\.?\s*)?(聚合变更|Aggregate Changes)/i);
|
|
311
|
+
if (rows.length) return rows;
|
|
312
|
+
return parseTableAfter(archMd, /^#{2,3}\s*\d*\.?\s*To-Be/i);
|
|
70
313
|
}
|
|
71
314
|
|
|
72
315
|
/**
|
|
73
316
|
* 从 architecture.md 提取聚合清单。聚合 id 格式 `context:Aggregate`。
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
317
|
+
*
|
|
318
|
+
* v0.53.0 §106.2:判据由「值判布局」改为「表头定位」。
|
|
319
|
+
*
|
|
320
|
+
* 原实现用**第二列的值**是否命中 `新增|修改|New|Update` 推断表布局,再据此决定后续列语义。
|
|
321
|
+
* C1 的 §2.1 写的是 `` `**extend**` ``(DDD 标准术语 + **加粗**)——**两重失配**
|
|
322
|
+
* (① 首字符 `*` 使 `^` 锚失败;② `extend` 不在词表)→ `isChange=false` → 按产品级布局
|
|
323
|
+
* 解读 → 全局台账被写入:
|
|
324
|
+
* ```
|
|
325
|
+
* | permission:FrontSystem | **extend** | SysFrontSystem(不变) | change:v1-C1 | 已落地 | emp-auth(不变) |
|
|
326
|
+
* ↑ 上下文取了「操作」列 ↑ 根实体取了聚合根列 ↑ 不变量取了「所属 BC」列
|
|
327
|
+
* ```
|
|
328
|
+
* **这是污染而非缺失**——错误数据进入台账比缺数据更难察觉。
|
|
329
|
+
*
|
|
330
|
+
* 表头定位后**布局判据自然消失**:change 级的 `所属 BC` 与产品级的 `上下文` 各自映射到
|
|
331
|
+
* `context`,无需推断表属于哪一类。
|
|
77
332
|
*/
|
|
78
333
|
export function extractAggregates(archMd) {
|
|
79
|
-
|
|
80
|
-
if (rows.length === 0)
|
|
81
|
-
|
|
334
|
+
const rows = locateAggregateTable(archMd);
|
|
335
|
+
if (rows.length === 0) return [];
|
|
336
|
+
const header = rows[0] || [];
|
|
337
|
+
const dataRows = rows.slice(1);
|
|
338
|
+
const col = mapAggregateColumns(header);
|
|
339
|
+
|
|
340
|
+
// 兜底:表头映射基本失败(< 2 个字段)→ 退回位置逻辑,但**必须告警**(不再静默猜列)
|
|
341
|
+
if (Object.keys(col).length < 2) {
|
|
342
|
+
console.warn(` [WARN] extractAggregates 表头未识别(${header.join(' | ')})——回退位置逻辑,可能列错位`);
|
|
343
|
+
return legacyPositionalAggregates(dataRows);
|
|
82
344
|
}
|
|
83
|
-
|
|
84
|
-
|
|
345
|
+
|
|
346
|
+
const aggregates = [];
|
|
347
|
+
for (const row of dataRows) {
|
|
348
|
+
const pick = f => (col[f] !== undefined ? (row[col[f]] || '').trim() : '');
|
|
349
|
+
const id = pick('id');
|
|
350
|
+
if (!id || !/^[\w]+:[\w]+$/.test(id)) continue;
|
|
351
|
+
const src = pick('source');
|
|
352
|
+
aggregates.push({
|
|
353
|
+
id,
|
|
354
|
+
context: pick('context'),
|
|
355
|
+
root: pick('root'),
|
|
356
|
+
invariants: pick('invariants'),
|
|
357
|
+
source: /^change:/.test(src) ? src : '',
|
|
358
|
+
});
|
|
85
359
|
}
|
|
360
|
+
return aggregates;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** 位置兜底(表头未识别时):保持 v0.52.0 及以前的行为,仅作降级路径。 */
|
|
364
|
+
function legacyPositionalAggregates(rows) {
|
|
86
365
|
const aggregates = [];
|
|
87
366
|
for (const row of rows) {
|
|
88
367
|
const id = row[0]?.trim();
|
|
@@ -90,7 +369,6 @@ export function extractAggregates(archMd) {
|
|
|
90
369
|
if (!/^[\w]+:[\w]+$/.test(id)) continue;
|
|
91
370
|
const col1 = row[1]?.trim() || '';
|
|
92
371
|
const isChange = /^(新增|修改|New|Update)/i.test(col1);
|
|
93
|
-
// source:产品级全局格式(buildCurrentStateSection 生成)列3=来源 changeName;change 级无来源列 → ''
|
|
94
372
|
const source = /^change:/.test(row[3]?.trim() || '') ? row[3].trim() : '';
|
|
95
373
|
aggregates.push({
|
|
96
374
|
id,
|
package/scripts/lib/cmd-arch.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import fs from 'node:fs';
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { parseArgs } from 'node:util';
|
|
9
9
|
import * as archPrecheck from './arch-precheck.mjs';
|
|
10
|
+
import { scaffoldGlobalLedger } from './arch-merge.mjs';
|
|
10
11
|
|
|
11
12
|
const ARCH_STATE_FILE = '.team-flow/arch-state.json';
|
|
12
13
|
|
|
@@ -25,12 +26,39 @@ export async function run(args) {
|
|
|
25
26
|
const sub = positionals[0];
|
|
26
27
|
if (sub === 'init') return init(values);
|
|
27
28
|
if (sub === 'show') return show(values);
|
|
29
|
+
// v0.53.0 §102.2.1 R1:全局台账脚手架(B2 Step 0 的替代,产出目标格式而非复制模板)
|
|
30
|
+
if (sub === 'scaffold') return scaffold(values);
|
|
28
31
|
// v0.22 §88.3.2:架构门判据的确定性证据工具(证据 only,退出码恒 0)
|
|
29
32
|
if (sub === 'precheck') return archPrecheck.run(positionals.slice(1), values);
|
|
30
|
-
console.error('Usage: tf arch init [--mode reconstruction|design] [--baseline-ref <prd/vN/>] | tf arch show | tf arch precheck <change-dir> [--json]');
|
|
33
|
+
console.error('Usage: tf arch init [--mode reconstruction|design] [--baseline-ref <prd/vN/>] | tf arch show | tf arch scaffold | tf arch precheck <change-dir> [--json]');
|
|
31
34
|
process.exit(2);
|
|
32
35
|
}
|
|
33
36
|
|
|
37
|
+
/**
|
|
38
|
+
* v0.53.0 §102.2.1 R1:全局台账脚手架。
|
|
39
|
+
*
|
|
40
|
+
* **背景(§101.3 根因 I)**:`workflow-bootstrap` B2 Step 0 原从
|
|
41
|
+
* `skills/architecture-design/templates/` 复制 **change 级模板** 到**全局台账路径**
|
|
42
|
+
* (`architecture.md→ARCHITECTURE.md`、`api.md→API-INDEX.md` 等 5 处)。两类写入方
|
|
43
|
+
* 语义互斥且互不知情——其中 DATABASE / PHYSICAL-MODEL / INDEX 因「无条件重建」被覆盖,
|
|
44
|
+
* 而 ARCHITECTURE / API-INDEX 永久卡在模板态。更隐蔽的是:`API-INDEX.md` 模板自带的
|
|
45
|
+
* 4 条 `/api/xxx` 占位行使扫描结果非空,**连"空结果拒绝覆盖"保护都不触发**,
|
|
46
|
+
* `updateIndex` 还把这 4 行统计成 `端点数量: 4` 写进 INDEX.md。
|
|
47
|
+
*
|
|
48
|
+
* **本命令产出目标格式的空基线**(与生成器同源),而非复制模板:
|
|
49
|
+
* 脚手架产出什么格式,生成器就维护什么格式——构造上消除格式漂移。
|
|
50
|
+
*
|
|
51
|
+
* **幂等**:已存在的文件不覆盖(只补缺失)。
|
|
52
|
+
*/
|
|
53
|
+
function scaffold(values) {
|
|
54
|
+
const root = findRoot(values['project-root']);
|
|
55
|
+
const result = scaffoldGlobalLedger(root);
|
|
56
|
+
console.log(`全局台账脚手架:${path.join(root, 'docs', 'architecture')}`);
|
|
57
|
+
console.log(` 新建 ${result.created.length} 个:${result.created.join(', ') || '(无)'}`);
|
|
58
|
+
console.log(` 已存在跳过 ${result.skipped.length} 个:${result.skipped.join(', ') || '(无)'}`);
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
|
|
34
62
|
function findRoot(rootOpt) {
|
|
35
63
|
if (rootOpt) return path.resolve(rootOpt);
|
|
36
64
|
let dir = process.cwd();
|
|
@@ -124,15 +124,46 @@ function collectWhitelist(projectRoot, targets, changeDir) {
|
|
|
124
124
|
return paths;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* 白名单条目 → 仓库相对路径(**唯一实现**,v0.53.0 §115.10)。
|
|
129
|
+
*
|
|
130
|
+
* P4 复核 Minor #6:`collectWhitelist` 允许 `isAbsolute(changeDir)` 原样入列,而原先
|
|
131
|
+
* 散落在 **3 处**的 `p.replace(projectRoot + '/', '')` 对**工程外**绝对路径是**无操作**
|
|
132
|
+
* (前缀不匹配)——"相对路径"仍是绝对路径。后果:
|
|
133
|
+
* ① `git add <abs>` / `git commit -- <abs>` → git 报 `pathspec ... is outside repository`,
|
|
134
|
+
* 再被 commit 的 catch 改写成 `git commit failed: <原消息>`(**归因错误**:真因是白名单
|
|
135
|
+
* 条目越界,不是 commit 失败);
|
|
136
|
+
* ② `detectOutsideDirty` 的比对集中混入绝对路径,而 `git status --porcelain` 输出恒为
|
|
137
|
+
* 仓库相对路径 → 该条目对所有文件都不匹配,覆盖比对静默失效。
|
|
138
|
+
* 三处收敛为单一实现(同属本轮根因 Ⅲ「同一契约多处各自实现」)。
|
|
139
|
+
*
|
|
140
|
+
* @returns {string|null} 相对路径;条目不在 projectRoot 之下时返回 null(越界)
|
|
141
|
+
*/
|
|
142
|
+
function toRelPath(p, projectRoot) {
|
|
143
|
+
const prefix = projectRoot.replace(/\/+$/, '') + '/';
|
|
144
|
+
return p.startsWith(prefix) ? p.slice(prefix.length) : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 严格版:越界即抛。用于会**写盘**的路径——越界属调用方/配置错误,不得静默降级为误导性报错。 */
|
|
148
|
+
function toRelPathStrict(p, projectRoot) {
|
|
149
|
+
const rel = toRelPath(p, projectRoot);
|
|
150
|
+
if (rel === null) {
|
|
151
|
+
throw new Error(`白名单条目不在工程根之下,无法构造 git pathspec:${p}(projectRoot=${projectRoot})`);
|
|
152
|
+
}
|
|
153
|
+
return rel;
|
|
154
|
+
}
|
|
155
|
+
|
|
127
156
|
/**
|
|
128
157
|
* 检测白名单之外的未提交/未跟踪文件(团队协作安全底线,仿 arch-merge detectUntouchedDirtyFiles)。
|
|
129
158
|
* 返回值:外部脏文件列表(含 staged + unstaged + untracked)。
|
|
130
159
|
*/
|
|
131
160
|
function detectOutsideDirty(projectRoot, whitelist) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
161
|
+
// 越界条目(toRelPath 返回 null)对仓库相对路径的比对无贡献,直接剔除;
|
|
162
|
+
// 写盘侧的越界由 toRelPathStrict 提前拦下,不会走到这里。
|
|
163
|
+
const relWhitelist = new Set(whitelist
|
|
164
|
+
.map(p => toRelPath(p, projectRoot))
|
|
165
|
+
.filter(r => r !== null)
|
|
166
|
+
.map(r => r.replace(/\/+$/, '')));
|
|
136
167
|
try {
|
|
137
168
|
const status = git(projectRoot, 'status', '--porcelain');
|
|
138
169
|
if (!status) return [];
|
|
@@ -247,13 +278,29 @@ export async function run(args = []) {
|
|
|
247
278
|
try {
|
|
248
279
|
// 白名单 git add(只 add 目标目录)
|
|
249
280
|
for (const p of whitelist) {
|
|
250
|
-
const rel = p
|
|
281
|
+
const rel = toRelPathStrict(p, projectRoot);
|
|
251
282
|
execFileSync('git', ['add', rel], { cwd: projectRoot, stdio: 'pipe' });
|
|
252
283
|
}
|
|
253
284
|
// commit(nothing to commit 可接受)
|
|
285
|
+
const relPaths = whitelist.map(p => toRelPathStrict(p, projectRoot));
|
|
254
286
|
try {
|
|
255
|
-
|
|
287
|
+
// v0.53.0 §109.2 R9:commit 必须带 pathspec。
|
|
288
|
+
// 裸 `git commit` 提交索引中**全部已暂存内容**——索引里若有他人/前序工具留下的
|
|
289
|
+
// staged 文件会被一并卷走。arch-merge 已在 v0.23 §93.3.2 修复同类问题,
|
|
290
|
+
// 而 cmd-publish 只复用了白名单的 **add** 侧、**未复用 commit 侧**(本文件头部
|
|
291
|
+
// 注释自称「复用 arch-merge 协作安全模式」,实际只复用了半套)。
|
|
292
|
+
execFileSync('git', ['commit', '-m', commitMsg, '--', ...relPaths], { cwd: projectRoot, stdio: 'pipe' });
|
|
256
293
|
console.log(`✅ committed: ${commitMsg}`);
|
|
294
|
+
|
|
295
|
+
// 第二道防线(仿 arch-merge v0.23 §93.3.3):回读实际提交清单与白名单比对。
|
|
296
|
+
// 不阻断(提交已完成),但白名单外文件必须可见。
|
|
297
|
+
const committed = execFileSync('git', ['show', '--name-only', '--format=', 'HEAD'],
|
|
298
|
+
{ cwd: projectRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).split('\n').filter(Boolean);
|
|
299
|
+
const wl = relPaths.map(r => r.replace(/\/+$/, ''));
|
|
300
|
+
const unexpected = committed.filter(p => !wl.some(w => p === w || p.startsWith(w + '/')));
|
|
301
|
+
if (unexpected.length > 0) {
|
|
302
|
+
console.warn(`[WARN] 本次 commit 含白名单外的文件(${unexpected.length} 个):${unexpected.slice(0, 5).join(', ')}${unexpected.length > 5 ? ' …' : ''}`);
|
|
303
|
+
}
|
|
257
304
|
} catch (e) {
|
|
258
305
|
// git 空提交消息在 stdout(execFileSync 失败时 stderr 常为空 Buffer,需先排除空值)
|
|
259
306
|
const msg = (e.stderr && e.stderr.toString()) || (e.stdout && e.stdout.toString()) || '';
|
|
@@ -40,6 +40,8 @@ const SETTABLE_FIELDS = [
|
|
|
40
40
|
'test_matrix_skipped', 'test_matrix_skip_reason',
|
|
41
41
|
// Tasks gate (v0.22 §85:hotfix/tweak 显式跳过 tasks.md,须附理由)
|
|
42
42
|
'tasks_skipped', 'tasks_skip_reason',
|
|
43
|
+
// Arch merge gate (v0.53.0 §110.2:arch-merged guard 维度的显式跳过键,须附理由)
|
|
44
|
+
'arch_merge_skipped', 'arch_merge_skip_reason',
|
|
43
45
|
];
|
|
44
46
|
|
|
45
47
|
export async function run(args) {
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* 3. 若涉及设计系统迭代(新组件/token/anti-pattern),合并进 prototype/design-system.md
|
|
12
12
|
* 4. 输出合并报告
|
|
13
13
|
*
|
|
14
|
-
* 回写顺序:arch-merge → prototype-sync
|
|
14
|
+
* 回写顺序:arch-merge → state transition closing → prototype-sync → test-merge → compound promotion
|
|
15
|
+
* (同一 change closing 内顺序执行;状态转换位次由 v0.53.0 §110 B' 时序前移确定)
|
|
15
16
|
*/
|
|
16
17
|
|
|
17
18
|
import { readFileSync, writeFileSync, existsSync, cpSync, mkdirSync } from 'node:fs';
|
|
@@ -77,6 +77,12 @@ const BUILTIN_DEFAULTS = {
|
|
|
77
77
|
// Tasks gate (v0.22 §85:hotfix/tweak 跳过 spec-writer 时显式跳过 tasks.md)
|
|
78
78
|
tasks_skipped: null,
|
|
79
79
|
tasks_skip_reason: null,
|
|
80
|
+
// Arch merge gate (v0.53.0 §110.2 加固 iii:arch-merged guard 维度的显式跳过键)
|
|
81
|
+
// 与 tasks_skipped / test_matrix_skipped 同一模式。用于 arch-merge 确为 no-op 的场景
|
|
82
|
+
// (如 change 的 architecture.md 无演进日志段且无聚合增量 → 全局台账不会出现
|
|
83
|
+
// `change:<name>`,若不给跳过键则 guard 永久 FAIL 无出路)。
|
|
84
|
+
arch_merge_skipped: null,
|
|
85
|
+
arch_merge_skip_reason: null,
|
|
80
86
|
// 注意:schema_version 故意不在 BUILTIN_DEFAULTS 中(v0.13 §48.1)——
|
|
81
87
|
// 它只由 `tf state init` 在 change 创建时打戳,字段缺失本身就是"存量 change"信号。
|
|
82
88
|
};
|
|
@@ -203,6 +209,10 @@ export function writeState(changeDir, state) {
|
|
|
203
209
|
lines.push('# === Tasks gate (v0.22 §85) ===');
|
|
204
210
|
lines.push(`tasks_skipped: ${state.tasks_skipped ?? 'null'}`);
|
|
205
211
|
lines.push(`tasks_skip_reason: ${state.tasks_skip_reason ?? 'null'}`);
|
|
212
|
+
lines.push('');
|
|
213
|
+
lines.push('# === Arch merge gate (v0.53.0 §110.2) ===');
|
|
214
|
+
lines.push(`arch_merge_skipped: ${state.arch_merge_skipped ?? 'null'}`);
|
|
215
|
+
lines.push(`arch_merge_skip_reason: ${state.arch_merge_skip_reason ?? 'null'}`);
|
|
206
216
|
|
|
207
217
|
fs.writeFileSync(filePath, lines.join('\n') + '\n', 'utf-8');
|
|
208
218
|
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* 5. rewriteIndex — 统计模块数/case 数/deferred 数,重写 INDEX.md
|
|
14
14
|
* 6. gitCommit — 单次原子提交
|
|
15
15
|
*
|
|
16
|
-
* 回写顺序:arch-merge → prototype-sync → test-merge → compound promotion
|
|
16
|
+
* 回写顺序:arch-merge → state transition closing → prototype-sync → test-merge → compound promotion
|
|
17
17
|
*
|
|
18
18
|
* v0.38.0(feedback 2026-08-05 修复 + E2E 层级):
|
|
19
19
|
* - resolveDeferred 只删 Deferred Items 段内被覆盖行(原全文件 regex 误删 Current Cases)
|
package/scripts/team-flow.mjs
CHANGED
|
@@ -67,6 +67,7 @@ Commands:
|
|
|
67
67
|
arch init [--mode reconstruction|design] [--baseline-ref <prd/vN/>]
|
|
68
68
|
Stamp project-level arch_baseline into .team-flow/arch-state.json (v0.35.0 §59.4)
|
|
69
69
|
arch show Show current project architecture baseline state
|
|
70
|
+
arch scaffold Scaffold global docs/architecture/ ledger in generator format (v0.53.0 §102)
|
|
70
71
|
arch precheck <change-dir> [--json]
|
|
71
72
|
Emit deterministic architecture-gate evidence (v0.22 §88; evidence only, exit 0)
|
|
72
73
|
arch-merge <change-dir> [--project-root <path>] [--dry-run]
|
|
@@ -198,7 +199,19 @@ async function main() {
|
|
|
198
199
|
}
|
|
199
200
|
|
|
200
201
|
const mod = await COMMANDS[command]();
|
|
201
|
-
await mod.run(commandArgs);
|
|
202
|
+
const result = await mod.run(commandArgs);
|
|
203
|
+
// v0.53.0 §104.2.3:命令以结构化失败结果结束 → 传播非零退出码。
|
|
204
|
+
//
|
|
205
|
+
// 背景:`arch-merge` 的 `[FAIL]` 汇总与 `arch-merge FAILED: N failure(s).` 消息
|
|
206
|
+
// 原本只在 `import.meta.url === process.argv[1]` 的**直调块**里设 `process.exitCode`,
|
|
207
|
+
// 而经 `tf` 调用走的是本 dispatcher(不消费返回值)→ **`tf arch-merge` 实测恒 exit 0**,
|
|
208
|
+
// 与 SKILL.md 承诺的"可能以非零退出码结束"不符,`tf arch-merge <dir> && tf state
|
|
209
|
+
// transition closing` 这类链式写法拿不到失败信号。
|
|
210
|
+
//
|
|
211
|
+
// 放在 dispatcher 而非 `run()` 内:`run()` 被 import 时不应设全局 exitCode
|
|
212
|
+
// (既有测试守护 "does not set process.exitCode when imported")。
|
|
213
|
+
// 守卫 `Array.isArray(result.failures)`:仅对返回该结构的命令生效,其余命令不受影响。
|
|
214
|
+
if (result && Array.isArray(result.failures) && result.failures.length > 0) process.exitCode = 1;
|
|
202
215
|
}
|
|
203
216
|
|
|
204
217
|
main().catch(err => {
|
|
@@ -30,23 +30,29 @@ api_contract_manager: swagger
|
|
|
30
30
|
|
|
31
31
|
## 2. To-Be 增量设计
|
|
32
32
|
|
|
33
|
+
> ⚠ **占位符必须替换**:示例行的路径写作 `<endpoint>` 形式而非 `/api/xxx` 这类**看起来像真实路径**的字符串。
|
|
34
|
+
> 原因(v0.53.0 §115.6):`/api/xxx` 是**合法的路径形状**,arch-merge 的端点提取器会**正常提取**它,
|
|
35
|
+
> 且新加的"候选>0 且提取=0 → 失败"与"覆盖率<0.5 → 告警"两条对账判据**都不会触发**
|
|
36
|
+
> (占位行被当作真实端点)。后果是占位符被计入全局 `API-INDEX.md` 与 `INDEX.md` 的「端点数量」
|
|
37
|
+
> ——与 v0.25 §101.3 记录的症状同源。`<endpoint>` 不以 `/` 开头,提取器天然拒绝。
|
|
38
|
+
|
|
33
39
|
### 2.1 Command API(改状态)
|
|
34
40
|
|
|
35
41
|
| 端点 | 方法 | 聚合 | 事务边界 | 说明 |
|
|
36
42
|
|------|------|------|---------|------|
|
|
37
|
-
|
|
|
43
|
+
| `<endpoint>` | POST | XxxAggregate | t_xxx 事务 | 简要说明 |
|
|
38
44
|
|
|
39
45
|
### 2.2 Read API(有逻辑不改状态)
|
|
40
46
|
|
|
41
47
|
| 端点 | 方法 | 聚合 | 数据来源 | 说明 |
|
|
42
48
|
|------|------|------|---------|------|
|
|
43
|
-
|
|
|
49
|
+
| `<endpoint>/{id}` | GET | XxxAggregate | t_xxx + JOIN | 简要说明 |
|
|
44
50
|
|
|
45
51
|
### 2.3 Query API(纯查询)
|
|
46
52
|
|
|
47
53
|
| 端点 | 方法 | 查询模型 | 阻断测试 | 说明 |
|
|
48
54
|
|------|------|---------|---------|------|
|
|
49
|
-
|
|
|
55
|
+
| `<endpoint>` | GET | XxxStatsView | 阻断=继续 → 数据服务 | 简要说明 |
|
|
50
56
|
|
|
51
57
|
> **阻断测试说明**:将服务阻断 1h,下游不能继续 → 业务服务;能继续 → 数据服务
|
|
52
58
|
|
|
@@ -56,7 +62,7 @@ api_contract_manager: swagger
|
|
|
56
62
|
|
|
57
63
|
| API 端点 | 对应数据实体 | 对齐状态 | 不一致说明 |
|
|
58
64
|
|---------|------------|---------|-----------|
|
|
59
|
-
|
|
|
65
|
+
| `<endpoint>` | t_xxx | ✅ 对齐 | — |
|
|
60
66
|
|
|
61
67
|
### 3.1 术语命名统一性
|
|
62
68
|
|