kld-sdd 2.5.2 → 2.6.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.
@@ -0,0 +1,489 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { generateUuidV7 } = require('./id.cjs');
7
+
8
+ const PRODUCER_VERSION = require('../../package.json').version;
9
+ const PRODUCER = `kld-sdd@${PRODUCER_VERSION}`;
10
+ const MANIFEST_SCHEMA_VERSION = 'kld-sdd-archive-manifest/v2';
11
+ const CANONICAL_FACTS_SCHEMA_VERSION = 'kld-sdd-canonical-facts/v1';
12
+ const CONVERSION_REPORT_SCHEMA_VERSION = 'kld-sdd-conversion-report/v1';
13
+ const PROJECT_IDENTITY_SCHEMA_VERSION = 'kld-sdd-project-identity/v1';
14
+ const MANIFEST_PATH = 'archive-manifest.json';
15
+ const ONTOLOGY_PATH = 'archive-ontology.json';
16
+ const CANONICAL_FACTS_PATH = 'canonical-facts.json';
17
+ const CONVERSION_REPORT_PATH = 'conversion-report.json';
18
+ const PACKAGE_SUFFIXES = new Set(['.md', '.json', '.jsonl', '.yaml', '.yml']);
19
+
20
+ function sha256(content) {
21
+ return crypto.createHash('sha256').update(content).digest('hex');
22
+ }
23
+
24
+ function readJson(filePath) {
25
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
26
+ }
27
+
28
+ function atomicWrite(filePath, content) {
29
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
30
+ const tempPath = `${filePath}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
31
+ fs.writeFileSync(tempPath, content);
32
+ fs.renameSync(tempPath, filePath);
33
+ }
34
+
35
+ function writeJson(filePath, value) {
36
+ const bytes = Buffer.from(`${JSON.stringify(value, null, 2)}\n`, 'utf8');
37
+ atomicWrite(filePath, bytes);
38
+ return bytes;
39
+ }
40
+
41
+ function toPosix(value) {
42
+ return String(value || '').replace(/\\/g, '/');
43
+ }
44
+
45
+ function safePackagePath(value) {
46
+ const input = toPosix(value).trim();
47
+ if (!input || input.startsWith('/') || /^[A-Za-z]:\//.test(input) || input.includes('\0')) {
48
+ throw new Error(`Archive Package 路径不安全: ${value}`);
49
+ }
50
+ const normalized = path.posix.normalize(input);
51
+ if (normalized !== input || normalized === '..' || normalized.startsWith('../')) {
52
+ throw new Error(`Archive Package 路径不规范: ${value}`);
53
+ }
54
+ return normalized;
55
+ }
56
+
57
+ function ensureProjectIdentity(projectRoot) {
58
+ const identityPath = path.join(projectRoot, 'skywalk-sdd', 'project-identity.json');
59
+ if (fs.existsSync(identityPath)) {
60
+ const existing = readJson(identityPath);
61
+ if (existing.schema_version !== PROJECT_IDENTITY_SCHEMA_VERSION || !existing.project_id) {
62
+ throw new Error(`项目身份文件格式非法: ${identityPath}`);
63
+ }
64
+ return existing;
65
+ }
66
+ const identity = {
67
+ schema_version: PROJECT_IDENTITY_SCHEMA_VERSION,
68
+ project_id: `urn:kld:sdd:project:${generateUuidV7()}`,
69
+ created_at: new Date().toISOString(),
70
+ };
71
+ writeJson(identityPath, identity);
72
+ return identity;
73
+ }
74
+
75
+ function sourceWithVerifiedHash(archiveDir, source, ownerAnchor, forceOwnerAnchor) {
76
+ const declared = source && typeof source === 'object' ? source : {};
77
+ const relativePath = safePackagePath(declared.file);
78
+ const absolutePath = path.resolve(archiveDir, relativePath);
79
+ const relativeCheck = toPosix(path.relative(archiveDir, absolutePath));
80
+ if (relativeCheck !== relativePath || !fs.existsSync(absolutePath) || !fs.statSync(absolutePath).isFile()) {
81
+ throw new Error(`本体来源文件不在 Archive Package 中: ${relativePath} (${ownerAnchor})`);
82
+ }
83
+ const line = Number(declared.line || 0);
84
+ return {
85
+ artifact_type: String(declared.artifact_type || 'unknown').trim().toLowerCase(),
86
+ file: relativePath,
87
+ ...(Number.isInteger(line) && line > 0 ? { line } : {}),
88
+ anchor_id: forceOwnerAnchor
89
+ ? ownerAnchor
90
+ : String(declared.anchor_id || ownerAnchor).trim().toUpperCase(),
91
+ content_hash: `sha256:${sha256(fs.readFileSync(absolutePath))}`,
92
+ };
93
+ }
94
+
95
+ function requiredText(value, field, owner) {
96
+ const normalized = String(value || '').trim();
97
+ if (!normalized) {
98
+ throw new Error(`${owner} 缺少 ${field}`);
99
+ }
100
+ return normalized;
101
+ }
102
+
103
+ function canonicalEntity(archiveDir, entity) {
104
+ const anchorId = requiredText(entity.anchor_id || entity.id, 'anchor_id', '本体实体').toUpperCase();
105
+ const versionId = requiredText(
106
+ entity.entity_version_id || entity.version_id,
107
+ 'entity_version_id/version_id',
108
+ anchorId,
109
+ ).toLowerCase();
110
+ return {
111
+ anchor_id: anchorId,
112
+ type: requiredText(entity.type, 'type', anchorId),
113
+ name: String(entity.name || anchorId).trim(),
114
+ entity_id: requiredText(entity.entity_id, 'entity_id', anchorId).toLowerCase(),
115
+ entity_version_id: versionId,
116
+ predecessor_version_id: entity.predecessor_version_id
117
+ ? String(entity.predecessor_version_id).trim().toLowerCase()
118
+ : null,
119
+ delta_state: String(entity.delta_state || 'added').trim().toLowerCase(),
120
+ content_hash: requiredText(entity.content_hash, 'content_hash', anchorId),
121
+ fact_kind: 'semantic',
122
+ assertion_type: 'asserted',
123
+ review_status: 'confirmed',
124
+ generation_role: entity.generation_role || 'current',
125
+ ...(entity.inherited_from ? { inherited_from: entity.inherited_from } : {}),
126
+ attributes: entity.attributes && typeof entity.attributes === 'object' ? entity.attributes : {},
127
+ source: sourceWithVerifiedHash(archiveDir, entity.source, anchorId, true),
128
+ };
129
+ }
130
+
131
+ function normalizeRelationDirection(type, fromAnchor, toAnchor, entityByAnchor) {
132
+ if (type !== 'constrains') {
133
+ return { type: type === 'acceptedBy' ? 'verifiedBy' : type, fromAnchor, toAnchor };
134
+ }
135
+ const fromType = entityByAnchor.get(fromAnchor)?.type;
136
+ const toType = entityByAnchor.get(toAnchor)?.type;
137
+ if (fromType === 'Constraint' && toType === 'SpecificationStatement') {
138
+ return { type: 'constrainedBy', fromAnchor: toAnchor, toAnchor: fromAnchor };
139
+ }
140
+ if (fromType === 'SpecificationStatement' && toType === 'Constraint') {
141
+ return { type: 'constrainedBy', fromAnchor, toAnchor };
142
+ }
143
+ throw new Error(`constrains 关系方向无法归一化: ${fromAnchor} -> ${toAnchor}`);
144
+ }
145
+
146
+ function canonicalRelation(archiveDir, relation, entityByAnchor, warningCounts) {
147
+ const originalType = requiredText(relation.type, 'type', '本体关系');
148
+ const originalFrom = requiredText(relation.from, 'from', originalType).toUpperCase();
149
+ const originalTo = requiredText(relation.to, 'to', originalType).toUpperCase();
150
+ const normalized = normalizeRelationDirection(originalType, originalFrom, originalTo, entityByAnchor);
151
+ if (normalized.type !== originalType || normalized.fromAnchor !== originalFrom) {
152
+ const warningKey = `${originalType}->${normalized.type}`;
153
+ warningCounts.set(warningKey, (warningCounts.get(warningKey) || 0) + 1);
154
+ }
155
+ const fromEntity = entityByAnchor.get(normalized.fromAnchor);
156
+ const toEntity = entityByAnchor.get(normalized.toAnchor);
157
+ if (!fromEntity || !toEntity) {
158
+ throw new Error(
159
+ `关系端点不在 canonical snapshot 中: ${normalized.fromAnchor} -${normalized.type}-> ${normalized.toAnchor}`,
160
+ );
161
+ }
162
+ const assertionType = String(relation.assertion_type || 'asserted').trim().toLowerCase();
163
+ const reviewStatus = assertionType === 'suggested' ? 'pending' : 'confirmed';
164
+ const relationKey = `${normalized.type}:${fromEntity.entity_id}:${toEntity.entity_id}`;
165
+ const output = {
166
+ type: normalized.type,
167
+ from_entity_id: fromEntity.entity_id,
168
+ to_entity_id: toEntity.entity_id,
169
+ assertion_type: assertionType,
170
+ fact_kind: 'semantic',
171
+ review_status: reviewStatus,
172
+ generation_role: relation.generation_role || 'current',
173
+ ...(relation.inherited_from ? { inherited_from: relation.inherited_from } : {}),
174
+ source: sourceWithVerifiedHash(archiveDir, relation.source, relationKey, false),
175
+ };
176
+ if (assertionType === 'inferred') {
177
+ output.rule_id = requiredText(relation.rule_id, 'rule_id', relationKey);
178
+ output.generator = relation.generator || 'kld-sdd';
179
+ output.generator_version = relation.generator_version || PRODUCER_VERSION;
180
+ } else if (relation.rule_id) {
181
+ output.rule_id = relation.rule_id;
182
+ }
183
+ if (assertionType === 'suggested') {
184
+ output.generator = requiredText(relation.generator, 'generator', relationKey);
185
+ output.generator_version = requiredText(relation.generator_version, 'generator_version', relationKey);
186
+ if (!Number.isFinite(relation.confidence)) {
187
+ throw new Error(`${relationKey} 缺少 suggested confidence`);
188
+ }
189
+ output.confidence = relation.confidence;
190
+ output.support_evidence = Array.isArray(relation.support_evidence) ? relation.support_evidence : [];
191
+ output.opposition_evidence = Array.isArray(relation.opposition_evidence) ? relation.opposition_evidence : [];
192
+ }
193
+ return output;
194
+ }
195
+
196
+ function buildCanonicalFacts(archiveDir, snapshot, projectId, archiveId) {
197
+ if (snapshot.review_status !== 'confirmed') {
198
+ throw new Error('archive-ontology.json 必须是 confirmed 快照');
199
+ }
200
+ const changeId = requiredText(snapshot.change_id, 'change_id', 'archive-ontology.json');
201
+ const entities = (snapshot.entities || [])
202
+ .map((entity) => canonicalEntity(archiveDir, entity))
203
+ .sort((left, right) => (
204
+ left.anchor_id.localeCompare(right.anchor_id)
205
+ || left.entity_version_id.localeCompare(right.entity_version_id)
206
+ ));
207
+ if (entities.length === 0) {
208
+ throw new Error('archive-ontology.json 没有可导出的实体');
209
+ }
210
+ const entityByAnchor = new Map(entities.map((entity) => [entity.anchor_id, entity]));
211
+ const warningCounts = new Map();
212
+ const seenRelations = new Set();
213
+ const relations = [];
214
+ for (const relation of snapshot.relations || []) {
215
+ const converted = canonicalRelation(archiveDir, relation, entityByAnchor, warningCounts);
216
+ const key = `${converted.type}:${converted.from_entity_id}:${converted.to_entity_id}`;
217
+ if (seenRelations.has(key)) {
218
+ warningCounts.set('duplicate-relation-removed', (warningCounts.get('duplicate-relation-removed') || 0) + 1);
219
+ continue;
220
+ }
221
+ seenRelations.add(key);
222
+ relations.push(converted);
223
+ }
224
+ relations.sort((left, right) => (
225
+ left.type.localeCompare(right.type)
226
+ || left.from_entity_id.localeCompare(right.from_entity_id)
227
+ || left.to_entity_id.localeCompare(right.to_entity_id)
228
+ ));
229
+ const warnings = [...warningCounts.entries()]
230
+ .sort(([left], [right]) => left.localeCompare(right))
231
+ .map(([type, count]) => `${type}: ${count}`);
232
+ return {
233
+ canonicalFacts: {
234
+ schema_version: CANONICAL_FACTS_SCHEMA_VERSION,
235
+ producer: PRODUCER,
236
+ project_id: projectId,
237
+ archive_id: archiveId,
238
+ change_id: changeId,
239
+ profile: requiredText(snapshot.profile, 'profile', 'archive-ontology.json').toLowerCase(),
240
+ review_status: 'confirmed',
241
+ facts_hash: requiredText(snapshot.facts_hash, 'facts_hash', 'archive-ontology.json'),
242
+ source_revision: snapshot.source_revision,
243
+ snapshot_id: snapshot.snapshot_id,
244
+ entities,
245
+ relations,
246
+ },
247
+ warnings,
248
+ };
249
+ }
250
+
251
+ function collectPackageFilePaths(archiveDir) {
252
+ const files = [];
253
+ function walk(currentDir) {
254
+ for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
255
+ if (entry.name.startsWith('.')) continue;
256
+ const absolutePath = path.join(currentDir, entry.name);
257
+ if (entry.isDirectory()) {
258
+ walk(absolutePath);
259
+ } else if (entry.isFile()) {
260
+ const relativePath = safePackagePath(toPosix(path.relative(archiveDir, absolutePath)));
261
+ if (relativePath === MANIFEST_PATH) continue;
262
+ if (!PACKAGE_SUFFIXES.has(path.posix.extname(relativePath).toLowerCase())) continue;
263
+ files.push(relativePath);
264
+ }
265
+ }
266
+ }
267
+ walk(archiveDir);
268
+ return files.sort((left, right) => left.localeCompare(right));
269
+ }
270
+
271
+ function dosTimestamp(isoValue) {
272
+ const parsed = new Date(isoValue || '');
273
+ const date = Number.isNaN(parsed.getTime()) ? new Date() : parsed;
274
+ const year = Math.min(2107, Math.max(1980, date.getUTCFullYear()));
275
+ const month = date.getUTCMonth() + 1;
276
+ const day = date.getUTCDate();
277
+ const hours = date.getUTCHours();
278
+ const minutes = date.getUTCMinutes();
279
+ const seconds = Math.floor(date.getUTCSeconds() / 2);
280
+ return {
281
+ time: (hours << 11) | (minutes << 5) | seconds,
282
+ date: ((year - 1980) << 9) | (month << 5) | day,
283
+ };
284
+ }
285
+
286
+ let crcTable;
287
+ function crc32(content) {
288
+ if (!crcTable) {
289
+ crcTable = Array.from({ length: 256 }, (_, index) => {
290
+ let value = index;
291
+ for (let bit = 0; bit < 8; bit += 1) {
292
+ value = (value & 1) ? (0xedb88320 ^ (value >>> 1)) : (value >>> 1);
293
+ }
294
+ return value >>> 0;
295
+ });
296
+ }
297
+ let value = 0xffffffff;
298
+ for (const byte of content) {
299
+ value = crcTable[(value ^ byte) & 0xff] ^ (value >>> 8);
300
+ }
301
+ return (value ^ 0xffffffff) >>> 0;
302
+ }
303
+
304
+ function storedZipEntry(name, content, timestamp, offset) {
305
+ const nameBytes = Buffer.from(name, 'utf8');
306
+ const checksum = crc32(content);
307
+ const local = Buffer.alloc(30);
308
+ local.writeUInt32LE(0x04034b50, 0);
309
+ local.writeUInt16LE(20, 4);
310
+ local.writeUInt16LE(0x0800, 6);
311
+ local.writeUInt16LE(0, 8);
312
+ local.writeUInt16LE(timestamp.time, 10);
313
+ local.writeUInt16LE(timestamp.date, 12);
314
+ local.writeUInt32LE(checksum, 14);
315
+ local.writeUInt32LE(content.length, 18);
316
+ local.writeUInt32LE(content.length, 22);
317
+ local.writeUInt16LE(nameBytes.length, 26);
318
+ local.writeUInt16LE(0, 28);
319
+
320
+ const central = Buffer.alloc(46);
321
+ central.writeUInt32LE(0x02014b50, 0);
322
+ central.writeUInt16LE(20, 4);
323
+ central.writeUInt16LE(20, 6);
324
+ central.writeUInt16LE(0x0800, 8);
325
+ central.writeUInt16LE(0, 10);
326
+ central.writeUInt16LE(timestamp.time, 12);
327
+ central.writeUInt16LE(timestamp.date, 14);
328
+ central.writeUInt32LE(checksum, 16);
329
+ central.writeUInt32LE(content.length, 20);
330
+ central.writeUInt32LE(content.length, 24);
331
+ central.writeUInt16LE(nameBytes.length, 28);
332
+ central.writeUInt16LE(0, 30);
333
+ central.writeUInt16LE(0, 32);
334
+ central.writeUInt16LE(0, 34);
335
+ central.writeUInt16LE(0, 36);
336
+ central.writeUInt32LE(0, 38);
337
+ central.writeUInt32LE(offset, 42);
338
+ return {
339
+ local: Buffer.concat([local, nameBytes, content]),
340
+ central: Buffer.concat([central, nameBytes]),
341
+ };
342
+ }
343
+
344
+ function writeArchiveZip(archiveDir, packagePath, manifest) {
345
+ const filePaths = [MANIFEST_PATH, ...(manifest.files || []).map((item) => item.path)];
346
+ if (filePaths.length > 0xffff) {
347
+ throw new Error(`Archive Package 文件数超过 ZIP32 上限: ${filePaths.length}`);
348
+ }
349
+ const timestamp = dosTimestamp(manifest.created_at);
350
+ const localParts = [];
351
+ const centralParts = [];
352
+ let offset = 0;
353
+ for (const declaredPath of filePaths) {
354
+ const relativePath = safePackagePath(declaredPath);
355
+ const content = fs.readFileSync(path.join(archiveDir, relativePath));
356
+ if (content.length > 0xffffffff) {
357
+ throw new Error(`Archive Package 单文件超过 ZIP32 上限: ${relativePath}`);
358
+ }
359
+ const entry = storedZipEntry(relativePath, content, timestamp, offset);
360
+ localParts.push(entry.local);
361
+ centralParts.push(entry.central);
362
+ offset += entry.local.length;
363
+ }
364
+ const centralDirectory = Buffer.concat(centralParts);
365
+ const end = Buffer.alloc(22);
366
+ end.writeUInt32LE(0x06054b50, 0);
367
+ end.writeUInt16LE(0, 4);
368
+ end.writeUInt16LE(0, 6);
369
+ end.writeUInt16LE(filePaths.length, 8);
370
+ end.writeUInt16LE(filePaths.length, 10);
371
+ end.writeUInt32LE(centralDirectory.length, 12);
372
+ end.writeUInt32LE(offset, 16);
373
+ end.writeUInt16LE(0, 20);
374
+ atomicWrite(packagePath, Buffer.concat([...localParts, centralDirectory, end]));
375
+ return packagePath;
376
+ }
377
+
378
+ function materializeArchivePackage(options = {}) {
379
+ const projectRoot = path.resolve(options.projectRoot || process.cwd());
380
+ const archiveDir = path.resolve(requiredText(options.archiveDir, 'archiveDir', 'Archive Package'));
381
+ const committedArchiveDir = path.resolve(options.archivePath || archiveDir);
382
+ const ontologyPath = path.join(archiveDir, ONTOLOGY_PATH);
383
+ if (!fs.existsSync(ontologyPath)) {
384
+ throw new Error(`缺少 ${ONTOLOGY_PATH}: ${archiveDir}`);
385
+ }
386
+ const snapshot = readJson(ontologyPath);
387
+ const projectIdentity = ensureProjectIdentity(projectRoot);
388
+ const changeId = requiredText(snapshot.change_id, 'change_id', ONTOLOGY_PATH);
389
+ const archiveId = `urn:kld:sdd:archive:${sha256(
390
+ `${projectIdentity.project_id}\n${changeId}\n${snapshot.facts_hash}`,
391
+ )}`;
392
+ const { canonicalFacts, warnings } = buildCanonicalFacts(
393
+ archiveDir,
394
+ snapshot,
395
+ projectIdentity.project_id,
396
+ archiveId,
397
+ );
398
+ const canonicalPath = path.join(archiveDir, CANONICAL_FACTS_PATH);
399
+ const canonicalBytes = writeJson(canonicalPath, canonicalFacts);
400
+
401
+ const conversionReport = {
402
+ schema_version: CONVERSION_REPORT_SCHEMA_VERSION,
403
+ producer: PRODUCER,
404
+ source_file: ONTOLOGY_PATH,
405
+ source_schema: String(snapshot.schema_version || 'unknown'),
406
+ target_file: CANONICAL_FACTS_PATH,
407
+ target_schema: CANONICAL_FACTS_SCHEMA_VERSION,
408
+ status: 'converted',
409
+ entity_count: canonicalFacts.entities.length,
410
+ relation_count: canonicalFacts.relations.length,
411
+ warnings,
412
+ };
413
+ writeJson(path.join(archiveDir, CONVERSION_REPORT_PATH), conversionReport);
414
+
415
+ const existingManifestPath = path.join(archiveDir, MANIFEST_PATH);
416
+ const existingManifest = fs.existsSync(existingManifestPath)
417
+ ? readJson(existingManifestPath)
418
+ : {};
419
+ const createdAt = options.archivedAt
420
+ || existingManifest.created_at
421
+ || existingManifest.archived_at
422
+ || new Date().toISOString();
423
+ const filePaths = collectPackageFilePaths(archiveDir);
424
+ const files = filePaths.map((relativePath) => ({
425
+ path: relativePath,
426
+ content_hash: `sha256:${sha256(fs.readFileSync(path.join(archiveDir, relativePath)))}`,
427
+ }));
428
+ const archiveRelativePath = toPosix(path.relative(projectRoot, committedArchiveDir));
429
+ const manifest = {
430
+ schema_version: MANIFEST_SCHEMA_VERSION,
431
+ producer: PRODUCER,
432
+ project_id: projectIdentity.project_id,
433
+ archive_id: archiveId,
434
+ change_id: changeId,
435
+ content_hash: `sha256:${sha256(canonicalBytes)}`,
436
+ canonical_facts_path: CANONICAL_FACTS_PATH,
437
+ conversion_report_path: CONVERSION_REPORT_PATH,
438
+ created_at: createdAt,
439
+ files,
440
+ change: options.changeName || snapshot.change || path.basename(committedArchiveDir),
441
+ archived_at: createdAt,
442
+ reason: options.reason || existingManifest.reason || '变更已完成实施',
443
+ method: options.method || existingManifest.method || 'skywalk-full-spec-archive',
444
+ source_path: options.sourcePath || existingManifest.source_path || '',
445
+ archive_path: archiveRelativePath,
446
+ copied_specs: Array.isArray(options.copiedSpecs)
447
+ ? options.copiedSpecs
448
+ : (existingManifest.copied_specs || []),
449
+ evidence_events_path: options.evidenceEventsPath === undefined
450
+ ? (existingManifest.evidence_events_path || null)
451
+ : options.evidenceEventsPath,
452
+ };
453
+ writeJson(existingManifestPath, manifest);
454
+
455
+ const packagePath = options.packagePath === false
456
+ ? ''
457
+ : path.resolve(options.packagePath || `${committedArchiveDir}.zip`);
458
+ if (packagePath) {
459
+ writeArchiveZip(archiveDir, packagePath, manifest);
460
+ }
461
+ return {
462
+ manifest,
463
+ conversion_report: conversionReport,
464
+ manifest_path: existingManifestPath,
465
+ canonical_facts_path: canonicalPath,
466
+ conversion_report_path: path.join(archiveDir, CONVERSION_REPORT_PATH),
467
+ package_path: packagePath,
468
+ };
469
+ }
470
+
471
+ module.exports = {
472
+ PRODUCER_VERSION,
473
+ PRODUCER,
474
+ MANIFEST_SCHEMA_VERSION,
475
+ CANONICAL_FACTS_SCHEMA_VERSION,
476
+ CONVERSION_REPORT_SCHEMA_VERSION,
477
+ PROJECT_IDENTITY_SCHEMA_VERSION,
478
+ MANIFEST_PATH,
479
+ ONTOLOGY_PATH,
480
+ CANONICAL_FACTS_PATH,
481
+ CONVERSION_REPORT_PATH,
482
+ sha256,
483
+ safePackagePath,
484
+ ensureProjectIdentity,
485
+ buildCanonicalFacts,
486
+ collectPackageFilePaths,
487
+ writeArchiveZip,
488
+ materializeArchivePackage,
489
+ };
@@ -97,6 +97,31 @@ function validateArchiveCandidate(candidate, facts) {
97
97
  return { eligible: false, snapshot, manifest, diagnostics };
98
98
  }
99
99
 
100
+ const artifactIndexPath = path.join(candidate.changeDir, 'artifact-index.json');
101
+ if (!fs.existsSync(artifactIndexPath)) {
102
+ diagnostics.push(catalogDiagnostic(
103
+ DIAGNOSTIC_CODES.ARCHIVE_NOT_CONFIRMED,
104
+ `Archive 缺少 change 目录下的 artifact-index.json: ${candidate.change}`,
105
+ { file: artifactIndexPath, suggestion: '通过 KLD-SDD reconcile/archive 在 change 目录生成工作态 JSON 后再归档' },
106
+ ));
107
+ return { eligible: false, snapshot, manifest, diagnostics };
108
+ }
109
+
110
+ if (manifest.schema_version === 'kld-sdd-archive-manifest/v2') {
111
+ const canonicalPath = path.join(
112
+ candidate.changeDir,
113
+ manifest.canonical_facts_path || 'canonical-facts.json',
114
+ );
115
+ if (!fs.existsSync(canonicalPath)) {
116
+ diagnostics.push(catalogDiagnostic(
117
+ DIAGNOSTIC_CODES.ARCHIVE_NOT_CONFIRMED,
118
+ `Archive v2 manifest 缺少 canonical facts: ${candidate.change}`,
119
+ { file: canonicalPath, suggestion: '运行 archive-docs 或 materializeArchivePackage 生成 canonical-facts.json' },
120
+ ));
121
+ return { eligible: false, snapshot, manifest, diagnostics };
122
+ }
123
+ }
124
+
100
125
  return { eligible: true, snapshot, manifest, diagnostics };
101
126
  }
102
127