kld-sdd 2.7.8 → 2.8.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.
Files changed (73) hide show
  1. package/README.md +26 -0
  2. package/USABILITY.md +72 -0
  3. package/bin/kld-sdd-init.js +718 -34
  4. package/lib/command-bridge.js +26 -17
  5. package/lib/deploy-codebuddy-hooks.js +19 -26
  6. package/lib/deploy-strategy.js +293 -0
  7. package/lib/hook-gate-core.js +7 -8
  8. package/lib/hook-install.js +75 -0
  9. package/lib/init.js +507 -726
  10. package/lib/maintenance.js +221 -0
  11. package/lib/managed-hook-hashes.json +24 -0
  12. package/lib/settings-merge.js +102 -25
  13. package/lib/skills-bundle.js +5 -5
  14. package/lib/spec-choice.js +85 -0
  15. package/lib/tool-profiles.js +2 -2
  16. package/lib/uninstall.js +453 -0
  17. package/package.json +7 -4
  18. package/skywalk-sdd/context-client.cjs +38 -7
  19. package/skywalk-sdd/index.cjs +153 -27
  20. package/skywalk-sdd/kb-upload.cjs +224 -82
  21. package/skywalk-sdd/lib/check-review.cjs +41 -0
  22. package/skywalk-sdd/lib/shared.cjs +62 -41
  23. package/skywalk-sdd/lib/test-execution.cjs +130 -0
  24. package/skywalk-sdd/lib/usage-reporter.cjs +17 -8
  25. package/skywalk-sdd/ontology/archive-package.cjs +141 -0
  26. package/skywalk-sdd/ontology/artifact-parser.cjs +24 -3
  27. package/skywalk-sdd/ontology/cli.cjs +53 -0
  28. package/skywalk-sdd/ontology/id.cjs +1 -1
  29. package/skywalk-sdd/ontology/identity-index.cjs +5 -1
  30. package/skywalk-sdd/ontology/resolve-spec-root.cjs +49 -0
  31. package/skywalk-sdd/ontology/schema.cjs +6 -0
  32. package/skywalk-sdd/ontology/sdd-config.cjs +33 -5
  33. package/skywalk-sdd/ontology/spec-detect.cjs +421 -0
  34. package/skywalk-sdd/ontology/spec-ontology.cjs +507 -0
  35. package/skywalk-sdd/ontology/workspace-layout.cjs +65 -17
  36. package/templates/git-hooks/commit-msg +17 -85
  37. package/templates/git-hooks/commit-msg-runner.cjs +44 -0
  38. package/templates/git-hooks/commit-msg-sdd-trailer.cjs +60 -69
  39. package/templates/git-hooks/consistency-check-core.cjs +92 -261
  40. package/templates/git-hooks/delivery-check.cjs +231 -0
  41. package/templates/git-hooks/delivery-snapshot.cjs +181 -0
  42. package/templates/git-hooks/hooks.config +8 -6
  43. package/templates/git-hooks/pre-commit +19 -5
  44. package/templates/git-hooks/pre-commit-consistency-check.cjs +13 -31
  45. package/templates/git-hooks/pre-push +19 -5
  46. package/templates/git-hooks/pre-push-consistency-check.cjs +36 -83
  47. package/templates/hooks/claude/hooks/sdd-post-tool.cjs +2 -2
  48. package/templates/hooks/codebuddy/hooks/hook-gate-core.cjs +7 -8
  49. package/templates/hooks/codebuddy/hooks/sdd-post-tool.cjs +2 -2
  50. package/templates/openspec/proposal.md +1 -1
  51. package/templates/openspec/spec.md +8 -0
  52. package/templates/skills/kld-sdd/opsx-apply/SKILL.md +28 -24
  53. package/templates/skills/kld-sdd/opsx-apply/checklist.md +12 -3
  54. package/templates/skills/kld-sdd/opsx-apply/reference.md +21 -1
  55. package/templates/skills/kld-sdd/opsx-archive/SKILL.md +20 -18
  56. package/templates/skills/kld-sdd/opsx-check/SKILL.md +18 -13
  57. package/templates/skills/kld-sdd/opsx-check/reference.md +3 -1
  58. package/templates/skills/kld-sdd/opsx-check/review-template.json +22 -0
  59. package/templates/skills/kld-sdd/opsx-check/reviewer.md +27 -6
  60. package/templates/skills/kld-sdd/opsx-consistency-check/SKILL.md +34 -112
  61. package/templates/skills/kld-sdd/opsx-consistency-check/scripts/scripts.cjs +15 -14
  62. package/templates/skills/kld-sdd/opsx-kb-config/SKILL.md +82 -61
  63. package/templates/skills/kld-sdd/opsx-kb-config/reference.md +8 -11
  64. package/templates/skills/kld-sdd/opsx-kb-ingest/SKILL.md +3 -2
  65. package/templates/skills/kld-sdd/opsx-kb-ingest/reference.md +2 -2
  66. package/templates/skills/kld-sdd/opsx-ontology-ingest/SKILL.md +77 -0
  67. package/templates/skills/kld-sdd/opsx-ontology-query/SKILL.md +5 -4
  68. package/templates/skills/kld-sdd/opsx-ontology-query/reference.md +14 -1
  69. package/templates/skills/kld-sdd/opsx-propose/SKILL.md +1 -1
  70. package/templates/skills/kld-sdd/opsx-propose/interaction-policy.md +9 -0
  71. package/templates/skills/kld-sdd/opsx-spec/SKILL.md +5 -3
  72. package/templates/skills/kld-sdd/opsx-task/SKILL.md +2 -2
  73. package/index.js +0 -13
