kld-sdd 2.7.3 → 2.7.8
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/README.md +12 -5
- package/USABILITY.md +84 -0
- package/kld-sdd-guide.html +16 -4
- package/lib/hook-gate-core.js +327 -0
- package/lib/init.js +221 -37
- package/lib/scale-thresholds.json +19 -0
- package/lib/skills-bundle.js +2 -1
- package/package.json +5 -3
- package/skywalk-sdd/context-client.cjs +50 -30
- package/skywalk-sdd/index.cjs +36 -16
- package/skywalk-sdd/kb-sync-identity.cjs +99 -451
- package/skywalk-sdd/kb-upload.cjs +67 -44
- package/skywalk-sdd/lib/usage-contract.cjs +3 -2
- package/skywalk-sdd/lib/usage-reporter.cjs +27 -2
- package/skywalk-sdd/metrics-v3.cjs +2 -2
- package/skywalk-sdd/ontology/active-changes.cjs +2 -1
- package/skywalk-sdd/ontology/archive-package.cjs +1 -1
- package/skywalk-sdd/ontology/artifact-parser.cjs +7 -4
- package/skywalk-sdd/ontology/id.cjs +5 -4
- package/skywalk-sdd/ontology/identity-index.cjs +4 -3
- package/skywalk-sdd/ontology/list-changes.cjs +1 -1
- package/skywalk-sdd/ontology/runtime.cjs +7 -3
- package/skywalk-sdd/ontology/schema.cjs +2 -0
- package/skywalk-sdd/ontology/traceability-validator.cjs +74 -4
- package/skywalk-sdd/ontology/workspace-layout.cjs +25 -5
- package/skywalk-sdd/reporting/change-report-model.cjs +4 -3
- package/templates/git-hooks/commit-msg +22 -21
- package/templates/git-hooks/consistency-check-core.cjs +1087 -0
- package/templates/git-hooks/hooks.config +20 -1
- package/templates/git-hooks/pre-commit +22 -21
- package/templates/git-hooks/pre-commit-consistency-check.cjs +29 -332
- package/templates/git-hooks/pre-commit-sdd-check.cjs +98 -0
- package/templates/git-hooks/pre-push +22 -21
- package/templates/git-hooks/pre-push-consistency-check.cjs +58 -406
- package/templates/hooks/codebuddy/hooks/sdd-tdd-rhythm-gate.cjs +1 -1
- package/templates/openspec/tasks.md +3 -3
- package/templates/skills/kld-sdd/opsx-apply/SKILL.md +43 -6
- package/templates/skills/kld-sdd/opsx-apply/checklist.md +1 -1
- package/templates/skills/kld-sdd/opsx-apply/reference.md +1 -1
- package/templates/skills/kld-sdd/opsx-archive/SKILL.md +9 -13
- package/templates/skills/kld-sdd/opsx-check/SKILL.md +54 -328
- package/templates/skills/kld-sdd/opsx-check/checklist.md +7 -4
- package/templates/skills/kld-sdd/opsx-check/reference.md +58 -0
- package/templates/skills/kld-sdd/opsx-check/result-template.json +52 -0
- package/templates/skills/kld-sdd/opsx-check/reviewer.md +100 -0
- package/templates/skills/kld-sdd/opsx-consistency-check/SKILL.md +82 -451
- package/templates/skills/kld-sdd/opsx-consistency-check/{reference.md → references/reference.md} +0 -1
- package/templates/skills/kld-sdd/opsx-consistency-check/scripts/scripts.cjs +517 -0
- package/templates/skills/kld-sdd/opsx-design/SKILL.md +10 -30
- package/templates/skills/kld-sdd/opsx-design/checklist.md +3 -4
- package/templates/skills/kld-sdd/opsx-design/reference.md +1 -1
- package/templates/skills/kld-sdd/opsx-kb-ingest/SKILL.md +12 -74
- package/templates/skills/kld-sdd/opsx-ontology-query/SKILL.md +7 -5
- package/templates/skills/kld-sdd/opsx-ontology-query/phase-1-prechange.md +2 -2
- package/templates/skills/kld-sdd/opsx-ontology-query/reference.md +1 -1
- package/templates/skills/kld-sdd/opsx-propose/SKILL.md +28 -73
- package/templates/skills/kld-sdd/opsx-propose/checklist.md +8 -8
- package/templates/skills/kld-sdd/opsx-propose/interaction-policy.md +28 -0
- package/templates/skills/kld-sdd/opsx-propose/reference.md +12 -46
- package/templates/skills/kld-sdd/opsx-spec/SKILL.md +14 -61
- package/templates/skills/kld-sdd/opsx-spec/checklist.md +3 -3
- package/templates/skills/kld-sdd/opsx-task/SKILL.md +38 -32
- package/templates/skills/kld-sdd/opsx-task/checklist.md +5 -6
- package/templates/skills/kld-sdd/opsx-test/SKILL.md +2 -0
- package/templates/skills/kld-sdd/tdd-rules/rules/tdd-strategy-selection.md +1 -1
|
@@ -77,10 +77,10 @@ function parseIdentityBlocks(content) {
|
|
|
77
77
|
const blocks = [];
|
|
78
78
|
|
|
79
79
|
// 匹配身份属性行
|
|
80
|
-
const entityIdRe = /^(
|
|
81
|
-
const versionIdRe = /^(
|
|
82
|
-
const deltaStateRe = /^(
|
|
83
|
-
const predecessorRe = /^(
|
|
80
|
+
const entityIdRe = /^(\s*(?:-\s+\*\*)?entity-id(?:\*\*)?:\s*)([0-9a-f]{8}(?:-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})?)\s*$/i;
|
|
81
|
+
const versionIdRe = /^(\s*(?:-\s+\*\*)?version-id(?:\*\*)?:\s*)([0-9a-f]{8}(?:-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})?)\s*$/i;
|
|
82
|
+
const deltaStateRe = /^(\s*(?:-\s+\*\*)?delta-state(?:\*\*)?:\s*)(\w+)\s*$/i;
|
|
83
|
+
const predecessorRe = /^(\s*(?:-\s+\*\*)?predecessor-version(?:\*\*)?:\s*)([0-9a-f]{8}(?:-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})?|none|无|\(none\))\s*$/i;
|
|
84
84
|
|
|
85
85
|
for (let i = 0; i < lines.length; i++) {
|
|
86
86
|
const entityIdMatch = lines[i].match(entityIdRe);
|
|
@@ -117,7 +117,7 @@ function parseIdentityBlocks(content) {
|
|
|
117
117
|
for (let k = i - 1; k >= Math.max(0, i - 10); k--) {
|
|
118
118
|
const line = lines[k];
|
|
119
119
|
// 匹配 [CAP-XXX] / [STMT-XXX] / [AC-XXX] / [CON-XXX] 等
|
|
120
|
-
const anchorMatch = line.match(/\[([A-Z]{2,5}-[A-Z0-9-]+)\]/);
|
|
120
|
+
const anchorMatch = line.match(/\[([A-Z]{2,5}-[A-Z0-9-]+)\]/) || line.match(/^change-id:\s*(CHG-[A-Z0-9-]+)/);
|
|
121
121
|
if (anchorMatch) {
|
|
122
122
|
anchor = anchorMatch[1];
|
|
123
123
|
break;
|
|
@@ -154,10 +154,10 @@ function parseIdentityBlocks(content) {
|
|
|
154
154
|
*/
|
|
155
155
|
function updateIdentityBlocks(content, updates) {
|
|
156
156
|
const lines = content.split('\n');
|
|
157
|
-
const entityIdRe = /^(
|
|
158
|
-
const versionIdRe = /^(
|
|
159
|
-
const deltaStateRe = /^(
|
|
160
|
-
const predecessorRe = /^(
|
|
157
|
+
const entityIdRe = /^(\s*(?:-\s+\*\*)?entity-id(?:\*\*)?:\s*)([0-9a-f]{8}(?:-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})?)\s*$/i;
|
|
158
|
+
const versionIdRe = /^(\s*(?:-\s+\*\*)?version-id(?:\*\*)?:\s*)([0-9a-f]{8}(?:-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})?)\s*$/i;
|
|
159
|
+
const deltaStateRe = /^(\s*(?:-\s+\*\*)?delta-state(?:\*\*)?:\s*)(\w+)\s*$/i;
|
|
160
|
+
const predecessorRe = /^(\s*(?:-\s+\*\*)?predecessor-version(?:\*\*)?:\s*)(.+)\s*$/i;
|
|
161
161
|
|
|
162
162
|
for (const update of updates) {
|
|
163
163
|
// 找到对应的 entity-id 行
|
|
@@ -190,7 +190,8 @@ function updateIdentityBlocks(content, updates) {
|
|
|
190
190
|
if (!hasPredecessorLine && update.predecessorVersion) {
|
|
191
191
|
for (let j = i; j < Math.min(i + 8, lines.length); j++) {
|
|
192
192
|
if (deltaStateRe.test(lines[j])) {
|
|
193
|
-
lines.
|
|
193
|
+
const prefix = lines[j].includes('**') ? `${lines[j].match(/^\s*/)[0]}- **predecessor-version**: ` : 'predecessor-version: ';
|
|
194
|
+
lines.splice(j + 1, 0, `${prefix}${update.predecessorVersion}`);
|
|
194
195
|
break;
|
|
195
196
|
}
|
|
196
197
|
}
|
|
@@ -228,7 +229,7 @@ function filterRegistryKeys(registry, filePath) {
|
|
|
228
229
|
* @param {string} specRoot
|
|
229
230
|
* @returns {{ kbState: object, projectId: string, kbParams: { apiBase, token, spaceId, kbId } }}
|
|
230
231
|
*/
|
|
231
|
-
function initKbConfig(specRoot) {
|
|
232
|
+
function initKbConfig(specRoot, args = {}) {
|
|
232
233
|
// 1. 读取 kb-state.json
|
|
233
234
|
const { config: kbState, warnings } = loadKbState(specRoot);
|
|
234
235
|
if (warnings && warnings.length > 0) {
|
|
@@ -258,6 +259,9 @@ function initKbConfig(specRoot) {
|
|
|
258
259
|
|
|
259
260
|
// 3. 解析 KB 参数
|
|
260
261
|
const kbParams = resolveKbParams(kbState);
|
|
262
|
+
const target = require('./kb-upload.cjs').resolveUploadTarget(kbState, { spaceId: args['space-id'], kbId: args['kb-id'] });
|
|
263
|
+
kbParams.spaceId = target.spaceId; kbParams.kbId = target.kbId;
|
|
264
|
+
kbParams.apiBase = kbParams.apiBase.replace(/\/+$/, '');
|
|
261
265
|
if (!kbParams.token) {
|
|
262
266
|
throw new Error('KB apiKey 未配置(kb-state.json 中缺少 apiKey)。');
|
|
263
267
|
}
|
|
@@ -266,13 +270,12 @@ function initKbConfig(specRoot) {
|
|
|
266
270
|
}
|
|
267
271
|
|
|
268
272
|
// 4. 校验 project_id 与 spaceKey 一致性
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
if (projectId !== targets[0].spaceKey) {
|
|
273
|
+
if (target.spaceKey) {
|
|
274
|
+
if (projectId !== target.spaceKey) {
|
|
272
275
|
throw new Error(
|
|
273
276
|
`project_id 不匹配:\n` +
|
|
274
277
|
` project-identity.json: ${projectId}\n` +
|
|
275
|
-
` kb-state.json spaceKey: ${
|
|
278
|
+
` kb-state.json spaceKey: ${target.spaceKey}\n` +
|
|
276
279
|
`请运行 /opsx-kb-config 更新配置。`
|
|
277
280
|
);
|
|
278
281
|
}
|
|
@@ -319,33 +322,16 @@ async function resolveEntityFromKb(kbParams, entityId, timeoutMs = 30000) {
|
|
|
319
322
|
try {
|
|
320
323
|
const resp = await requestJson(endpoint, payload, kbParams.token, timeoutMs);
|
|
321
324
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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 };
|
|
325
|
+
const result = resp?.data || resp;
|
|
326
|
+
if (result.inheritanceAllowed !== true) {
|
|
327
|
+
return { found: false, inactive: result.matchType === 'HISTORICAL_ONLY', entityVersionId: null, raw: result };
|
|
333
328
|
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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 };
|
|
329
|
+
const currentUrl = `${kbParams.apiBase}/v1/spaces/${encodeURIComponent(kbParams.spaceId)}` +
|
|
330
|
+
`/knowledge-bases/${encodeURIComponent(kbParams.kbId)}/entities/${encodeURIComponent(entityId)}/current-version`;
|
|
331
|
+
const response = await httpGetJson(currentUrl, kbParams.token, timeoutMs);
|
|
332
|
+
const current = response?.data || response;
|
|
333
|
+
if (!current?.entityVersionId || !current.contentHash) throw new Error('当前版本响应缺少版本或内容哈希');
|
|
334
|
+
return { found: true, entityVersionId: current.entityVersionId, contentHash: current.contentHash, raw: current };
|
|
349
335
|
} catch (e) {
|
|
350
336
|
// KB API 错误(网络/权限/服务器等)
|
|
351
337
|
throw new Error(`KB resolve 失败 (entity=${entityId}): ${e.message}`);
|
|
@@ -354,427 +340,89 @@ async function resolveEntityFromKb(kbParams, entityId, timeoutMs = 30000) {
|
|
|
354
340
|
|
|
355
341
|
// ── 主流程 ────────────────────────────────────────────
|
|
356
342
|
|
|
357
|
-
async function main() {
|
|
358
|
-
const args = parseArgs(
|
|
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
|
-
|
|
343
|
+
async function main(argv = process.argv.slice(2)) {
|
|
344
|
+
const args = parseArgs(argv);
|
|
345
|
+
if (!args.file || !args.change) throw new Error('需要 --file、--change 和可选的 --project');
|
|
375
346
|
const specRoot = resolveSpecRoot(args.project);
|
|
376
|
-
const
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
const
|
|
380
|
-
if (!
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
347
|
+
const changeDir = path.resolve(specRoot, 'openspec', 'changes', args.change);
|
|
348
|
+
const changesRoot = path.resolve(specRoot, 'openspec', 'changes');
|
|
349
|
+
if (!changeDir.startsWith(changesRoot + path.sep)) throw new Error('非法 change 路径');
|
|
350
|
+
const mdPath = path.resolve(changeDir, args.file);
|
|
351
|
+
if (!mdPath.startsWith(changeDir + path.sep)) throw new Error('非法文件路径');
|
|
352
|
+
const filePath = path.relative(changeDir, mdPath).split(path.sep).join('/');
|
|
353
|
+
const { acquireChangeLock, releaseChangeLock } = require('./ontology/change-lock.cjs');
|
|
354
|
+
const lock = acquireChangeLock(specRoot, args.change);
|
|
355
|
+
try {
|
|
591
356
|
const mdContent = fs.readFileSync(mdPath, 'utf8');
|
|
592
|
-
const
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
const
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
357
|
+
const registry = readRegistry(changeDir);
|
|
358
|
+
const registryBefore = JSON.parse(JSON.stringify(registry));
|
|
359
|
+
const { parseChangeArtifacts } = require('./ontology/artifact-parser.cjs');
|
|
360
|
+
const parsed = parseChangeArtifacts(specRoot, args.change, { persistStructuralIdentities: false });
|
|
361
|
+
const blocks = parseIdentityBlocks(mdContent);
|
|
362
|
+
const anchors = args.anchors ? new Set(String(args.anchors).split(',').map(x => x.trim().toUpperCase())) : null;
|
|
363
|
+
const kbParams = args['skip-kb-verify'] ? null : initKbConfig(specRoot, args).kbParams;
|
|
364
|
+
const updates = [];
|
|
365
|
+
const report = { structuralSynced: [], businessSynced: [], skipped: [], errors: [], dryRun: Boolean(args['dry-run']), baselineVerified: Boolean(kbParams) };
|
|
366
|
+
const cleanHash = value => String(value || '').replace(/^sha256:/, '');
|
|
367
|
+
// Plan every change before writing. Network errors and stale baselines leave source files untouched.
|
|
368
|
+
for (const entity of parsed.entities.filter(e => e.source?.file === filePath)) {
|
|
369
|
+
const structural = ['Artifact', 'DocumentSection'].includes(entity.type);
|
|
370
|
+
if (!structural && args['structural-only']) continue;
|
|
371
|
+
if (!structural && anchors && !anchors.has(entity.anchor_id || entity.id)) continue;
|
|
372
|
+
const registryItem = structural ? Object.entries(registry.identities).find(([, entry]) => entry.entity_id === entity.entity_id) : null;
|
|
373
|
+
const block = structural ? null : blocks.find(b => b.entityId === entity.entity_id);
|
|
374
|
+
if (!registryItem && !block) continue;
|
|
375
|
+
const local = registryItem ? registryItem[1] : entity;
|
|
376
|
+
const remote = kbParams ? await resolveEntityFromKb(kbParams, entity.entity_id) : null;
|
|
377
|
+
if (remote?.inactive) throw new Error(`NO_CURRENT_EVIDENCE: ${entity.id} 已失效,不能自动恢复身份`);
|
|
378
|
+
let next = null;
|
|
379
|
+
if (remote?.found) {
|
|
380
|
+
const current = normalizeUuid(remote.entityVersionId);
|
|
381
|
+
const localVersion = normalizeUuid(local.version_id);
|
|
382
|
+
if (current !== localVersion) {
|
|
383
|
+
if (normalizeUuid(local.predecessor_version_id) !== current) {
|
|
384
|
+
throw new Error(`STALE_BASE_VERSION: ${entity.id} 本地=${localVersion},当前=${current};先比较并合并正文,禁止只改 predecessor`);
|
|
385
|
+
}
|
|
386
|
+
// This is an unpublished draft already based on current. Its identity stays stable while editing.
|
|
387
|
+
} else if (cleanHash(remote.contentHash) !== cleanHash(entity.content_hash)) {
|
|
388
|
+
next = allocateIdentity({ deltaState: entity.delta_state === 'removed' ? 'removed' : 'modified',
|
|
389
|
+
entityId: entity.entity_id, predecessorVersionId: localVersion });
|
|
604
390
|
}
|
|
391
|
+
} else if (remote && !remote.found) {
|
|
392
|
+
if ((local.delta_state || 'added') !== 'added') throw new Error(`BASE_VERSION_MISSING: ${entity.id} 的前驱在目标 KB 中不存在`);
|
|
393
|
+
} else if (cleanHash(local.content_hash) !== cleanHash(entity.content_hash)) {
|
|
394
|
+
// Explicit offline mode preserves the real local base. Publication still validates it remotely.
|
|
395
|
+
next = allocateIdentity({ deltaState: 'modified', entityId: entity.entity_id, predecessorVersionId: local.version_id });
|
|
605
396
|
}
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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 状态(不污染版本)`);
|
|
397
|
+
if (registryItem) {
|
|
398
|
+
Object.assign(registryItem[1], next || {}, { content_hash: entity.content_hash });
|
|
399
|
+
} else if (next) {
|
|
400
|
+
updates.push({ entityId: entity.entity_id, oldVersionId: block.versionId, newVersionId: next.version_id,
|
|
401
|
+
newDeltaState: next.delta_state, predecessorVersion: next.predecessor_version_id });
|
|
649
402
|
}
|
|
403
|
+
if (next) report[structural ? 'structuralSynced' : 'businessSynced'].push({ anchor: entity.id, ...next });
|
|
404
|
+
else report.skipped.push(entity.id);
|
|
650
405
|
}
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
for (const block of blocksToSync) {
|
|
406
|
+
if (!args['dry-run']) {
|
|
407
|
+
const nextContent = updateIdentityBlocks(mdContent, updates);
|
|
655
408
|
try {
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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}`);
|
|
409
|
+
writeTextAtomic(mdPath, nextContent);
|
|
410
|
+
writeRegistry(changeDir, registry);
|
|
411
|
+
} catch (error) {
|
|
412
|
+
writeTextAtomic(mdPath, mdContent);
|
|
413
|
+
writeRegistry(changeDir, registryBefore);
|
|
414
|
+
throw error;
|
|
714
415
|
}
|
|
715
416
|
}
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
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✓ 无需同步(所有实体内容未变更)');
|
|
417
|
+
console.log(JSON.stringify(report, null, 2));
|
|
418
|
+
return report;
|
|
419
|
+
} finally {
|
|
420
|
+
releaseChangeLock(lock);
|
|
774
421
|
}
|
|
775
422
|
}
|
|
776
423
|
|
|
777
|
-
main().catch(err => {
|
|
424
|
+
if (require.main === module) main().catch(err => {
|
|
778
425
|
console.error(`\n❌ ${err.message}`);
|
|
779
|
-
process.
|
|
426
|
+
process.exitCode = 1;
|
|
780
427
|
});
|
|
428
|
+
module.exports = { main, parseIdentityBlocks, updateIdentityBlocks, resolveEntityFromKb };
|