kld-sdd 2.7.8 → 2.8.1
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 +26 -0
- package/USABILITY.md +72 -0
- package/bin/kld-sdd-init.js +718 -34
- package/lib/command-bridge.js +26 -17
- package/lib/deploy-codebuddy-hooks.js +19 -26
- package/lib/deploy-strategy.js +293 -0
- package/lib/hook-gate-core.js +7 -8
- package/lib/hook-install.js +75 -0
- package/lib/init.js +507 -726
- package/lib/maintenance.js +221 -0
- package/lib/managed-hook-hashes.json +24 -0
- package/lib/settings-merge.js +102 -25
- package/lib/skills-bundle.js +5 -5
- package/lib/spec-choice.js +85 -0
- package/lib/tool-profiles.js +2 -2
- package/lib/uninstall.js +453 -0
- package/package.json +7 -4
- package/skywalk-sdd/context-client.cjs +38 -7
- package/skywalk-sdd/index.cjs +153 -27
- package/skywalk-sdd/kb-sync-identity.cjs +1 -1
- package/skywalk-sdd/kb-upload.cjs +242 -85
- package/skywalk-sdd/lib/check-review.cjs +41 -0
- package/skywalk-sdd/lib/shared.cjs +62 -41
- package/skywalk-sdd/lib/test-execution.cjs +130 -0
- package/skywalk-sdd/lib/usage-reporter.cjs +17 -8
- package/skywalk-sdd/ontology/archive-package.cjs +141 -0
- package/skywalk-sdd/ontology/artifact-parser.cjs +24 -3
- package/skywalk-sdd/ontology/cli.cjs +53 -0
- package/skywalk-sdd/ontology/id.cjs +1 -1
- package/skywalk-sdd/ontology/identity-index.cjs +5 -1
- package/skywalk-sdd/ontology/resolve-spec-root.cjs +49 -0
- package/skywalk-sdd/ontology/schema.cjs +6 -0
- package/skywalk-sdd/ontology/sdd-config.cjs +33 -5
- package/skywalk-sdd/ontology/spec-detect.cjs +421 -0
- package/skywalk-sdd/ontology/spec-ontology.cjs +507 -0
- package/skywalk-sdd/ontology/workspace-layout.cjs +65 -17
- package/templates/git-hooks/commit-msg +17 -85
- package/templates/git-hooks/commit-msg-runner.cjs +44 -0
- package/templates/git-hooks/commit-msg-sdd-trailer.cjs +60 -69
- package/templates/git-hooks/consistency-check-core.cjs +92 -261
- package/templates/git-hooks/delivery-check.cjs +231 -0
- package/templates/git-hooks/delivery-snapshot.cjs +181 -0
- package/templates/git-hooks/hooks.config +8 -6
- package/templates/git-hooks/pre-commit +19 -5
- package/templates/git-hooks/pre-commit-consistency-check.cjs +13 -31
- package/templates/git-hooks/pre-push +19 -5
- package/templates/git-hooks/pre-push-consistency-check.cjs +36 -83
- package/templates/hooks/claude/hooks/sdd-post-tool.cjs +2 -2
- package/templates/hooks/codebuddy/hooks/hook-gate-core.cjs +7 -8
- package/templates/hooks/codebuddy/hooks/sdd-post-tool.cjs +2 -2
- package/templates/openspec/proposal.md +1 -1
- package/templates/openspec/spec.md +8 -0
- package/templates/skills/kld-sdd/opsx-apply/SKILL.md +28 -24
- package/templates/skills/kld-sdd/opsx-apply/checklist.md +12 -3
- package/templates/skills/kld-sdd/opsx-apply/reference.md +21 -1
- package/templates/skills/kld-sdd/opsx-archive/SKILL.md +20 -18
- package/templates/skills/kld-sdd/opsx-check/SKILL.md +18 -13
- package/templates/skills/kld-sdd/opsx-check/reference.md +3 -1
- package/templates/skills/kld-sdd/opsx-check/review-template.json +22 -0
- package/templates/skills/kld-sdd/opsx-check/reviewer.md +27 -6
- package/templates/skills/kld-sdd/opsx-consistency-check/SKILL.md +34 -112
- package/templates/skills/kld-sdd/opsx-consistency-check/scripts/scripts.cjs +15 -14
- package/templates/skills/kld-sdd/opsx-kb-config/SKILL.md +93 -61
- package/templates/skills/kld-sdd/opsx-kb-config/reference.md +14 -12
- package/templates/skills/kld-sdd/opsx-kb-ingest/SKILL.md +16 -5
- package/templates/skills/kld-sdd/opsx-kb-ingest/reference.md +13 -5
- package/templates/skills/kld-sdd/opsx-kb-ingest/state.example.json +18 -0
- package/templates/skills/kld-sdd/opsx-ontology-ingest/SKILL.md +77 -0
- package/templates/skills/kld-sdd/opsx-ontology-query/SKILL.md +5 -4
- package/templates/skills/kld-sdd/opsx-ontology-query/reference.md +14 -1
- package/templates/skills/kld-sdd/opsx-propose/SKILL.md +1 -1
- package/templates/skills/kld-sdd/opsx-propose/interaction-policy.md +9 -0
- package/templates/skills/kld-sdd/opsx-spec/SKILL.md +5 -3
- package/templates/skills/kld-sdd/opsx-task/SKILL.md +2 -2
- 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
|
-
|
|
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.
|
|
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
|
};
|