kld-sdd 2.6.16 → 2.6.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/bin/kld-sdd-init.js +10 -0
  2. package/kld-sdd-guide.html +22 -0
  3. package/lib/device-auth-cli.js +130 -0
  4. package/lib/device-auth.js +345 -0
  5. package/lib/init-account-binding.js +108 -0
  6. package/lib/init.js +238 -55
  7. package/lib/skills-bundle.js +20 -1
  8. package/lib/tool-profiles.js +8 -0
  9. package/package.json +7 -3
  10. package/skywalk-sdd/apply-worktree-finish.cjs +2 -23
  11. package/skywalk-sdd/context-client.cjs +38 -87
  12. package/skywalk-sdd/index.cjs +860 -132
  13. package/skywalk-sdd/kb-sync-identity.cjs +780 -0
  14. package/skywalk-sdd/kb-upload.cjs +505 -0
  15. package/skywalk-sdd/lib/shared.cjs +811 -0
  16. package/skywalk-sdd/lib/usage-contract.cjs +276 -0
  17. package/skywalk-sdd/lib/usage-reporter.cjs +354 -0
  18. package/skywalk-sdd/lib/user-config.cjs +157 -0
  19. package/skywalk-sdd/metrics-v3.cjs +138 -8
  20. package/skywalk-sdd/ontology/archive-package.cjs +19 -34
  21. package/skywalk-sdd/ontology/change-lock.cjs +3 -7
  22. package/skywalk-sdd/ontology/external-key.cjs +18 -4
  23. package/skywalk-sdd/ontology/id.cjs +26 -5
  24. package/skywalk-sdd/ontology/identity-index.cjs +3 -7
  25. package/skywalk-sdd/ontology/resolve-spec-root.cjs +20 -6
  26. package/skywalk-sdd/ontology/runtime.cjs +16 -12
  27. package/skywalk-sdd/ontology/traceability-validator.cjs +7 -4
  28. package/skywalk-sdd/reporting/change-report-markdown.cjs +137 -19
  29. package/skywalk-sdd/reporting/change-report-model.cjs +993 -14
  30. package/skywalk-sdd/reporting/change-report-renderer.cjs +106 -41
  31. package/skywalk-sdd/reporting/change-report-view-model.cjs +272 -43
  32. package/skywalk-sdd/reporting/core-metric-definitions.cjs +192 -0
  33. package/skywalk-sdd/spec-root.cjs +31 -0
  34. package/templates/git-hooks/pre-commit-consistency-check.cjs +271 -116
  35. package/templates/git-hooks/pre-push-consistency-check.cjs +252 -123
  36. package/templates/hooks/codebuddy/hooks/hook-gate-core.cjs +327 -0
  37. package/templates/hooks/codebuddy/hooks/sdd-apply-test-gate.cjs +54 -9
  38. package/templates/hooks/codebuddy/hooks/sdd-mid-checkpoint.cjs +63 -6
  39. package/templates/hooks/codebuddy/hooks/sdd-tdd-rhythm-gate.cjs +113 -65
  40. package/templates/openspec/proposal.md +7 -3
  41. package/templates/openspec/spec.md +3 -3
  42. package/templates/skills/kld-sdd/opsx-apply/SKILL.md +8 -6
  43. package/templates/skills/kld-sdd/opsx-apply/checklist.md +2 -0
  44. package/templates/skills/kld-sdd/opsx-apply/reference.md +29 -7
  45. package/templates/skills/kld-sdd/opsx-archive/SKILL.md +6 -5
  46. package/templates/skills/kld-sdd/opsx-check/SKILL.md +49 -17
  47. package/templates/skills/kld-sdd/opsx-check/checklist.md +4 -2
  48. package/templates/skills/kld-sdd/opsx-consistency-check/SKILL.md +187 -257
  49. package/templates/skills/kld-sdd/opsx-consistency-check/reference.md +129 -0
  50. package/templates/skills/kld-sdd/opsx-design/SKILL.md +2 -2
  51. package/templates/skills/kld-sdd/opsx-explore/SKILL.md +2 -2
  52. package/templates/skills/kld-sdd/opsx-kb-config/SKILL.md +185 -0
  53. package/templates/skills/kld-sdd/opsx-kb-config/reference.md +127 -0
  54. package/templates/skills/kld-sdd/opsx-kb-ingest/SKILL.md +218 -53
  55. package/templates/skills/kld-sdd/opsx-kb-ingest/reference.md +51 -9
  56. package/templates/skills/kld-sdd/opsx-ontology-query/SKILL.md +12 -50
  57. package/templates/skills/kld-sdd/opsx-ontology-query/phase-3-postchange.md +2 -2
  58. package/templates/skills/kld-sdd/opsx-ontology-query/reference.md +1 -1
  59. package/templates/skills/kld-sdd/opsx-propose/SKILL.md +35 -23
  60. package/templates/skills/kld-sdd/opsx-propose/checklist.md +2 -0
  61. package/templates/skills/kld-sdd/opsx-propose/reference.md +22 -17
  62. package/templates/skills/kld-sdd/opsx-rules/SKILL.md +2 -2
  63. package/templates/skills/kld-sdd/opsx-spec/SKILL.md +19 -15
  64. package/templates/skills/kld-sdd/opsx-spec/checklist.md +2 -0
  65. package/templates/skills/kld-sdd/opsx-task/SKILL.md +2 -4
  66. package/templates/skills/kld-sdd/opsx-test/SKILL.md +2 -2
  67. package/templates/skills/kld-sdd/tdd-core/reference.md +1 -1
  68. package/templates/skills/kld-sdd/tdd-rules/rules/test-skeleton-telemetry.md +1 -1
  69. package/templates/skills/kld-sdd/opsx-kb-ingest/state.example.json +0 -7
  70. package/templates/skills/kld-sdd/opsx-ontology-query/state.example.json +0 -7