@@ -0,0 +1,507 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { parseChangeArtifacts } = require('./artifact-parser.cjs');
7
+ const { normalizeFacts } = require('./normalizer.cjs');
8
+ const { resolveChangeDir } = require('./runtime.cjs');
9
+ const { resolveRuntimeMetadata } = require('../runtime-metadata.cjs');
10
+ const { ensureDir, readJsonSafe, writeJsonAtomic } = require('../lib/shared.cjs');
11
+ const { DIAGNOSTIC_CODES } = require('./schema.cjs');
12
+ const { isValidUuid } = require('./id.cjs');
13
+
14
+ const SPEC_ONTOLOGY_SCHEMA_VERSION = 'kld-sdd-spec-ontology/v1';
15
+ const LEGACY_MAPPING_SCHEMA_VERSION = 'kld-sdd-legacy-identity-mapping/v1';
16
+ const SPEC_ONTOLOGY_PATH = 'spec-ontology.json';
17
+ const LEGACY_MAPPING_PATH = 'ontology/legacy-mapping.json';
18
+ const RUNTIME_METADATA = resolveRuntimeMetadata();
19
+
20
+ const TYPE_MAP = Object.freeze({
21
+ BusinessObject: 'BusinessObject',
22
+ Capability: 'Capability',
23
+ AcceptanceCriterion: 'Scenario',
24
+ Constraint: 'Rule',
25
+ SpecificationStatement: 'SpecPattern',
26
+ });
27
+
28
+ const RELATION_MAP = Object.freeze({
29
+ contains: 'contains',
30
+ usesObject: 'usesObject',
31
+ acceptedBy: 'verifiedByScenario',
32
+ verifiedBy: 'verifiedByScenario',
33
+ constrainedBy: 'constrainedBy',
34
+ constrains: 'constrainedBy',
35
+ });
36
+
37
+ // 工具升级后新增的身份、归档和任务校验。历史稿上传遇到这些诊断时旁路物化,
38
+ // 由上传流程自动消解,不把整包失败交回给使用者。
39
+ const LEGACY_TOLERATED_CODES = Object.freeze([
40
+ DIAGNOSTIC_CODES.UUID_MISSING,
41
+ DIAGNOSTIC_CODES.UUID_INVALID,
42
+ DIAGNOSTIC_CODES.ID_MISSING,
43
+ DIAGNOSTIC_CODES.ID_INVALID,
44
+ DIAGNOSTIC_CODES.ID_DUPLICATE,
45
+ DIAGNOSTIC_CODES.DELTA_STATE_INVALID,
46
+ DIAGNOSTIC_CODES.SOURCE_MISSING,
47
+ DIAGNOSTIC_CODES.VERSION_LINEAGE_MISSING,
48
+ DIAGNOSTIC_CODES.ENTITY_IDENTITY_CONFLICT,
49
+ DIAGNOSTIC_CODES.VERSION_IDENTITY_CONFLICT,
50
+ DIAGNOSTIC_CODES.INHERITED_SOURCE_MISMATCH,
51
+ DIAGNOSTIC_CODES.INHERITED_RELATION_MISSING,
52
+ DIAGNOSTIC_CODES.ARCHIVE_NOT_CONFIRMED,
53
+ DIAGNOSTIC_CODES.ARCHIVE_REVISION_MISMATCH,
54
+ DIAGNOSTIC_CODES.TASK_DEPENDENCY_CYCLE,
55
+ DIAGNOSTIC_CODES.TASK_DEPENDENCY_LAYER_GAP,
56
+ DIAGNOSTIC_CODES.TASK_DEPENDENCY_MISSING_EDGE,
57
+ DIAGNOSTIC_CODES.STMT_WITHOUT_AC,
58
+ DIAGNOSTIC_CODES.STMT_WITHOUT_DESIGN,
59
+ DIAGNOSTIC_CODES.DESIGN_WITHOUT_TASK,
60
+ DIAGNOSTIC_CODES.AC_WITHOUT_STMT,
61
+ DIAGNOSTIC_CODES.TASK_WITHOUT_UPSTREAM,
62
+ ]);
63
+
64
+ // Check 对用户隐藏并自行处理的工具诊断。需求、设计、任务之间的覆盖缺口不在此列。
65
+ const CHECK_TOOL_CODES = Object.freeze([
66
+ DIAGNOSTIC_CODES.UUID_MISSING,
67
+ DIAGNOSTIC_CODES.UUID_INVALID,
68
+ DIAGNOSTIC_CODES.ID_MISSING,
69
+ DIAGNOSTIC_CODES.ID_INVALID,
70
+ DIAGNOSTIC_CODES.ID_DUPLICATE,
71
+ DIAGNOSTIC_CODES.DELTA_STATE_INVALID,
72
+ DIAGNOSTIC_CODES.SOURCE_MISSING,
73
+ DIAGNOSTIC_CODES.VERSION_LINEAGE_MISSING,
74
+ DIAGNOSTIC_CODES.ENTITY_IDENTITY_CONFLICT,
75
+ DIAGNOSTIC_CODES.VERSION_IDENTITY_CONFLICT,
76
+ DIAGNOSTIC_CODES.INHERITED_SOURCE_MISMATCH,
77
+ DIAGNOSTIC_CODES.INHERITED_RELATION_MISSING,
78
+ DIAGNOSTIC_CODES.ARCHIVE_NOT_CONFIRMED,
79
+ DIAGNOSTIC_CODES.ARCHIVE_REVISION_MISMATCH,
80
+ ]);
81
+
82
+ function sha256(value) {
83
+ return crypto.createHash('sha256').update(value).digest('hex');
84
+ }
85
+
86
+ function splitCheckDiagnostics(diagnostics = []) {
87
+ const tool = [];
88
+ const product = [];
89
+ for (const item of diagnostics || []) {
90
+ if (item && item.severity === 'error' && CHECK_TOOL_CODES.includes(item.code)) tool.push(item);
91
+ else product.push(item);
92
+ }
93
+ return {
94
+ tool_diagnostics: tool,
95
+ product_diagnostics: product,
96
+ product_valid: product.every((item) => !item || item.severity !== 'error'),
97
+ };
98
+ }
99
+
100
+ function classifyLegacyDiagnostics(diagnostics = []) {
101
+ const blocking = (diagnostics || []).filter((item) => item && item.severity === 'error');
102
+ const tolerated = blocking.filter((item) => LEGACY_TOLERATED_CODES.includes(item.code));
103
+ const rejected = blocking.filter((item) => !LEGACY_TOLERATED_CODES.includes(item.code));
104
+ return {
105
+ blocking,
106
+ tolerated,
107
+ rejected,
108
+ legacyCompatible: blocking.length > 0 && rejected.length === 0,
109
+ };
110
+ }
111
+
112
+ // 同一份 Markdown 反复物化时用于判断“已审核本体是否仍对应当前正文”。
113
+ function specOntologyContentSignature(payload) {
114
+ const entities = (payload?.entities || [])
115
+ .map((entity) => `${entity.anchor_id}:${entity.content_hash}`)
116
+ .sort();
117
+ const relations = (payload?.relations || [])
118
+ .map((relation) => `${relation.type}:${relation.source?.anchor_id || ''}:${relation.source?.content_hash || ''}`)
119
+ .sort();
120
+ return JSON.stringify({ entities, relations });
121
+ }
122
+
123
+ function withOntologyHash(payload) {
124
+ const result = { ...payload };
125
+ delete result.ontology_hash;
126
+ result.ontology_hash = `sha256:${sha256(JSON.stringify(result))}`;
127
+ return result;
128
+ }
129
+
130
+ function deterministicUuid(seed) {
131
+ const bytes = Buffer.from(sha256(String(seed)).slice(0, 32), 'hex');
132
+ bytes[6] = (bytes[6] & 0x0f) | 0x50;
133
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
134
+ const hex = bytes.toString('hex');
135
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
136
+ }
137
+
138
+ function normalizedText(value) {
139
+ return String(value || '').trim();
140
+ }
141
+
142
+ function normalizedAnchor(entity) {
143
+ return normalizedText(entity.anchor_id || entity.id).toUpperCase();
144
+ }
145
+
146
+ function semanticKeyFor(entity) {
147
+ const ontologyType = TYPE_MAP[entity.type];
148
+ const external = (entity.external_refs || []).find((ref) => (
149
+ ref && ref.system && ref.object_type && ref.external_id
150
+ ));
151
+ if (external) {
152
+ return `${ontologyType.toLowerCase()}:${normalizedText(external.system).toLowerCase()}`
153
+ + `:${normalizedText(external.object_type).toLowerCase()}:${normalizedText(external.external_id)}`;
154
+ }
155
+ return `${ontologyType.toLowerCase()}:${normalizedAnchor(entity).toLowerCase()}`;
156
+ }
157
+
158
+ function normalizeSource(source = {}) {
159
+ const line = Number(source.line || 0);
160
+ return {
161
+ artifact_type: normalizedText(source.artifact_type || 'unknown').toLowerCase(),
162
+ file: normalizedText(source.file),
163
+ ...(Number.isInteger(line) && line > 0 ? { line } : {}),
164
+ anchor_id: normalizedText(source.anchor_id).toUpperCase(),
165
+ ...(source.content_hash ? { content_hash: normalizedText(source.content_hash) } : {}),
166
+ };
167
+ }
168
+
169
+ function compatibilityMode(facts, selectedEntities) {
170
+ if (selectedEntities.length === 0) return 'EVIDENCE_ONLY';
171
+ const complete = selectedEntities.every((entity) => entity.entity_id && entity.version_id);
172
+ const sourceSchema = normalizedText(facts.schema_version);
173
+ if (complete && sourceSchema === SPEC_ONTOLOGY_SCHEMA_VERSION) return 'NATIVE';
174
+ if (complete) return 'ADAPTED_V2';
175
+ return 'LEGACY_MATERIALIZED';
176
+ }
177
+
178
+ function identityFor(entity, projectId, semanticKey, options = {}) {
179
+ const declaredEntityId = normalizedText(entity.entity_id).toLowerCase();
180
+ const declaredVersionId = normalizedText(entity.entity_version_id || entity.version_id).toLowerCase();
181
+ const declaredUsable = isValidUuid(declaredEntityId) && isValidUuid(declaredVersionId);
182
+ const contentHash = normalizedText(entity.content_hash) || `sha256:${sha256(JSON.stringify(entity))}`;
183
+ if (!options.repairLegacy || declaredUsable) {
184
+ return {
185
+ entity_id: declaredEntityId || deterministicUuid(`${projectId}\n${semanticKey}`),
186
+ entity_version_id: declaredVersionId || deterministicUuid(`${declaredEntityId || semanticKey}\n${contentHash}`),
187
+ identity_origin: declaredUsable ? 'declared' : 'derived_legacy',
188
+ repaired: false,
189
+ };
190
+ }
191
+ return {
192
+ entity_id: deterministicUuid(`${projectId}\n${semanticKey}`),
193
+ entity_version_id: deterministicUuid(`${projectId}\n${semanticKey}\n${contentHash}`),
194
+ identity_origin: 'derived_legacy',
195
+ repaired: true,
196
+ repair_reason: declaredEntityId || declaredVersionId ? 'invalid_declared_uuid' : 'missing_uuid',
197
+ };
198
+ }
199
+
200
+ function buildSpecOntology(facts, options = {}) {
201
+ const projectId = normalizedText(options.projectId || facts.project_id || 'urn:kld:sdd:project:unconfigured');
202
+ const selected = (facts.entities || []).filter((entity) => TYPE_MAP[entity.type]);
203
+ const mode = options.compatibilityMode || compatibilityMode(facts, selected);
204
+ const reviewStatus = options.reviewStatus
205
+ || (mode === 'LEGACY_MATERIALIZED' || mode === 'EVIDENCE_ONLY' ? 'pending' : 'confirmed');
206
+ const baseKeys = selected.map(semanticKeyFor);
207
+ const keyCounts = new Map();
208
+ for (const key of baseKeys) keyCounts.set(key, (keyCounts.get(key) || 0) + 1);
209
+ const warnings = [];
210
+ const entities = [];
211
+ const identityMap = [];
212
+ const entityByAnchor = new Map();
213
+
214
+ for (let index = 0; index < selected.length; index += 1) {
215
+ const sourceEntity = selected[index];
216
+ const anchor = normalizedAnchor(sourceEntity);
217
+ const baseKey = baseKeys[index];
218
+ const source = normalizeSource(sourceEntity.source);
219
+ const semanticKey = keyCounts.get(baseKey) > 1
220
+ ? `${baseKey}@${normalizedText(source.file).toLowerCase() || 'source'}#${index + 1}`
221
+ : baseKey;
222
+ if (keyCounts.get(baseKey) > 1) {
223
+ warnings.push(`LEGACY_DUPLICATE_ANCHOR: ${baseKey} 已按来源路径拆成独立事实,上传不再等待人工合并`);
224
+ }
225
+ const repairLegacy = mode === 'LEGACY_MATERIALIZED';
226
+ let identity = identityFor(sourceEntity, projectId, semanticKey, { repairLegacy });
227
+ if (repairLegacy && entities.some((item) => item.entity_id === identity.entity_id)) {
228
+ identity = {
229
+ entity_id: deterministicUuid(`${projectId}\n${semanticKey}\n${index}`),
230
+ entity_version_id: deterministicUuid(`${projectId}\n${semanticKey}\n${index}\nversion`),
231
+ identity_origin: 'derived_legacy',
232
+ repaired: true,
233
+ repair_reason: 'entity_id_conflict',
234
+ };
235
+ }
236
+ if (repairLegacy && entities.some((item) => item.entity_version_id === identity.entity_version_id)) {
237
+ identity = {
238
+ ...identity,
239
+ entity_version_id: deterministicUuid(`${identity.entity_id}\n${semanticKey}\n${index}\nversion`),
240
+ identity_origin: 'derived_legacy',
241
+ repaired: true,
242
+ repair_reason: identity.repair_reason || 'version_id_conflict',
243
+ };
244
+ }
245
+ if (identity.repaired) {
246
+ warnings.push(`LEGACY_IDENTITY_REPAIRED: ${anchor} ${identity.repair_reason}`);
247
+ }
248
+ const declaredPredecessor = normalizedText(sourceEntity.predecessor_version_id);
249
+ const predecessor = repairLegacy || !isValidUuid(declaredPredecessor) ? null : declaredPredecessor;
250
+ const ontologyEntity = {
251
+ semantic_key: semanticKey,
252
+ type: TYPE_MAP[sourceEntity.type],
253
+ name: normalizedText(sourceEntity.name || anchor),
254
+ summary: normalizedText(
255
+ sourceEntity.attributes?.description
256
+ || sourceEntity.attributes?.summary
257
+ || sourceEntity.name
258
+ || anchor,
259
+ ),
260
+ anchor_id: anchor,
261
+ entity_id: identity.entity_id,
262
+ entity_version_id: identity.entity_version_id,
263
+ identity_origin: identity.identity_origin,
264
+ predecessor_version_id: predecessor,
265
+ content_hash: normalizedText(sourceEntity.content_hash),
266
+ review_status: reviewStatus,
267
+ publication_status: reviewStatus === 'confirmed' ? 'publishable' : 'candidate',
268
+ attributes: {
269
+ legacy_type: sourceEntity.type,
270
+ ...(sourceEntity.attributes && typeof sourceEntity.attributes === 'object'
271
+ ? sourceEntity.attributes : {}),
272
+ },
273
+ ...(Array.isArray(sourceEntity.external_refs) && sourceEntity.external_refs.length > 0
274
+ ? { external_refs: sourceEntity.external_refs } : {}),
275
+ source,
276
+ };
277
+ entities.push(ontologyEntity);
278
+ entityByAnchor.set(anchor, ontologyEntity);
279
+ identityMap.push({
280
+ anchor_id: anchor,
281
+ semantic_key: semanticKey,
282
+ entity_id: identity.entity_id,
283
+ entity_version_id: identity.entity_version_id,
284
+ identity_origin: identity.identity_origin,
285
+ source,
286
+ });
287
+ }
288
+
289
+ const relations = [];
290
+ const seenRelations = new Set();
291
+ for (const sourceRelation of facts.relations || []) {
292
+ let from = entityByAnchor.get(normalizedText(sourceRelation.from).toUpperCase());
293
+ let to = entityByAnchor.get(normalizedText(sourceRelation.to).toUpperCase());
294
+ let type = RELATION_MAP[sourceRelation.type];
295
+ if (!from || !to || !type) continue;
296
+ if (sourceRelation.type === 'constrains') {
297
+ [from, to] = [to, from];
298
+ }
299
+ const key = `${type}:${from.semantic_key}:${to.semantic_key}`;
300
+ if (seenRelations.has(key)) continue;
301
+ seenRelations.add(key);
302
+ relations.push({
303
+ type,
304
+ from_semantic_key: from.semantic_key,
305
+ to_semantic_key: to.semantic_key,
306
+ from_entity_id: from.entity_id,
307
+ to_entity_id: to.entity_id,
308
+ assertion_type: normalizedText(sourceRelation.assertion_type || 'asserted').toLowerCase(),
309
+ review_status: reviewStatus,
310
+ source: normalizeSource(sourceRelation.source),
311
+ });
312
+ }
313
+
314
+ if (mode === 'LEGACY_MATERIALIZED') {
315
+ warnings.unshift('LEGACY_IDENTITIES_DERIVED: 历史文档未被改写,身份已物化到旁路映射,发布前需要审核');
316
+ } else if (mode === 'EVIDENCE_ONLY') {
317
+ warnings.unshift('NO_STRUCTURED_ONTOLOGY_FACTS: 仅作为可追溯证据入库,不自动发布本体事实');
318
+ }
319
+
320
+ const payload = {
321
+ schema_version: SPEC_ONTOLOGY_SCHEMA_VERSION,
322
+ producer: RUNTIME_METADATA.producer,
323
+ project_id: projectId,
324
+ ...(options.archiveId ? { archive_id: options.archiveId } : {}),
325
+ change: normalizedText(facts.change),
326
+ change_id: normalizedText(facts.change_id) || null,
327
+ source_schema: normalizedText(facts.schema_version || 'legacy-markdown'),
328
+ source_facts_hash: normalizedText(facts.facts_hash) || null,
329
+ compatibility_mode: mode,
330
+ review_status: reviewStatus,
331
+ publication_eligible: reviewStatus === 'confirmed' && mode !== 'EVIDENCE_ONLY',
332
+ entities,
333
+ relations,
334
+ identity_map: identityMap,
335
+ warnings: [...new Set(warnings)],
336
+ };
337
+ return withOntologyHash(payload);
338
+ }
339
+
340
+ function validateSpecOntology(payload) {
341
+ const errors = [];
342
+ const warnings = [];
343
+ if (!payload || payload.schema_version !== SPEC_ONTOLOGY_SCHEMA_VERSION) {
344
+ errors.push(`schema_version 必须为 ${SPEC_ONTOLOGY_SCHEMA_VERSION}`);
345
+ }
346
+ if (!payload?.project_id) errors.push('缺少 project_id');
347
+ if (!Array.isArray(payload?.entities)) errors.push('entities 必须是数组');
348
+ if (!Array.isArray(payload?.relations)) errors.push('relations 必须是数组');
349
+ const keys = new Set();
350
+ const ids = new Set();
351
+ for (const entity of payload?.entities || []) {
352
+ if (!entity.semantic_key) errors.push('实体缺少 semantic_key');
353
+ if (keys.has(entity.semantic_key)) errors.push(`semantic_key 重复: ${entity.semantic_key}`);
354
+ keys.add(entity.semantic_key);
355
+ if (!entity.entity_id || !entity.entity_version_id) {
356
+ errors.push(`实体缺少内部身份: ${entity.semantic_key || entity.anchor_id}`);
357
+ }
358
+ ids.add(entity.entity_id);
359
+ if (!Object.values(TYPE_MAP).includes(entity.type)) {
360
+ errors.push(`不支持的本体类型: ${entity.type || '(empty)'}`);
361
+ }
362
+ }
363
+ for (const relation of payload?.relations || []) {
364
+ if (!keys.has(relation.from_semantic_key) || !keys.has(relation.to_semantic_key)) {
365
+ errors.push(`关系端点不存在: ${relation.from_semantic_key} -${relation.type}-> ${relation.to_semantic_key}`);
366
+ }
367
+ }
368
+ const identityByKey = new Map((payload?.identity_map || []).map((item) => [item.semantic_key, item]));
369
+ for (const entity of payload?.entities || []) {
370
+ const identity = identityByKey.get(entity.semantic_key);
371
+ if (!identity) {
372
+ errors.push(`identity_map 缺少实体: ${entity.semantic_key}`);
373
+ } else if (identity.entity_id !== entity.entity_id || identity.entity_version_id !== entity.entity_version_id) {
374
+ errors.push(`identity_map 与实体身份不一致: ${entity.semantic_key}`);
375
+ }
376
+ }
377
+ if (payload?.publication_eligible === true) {
378
+ if (payload.review_status !== 'confirmed') errors.push('可发布本体的 review_status 必须为 confirmed');
379
+ for (const entity of payload?.entities || []) {
380
+ if (entity.review_status !== 'confirmed' || entity.publication_status !== 'publishable') {
381
+ errors.push(`可发布实体状态不完整: ${entity.semantic_key}`);
382
+ }
383
+ }
384
+ for (const relation of payload?.relations || []) {
385
+ if (relation.review_status !== 'confirmed') {
386
+ errors.push(`可发布关系尚未确认: ${relation.from_semantic_key} -${relation.type}-> ${relation.to_semantic_key}`);
387
+ }
388
+ }
389
+ }
390
+ if (payload?.ontology_hash && withOntologyHash(payload).ontology_hash !== payload.ontology_hash) {
391
+ errors.push('ontology_hash 与内容不一致');
392
+ }
393
+ if (payload?.compatibility_mode === 'LEGACY_MATERIALIZED') {
394
+ warnings.push('历史产物已兼容物化,只有审核后才能发布为 current 事实');
395
+ }
396
+ return { valid: errors.length === 0, errors, warnings, counts: { entities: keys.size, identities: ids.size, relations: payload?.relations?.length || 0 } };
397
+ }
398
+
399
+ function approveSpecOntologyFile(filePath, options = {}) {
400
+ const resolved = path.resolve(filePath);
401
+ const payload = readJsonSafe(resolved);
402
+ if (!payload) throw new Error(`无法读取 Spec 本体文件: ${resolved}`);
403
+ const validation = validateSpecOntology(payload);
404
+ if (!validation.valid) {
405
+ throw new Error(`Spec 本体审核前校验失败: ${validation.errors.join('; ')}`);
406
+ }
407
+ if (payload.compatibility_mode === 'EVIDENCE_ONLY' || payload.entities.length === 0) {
408
+ throw new Error('EVIDENCE_ONLY 产物没有足够结构化事实,不能直接发布');
409
+ }
410
+ const collisions = (payload.warnings || []).filter((warning) => warning.startsWith('SEMANTIC_KEY_COLLISION:'));
411
+ if (collisions.length > 0 && !options.allowCollisions) {
412
+ throw new Error(`仍有 semantic_key 冲突,需先完成合并判断: ${collisions.join('; ')}`);
413
+ }
414
+ const approved = withOntologyHash({
415
+ ...payload,
416
+ review_status: 'confirmed',
417
+ publication_eligible: true,
418
+ entities: payload.entities.map((entity) => ({
419
+ ...entity,
420
+ review_status: 'confirmed',
421
+ publication_status: 'publishable',
422
+ })),
423
+ relations: payload.relations.map((relation) => ({
424
+ ...relation,
425
+ review_status: 'confirmed',
426
+ })),
427
+ warnings: [
428
+ ...(payload.warnings || []).filter((warning) => (
429
+ !warning.startsWith('LEGACY_IDENTITIES_DERIVED:')
430
+ && !warning.startsWith('SEMANTIC_KEY_COLLISION:')
431
+ )),
432
+ 'LEGACY_IDENTITIES_REVIEWED: 旁路身份和语义键已审核,可发布为 current 事实',
433
+ ],
434
+ });
435
+ writeJsonAtomic(resolved, approved);
436
+ return {
437
+ file: resolved,
438
+ payload: approved,
439
+ validation: validateSpecOntology(approved),
440
+ };
441
+ }
442
+
443
+ function resolveProjectIdentity(projectRoot) {
444
+ const identityPath = path.join(projectRoot, 'skywalk-sdd', 'project-identity.json');
445
+ const identity = readJsonSafe(identityPath);
446
+ if (identity?.project_id) return { projectId: identity.project_id, configured: true, identityPath };
447
+ return {
448
+ projectId: `urn:kld:sdd:project:local:${sha256(path.resolve(projectRoot)).slice(0, 16)}`,
449
+ configured: false,
450
+ identityPath,
451
+ };
452
+ }
453
+
454
+ function materializeChangeSpecOntology(projectRoot, changeName, options = {}) {
455
+ const root = path.resolve(projectRoot || process.cwd());
456
+ const changeDir = options.changeDir ? path.resolve(options.changeDir) : resolveChangeDir(root, changeName);
457
+ const facts = normalizeFacts(parseChangeArtifacts(root, changeName, {
458
+ changeDir,
459
+ persistStructuralIdentities: false,
460
+ }));
461
+ const projectIdentity = resolveProjectIdentity(root);
462
+ let payload = buildSpecOntology(facts, {
463
+ projectId: options.projectId || projectIdentity.projectId,
464
+ compatibilityMode: options.compatibilityMode,
465
+ reviewStatus: options.reviewStatus,
466
+ });
467
+ if (!projectIdentity.configured) {
468
+ payload.warnings.unshift('PROJECT_ID_UNCONFIGURED: 可先在本地物化,上传前通过 opsx-kb-config 绑定 Space');
469
+ }
470
+ payload = withOntologyHash(payload);
471
+ const outputPath = path.resolve(options.outputPath || path.join(changeDir, 'ontology', SPEC_ONTOLOGY_PATH));
472
+ const mappingPath = path.resolve(options.mappingPath || path.join(changeDir, LEGACY_MAPPING_PATH));
473
+ ensureDir(path.dirname(outputPath));
474
+ ensureDir(path.dirname(mappingPath));
475
+ writeJsonAtomic(outputPath, payload);
476
+ writeJsonAtomic(mappingPath, {
477
+ schema_version: LEGACY_MAPPING_SCHEMA_VERSION,
478
+ project_id: payload.project_id,
479
+ change: payload.change,
480
+ compatibility_mode: payload.compatibility_mode,
481
+ mappings: payload.identity_map,
482
+ });
483
+ return {
484
+ payload,
485
+ validation: validateSpecOntology(payload),
486
+ output_path: outputPath,
487
+ mapping_path: mappingPath,
488
+ };
489
+ }
490
+
491
+ module.exports = {
492
+ SPEC_ONTOLOGY_SCHEMA_VERSION,
493
+ LEGACY_MAPPING_SCHEMA_VERSION,
494
+ SPEC_ONTOLOGY_PATH,
495
+ LEGACY_MAPPING_PATH,
496
+ TYPE_MAP,
497
+ LEGACY_TOLERATED_CODES,
498
+ deterministicUuid,
499
+ semanticKeyFor,
500
+ classifyLegacyDiagnostics,
501
+ splitCheckDiagnostics,
502
+ specOntologyContentSignature,
503
+ buildSpecOntology,
504
+ validateSpecOntology,
505
+ approveSpecOntologyFile,
506
+ materializeChangeSpecOntology,
507
+ };
@@ -3,6 +3,10 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
 
