@xulthekl/team-flow 0.64.0 → 0.65.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/CHANGELOG.md +29 -0
- package/GEMINI.md +1 -1
- package/INSTALL.md +1 -1
- package/README.md +1 -1
- package/agents/architecture-design.md +1 -1
- package/agents/architecture-reviewer.md +2 -2
- package/docs/README_en.md +1 -1
- package/docs/team-flow /344/275/277/347/224/250/350/257/264/346/230/216/357/274/210/347/240/224/345/217/221/345/233/242/351/230/237/347/211/210/357/274/211.md" +1 -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-gate-exemptions.mjs +5 -3
- package/scripts/guard/checks/arch-readiness.mjs +1 -1
- package/scripts/guard/checks/arch-snapshot.mjs +5 -3
- package/scripts/lib/arch-merge.mjs +384 -50
- package/scripts/lib/arch-parse.mjs +5 -2
- package/scripts/lib/arch-registry.mjs +523 -0
- package/scripts/lib/arch-scan-code.mjs +518 -0
- package/scripts/lib/cmd-arch.mjs +9 -1
- package/scripts/lib/cmd-doctor.mjs +1 -1
- package/scripts/lib/config-loader.mjs +20 -0
- package/scripts/team-flow.mjs +3 -0
- package/skills/architecture-design/SKILL.md +29 -11
- package/skills/architecture-design/chapters/ch04-entity-to-aggregate.md +18 -7
- package/skills/architecture-design/chapters/ch06-integration.md +12 -3
- package/skills/architecture-design/glossary.md +5 -1
- package/skills/architecture-design/references/adr-templates.md +56 -0
- package/skills/architecture-design/references/context-map-8.md +47 -0
- package/skills/architecture-design/references/ddd-evented-playbook.md +41 -0
- package/skills/architecture-design/references/s3.5-architecture-template.md +36 -4
- package/skills/architecture-design/references/s3.5-loading-protocol.md +5 -4
- package/skills/architecture-design/references/s3.5-product-architecture.md +6 -6
- package/skills/architecture-design/templates/architecture.md +20 -0
- package/skills/ce-compound/references/concepts-vocabulary.md +1 -1
- package/skills/ce-compound/references/full-mode-workflow.md +2 -2
- package/skills/ce-compound/references/lightweight-mode.md +1 -1
- package/skills/release-archivist/SKILL.md +21 -0
- package/skills/release-archivist/references/closing-procedures.md +1 -1
- package/skills/workflow-orchestrator/SKILL.md +2 -2
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
// scripts/lib/arch-registry.mjs — O8 registry 持久层(P0-B'',A1)
|
|
2
|
+
// 设计:docs/plan/ddd-purity-and-arch-merge-design.md v1.5 §5.4-1(schema)/ §5.4-4c(delta)/
|
|
3
|
+
// §5.4-2(base = source:base* 无特殊地位)/ §8 红牌 6/10(删除须显式 · 零依赖可重放)
|
|
4
|
+
//
|
|
5
|
+
// 存储:`docs/architecture/.registry/registry.json`(唯一存储;JSON——红牌 10 禁 YAML 依赖)。
|
|
6
|
+
// 约束:
|
|
7
|
+
// - 固定键序 + 条目按稳定键排序 → 同输入逐字节相同(可重放)
|
|
8
|
+
// - `source: base | change:<name>` 全段必填(guard 双通道判定输入 + 审计追溯键)
|
|
9
|
+
// - 删除仅经 retire(status: retired + reason + retired_in_change),禁止静默消失
|
|
10
|
+
// - events[](O4)/ context_map[](O7)在本 schema 冻结时即预留 —— §3 依赖不是空头支票
|
|
11
|
+
//
|
|
12
|
+
// schema 冻结补充定义(设计未明写、由实施补齐,已记 §12):
|
|
13
|
+
// - event 条目 key = 事件名(全局命名约定 OrderPlaced 风格);owner 由 fields 表达
|
|
14
|
+
// - context_map 条目 key = `<source_bc>→<target_bc>`(关系方向敏感)
|
|
15
|
+
// - 各段统一附 `status: active | retired`(retire 规则对全部 kind 生效,红牌 6)
|
|
16
|
+
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
|
|
20
|
+
export const REGISTRY_SCHEMA_VERSION = 1;
|
|
21
|
+
/** 相对 docs/architecture/ 的存储路径 */
|
|
22
|
+
export const REGISTRY_REL_PATH = path.join('.registry', 'registry.json');
|
|
23
|
+
|
|
24
|
+
export const DELTA_KINDS = ['aggregate', 'bounded_context', 'subdomain', 'table', 'endpoint', 'event', 'context_map'];
|
|
25
|
+
export const DELTA_OPS = ['new', 'extend', 'refactor', 'retire'];
|
|
26
|
+
/** O7 上下文映射 8 模式(经典 Context Mapping 全集,schema 冻结即约束枚举) */
|
|
27
|
+
export const CONTEXT_MAP_PATTERNS = [
|
|
28
|
+
'Shared Kernel', 'Customer-Supplier', 'Conformist', 'Anti-Corruption Layer',
|
|
29
|
+
'Open Host Service', 'Separate Ways', 'Partnership', 'Published Language',
|
|
30
|
+
];
|
|
31
|
+
/** O3 子域三分类 */
|
|
32
|
+
export const SUBDOMAIN_CLASSES = ['Core', 'Supporting', 'Generic'];
|
|
33
|
+
|
|
34
|
+
/** 空 registry(五段 + schema 头)。键序即序列化键序。 */
|
|
35
|
+
export function emptyRegistry() {
|
|
36
|
+
return {
|
|
37
|
+
schema_version: REGISTRY_SCHEMA_VERSION,
|
|
38
|
+
updated_at: null,
|
|
39
|
+
aggregates: [],
|
|
40
|
+
bounded_contexts: [],
|
|
41
|
+
subdomains: [],
|
|
42
|
+
tables: [],
|
|
43
|
+
endpoints: [],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* ============ 序列化:固定键序 + 稳定排序(红牌 10 可重放) ============ */
|
|
48
|
+
|
|
49
|
+
const SEGMENT_KEYS = {
|
|
50
|
+
// reason / retired_in_change:retire/refactor 审计元数据必须落盘(红牌 6 落盘态——
|
|
51
|
+
// 缺它们则 status:retired 无出处,审计断链;P3 M 级自查补入)
|
|
52
|
+
aggregates: ['id', 'name', 'bc', 'root', 'owner_change', 'status', 'source', 'events', 'invariants', 'reason', 'retired_in_change'],
|
|
53
|
+
bounded_contexts: ['id', 'name', 'responsibility', 'aggregates', 'context_map', 'status', 'source', 'reason', 'retired_in_change'],
|
|
54
|
+
subdomains: ['id', 'name', 'classification', 'business_problem', 'status', 'source', 'reason', 'retired_in_change'],
|
|
55
|
+
tables: ['name', 'change', 'ddl_ref', 'status', 'source', 'reason', 'retired_in_change'],
|
|
56
|
+
endpoints: ['method', 'path', 'kind', 'change', 'status', 'source', 'reason', 'retired_in_change'],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 聚合行规范化(P3 C-2 修复):把 extractAggregates 的 `context` 字段映射为持久层契约 `bc`,
|
|
61
|
+
* 并补齐 `events`/`name` 缺省——seed/机械补种注入点统一走本函数,防落盘行违反自身 schema(C-1)。
|
|
62
|
+
*/
|
|
63
|
+
export function normalizeAggregateRow(a, { source, status } = {}) {
|
|
64
|
+
return {
|
|
65
|
+
id: a.id,
|
|
66
|
+
name: a.name ?? a.id,
|
|
67
|
+
bc: a.bc ?? a.context ?? null,
|
|
68
|
+
root: a.root ?? null,
|
|
69
|
+
owner_change: a.owner_change ?? null,
|
|
70
|
+
status: status ?? a.status ?? 'active',
|
|
71
|
+
source: source ?? a.source,
|
|
72
|
+
events: Array.isArray(a.events) ? a.events : [],
|
|
73
|
+
invariants: a.invariants ?? null,
|
|
74
|
+
...(a.reason !== undefined ? { reason: a.reason } : {}),
|
|
75
|
+
...(a.retired_in_change !== undefined ? { retired_in_change: a.retired_in_change } : {}),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function projectKeys(obj, keys) {
|
|
80
|
+
const out = {};
|
|
81
|
+
for (const k of keys) if (obj[k] !== undefined) out[k] = obj[k];
|
|
82
|
+
// 白名单外字段丢弃前先暴露:静默丢字段违反"不静默"——validateRegistry 负责报错,
|
|
83
|
+
// 序列化层只做投影(调用序列:validate → sort → save)。
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function compareBy(...keys) {
|
|
88
|
+
return (a, b) => {
|
|
89
|
+
for (const k of keys) {
|
|
90
|
+
const av = String(a[k] ?? '');
|
|
91
|
+
const bv = String(b[k] ?? '');
|
|
92
|
+
if (av !== bv) return av < bv ? -1 : 1;
|
|
93
|
+
}
|
|
94
|
+
return 0;
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 按稳定键排序各段(原地)。子结构:events 按 name;context_map 按 key;bc.aggregates 按 id。 */
|
|
99
|
+
export function sortRegistry(reg) {
|
|
100
|
+
reg.aggregates.sort(compareBy('id'));
|
|
101
|
+
reg.bounded_contexts.sort(compareBy('id'));
|
|
102
|
+
reg.subdomains.sort(compareBy('id'));
|
|
103
|
+
reg.tables.sort(compareBy('name'));
|
|
104
|
+
reg.endpoints.sort(compareBy('method', 'path'));
|
|
105
|
+
for (const a of reg.aggregates) {
|
|
106
|
+
if (Array.isArray(a.events)) a.events.sort(compareBy('name'));
|
|
107
|
+
}
|
|
108
|
+
for (const bc of reg.bounded_contexts) {
|
|
109
|
+
if (Array.isArray(bc.aggregates)) bc.aggregates.sort();
|
|
110
|
+
if (Array.isArray(bc.context_map)) bc.context_map.sort(compareBy('key'));
|
|
111
|
+
}
|
|
112
|
+
return reg;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 规范化为可序列化对象:固定键序投影 + 排序。 */
|
|
116
|
+
export function canonicalizeRegistry(reg) {
|
|
117
|
+
const out = {
|
|
118
|
+
schema_version: reg.schema_version ?? REGISTRY_SCHEMA_VERSION,
|
|
119
|
+
updated_at: reg.updated_at ?? null,
|
|
120
|
+
};
|
|
121
|
+
for (const [seg, keys] of Object.entries(SEGMENT_KEYS)) {
|
|
122
|
+
out[seg] = (reg[seg] || []).map(item => projectKeys(item, keys));
|
|
123
|
+
}
|
|
124
|
+
sortRegistry(out);
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/* ============ 读写 ============ */
|
|
129
|
+
|
|
130
|
+
export function registryPath(globalArchDir) {
|
|
131
|
+
return path.join(globalArchDir, REGISTRY_REL_PATH);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 读 registry;文件不存在 → null(调用方决定是否首跑 seed)。解析失败 → throw(不静默产出半份)。 */
|
|
135
|
+
export function loadRegistry(globalArchDir) {
|
|
136
|
+
const p = registryPath(globalArchDir);
|
|
137
|
+
if (!fs.existsSync(p)) return null;
|
|
138
|
+
const reg = JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
139
|
+
const errors = validateRegistry(reg);
|
|
140
|
+
if (errors.length > 0) {
|
|
141
|
+
throw new Error(`registry schema 校验失败(${p}):\n - ${errors.join('\n - ')}`);
|
|
142
|
+
}
|
|
143
|
+
return reg;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 固定键序 + 排序后写盘。返回写入路径。
|
|
148
|
+
* ★ 双防线(P3 C-1/M-1 修复):
|
|
149
|
+
* 1. 写盘前 validateRegistry——违反 schema 一律拒绝落盘(fail-closed:写不出去好过写出坏文件,
|
|
150
|
+
* 坏文件会让此后每次 load 必炸且不可自愈);
|
|
151
|
+
* 2. run 级幂等(红牌 10/I-5):内容(除 updated_at)与磁盘一致 → 不写盘,保留旧时间戳,
|
|
152
|
+
* 同输入重跑逐字节不变。
|
|
153
|
+
*/
|
|
154
|
+
export function saveRegistry(globalArchDir, reg) {
|
|
155
|
+
const errors = validateRegistry(reg);
|
|
156
|
+
if (errors.length > 0) {
|
|
157
|
+
throw new Error(`拒绝写出违反 schema 的 registry:\n - ${errors.join('\n - ')}`);
|
|
158
|
+
}
|
|
159
|
+
const canonical = canonicalizeRegistry(reg);
|
|
160
|
+
const p = registryPath(globalArchDir);
|
|
161
|
+
if (fs.existsSync(p)) {
|
|
162
|
+
try {
|
|
163
|
+
const prev = JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
164
|
+
if (JSON.stringify({ ...prev, updated_at: canonical.updated_at }) === JSON.stringify(canonical)) {
|
|
165
|
+
return p; // 内容无实质变化:不写(保留磁盘旧 updated_at)
|
|
166
|
+
}
|
|
167
|
+
} catch { /* 磁盘损坏 → 走下方写入以新内容修复 */ }
|
|
168
|
+
}
|
|
169
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
170
|
+
fs.writeFileSync(p, JSON.stringify(canonical, null, 2) + '\n', 'utf-8');
|
|
171
|
+
return p;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* ============ 校验(A8:机械层——结构/必填/枚举;evidence 内容正确性属人审) ============ */
|
|
175
|
+
|
|
176
|
+
export function validateRegistry(reg) {
|
|
177
|
+
const errors = [];
|
|
178
|
+
if (!reg || typeof reg !== 'object') return ['registry 不是对象'];
|
|
179
|
+
if (reg.schema_version !== REGISTRY_SCHEMA_VERSION) {
|
|
180
|
+
errors.push(`schema_version 须为 ${REGISTRY_SCHEMA_VERSION},实为 ${reg.schema_version}`);
|
|
181
|
+
}
|
|
182
|
+
const sourceOk = s => typeof s === 'string' && (s === 'base' || s.startsWith('base@') || s.startsWith('change:'));
|
|
183
|
+
const statusOk = s => s === undefined || s === 'active' || s === 'retired';
|
|
184
|
+
|
|
185
|
+
for (const seg of Object.keys(SEGMENT_KEYS)) {
|
|
186
|
+
if (!Array.isArray(reg[seg])) { errors.push(`${seg} 须为数组`); continue; }
|
|
187
|
+
for (const item of reg[seg]) {
|
|
188
|
+
const label = `${seg}[${item.id ?? item.name ?? item.path ?? '?'}]`;
|
|
189
|
+
if (!sourceOk(item.source)) errors.push(`${label}: source 必填(base | base@* | change:<name>),实为 ${JSON.stringify(item.source)}`);
|
|
190
|
+
if (!statusOk(item.status)) errors.push(`${label}: status 须 active|retired`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// 分段必填(按 §5.4-1 字段清单)
|
|
194
|
+
for (const a of reg.aggregates || []) {
|
|
195
|
+
if (!a.id) errors.push('aggregates[] 缺 id');
|
|
196
|
+
if (!('bc' in a)) errors.push(`aggregates[${a.id}]: 缺 bc 字段(持久层契约字段——context 注入时须映射,C-2 防线)`);
|
|
197
|
+
if (!Array.isArray(a.events)) errors.push(`aggregates[${a.id}]: events 须为数组(O4 登记载体,可为空)`);
|
|
198
|
+
else if (a.events.some(e => !e || !e.name)) errors.push(`aggregates[${a.id}]: events 条目须含 name`);
|
|
199
|
+
}
|
|
200
|
+
for (const bc of reg.bounded_contexts || []) {
|
|
201
|
+
if (!bc.id) errors.push('bounded_contexts[] 缺 id');
|
|
202
|
+
if (!Array.isArray(bc.context_map)) errors.push(`bounded_contexts[${bc.id}]: context_map 须为数组(O7 登记载体)`);
|
|
203
|
+
else {
|
|
204
|
+
// M-4:O7 八模式枚举 enforcement(枚举冻结不校验 = 零判别力)
|
|
205
|
+
for (const cm of bc.context_map) {
|
|
206
|
+
if (!cm || !cm.pattern) errors.push(`bounded_contexts[${bc.id}].context_map[${cm?.key ?? '?'}]: 缺 pattern(O7 八模式必填)`);
|
|
207
|
+
else if (!CONTEXT_MAP_PATTERNS.includes(cm.pattern)) {
|
|
208
|
+
errors.push(`bounded_contexts[${bc.id}].context_map[${cm.key}]: pattern ${JSON.stringify(cm.pattern)} 不在 O7 八模式枚举内`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
for (const sd of reg.subdomains || []) {
|
|
214
|
+
if (!sd.id) errors.push('subdomains[] 缺 id');
|
|
215
|
+
if (!SUBDOMAIN_CLASSES.includes(sd.classification)) {
|
|
216
|
+
errors.push(`subdomains[${sd.id}]: classification 须 Core|Supporting|Generic`);
|
|
217
|
+
}
|
|
218
|
+
// O3 红线:子域描述不得含实现载体名(反例校验,与模板/评审门同口径)
|
|
219
|
+
if (sd.business_problem && /\b\w*Service\b|\b\w*Impl\b|com\.\w+/.test(sd.business_problem)) {
|
|
220
|
+
errors.push(`subdomains[${sd.id}]: business_problem 含实现载体词(问题空间 ≠ 解空间)`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
for (const t of reg.tables || []) {
|
|
224
|
+
if (!t.name) errors.push('tables[] 缺 name');
|
|
225
|
+
}
|
|
226
|
+
for (const e of reg.endpoints || []) {
|
|
227
|
+
if (!e.method || !e.path) errors.push(`endpoints[${e.method}:${e.path}]: method/path 必填`);
|
|
228
|
+
if (e.kind !== undefined && !['server', 'feign'].includes(e.kind)) {
|
|
229
|
+
errors.push(`endpoints[${e.method}:${e.path}]: kind 须 server|feign(审计 B10 分流)`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return errors;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* delta 结构校验(§5.4-4c;A8 强度分级:结构错误 = abort 级;evidence 类错误带 `EVIDENCE:`
|
|
237
|
+
* 前缀返回,由调用方按软着陆策略分流(P3 I-2:软着陆期 WARN 不 abort、v0.66.0 硬切 abort)。
|
|
238
|
+
* evidence 机械层 = 非空 +(给定 changeDir 时)引用文件真实存在——内容正确性仍属人审。
|
|
239
|
+
*/
|
|
240
|
+
export function validateDelta(delta, { changeDir } = {}) {
|
|
241
|
+
const errors = [];
|
|
242
|
+
if (!delta || typeof delta !== 'object') return ['delta 不是对象'];
|
|
243
|
+
if (delta.delta_version !== 1) errors.push('delta_version 须为 1');
|
|
244
|
+
if (!delta.change || typeof delta.change !== 'string') errors.push('change 必填');
|
|
245
|
+
if (!Array.isArray(delta.entries)) { errors.push('entries 须为数组'); return errors; }
|
|
246
|
+
delta.entries.forEach((e, i) => {
|
|
247
|
+
const at = `entries[${i}]`;
|
|
248
|
+
if (!DELTA_KINDS.includes(e.kind)) errors.push(`${at}: kind 非法(${e.kind})`);
|
|
249
|
+
if (!DELTA_OPS.includes(e.op)) errors.push(`${at}: op 非法(${e.op})`);
|
|
250
|
+
if (!e.key || typeof e.key !== 'string') errors.push(`${at}: key 必填`);
|
|
251
|
+
if (e.op === 'refactor' && !e.prev_key) errors.push(`${at}: refactor 须 prev_key`);
|
|
252
|
+
if ((e.op === 'retire' || e.op === 'refactor') && !e.reason) errors.push(`${at}: ${e.op} 须 reason`);
|
|
253
|
+
if (e.op === 'new' || e.op === 'extend') {
|
|
254
|
+
if (!e.evidence || !String(e.evidence).trim()) {
|
|
255
|
+
errors.push(`EVIDENCE: ${at}: ${e.op} 须 evidence(防幻觉机械层——非空 + 可定位)`);
|
|
256
|
+
} else if (changeDir) {
|
|
257
|
+
// I-1:路径存在性半边(A8「其引用的路径在 change 内真实存在」)
|
|
258
|
+
const file = String(e.evidence).split(/[:#§]/)[0].trim();
|
|
259
|
+
if (file && !file.startsWith('/') && !fs.existsSync(path.join(changeDir, file))) {
|
|
260
|
+
errors.push(`EVIDENCE: ${at}: evidence 引用的文件在 change 内不存在(${file})`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (e.fields !== undefined && (typeof e.fields !== 'object' || e.fields === null || Array.isArray(e.fields))) {
|
|
265
|
+
errors.push(`${at}: fields 须为对象`);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
return errors;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/* ============ delta 消费:op 匹配规则(§5.4-4c 表;R3-3 seed 先行由调用方保证) ============ */
|
|
272
|
+
|
|
273
|
+
function segmentOf(reg, kind) {
|
|
274
|
+
const map = {
|
|
275
|
+
aggregate: 'aggregates', bounded_context: 'bounded_contexts', subdomain: 'subdomains',
|
|
276
|
+
table: 'tables', endpoint: 'endpoints',
|
|
277
|
+
// event / context_map 是子结构:宿主分别为 aggregates[] / bounded_contexts[]
|
|
278
|
+
};
|
|
279
|
+
return map[kind];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function findEntry(reg, kind, key) {
|
|
283
|
+
if (kind === 'event') {
|
|
284
|
+
for (const a of reg.aggregates) {
|
|
285
|
+
const ev = (a.events || []).find(x => x.name === key);
|
|
286
|
+
if (ev) return { host: a, entry: ev, list: a.events, segLabel: `aggregates[${a.id}].events` };
|
|
287
|
+
}
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
if (kind === 'context_map') {
|
|
291
|
+
for (const bc of reg.bounded_contexts) {
|
|
292
|
+
const cm = (bc.context_map || []).find(x => x.key === key);
|
|
293
|
+
if (cm) return { host: bc, entry: cm, list: bc.context_map, segLabel: `bounded_contexts[${bc.id}].context_map` };
|
|
294
|
+
}
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
const seg = segmentOf(reg, kind);
|
|
298
|
+
const idField = kind === 'table' ? 'name' : kind === 'endpoint' ? null : 'id';
|
|
299
|
+
const found = (reg[seg] || []).find(x => {
|
|
300
|
+
if (kind === 'table') return x.name === key;
|
|
301
|
+
if (kind === 'endpoint') return `${x.method}:${x.path}` === key;
|
|
302
|
+
return x[idField] === key;
|
|
303
|
+
});
|
|
304
|
+
return found ? { host: null, entry: found, list: reg[seg], segLabel: seg } : null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* 消费一份 delta,返回新 registry。
|
|
309
|
+
* @param {object} reg 当前 registry(会被复制修改,不改入参)
|
|
310
|
+
* @param {object} delta `.arch-delta.json`
|
|
311
|
+
* @param {object} opts { changeName, hard?: boolean } —— hard=false(软着陆,v0.65.0 默认):
|
|
312
|
+
* 违约降级为 warnings;hard=true(v0.66.0 硬切):违约进 violations,调用方须 abort。
|
|
313
|
+
* @returns {{ registry, applied: string[], warnings: string[], violations: string[] }}
|
|
314
|
+
*/
|
|
315
|
+
export function applyDelta(reg, delta, { changeName, hard = false } = {}) {
|
|
316
|
+
const warnings = [];
|
|
317
|
+
const violations = [];
|
|
318
|
+
const applied = [];
|
|
319
|
+
const out = JSON.parse(JSON.stringify(reg)); // 深拷贝(结构简单,JSON 足够)
|
|
320
|
+
const src = `change:${changeName ?? delta.change}`;
|
|
321
|
+
const record = msg => { if (hard) violations.push(msg); else warnings.push(msg); };
|
|
322
|
+
|
|
323
|
+
// 确定性:按 kind 再按 key 排序处理(不依赖 LLM 输出顺序,红牌 10)
|
|
324
|
+
const entries = [...delta.entries].sort((a, b) =>
|
|
325
|
+
(a.kind + a.key).localeCompare(b.kind + b.key));
|
|
326
|
+
|
|
327
|
+
for (const e of entries) {
|
|
328
|
+
const found = findEntry(out, e.kind, e.key);
|
|
329
|
+
|
|
330
|
+
if (e.op === 'new') {
|
|
331
|
+
if (found) {
|
|
332
|
+
// 软着陆:seed 键 / 既有键上写 new → WARN 视作 extend(R3-3;硬切 abort)
|
|
333
|
+
record(`new 冲突:${e.kind} ${e.key} 已存在(seed 或既有)→ 视作 extend(R3-3 对称处置)`);
|
|
334
|
+
mergeFields(found.entry, e.fields);
|
|
335
|
+
applied.push(`extend(by-new-conflict):${e.kind}:${e.key}`);
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
// P3 I-6 幻影宿主防线:宿主字段已填但宿主不存在 → 不造无从区分的壳行(typo 即污染事实源),
|
|
339
|
+
// 记录后跳过(软着陆 WARN / 硬切 violation);条目不丢,语义段补宿主 id 后可重放。
|
|
340
|
+
if (e.kind === 'event' && e.fields?.owner_aggregate
|
|
341
|
+
&& !out.aggregates.some(a => a.id === e.fields.owner_aggregate)) {
|
|
342
|
+
record(`event ${e.key}: owner_aggregate「${e.fields.owner_aggregate}」不存在 → 跳过(幻影宿主防线;请语义段核对聚合 id 后重放)`);
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
if (e.kind === 'context_map' && e.fields?.source_bc
|
|
346
|
+
&& !out.bounded_contexts.some(b => b.id === e.fields.source_bc)) {
|
|
347
|
+
record(`context_map ${e.key}: source_bc「${e.fields.source_bc}」不存在 → 跳过(幻影宿主防线;请语义段核对 BC id 后重放)`);
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
insertEntry(out, e.kind, e.key, e.fields, src);
|
|
351
|
+
applied.push(`new:${e.kind}:${e.key}`);
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (e.op === 'extend') {
|
|
356
|
+
if (!found) {
|
|
357
|
+
record(`extend 落空:${e.kind} ${e.key} 不存在 → 视作 new(软着陆)`);
|
|
358
|
+
insertEntry(out, e.kind, e.key, e.fields, src);
|
|
359
|
+
applied.push(`new(by-extend-miss):${e.kind}:${e.key}`);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if (found.entry.source?.startsWith('change:') && found.entry.source !== src) {
|
|
363
|
+
// 跨 change 改动既有条目:不覆盖 source 归属(本次合并是权威仅限同 change;
|
|
364
|
+
// 跨 change 修改既有所有权条目属结构性变更,须人审——WARN 不阻断)
|
|
365
|
+
warnings.push(`extend 跨 change:${e.kind} ${e.key} 属 ${found.entry.source},本次 ${src} 的字段合并不改归属`);
|
|
366
|
+
}
|
|
367
|
+
mergeFields(found.entry, e.fields);
|
|
368
|
+
applied.push(`extend:${e.kind}:${e.key}`);
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (e.op === 'refactor') {
|
|
373
|
+
const from = findEntry(out, e.kind, e.prev_key);
|
|
374
|
+
if (!from) {
|
|
375
|
+
violations.push(`refactor 前置缺失:${e.kind} ${e.prev_key} 不存在(无软着陆豁免)`);
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
if (findEntry(out, e.kind, e.key)) {
|
|
379
|
+
violations.push(`refactor 目标已存在:${e.kind} ${e.key}`);
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
// 改键 + 保留 source 历史 + 合并本次字段
|
|
383
|
+
if (e.kind === 'table') from.entry.name = e.key;
|
|
384
|
+
else if (e.kind === 'endpoint') { const [m, ...rest] = e.key.split(':'); from.entry.method = m; from.entry.path = rest.join(':'); }
|
|
385
|
+
else if (e.kind === 'event') from.entry.name = e.key;
|
|
386
|
+
else if (e.kind === 'context_map') from.entry.key = e.key;
|
|
387
|
+
else from.entry.id = e.key;
|
|
388
|
+
mergeFields(from.entry, e.fields);
|
|
389
|
+
from.entry.reason = e.reason; // 结构变更可审计(§5.7)
|
|
390
|
+
applied.push(`refactor:${e.kind}:${e.prev_key}->${e.key}`);
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (e.op === 'retire') {
|
|
395
|
+
if (!found) {
|
|
396
|
+
warnings.push(`retire 幂等:${e.kind} ${e.key} 不存在(重放或先行清理)`);
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
// 不删行(红牌 6):置标记
|
|
400
|
+
found.entry.status = 'retired';
|
|
401
|
+
found.entry.reason = e.reason;
|
|
402
|
+
found.entry.retired_in_change = changeName ?? delta.change;
|
|
403
|
+
applied.push(`retire:${e.kind}:${e.key}`);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
sortRegistry(out);
|
|
409
|
+
return { registry: out, applied, warnings, violations };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function mergeFields(entry, fields) {
|
|
413
|
+
if (!fields) return;
|
|
414
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
415
|
+
if (k === 'source' || k === 'status') continue; // 生命周期/归属不由字段合入(retire/refactor 走专属 op)
|
|
416
|
+
entry[k] = v;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function insertEntry(reg, kind, key, fields, source) {
|
|
421
|
+
// fields 不得携带 source/status(生命周期/归属只经 retire/refactor/专属 op 变更)
|
|
422
|
+
const clean = {};
|
|
423
|
+
for (const [k, v] of Object.entries(fields || {})) {
|
|
424
|
+
if (k === 'source' || k === 'status') continue;
|
|
425
|
+
clean[k] = v;
|
|
426
|
+
}
|
|
427
|
+
const base = { status: 'active', source };
|
|
428
|
+
const ownerName = source.startsWith('change:') ? source.slice('change:'.length) : null;
|
|
429
|
+
|
|
430
|
+
if (kind === 'event') {
|
|
431
|
+
// schema 冻结决策:事件经 fields.owner_aggregate 挂宿主聚合(O4 事件表「源聚合」列);
|
|
432
|
+
// 缺宿主时创建最小聚合壳(不静默丢事件),壳 id 带 event-sink 前缀可辨识、待人审归位。
|
|
433
|
+
const ownerId = clean.owner_aggregate;
|
|
434
|
+
if (!ownerId) {
|
|
435
|
+
reg.aggregates.push({
|
|
436
|
+
id: `event-sink:${key}`, name: key, bc: null, owner_change: ownerName,
|
|
437
|
+
status: 'active', source, events: [{ name: key, ...base, ...clean }],
|
|
438
|
+
});
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
let host = reg.aggregates.find(a => a.id === ownerId);
|
|
442
|
+
if (!host) {
|
|
443
|
+
host = { id: ownerId, name: ownerId, bc: null, owner_change: ownerName, status: 'active', source, events: [] };
|
|
444
|
+
reg.aggregates.push(host);
|
|
445
|
+
}
|
|
446
|
+
if (!Array.isArray(host.events)) host.events = [];
|
|
447
|
+
host.events.push({ name: key, ...base, ...clean });
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
if (kind === 'context_map') {
|
|
451
|
+
const srcBc = clean.source_bc;
|
|
452
|
+
if (!srcBc) throw new Error(`context_map ${key} 缺 fields.source_bc`);
|
|
453
|
+
let host = reg.bounded_contexts.find(b => b.id === srcBc);
|
|
454
|
+
if (!host) {
|
|
455
|
+
host = { id: srcBc, name: srcBc, responsibility: null, aggregates: [], context_map: [], status: 'active', source };
|
|
456
|
+
reg.bounded_contexts.push(host);
|
|
457
|
+
}
|
|
458
|
+
if (!Array.isArray(host.context_map)) host.context_map = [];
|
|
459
|
+
host.context_map.push({ key, ...base, ...clean });
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
if (kind === 'aggregate') {
|
|
463
|
+
reg.aggregates.push({
|
|
464
|
+
...base, id: key, name: clean.name ?? key, bc: clean.bc ?? null,
|
|
465
|
+
owner_change: ownerName, events: [], ...clean,
|
|
466
|
+
});
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (kind === 'bounded_context') {
|
|
470
|
+
reg.bounded_contexts.push({
|
|
471
|
+
...base, id: key, name: clean.name ?? key, responsibility: clean.responsibility ?? null,
|
|
472
|
+
aggregates: [], context_map: [], ...clean,
|
|
473
|
+
});
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (kind === 'subdomain') {
|
|
477
|
+
reg.subdomains.push({
|
|
478
|
+
...base, id: key, name: clean.name ?? key,
|
|
479
|
+
classification: clean.classification, business_problem: clean.business_problem, ...clean,
|
|
480
|
+
});
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (kind === 'table') {
|
|
484
|
+
reg.tables.push({ ...base, name: key, change: ownerName, ddl_ref: clean.ddl_ref ?? null, ...clean });
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (kind === 'endpoint') {
|
|
488
|
+
const [method, ...rest] = key.split(':');
|
|
489
|
+
reg.endpoints.push({ ...base, method, path: rest.join(':'), kind: clean.kind ?? 'server', change: ownerName, ...clean });
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
throw new Error(`未知 kind: ${kind}`);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/* ============ seed(红牌 12 首次 merge 自动 seed 的承载层) ============ */
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* 把一组 seed 条目按 source=seedSource 并入 registry(只补缺,不覆盖已有行——
|
|
499
|
+
* 与 P0-B' seedFromSnapshots 的"最低优先级"语义一致)。
|
|
500
|
+
* @param {object} reg
|
|
501
|
+
* @param {{ aggregates?: [], tables?: [], endpoints?: [], bounded_contexts?: [], subdomains?: [] }} seed
|
|
502
|
+
* @param {string} seedSource 如 `base@snapshot-backfill` / `base@schema-baseline`
|
|
503
|
+
* @returns {{ registry, seeded: number }}
|
|
504
|
+
*/
|
|
505
|
+
export function seedRegistry(reg, seed, seedSource = 'base') {
|
|
506
|
+
let seeded = 0;
|
|
507
|
+
const add = (list, keyFn) => {
|
|
508
|
+
for (const item of seed[list] || []) {
|
|
509
|
+
const existing = findEntry(reg, { aggregates: 'aggregate', tables: 'table', endpoints: 'endpoint', bounded_contexts: 'bounded_context', subdomains: 'subdomain' }[list], keyFn(item));
|
|
510
|
+
if (existing) continue;
|
|
511
|
+
const withSource = { ...item, source: item.source ?? seedSource, status: item.status ?? 'active' };
|
|
512
|
+
reg[list].push(withSource);
|
|
513
|
+
seeded++;
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
add('aggregates', x => x.id);
|
|
517
|
+
add('tables', x => x.name);
|
|
518
|
+
add('endpoints', x => `${x.method}:${x.path}`);
|
|
519
|
+
add('bounded_contexts', x => x.id);
|
|
520
|
+
add('subdomains', x => x.id);
|
|
521
|
+
sortRegistry(reg);
|
|
522
|
+
return { registry: reg, seeded };
|
|
523
|
+
}
|