@@ -0,0 +1,780 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * kb-sync-identity.cjs — KB 身份同步脚本
6
+ *
7
+ * 当 markdown 文件(spec.md / proposal.md 等)内容被修改后重新入库时,
8
+ * 需要更新被修改实体的身份元数据(version-id + delta-state + predecessor-version),
9
+ * 否则 KB 会拒绝(VERSION_IDENTITY_CONFLICT:同 version-id 但 content_hash 不同)。
10
+ *
11
+ * 本脚本自动完成以下工作:
12
+ * Phase 0: 初始化与预检
13
+ * - 读取 KB 配置(kb-state.json + project-identity.json)
14
+ * - KB API 健康检查
15
+ * - KB resolve 验证实体当前 version-id(predecessor 来源)
16
+ * Phase 1: 结构实体同步(Artifact / DocumentSection)
17
+ * - 对比 content_hash 变化,为变更实体生成新 version-id
18
+ * Phase 2: 业务实体同步(STMT / AC / CON / CAP)
19
+ * - 为指定实体生成新 version-id 并更新 markdown 身份块
20
+ * Phase 3: 汇总报告
21
+ *
22
+ * 用法:
23
+ * # 同步 spec.md 中所有结构实体 + 内容有变化的业务实体
24
+ * # (自动检测 content_hash 变化,只同步真正变化的实体,不污染未变实体)
25
+ * node skywalk-sdd/kb-sync-identity.cjs --file=spec.md --change=us-260801-user-crud --project=.
26
+ *
27
+ * # 仅同步指定锚点的业务实体(精准模式,用户明确指定变更实体)
28
+ * node skywalk-sdd/kb-sync-identity.cjs --file=spec.md --change=us-260801-user-crud --anchors=AC-USER-CRUD-006
29
+ *
30
+ * # 仅同步结构实体(Artifact / DocumentSection),不修改业务实体
31
+ * node skywalk-sdd/kb-sync-identity.cjs --file=spec.md --change=us-260801-user-crud --structural-only
32
+ *
33
+ * # 干跑模式(只输出报告,不修改文件)
34
+ * node skywalk-sdd/kb-sync-identity.cjs --file=spec.md --change=us-260801-user-crud --dry-run
35
+ *
36
+ * # 跳过 KB 验证(KB 不可用时强制本地同步,风险:predecessor 可能不匹配 KB)
37
+ * node skywalk-sdd/kb-sync-identity.cjs --file=spec.md --change=us-260801-user-crud --skip-kb-verify
38
+ */
39
+
40
+ const fs = require('fs');
41
+ const path = require('path');
42
+ const {
43
+ parseArgs,
44
+ readJson,
45
+ writeTextAtomic,
46
+ resolveSpecRootFast,
47
+ loadKbState,
48
+ resolveKbParams,
49
+ httpGetJson,
50
+ requestJson,
51
+ } = require('./lib/shared.cjs');
52
+
53
+ const { allocateIdentity, normalizeUuid } = require('./ontology/id.cjs');
54
+ const { readRegistry, writeRegistry } = require('./ontology/structural-identity.cjs');
55
+
56
+ // ── 工具函数 ──────────────────────────────────────────
57
+
58
+ function resolveSpecRoot(explicit) {
59
+ if (explicit) return path.resolve(explicit);
60
+ return resolveSpecRootFast(__dirname);
61
+ }
62
+
63
+ /**
64
+ * 解析 markdown 中的身份块。
65
+ *
66
+ * 身份块格式:
67
+ * - **entity-id**: <8-hex>
68
+ * - **version-id**: <8-hex>
69
+ * - **delta-state**: added|modified|unchanged|removed
70
+ * - **predecessor-version**: <8-hex> (可选,modified/removed 时存在)
71
+ *
72
+ * @param {string} content — markdown 文件内容
73
+ * @returns {Array<{start: number, end: number, entityId: string, versionId: string, deltaState: string, predecessorVersion: string|null, anchor: string|null}>}
74
+ */
75
+ function parseIdentityBlocks(content) {
76
+ const lines = content.split('\n');
77
+ const blocks = [];
78
+
79
+ // 匹配身份属性行
80
+ const entityIdRe = /^(-\s+\*\*entity-id\*\*:\s*)([0-9a-f]{8})\s*$/i;
81
+ const versionIdRe = /^(-\s+\*\*version-id\*\*:\s*)([0-9a-f]{8})\s*$/i;
82
+ const deltaStateRe = /^(-\s+\*\*delta-state\*\*:\s*)(\w+)\s*$/i;
83
+ const predecessorRe = /^(-\s+\*\*predecessor-version\*\*:\s*)([0-9a-f]{8}|none|\(none\))\s*$/i;
84
+
85
+ for (let i = 0; i < lines.length; i++) {
86
+ const entityIdMatch = lines[i].match(entityIdRe);
87
+ if (!entityIdMatch) continue;
88
+
89
+ // 向后扫描最多 6 行找完属性块
90
+ let versionId = null;
91
+ let deltaState = null;
92
+ let predecessorVersion = null;
93
+ let blockEnd = i;
94
+
95
+ for (let j = i; j < Math.min(i + 8, lines.length); j++) {
96
+ const vm = lines[j].match(versionIdRe);
97
+ if (vm) { versionId = vm[2]; blockEnd = j; }
98
+
99
+ const dm = lines[j].match(deltaStateRe);
100
+ if (dm) { deltaState = dm[2]; blockEnd = j; }
101
+
102
+ const pm = lines[j].match(predecessorRe);
103
+ if (pm) {
104
+ predecessorVersion = (pm[2].toLowerCase() === 'none' || pm[2] === '(none)') ? null : pm[2];
105
+ blockEnd = j;
106
+ }
107
+
108
+ // 遇到空行或下一个 entity-id 行,结束扫描
109
+ if (j > i && entityIdRe.test(lines[j])) break;
110
+ if (j > i && lines[j].trim() === '' && versionId && deltaState) break;
111
+ }
112
+
113
+ if (!versionId || !deltaState) continue;
114
+
115
+ // 向前查找锚点(最近的标题行或列表项中的 [XXX-YYY-ZZZ] 标识)
116
+ let anchor = null;
117
+ for (let k = i - 1; k >= Math.max(0, i - 10); k--) {
118
+ const line = lines[k];
119
+ // 匹配 [CAP-XXX] / [STMT-XXX] / [AC-XXX] / [CON-XXX] 等
120
+ const anchorMatch = line.match(/\[([A-Z]{2,5}-[A-Z0-9-]+)\]/);
121
+ if (anchorMatch) {
122
+ anchor = anchorMatch[1];
123
+ break;
124
+ }
125
+ // 匹配标题行
126
+ if (/^#{1,6}\s+/.test(line)) {
127
+ const titleMatch = line.match(/\[([A-Z]{2,5}-[A-Z0-9-]+)\]/);
128
+ if (titleMatch) { anchor = titleMatch[1]; break; }
129
+ // 标题行但没有锚点标识,仍可作为上下文
130
+ if (!anchor) { anchor = line.replace(/^#{1,6}\s+/, '').trim(); }
131
+ break;
132
+ }
133
+ }
134
+
135
+ blocks.push({
136
+ lineStart: i,
137
+ lineEnd: blockEnd,
138
+ entityId: entityIdMatch[2],
139
+ versionId,
140
+ deltaState,
141
+ predecessorVersion,
142
+ anchor,
143
+ });
144
+ }
145
+
146
+ return blocks;
147
+ }
148
+
149
+ /**
150
+ * 更新 markdown 中的身份块。
151
+ * @param {string} content — 原始 markdown 内容
152
+ * @param {Array} updates — 要更新的身份块列表
153
+ * @returns {string} 更新后的内容
154
+ */
155
+ function updateIdentityBlocks(content, updates) {
156
+ const lines = content.split('\n');
157
+ const entityIdRe = /^(-\s+\*\*entity-id\*\*:\s*)([0-9a-f]{8})\s*$/i;
158
+ const versionIdRe = /^(-\s+\*\*version-id\*\*:\s*)([0-9a-f]{8})\s*$/i;
159
+ const deltaStateRe = /^(-\s+\*\*delta-state\*\*:\s*)(\w+)\s*$/i;
160
+ const predecessorRe = /^(-\s+\*\*predecessor-version\*\*:\s*)(.+)\s*$/i;
161
+
162
+ for (const update of updates) {
163
+ // 找到对应的 entity-id 行
164
+ for (let i = 0; i < lines.length; i++) {
165
+ const m = lines[i].match(entityIdRe);
166
+ if (!m || m[2].toLowerCase() !== update.entityId.toLowerCase()) continue;
167
+
168
+ // 扫描后续行更新属性
169
+ let hasPredecessorLine = false;
170
+ for (let j = i; j < Math.min(i + 8, lines.length); j++) {
171
+ const vm = lines[j].match(versionIdRe);
172
+ if (vm && vm[2].toLowerCase() === update.oldVersionId.toLowerCase()) {
173
+ lines[j] = `${vm[1]}${update.newVersionId}`;
174
+ }
175
+
176
+ const dm = lines[j].match(deltaStateRe);
177
+ if (dm && update.newDeltaState) {
178
+ lines[j] = `${dm[1]}${update.newDeltaState}`;
179
+ }
180
+
181
+ // 更新或添加 predecessor-version
182
+ const pm = lines[j].match(predecessorRe);
183
+ if (pm && update.predecessorVersion) {
184
+ lines[j] = `${pm[1]}${update.predecessorVersion}`;
185
+ hasPredecessorLine = true;
186
+ }
187
+ }
188
+
189
+ // 如果原文件没有 predecessor-version 行,在 delta-state 行后插入
190
+ if (!hasPredecessorLine && update.predecessorVersion) {
191
+ for (let j = i; j < Math.min(i + 8, lines.length); j++) {
192
+ if (deltaStateRe.test(lines[j])) {
193
+ lines.splice(j + 1, 0, ` - **predecessor-version**: ${update.predecessorVersion}`);
194
+ break;
195
+ }
196
+ }
197
+ }
198
+ break;
199
+ }
200
+ }
201
+
202
+ return lines.join('\n');
203
+ }
204
+
205
+ /**
206
+ * 从 registry 中过滤出指定文件相关的结构实体 key。
207
+ *
208
+ * key 格式:
209
+ * Artifact: artifact:CHG-xxx:spec.md → 以 :filePath 结尾
210
+ * DocumentSection: section:CHG-xxx:spec.md#AC-xxx → 含 :filePath#
211
+ */
212
+ function filterRegistryKeys(registry, filePath) {
213
+ const result = [];
214
+ for (const [key, entry] of Object.entries(registry.identities)) {
215
+ // Artifact key 以 :filePath 结尾(无尾随冒号或井号)
216
+ // DocumentSection key 含 :filePath#
217
+ if (key.endsWith(`:${filePath}`) || key.includes(`:${filePath}#`)) {
218
+ result.push({ key, entry });
219
+ }
220
+ }
221
+ return result;
222
+ }
223
+
224
+ // ── Phase 0: KB 初始化与预检 ──────────────────────────
225
+
226
+ /**
227
+ * 读取 KB 配置(kb-state.json + project-identity.json)。
228
+ * @param {string} specRoot
229
+ * @returns {{ kbState: object, projectId: string, kbParams: { apiBase, token, spaceId, kbId } }}
230
+ */
231
+ function initKbConfig(specRoot) {
232
+ // 1. 读取 kb-state.json
233
+ const { config: kbState, warnings } = loadKbState(specRoot);
234
+ if (warnings && warnings.length > 0) {
235
+ for (const w of warnings) console.warn(` ⚠️ ${w}`);
236
+ }
237
+ if (!kbState) {
238
+ throw new Error(
239
+ 'KB 配置不存在(kb-state.json 缺失)。\n' +
240
+ '请运行 /opsx-kb-config 配置知识库后再使用本脚本,\n' +
241
+ '或使用 --skip-kb-verify 跳过 KB 验证(不推荐)。'
242
+ );
243
+ }
244
+
245
+ // 2. 读取 project-identity.json
246
+ const identityPath = path.join(specRoot, 'skywalk-sdd', 'project-identity.json');
247
+ let projectId = null;
248
+ if (fs.existsSync(identityPath)) {
249
+ const identity = readJson(identityPath);
250
+ projectId = identity.project_id || null;
251
+ }
252
+ if (!projectId) {
253
+ throw new Error(
254
+ '无法确定 project_id。project-identity.json 不存在或无 project_id 字段。\n' +
255
+ '请运行 /opsx-kb-config 配置知识库。'
256
+ );
257
+ }
258
+
259
+ // 3. 解析 KB 参数
260
+ const kbParams = resolveKbParams(kbState);
261
+ if (!kbParams.token) {
262
+ throw new Error('KB apiKey 未配置(kb-state.json 中缺少 apiKey)。');
263
+ }
264
+ if (!kbParams.spaceId || !kbParams.kbId) {
265
+ throw new Error('KB targets 未配置(kb-state.json 中缺少 targets[0].spaceId/kbId)。');
266
+ }
267
+
268
+ // 4. 校验 project_id 与 spaceKey 一致性
269
+ const targets = kbState.targets || [];
270
+ if (targets.length > 0 && targets[0].spaceKey) {
271
+ if (projectId !== targets[0].spaceKey) {
272
+ throw new Error(
273
+ `project_id 不匹配:\n` +
274
+ ` project-identity.json: ${projectId}\n` +
275
+ ` kb-state.json spaceKey: ${targets[0].spaceKey}\n` +
276
+ `请运行 /opsx-kb-config 更新配置。`
277
+ );
278
+ }
279
+ }
280
+
281
+ return { kbState, projectId, kbParams };
282
+ }
283
+
284
+ /**
285
+ * KB API 健康检查。
286
+ * @param {string} apiBase
287
+ * @returns {Promise<boolean>}
288
+ */
289
+ async function checkKbHealth(apiBase) {
290
+ const healthUrl = apiBase.replace(/\/api\/?$/, '') + '/health';
291
+ try {
292
+ await httpGetJson(healthUrl, '', 10000);
293
+ return true;
294
+ } catch (e) {
295
+ // 尝试备用路径
296
+ try {
297
+ await httpGetJson(apiBase.replace(/\/$/, '') + '/health', '', 10000);
298
+ return true;
299
+ } catch (e2) {
300
+ return false;
301
+ }
302
+ }
303
+ }
304
+
305
+ /**
306
+ * 调用 KB resolve API 查询实体的当前 version-id。
307
+ *
308
+ * @param {object} kbParams — { apiBase, token, spaceId, kbId }
309
+ * @param {string} entityId — 实体 ID(8-hex)
310
+ * @param {number} timeoutMs
311
+ * @returns {Promise<{ found: boolean, entityVersionId: string|null, raw: object }>}
312
+ */
313
+ async function resolveEntityFromKb(kbParams, entityId, timeoutMs = 30000) {
314
+ const endpoint = `${kbParams.apiBase}/v1/spaces/${encodeURIComponent(kbParams.spaceId)}` +
315
+ `/knowledge-bases/${encodeURIComponent(kbParams.kbId)}/entities/resolve`;
316
+
317
+ const payload = { entityId: normalizeUuid(entityId) };
318
+
319
+ try {
320
+ const resp = await requestJson(endpoint, payload, kbParams.token, timeoutMs);
321
+
322
+ // 尝试从响应中提取当前 version-id
323
+ // 响应结构可能因 KB 版本而异,尝试多种路径
324
+ const candidates = resp?.candidates || resp?.data?.candidates || [];
325
+ if (candidates.length > 0) {
326
+ const candidate = candidates[0];
327
+ const versionId = candidate.entityVersionId
328
+ || candidate.version_id
329
+ || candidate.versionId
330
+ || candidate.entity_version_id
331
+ || null;
332
+ return { found: true, entityVersionId: versionId, raw: resp };
333
+ }
334
+
335
+ // 如果没有 candidates 但有 resolution,尝试从 resolution 提取
336
+ const resolution = resp?.resolution || resp?.data?.resolution;
337
+ if (resolution) {
338
+ const versionId = resolution.entityVersionId
339
+ || resolution.version_id
340
+ || resolution.versionId
341
+ || null;
342
+ if (versionId) {
343
+ return { found: true, entityVersionId: versionId, raw: resp };
344
+ }
345
+ }
346
+
347
+ // 实体在 KB 中不存在(首次上传场景)
348
+ return { found: false, entityVersionId: null, raw: resp };
349
+ } catch (e) {
350
+ // KB API 错误(网络/权限/服务器等)
351
+ throw new Error(`KB resolve 失败 (entity=${entityId}): ${e.message}`);
352
+ }
353
+ }
354
+
355
+ // ── 主流程 ────────────────────────────────────────────
356
+
357
+ async function main() {
358
+ const args = parseArgs(process.argv.slice(2));
359
+
360
+ if (!args.file || !args.change) {
361
+ console.error('用法:');
362
+ console.error(' node skywalk-sdd/kb-sync-identity.cjs --file=<spec.md> --change=<change-key> [--project=.]');
363
+ console.error(' [--anchors=AC-USER-CRUD-006,AC-USER-CRUD-007] [--structural-only] [--dry-run]');
364
+ console.error(' [--skip-kb-verify]');
365
+ process.exit(1);
366
+ }
367
+
368
+ const dryRun = Boolean(args['dry-run']);
369
+ const structuralOnly = Boolean(args['structural-only']);
370
+ const skipKbVerify = Boolean(args['skip-kb-verify']);
371
+ const anchorList = args.anchors
372
+ ? String(args.anchors).split(',').map(s => s.trim().toUpperCase()).filter(Boolean)
373
+ : null;
374
+
375
+ const specRoot = resolveSpecRoot(args.project);
376
+ const changeName = args.change;
377
+ const filePath = args.file;
378
+
379
+ const changeDir = path.join(specRoot, 'openspec', 'changes', changeName);
380
+ if (!fs.existsSync(changeDir)) {
381
+ console.error(`❌ 变更目录不存在: ${changeDir}`);
382
+ process.exit(1);
383
+ }
384
+
385
+ const mdPath = path.join(changeDir, filePath);
386
+ if (!fs.existsSync(mdPath)) {
387
+ console.error(`❌ 文件不存在: ${mdPath}`);
388
+ process.exit(1);
389
+ }
390
+
391
+ console.log(`模式: KB 身份同步`);
392
+ console.log(` 文件: ${filePath}`);
393
+ console.log(` 变更: ${changeName}`);
394
+ console.log(` Spec Root: ${specRoot}`);
395
+ console.log(` 干跑: ${dryRun ? '是' : '否'}`);
396
+ console.log(` KB 验证: ${skipKbVerify ? '跳过' : '启用'}`);
397
+ console.log('');
398
+
399
+ const report = {
400
+ structuralSynced: [],
401
+ structuralSkipped: [],
402
+ businessSynced: [],
403
+ businessSkipped: [],
404
+ errors: [],
405
+ warnings: [],
406
+ };
407
+
408
+ // ═════════════════════════════════════════════════════
409
+ // Phase 0: 初始化与预检
410
+ // ═════════════════════════════════════════════════════
411
+
412
+ console.log('── Phase 0: 初始化与预检 ──');
413
+
414
+ let kbParams = null;
415
+ let kbHealthOk = false;
416
+
417
+ if (skipKbVerify) {
418
+ console.log(' ⚠️ 已跳过 KB 验证(--skip-kb-verify)');
419
+ console.log(' ⚠️ 风险:本地 version-id 可能与 KB 不一致,导致上传被拒绝');
420
+ report.warnings.push('skip-kb-verify: 本地 version-id 未经 KB 验证');
421
+ } else {
422
+ // 0a. 读取 KB 配置
423
+ console.log(' → 读取 KB 配置...');
424
+ try {
425
+ const kbConfig = initKbConfig(specRoot);
426
+ kbParams = kbConfig.kbParams;
427
+ console.log(` ✓ KB 配置: api=${kbParams.apiBase}, space=${kbParams.spaceId}, kb=${kbParams.kbId}`);
428
+ console.log(` ✓ project_id: ${kbConfig.projectId}`);
429
+ } catch (e) {
430
+ console.error(` ❌ KB 配置错误: ${e.message}`);
431
+ process.exit(1);
432
+ }
433
+
434
+ // 0b. KB API 健康检查
435
+ console.log(' → KB API 健康检查...');
436
+ kbHealthOk = await checkKbHealth(kbParams.apiBase);
437
+ if (!kbHealthOk) {
438
+ console.warn(' ⚠️ KB API 不可达,降级为本地模式(不查询 KB)');
439
+ console.warn(' ⚠️ 风险:predecessor-version 将使用本地 version-id,可能与 KB 不一致');
440
+ report.warnings.push('kb-unreachable: KB API 不可达,predecessor 使用本地 version-id');
441
+ } else {
442
+ console.log(' ✓ KB API 可达');
443
+ }
444
+ }
445
+
446
+ const kbVerifyEnabled = !skipKbVerify && kbHealthOk && kbParams;
447
+ console.log('');
448
+
449
+ // ═════════════════════════════════════════════════════
450
+ // Part 1: 结构实体同步(Artifact / DocumentSection)
451
+ // ═════════════════════════════════════════════════════
452
+
453
+ console.log('── 结构实体同步 ──');
454
+
455
+ // 1. 读取旧 registry(上传前的 version_id)
456
+ const oldRegistry = readRegistry(changeDir);
457
+ const oldEntries = filterRegistryKeys(oldRegistry, filePath);
458
+
459
+ console.log(` 扫描到 ${oldEntries.length} 个结构实体(Artifact + DocumentSection)`);
460
+
461
+ if (oldEntries.length === 0) {
462
+ console.log(' ⚠️ 未找到结构实体记录,跳过结构实体同步');
463
+ console.log(' (可能是首次上传,无需同步)');
464
+ } else {
465
+ // 2. 运行 scanChange 更新 content_hash(但不改 version_id)
466
+ // ⚠️ dry-run 模式下需要保存旧 registry 快照,扫描后恢复,避免污染后续正式执行
467
+ const oldRegistrySnapshot = dryRun
468
+ ? JSON.parse(JSON.stringify(oldRegistry))
469
+ : null;
470
+
471
+ console.log(' → 运行语义扫描(更新 content_hash)...');
472
+ const runtime = require('./ontology/runtime.cjs');
473
+ const scanResult = runtime.scanChange(specRoot, changeName, {
474
+ profile: 'auto',
475
+ markPending: true,
476
+ });
477
+ console.log(` ✓ 语义扫描完成 (valid=${scanResult.valid})`);
478
+
479
+ // 3. 读取新 registry(content_hash 已更新,version_id 未变)
480
+ const newRegistry = readRegistry(changeDir);
481
+ const newEntries = filterRegistryKeys(newRegistry, filePath);
482
+
483
+ // dry-run 恢复:将 registry 恢复为扫描前的状态,确保正式执行时能正确检测变化
484
+ if (dryRun) {
485
+ writeRegistry(changeDir, oldRegistrySnapshot);
486
+ console.log(' ✓ dry-run: 已恢复 ontology-identities.json 到扫描前状态');
487
+ }
488
+
489
+ // 4. 比较 content_hash,为变更实体生成新 version_id
490
+ for (const { key, entry } of newEntries) {
491
+ const oldEntry = oldRegistry.identities[key];
492
+ if (!oldEntry) {
493
+ // 新增实体,跳过(首次分配由 reconcile 处理)
494
+ report.structuralSkipped.push({ key, reason: 'new_entity' });
495
+ continue;
496
+ }
497
+
498
+ if (entry.content_hash === oldEntry.content_hash) {
499
+ // content_hash 未变,跳过
500
+ report.structuralSkipped.push({ key, reason: 'unchanged' });
501
+ continue;
502
+ }
503
+
504
+ // content_hash 变了 → 需要新 version_id
505
+ try {
506
+ // 确定正确的 predecessor version-id
507
+ let predecessorVersionId = entry.version_id;
508
+ let predecessorSource = 'local';
509
+
510
+ if (kbVerifyEnabled) {
511
+ // 查询 KB 获取当前 version-id
512
+ const kbResult = await resolveEntityFromKb(kbParams, entry.entity_id);
513
+ if (kbResult.found && kbResult.entityVersionId) {
514
+ const kbVersionId = normalizeUuid(kbResult.entityVersionId);
515
+ if (kbVersionId !== entry.version_id.toLowerCase()) {
516
+ console.log(` ⚠️ KB version-id 不一致: local=${entry.version_id}, kb=${kbVersionId}`);
517
+ console.log(` → 使用 KB version-id 作为 predecessor`);
518
+ predecessorVersionId = kbVersionId;
519
+ predecessorSource = 'kb';
520
+ report.warnings.push(
521
+ `${key}: local version ${entry.version_id} != KB version ${kbVersionId}, using KB as predecessor`
522
+ );
523
+ }
524
+ } else if (!kbResult.found) {
525
+ console.log(` ⚠️ 实体在 KB 中不存在: ${entry.entity_id} (${key})`);
526
+ console.log(` → 可能是首次上传,跳过同步`);
527
+ report.structuralSkipped.push({ key, reason: 'not_in_kb' });
528
+ continue;
529
+ }
530
+ }
531
+
532
+ const newIdentity = allocateIdentity({
533
+ deltaState: 'modified',
534
+ entityId: entry.entity_id,
535
+ predecessorVersionId,
536
+ });
537
+
538
+ if (!dryRun) {
539
+ entry.version_id = newIdentity.version_id;
540
+ }
541
+
542
+ report.structuralSynced.push({
543
+ key,
544
+ type: entry.type,
545
+ entityId: entry.entity_id,
546
+ oldVersionId: oldEntry.version_id,
547
+ newVersionId: newIdentity.version_id,
548
+ predecessorSource,
549
+ contentHashChanged: true,
550
+ });
551
+
552
+ console.log(` ✓ 同步: ${key}`);
553
+ console.log(` ${entry.type} entity=${entry.entity_id}`);
554
+ console.log(` version: ${oldEntry.version_id} → ${newIdentity.version_id}`);
555
+ console.log(` predecessor: ${predecessorVersionId} (来源: ${predecessorSource})`);
556
+ } catch (e) {
557
+ report.errors.push({ key, error: e.message });
558
+ console.error(` ❌ 同步失败: ${key}: ${e.message}`);
559
+ }
560
+ }
561
+
562
+ // 5. 写入更新后的 registry
563
+ if (!dryRun && report.structuralSynced.length > 0) {
564
+ writeRegistry(changeDir, newRegistry);
565
+ console.log(` ✓ 已更新 ontology-identities.json (${report.structuralSynced.length} 个实体)`);
566
+ }
567
+
568
+ // 6. 检测残留实体(registry 中有但 markdown 中已删除的实体)
569
+ const newKeys = new Set(newEntries.map(e => e.key));
570
+ const staleKeys = oldEntries.filter(e => !newKeys.has(e.key));
571
+ if (staleKeys.length > 0) {
572
+ console.log(`\n ⚠️ 检测到 ${staleKeys.length} 个残留结构实体(已在 ${filePath} 中删除但 registry 仍有记录):`);
573
+ for (const { key, entry } of staleKeys) {
574
+ console.log(` - ${key} (entity=${entry.entity_id}, version=${entry.version_id})`);
575
+ report.warnings.push(`stale_entity: ${key} — 已从 markdown 删除但 registry 仍有记录`);
576
+ }
577
+ console.log(' → 这些实体不会影响本次上传(canonical-facts 仅包含当前 markdown 中的实体)');
578
+ console.log(' → 但建议在 KB 中标记为 removed 或清理 registry 以避免未来锚点复用冲突');
579
+ }
580
+ }
581
+
582
+ console.log(` 结构实体: ${report.structuralSynced.length} 同步, ${report.structuralSkipped.length} 跳过`);
583
+
584
+ // ═════════════════════════════════════════════════════
585
+ // Part 2: 业务实体同步(STMT / AC / CON / CAP)
586
+ // ═════════════════════════════════════════════════════
587
+
588
+ if (!structuralOnly) {
589
+ console.log('\n── 业务实体同步 ──');
590
+
591
+ const mdContent = fs.readFileSync(mdPath, 'utf8');
592
+ const identityBlocks = parseIdentityBlocks(mdContent);
593
+
594
+ console.log(` 扫描到 ${identityBlocks.length} 个身份块`);
595
+
596
+ // ── 从 Part 1 结果中提取 content_hash 变化的 DocumentSection 锚点 ──
597
+ // key 格式: section:CHG-xxx:spec.md#AC-USER-CRUD-001
598
+ const changedSectionAnchors = new Set();
599
+ for (const synced of report.structuralSynced) {
600
+ if (synced.type === 'DocumentSection' && synced.key) {
601
+ const hashIndex = synced.key.lastIndexOf('#');
602
+ if (hashIndex >= 0) {
603
+ changedSectionAnchors.add(synced.key.substring(hashIndex + 1).toUpperCase());
604
+ }
605
+ }
606
+ }
607
+
608
+ // 过滤需要同步的实体
609
+ let blocksToSync = [];
610
+
611
+ if (anchorList) {
612
+ // 精准模式:只同步指定锚点(用户明确指定,信任用户判断)
613
+ blocksToSync = identityBlocks.filter(b =>
614
+ b.anchor && anchorList.some(a => b.anchor.toUpperCase().includes(a)),
615
+ );
616
+ console.log(` 精准模式: 匹配 ${blocksToSync.length}/${identityBlocks.length} 个实体(anchors: ${anchorList.join(', ')})`);
617
+
618
+ // 内容变化检测提示(仅信息性,不阻断)
619
+ const anchorSet = new Set(anchorList);
620
+ const matchedWithoutChange = blocksToSync.filter(b =>
621
+ b.deltaState === 'added' && b.anchor && !changedSectionAnchors.has(b.anchor.toUpperCase()),
622
+ );
623
+ if (matchedWithoutChange.length > 0) {
624
+ console.log(` ℹ️ 其中 ${matchedWithoutChange.length} 个实体在 Part 1 中未检测到 content_hash 变化`);
625
+ console.log(` (可能是身份元数据变更或用户强制指定,仍将同步)`);
626
+ }
627
+ } else {
628
+ // 默认/全量模式:基于 Part 1 的 content_hash 变化结果驱动筛选
629
+ // 只同步 delta-state=added 且对应 DocumentSection content_hash 变化的实体
630
+ blocksToSync = identityBlocks.filter(b => {
631
+ if (b.deltaState !== 'added') return false;
632
+ if (!b.anchor) return false;
633
+ return changedSectionAnchors.has(b.anchor.toUpperCase());
634
+ });
635
+
636
+ console.log(` 内容驱动模式: ${blocksToSync.length}/${identityBlocks.length} 个实体需要同步`);
637
+ console.log(` (DocumentSection content_hash 变化: ${changedSectionAnchors.size} 个)`);
638
+
639
+ if (blocksToSync.length === 0 && changedSectionAnchors.size === 0) {
640
+ console.log(' ✓ 无内容变化,跳过业务实体同步');
641
+ }
642
+
643
+ // 输出未同步的 added 实体(信息性)
644
+ const unchangedAdded = identityBlocks.filter(b =>
645
+ b.deltaState === 'added' && b.anchor && !changedSectionAnchors.has(b.anchor.toUpperCase()),
646
+ );
647
+ if (unchangedAdded.length > 0) {
648
+ console.log(` ℹ️ ${unchangedAdded.length} 个 added 实体内容未变化,保持 added 状态(不污染版本)`);
649
+ }
650
+ }
651
+
652
+ const mdUpdates = [];
653
+
654
+ for (const block of blocksToSync) {
655
+ try {
656
+ // 确定正确的 predecessor version-id
657
+ let predecessorVersionId = block.versionId;
658
+ let predecessorSource = 'local';
659
+
660
+ if (kbVerifyEnabled) {
661
+ // 查询 KB 获取当前 version-id
662
+ const kbResult = await resolveEntityFromKb(kbParams, block.entityId);
663
+ if (kbResult.found && kbResult.entityVersionId) {
664
+ const kbVersionId = normalizeUuid(kbResult.entityVersionId);
665
+ if (kbVersionId !== block.versionId.toLowerCase()) {
666
+ console.log(` ⚠️ KB version-id 不一致: ${block.anchor || block.entityId}`);
667
+ console.log(` local=${block.versionId}, kb=${kbVersionId}`);
668
+ console.log(` → 使用 KB version-id 作为 predecessor`);
669
+ predecessorVersionId = kbVersionId;
670
+ predecessorSource = 'kb';
671
+ report.warnings.push(
672
+ `${block.anchor}: local version ${block.versionId} != KB version ${kbVersionId}, using KB as predecessor`
673
+ );
674
+ }
675
+ } else if (!kbResult.found) {
676
+ console.log(` ⚠️ 实体在 KB 中不存在: ${block.anchor || block.entityId} (entity=${block.entityId})`);
677
+ console.log(` → 可能是首次上传,仍生成新 version-id(delta-state=added → modified)`);
678
+ report.warnings.push(
679
+ `${block.anchor}: entity not found in KB, treating as first-time upload`
680
+ );
681
+ }
682
+ }
683
+
684
+ // 生成新 version-id(modified, predecessor=KB验证的 version-id)
685
+ const newIdentity = allocateIdentity({
686
+ deltaState: 'modified',
687
+ entityId: block.entityId,
688
+ predecessorVersionId,
689
+ });
690
+
691
+ mdUpdates.push({
692
+ entityId: block.entityId,
693
+ oldVersionId: block.versionId,
694
+ newVersionId: newIdentity.version_id,
695
+ newDeltaState: 'modified',
696
+ predecessorVersion: predecessorVersionId,
697
+ anchor: block.anchor,
698
+ });
699
+
700
+ report.businessSynced.push({
701
+ anchor: block.anchor,
702
+ entityId: block.entityId,
703
+ oldVersionId: block.versionId,
704
+ newVersionId: newIdentity.version_id,
705
+ predecessorSource,
706
+ });
707
+
708
+ console.log(` ✓ 同步: ${block.anchor || block.entityId}`);
709
+ console.log(` version: ${block.versionId} → ${newIdentity.version_id}`);
710
+ console.log(` predecessor: ${predecessorVersionId} (来源: ${predecessorSource})`);
711
+ } catch (e) {
712
+ report.errors.push({ anchor: block.anchor, error: e.message });
713
+ console.error(` ❌ 同步失败: ${block.anchor || block.entityId}: ${e.message}`);
714
+ }
715
+ }
716
+
717
+ // 跳过的实体
718
+ for (const block of identityBlocks) {
719
+ if (blocksToSync.includes(block)) continue;
720
+ let reason;
721
+ if (block.deltaState !== 'added') {
722
+ reason = `delta-state=${block.deltaState}`;
723
+ } else if (anchorList) {
724
+ reason = 'not matched by anchors';
725
+ } else {
726
+ reason = 'content unchanged (no DocumentSection hash change)';
727
+ }
728
+ report.businessSkipped.push({
729
+ anchor: block.anchor,
730
+ reason,
731
+ });
732
+ }
733
+
734
+ // 更新 markdown 文件
735
+ if (!dryRun && mdUpdates.length > 0) {
736
+ const newContent = updateIdentityBlocks(mdContent, mdUpdates);
737
+ writeTextAtomic(mdPath, newContent);
738
+ console.log(` ✓ 已更新 ${filePath} (${mdUpdates.length} 个身份块)`);
739
+ }
740
+
741
+ console.log(` 业务实体: ${report.businessSynced.length} 同步, ${report.businessSkipped.length} 跳过`);
742
+ }
743
+
744
+ // ═════════════════════════════════════════════════════
745
+ // Part 3: 汇总报告
746
+ // ═════════════════════════════════════════════════════
747
+
748
+ console.log('\n── 同步报告 ──');
749
+ console.log(` 结构实体: ${report.structuralSynced.length} 同步, ${report.structuralSkipped.length} 跳过`);
750
+ console.log(` 业务实体: ${report.businessSynced.length} 同步, ${report.businessSkipped.length} 跳过`);
751
+
752
+ if (report.warnings.length > 0) {
753
+ console.log(` ⚠️ 警告: ${report.warnings.length}`);
754
+ for (const w of report.warnings) {
755
+ console.log(` - ${w}`);
756
+ }
757
+ }
758
+
759
+ if (report.errors.length > 0) {
760
+ console.log(` ❌ 错误: ${report.errors.length}`);
761
+ for (const e of report.errors) {
762
+ console.log(` - ${e.key || e.anchor}: ${e.error}`);
763
+ }
764
+ }
765
+
766
+ if (dryRun) {
767
+ console.log('\n⚠️ 干跑模式(--dry-run):未修改任何文件');
768
+ } else if (report.structuralSynced.length > 0 || report.businessSynced.length > 0) {
769
+ console.log('\n✓ 同步完成');
770
+ console.log(' 下一步: 运行 kb-upload.cjs --folder 上传到 KB');
771
+ console.log(' → node skywalk-sdd/kb-upload.cjs --folder=<变更目录> --project=.');
772
+ } else {
773
+ console.log('\n✓ 无需同步(所有实体内容未变更)');
774
+ }
775
+ }
776
+
777
+ main().catch(err => {
778
+ console.error(`\n❌ ${err.message}`);
779
+ process.exit(1);
780
+ });