6
+ // spec 判据的唯一真相源。spec-detect 对 workspace-layout 的依赖是惰性(函数内
7
+ // require),故此处顶层 require 不构成循环依赖。
8
+ const specDetect = require('./spec-detect.cjs');
9
+
6
10
  const WORKSPACE_FILE = '.sdd-workspace.yaml';
7
11
  const IGNORE_DIR_NAMES = new Set([
8
12
  'node_modules',
@@ -24,19 +28,19 @@ function isGitRepo(dir) {
24
28
  return fs.existsSync(path.join(dir, '.git'));
25
29
  }
26
30
 
31
+ /**
32
+ * 是否像 SDD spec 包裹包。
33
+ *
34
+ * ⚠️ 判据已收敛到 `spec-detect.cjs#isSpecCandidate`(全仓唯一判据入口),
35
+ * 本函数保留仅为向后兼容既有调用点,**不得再在此处新增判据**。
36
+ *
37
+ * 语义(与 spec-detect 一致):
38
+ * - 四结构齐全(modules.yaml + sdd.config.yaml + openspec/changes
39
+ * + openspec/specs/overview.md)→ 命中,**不看目录名**
40
+ * - 目录名 `*-sdd-specs` → 命中(含预填空壳,由 tier 排序决定取舍)
41
+ */
27
42
  function looksLikeSpecRepo(dir) {
28
- const name = path.basename(dir).toLowerCase();
29
- // 命名约定:*-sdd-specs 目录名直接命中
30
- if (/-sdd-specs$/.test(name) || name.endsWith('sdd-specs')) return true;
31
- // 结构判据(四项同时满足):
32
- // modules.yaml(模块登记)+ sdd.config.yaml(活动变更登记)
33
- // + openspec/changes(变更结构)+ openspec/specs/overview.md(全局契约)
34
- return (
35
- fs.existsSync(path.join(dir, 'modules.yaml'))
36
- && fs.existsSync(path.join(dir, 'sdd.config.yaml'))
37
- && fs.existsSync(path.join(dir, 'openspec', 'changes'))
38
- && fs.existsSync(path.join(dir, 'openspec', 'specs', 'overview.md'))
39
- );
43
+ return specDetect.isSpecCandidate(dir);
40
44
  }
41
45
 
42
46
  function listChildGitRepos(workspaceRoot) {
@@ -88,7 +92,6 @@ function detectWorkspaceLayout(workspaceRoot = process.cwd()) {
88
92
  rootIsGit,
89
93
  specRepo: spec,
90
94
  codeRepos,
91
- allChildGitRepos: children,
92
95
  };
93
96
  }
94
97
 
@@ -164,7 +167,25 @@ function writeWorkspaceFile(workspaceRoot, layout) {
164
167
  '',
165
168
  ];
166
169
  const filePath = path.join(workspaceRoot, WORKSPACE_FILE);
167
- fs.writeFileSync(filePath, lines.join('\n'), 'utf8');
170
+ if (fs.existsSync(filePath)) {
171
+ // sync-repos 只追加新仓;既有行、注释、自定义字段和暂时离线的仓库保留。
172
+ const raw = fs.readFileSync(filePath, 'utf8');
173
+ const saved = parseWorkspaceYaml(raw);
174
+ if (!saved.spec_path || path.resolve(workspaceRoot, saved.spec_path) !== path.resolve(workspaceRoot, specRel)) {
175
+ throw new Error('已有多仓清单的 Spec 指向不一致,请先核对配置');
176
+ }
177
+ const known = new Set(saved.code_repos.map(rel => path.resolve(workspaceRoot, rel.replace(/^(["'])(.*)\1$/, '$2'))));
178
+ const additions = codeRels.filter(rel => !known.has(path.resolve(workspaceRoot, rel)));
179
+ if (!additions.length) return filePath;
180
+ const newline = raw.includes('\r\n') ? '\r\n' : '\n';
181
+ const existing = raw.split(/\r?\n/);
182
+ const header = existing.findIndex(line => /^code_repos:\s*(?:#.*)?$/.test(line));
183
+ if (header < 0) throw new Error('已有多仓清单缺少 code_repos 列表,请先修复配置');
184
+ existing.splice(header + 1, 0, ...additions.map(rel => ` - ${rel}`));
185
+ fs.writeFileSync(filePath, existing.join(newline), 'utf8');
186
+ } else {
187
+ fs.writeFileSync(filePath, lines.join('\n'), 'utf8');
188
+ }
168
189
  return filePath;
169
190
  }
170
191
 
@@ -228,6 +249,22 @@ function findNestedSpecPackage(parentDir) {
228
249
  );
229
250
  }
230
251
 
252
+ /**
253
+ * 取候选来源的中文标签;未知来源原样返回。
254
+ *
255
+ * ⚠️ 标签表与候选枚举均已收敛到 `spec-detect.cjs`(唯一真相源):
256
+ * - 枚举用 `detectSpecCandidates` / `detectSpecCandidatesWithGit`
257
+ * - 标签用 `spec-detect#specSourceLabel`
258
+ *
259
+ * 本模块曾另有一份 `listSpecCandidates`(多出一个 'workspace' 来源标签),
260
+ * 现已删除 —— 它只是给「扫描已能找到的同一个目录」换个标签,对候选集合
261
+ * 无增量,反而造成「同一目录两种来源说法」。判据强度统一由候选的 `tier`
262
+ * 承载(0=显式声明 / 1=四结构 / 2=名称含空壳)。
263
+ */
264
+ function specSourceLabel(source) {
265
+ return specDetect.specSourceLabel(source);
266
+ }
267
+
231
268
  /**
232
269
  * 确保父目录下存在统一的 SDD 包裹包目录。
233
270
  * @param {{ name?: string, initGit?: boolean }} options
@@ -278,18 +315,29 @@ function ensureSpecPackage(parentDir, options = {}) {
278
315
  return { name, abs, created: !existed, isGit };
279
316
  }
280
317
 
318
+ /**
319
+ * 公开 API —— 只导出「有跨模块消费者」的符号。
320
+ *
321
+ * 刻意不导出(函数体保留,模块内部使用):
322
+ * - `WORKSPACE_FILE` 仅本模块 loadWorkspaceFile / writeWorkspaceFile 使用
323
+ * - `listChildGitRepos` 仅 detectWorkspaceLayout 使用
324
+ * - `parseWorkspaceYaml` 仅 loadWorkspaceFile 使用
325
+ *
326
+ * 标签表 `SPEC_SOURCE_LABELS` 已迁至 `spec-detect.cjs`(唯一真相源),
327
+ * 本模块仅转发 `specSourceLabel()`;如需直接读表请从 spec-detect 引入。
328
+ *
329
+ * 如需外部使用请重新加入本表,而不是依赖 `module.exports` 的隐式宽松。
330
+ */
281
331
  module.exports = {
282
- WORKSPACE_FILE,
283
332
  isGitRepo,
284
333
  looksLikeSpecRepo,
285
- listChildGitRepos,
286
334
  listChildDirectories,
287
335
  detectWorkspaceLayout,
288
- parseWorkspaceYaml,
289
336
  loadWorkspaceFile,
290
337
  writeWorkspaceFile,
291
338
  findNewCodeRepos,
292
339
  deriveSpecPackageName,
293
340
  findNestedSpecPackage,
341
+ specSourceLabel,
294
342
  ensureSpecPackage,
295
343
  };