@gefyra/diffyr6-cli 1.0.2 → 1.0.3

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 CHANGED
@@ -8,6 +8,7 @@ An automated toolkit for comparing FHIR profiles across different versions, anal
8
8
  - 🔄 **FSH Generation** - Converts FHIR resources to FSH using GoFSH
9
9
  - ⬆️ **Profile Upgrade Pipeline** - Automatically upgrades profiles between FHIR versions using SUSHI
10
10
  - 📊 **Profile Comparison** - Compares profile versions using the HL7 FHIR Validator
11
+ - 🎯 **Terminology Binding Analysis** - Compares terminology bindings between versions and checks ValueSet content from local package cache
11
12
  - 📝 **Rule-Based Difference Analysis** - Applies customizable rules to classify and score structural changes
12
13
  - 🎯 **Impact Scoring** - Calculates complexity scores based on breaking changes and migration risks
13
14
  - 🚨 **Removed Resource Detection** - Identifies profiles based on resource types removed in newer FHIR versions
@@ -166,7 +167,7 @@ console.log('Findings:', result.findingsCount);
166
167
 
167
168
  ## Pipeline Steps
168
169
 
169
- The comparison pipeline consists of 4 steps:
170
+ The comparison pipeline consists of 5 steps:
170
171
 
171
172
  ### 1. GoFSH (Optional)
172
173
 
@@ -195,6 +196,19 @@ Applies rules to the comparison HTML files and generates a markdown report with:
195
196
  - Impact score based on breaking changes
196
197
  - Timestamped filename (e.g., `comparison-report-20260123-143052.md`)
197
198
 
199
+ ### 5. Terminology Binding Analysis
200
+
201
+ Compares terminology bindings between R4 and R6 profiles:
202
+ 1. Runs `sushi -s` in both Resources and ResourcesR6 directories to generate snapshots
203
+ 2. Compares `element[].binding.strength` and `valueSet` between R4 and R6 profiles
204
+ 3. For ValueSets with version notation (pipe `|`), loads and compares content from local FHIR package cache:
205
+ - R4: `%USERPROFILE%\.fhir\packages\hl7.fhir.r4.core#4.0.1\package`
206
+ - R6: `%USERPROFILE%\.fhir\packages\hl7.fhir.r6.core#6.0.0-ballot3\package`
207
+ 4. Generates a `terminology-report-<timestamp>.md` with all binding differences
208
+ 5. Includes the report in the ZIP export
209
+
210
+ **Note:** This step continues even if it fails, to ensure the main migration completes.
211
+
198
212
  ## Compare Modes
199
213
 
200
214
  ### Incremental Mode (Default)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gefyra/diffyr6-cli",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "FHIR R4 to R6 migration pipeline runner with automated profile comparison and rule-based analysis",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -0,0 +1,1004 @@
1
+ import fsp from 'fs/promises';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { createAnimator, spawnProcess } from './utils/process.js';
5
+ import { directoryExists, fileExists } from './utils/fs.js';
6
+
7
+ /**
8
+ * Compares terminology bindings between R4 and R6 profiles
9
+ *
10
+ * Steps:
11
+ * 1. Run sushi -s in Resources and ResourcesR6 directories to generate snapshots
12
+ * 2. Find all profile pairs from the comparison run
13
+ * 3. Compare element[].binding.strength and valueSet between R4 and R6
14
+ * 4. If valueSet has a version (pipe notation), compare the actual ValueSet content from local package cache
15
+ * 5. Generate a markdown report with all findings
16
+ *
17
+ * @param {string} resourcesDir - R4 resources directory
18
+ * @param {string} resourcesR6Dir - R6 resources directory
19
+ * @param {string} outputDir - Output directory for the report
20
+ * @param {Object} options - Configuration options
21
+ * @returns {Promise<Object>} Report info with path and findings count
22
+ */
23
+ export async function compareTerminology(resourcesDir, resourcesR6Dir, outputDir, options = {}) {
24
+ const { debug = false } = options;
25
+
26
+ console.log(' Checking for existing snapshots...');
27
+
28
+ // Step 1: Check if snapshots already exist, if not run sushi -s
29
+ const resourcesDirHasSnapshots = await hasSnapshots(resourcesDir);
30
+ const resourcesR6DirHasSnapshots = await hasSnapshots(resourcesR6Dir);
31
+
32
+ if (resourcesDirHasSnapshots && resourcesR6DirHasSnapshots) {
33
+ console.log(' Snapshots already exist in both directories, skipping SUSHI build');
34
+ } else {
35
+ console.log(' Building snapshots with SUSHI...');
36
+
37
+ try {
38
+ if (!resourcesDirHasSnapshots) {
39
+ await runSushiWithSnapshots(resourcesDir, debug);
40
+ } else {
41
+ console.log(` Skipping ${path.basename(resourcesDir)} (snapshots already exist)`);
42
+ }
43
+
44
+ if (!resourcesR6DirHasSnapshots) {
45
+ await runSushiWithSnapshots(resourcesR6Dir, debug);
46
+ } else {
47
+ console.log(` Skipping ${path.basename(resourcesR6Dir)} (snapshots already exist)`);
48
+ }
49
+ } catch (error) {
50
+ throw new Error(`Failed to build snapshots: ${error.message}`);
51
+ }
52
+
53
+ console.log(' Snapshots built successfully');
54
+ }
55
+ console.log(' Analyzing binding differences...');
56
+
57
+ // Step 2-3: Collect profile pairs and compare bindings
58
+ const r4Profiles = await collectStructureDefinitions(resourcesDir);
59
+ const r6Profiles = await collectStructureDefinitions(resourcesR6Dir);
60
+
61
+ const pairs = buildProfilePairs(r4Profiles, r6Profiles);
62
+
63
+ if (pairs.length === 0) {
64
+ console.log(' No matching profile pairs found');
65
+ return null;
66
+ }
67
+
68
+ const findings = [];
69
+
70
+ for (const pair of pairs) {
71
+ const profileFindings = await compareProfileBindings(pair.r4, pair.r6, options);
72
+ if (profileFindings.length > 0) {
73
+ findings.push({
74
+ profileName: pair.name,
75
+ r4Url: pair.r4.url,
76
+ r6Url: pair.r6.url,
77
+ findings: profileFindings,
78
+ });
79
+ }
80
+ }
81
+
82
+ console.log(` Found ${findings.length} profile(s) with binding differences`);
83
+
84
+ // Step 4: Identify common bindings across all profiles
85
+ const commonBindings = identifyCommonBindings(findings);
86
+
87
+ // Remove common bindings from individual profiles
88
+ const filteredFindings = removeCommonBindingsFromProfiles(findings, commonBindings);
89
+
90
+ // Step 5: Generate reports
91
+ const timestamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-');
92
+ const reportFilename = `terminology-report-${timestamp}.md`;
93
+ const reportPath = path.join(outputDir, reportFilename);
94
+ const jsonFilename = `terminology-report-${timestamp}.json`;
95
+ const jsonPath = path.join(outputDir, jsonFilename);
96
+
97
+ // Generate markdown report
98
+ const markdown = generateTerminologyReport(filteredFindings, commonBindings);
99
+ await fsp.writeFile(reportPath, markdown, 'utf8');
100
+
101
+ // Generate JSON report
102
+ const jsonData = {
103
+ generated: new Date().toISOString(),
104
+ profilesWithDifferences: filteredFindings.length,
105
+ totalFindings: filteredFindings.reduce((sum, p) => sum + p.findings.length, 0) + commonBindings.length,
106
+ commonBindings,
107
+ profiles: filteredFindings,
108
+ };
109
+ await fsp.writeFile(jsonPath, JSON.stringify(jsonData, null, 2), 'utf8');
110
+
111
+ const totalFindings = filteredFindings.reduce((sum, p) => sum + p.findings.length, 0);
112
+
113
+ return {
114
+ path: reportPath,
115
+ filename: reportFilename,
116
+ jsonPath,
117
+ jsonFilename,
118
+ profilesWithDifferences: filteredFindings.length,
119
+ totalFindings,
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Check if snapshots already exist in the StructureDefinition files
125
+ */
126
+ async function hasSnapshots(dir) {
127
+ const resourcesPath = path.join(dir, 'fsh-generated', 'resources');
128
+ const exists = await directoryExists(resourcesPath);
129
+
130
+ if (!exists) {
131
+ return false;
132
+ }
133
+
134
+ const files = await fsp.readdir(resourcesPath);
135
+
136
+ // Check if at least one StructureDefinition has a snapshot
137
+ for (const file of files) {
138
+ if (!file.startsWith('StructureDefinition-') || !file.endsWith('.json')) {
139
+ continue;
140
+ }
141
+
142
+ try {
143
+ const filePath = path.join(resourcesPath, file);
144
+ const content = await fsp.readFile(filePath, 'utf8');
145
+ const data = JSON.parse(content);
146
+
147
+ if (data.resourceType === 'StructureDefinition' && data.snapshot && data.snapshot.element) {
148
+ // Found at least one StructureDefinition with snapshot
149
+ return true;
150
+ }
151
+ } catch (error) {
152
+ // Skip files that can't be read or parsed
153
+ continue;
154
+ }
155
+ }
156
+
157
+ return false;
158
+ }
159
+
160
+ /**
161
+ * Run sushi with snapshots flag in a directory
162
+ */
163
+ async function runSushiWithSnapshots(dir, debug = false) {
164
+ const sushiConfigPath = path.join(dir, 'sushi-config.yaml');
165
+ const exists = await fileExists(sushiConfigPath);
166
+
167
+ if (!exists) {
168
+ throw new Error(`sushi-config.yaml not found in ${dir}`);
169
+ }
170
+
171
+ const dirName = path.basename(dir);
172
+ const animator = createAnimator(`SUSHI building snapshots for ${dirName}...`);
173
+ animator.start();
174
+
175
+ try {
176
+ const result = await spawnProcess('sushi', ['-s'], dir, {
177
+ rejectOnNonZero: true,
178
+ });
179
+
180
+ if (result.exitCode !== 0) {
181
+ if (debug) {
182
+ console.error('SUSHI stderr:', result.stderr);
183
+ }
184
+ throw new Error(`SUSHI failed in ${dir}: exit code ${result.exitCode}`);
185
+ }
186
+ } finally {
187
+ animator.stop();
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Collect StructureDefinition files from a directory
193
+ */
194
+ async function collectStructureDefinitions(rootDir) {
195
+ const resourcesPath = path.join(rootDir, 'fsh-generated', 'resources');
196
+ const exists = await directoryExists(resourcesPath);
197
+
198
+ if (!exists) {
199
+ return [];
200
+ }
201
+
202
+ const files = await fsp.readdir(resourcesPath);
203
+ const definitions = [];
204
+
205
+ for (const file of files) {
206
+ if (!file.startsWith('StructureDefinition-') || !file.endsWith('.json')) {
207
+ continue;
208
+ }
209
+
210
+ const filePath = path.join(resourcesPath, file);
211
+ const content = await fsp.readFile(filePath, 'utf8');
212
+ const data = JSON.parse(content);
213
+
214
+ if (data.resourceType === 'StructureDefinition' && data.url) {
215
+ definitions.push({
216
+ url: data.url,
217
+ id: data.id || '',
218
+ name: data.name || '',
219
+ filePath,
220
+ data,
221
+ });
222
+ }
223
+ }
224
+
225
+ return definitions;
226
+ }
227
+
228
+ /**
229
+ * Build profile pairs for comparison
230
+ */
231
+ function buildProfilePairs(r4Profiles, r6Profiles) {
232
+ const pairs = [];
233
+
234
+ // Create a map of R4 profiles by their last segment
235
+ const r4Map = new Map();
236
+ for (const r4 of r4Profiles) {
237
+ const segment = extractLastSegment(r4.url).toLowerCase();
238
+ r4Map.set(segment, r4);
239
+ }
240
+
241
+ // Match R6 profiles to R4 profiles
242
+ for (const r6 of r6Profiles) {
243
+ const r6Segment = extractLastSegment(r6.url).toLowerCase();
244
+
245
+ // Try direct match
246
+ let r4 = r4Map.get(r6Segment);
247
+
248
+ // Try without version suffix
249
+ if (!r4) {
250
+ const withoutR6 = r6Segment.replace(/-?r6$/i, '');
251
+ r4 = r4Map.get(withoutR6) || r4Map.get(withoutR6 + '-r4') || r4Map.get(withoutR6 + 'r4');
252
+ }
253
+
254
+ if (r4) {
255
+ pairs.push({
256
+ name: r6.name || r6.id || extractLastSegment(r6.url),
257
+ r4,
258
+ r6,
259
+ });
260
+ }
261
+ }
262
+
263
+ return pairs;
264
+ }
265
+
266
+ /**
267
+ * Extract last segment from URL
268
+ */
269
+ function extractLastSegment(url) {
270
+ if (!url) return '';
271
+ const hashIndex = url.lastIndexOf('#');
272
+ const slashIndex = url.lastIndexOf('/');
273
+ const index = Math.max(hashIndex, slashIndex);
274
+ return index >= 0 ? url.slice(index + 1) : url;
275
+ }
276
+
277
+ /**
278
+ * Compare bindings between two profiles
279
+ */
280
+ async function compareProfileBindings(r4Profile, r6Profile, options = {}) {
281
+ const findings = [];
282
+
283
+ // Get snapshots from both profiles
284
+ const r4Snapshot = r4Profile.data.snapshot?.element || [];
285
+ const r6Snapshot = r6Profile.data.snapshot?.element || [];
286
+
287
+ // Create maps of elements by path
288
+ const r4Elements = new Map();
289
+ for (const elem of r4Snapshot) {
290
+ if (elem.binding) {
291
+ r4Elements.set(elem.path, elem);
292
+ }
293
+ }
294
+
295
+ const r6Elements = new Map();
296
+ for (const elem of r6Snapshot) {
297
+ if (elem.binding) {
298
+ r6Elements.set(elem.path, elem);
299
+ }
300
+ }
301
+
302
+ // Compare bindings for each element
303
+ for (const [path, r6Elem] of r6Elements) {
304
+ const r4Elem = r4Elements.get(path);
305
+
306
+ if (!r4Elem) {
307
+ // New binding in R6
308
+ findings.push({
309
+ type: 'new-binding',
310
+ path,
311
+ r6Binding: r6Elem.binding,
312
+ });
313
+ continue;
314
+ }
315
+
316
+ const r4Binding = r4Elem.binding;
317
+ const r6Binding = r6Elem.binding;
318
+
319
+ // Check what changed
320
+ const strengthChanged = r4Binding.strength !== r6Binding.strength;
321
+ const valueSetChanged = r4Binding.valueSet !== r6Binding.valueSet;
322
+
323
+ // If both changed, create a combined finding
324
+ if (strengthChanged && valueSetChanged) {
325
+ const finding = {
326
+ type: 'strength-and-valueset-change',
327
+ path,
328
+ r4Strength: r4Binding.strength,
329
+ r6Strength: r6Binding.strength,
330
+ r4ValueSet: r4Binding.valueSet,
331
+ r6ValueSet: r6Binding.valueSet,
332
+ };
333
+
334
+ // If both have version, compare content
335
+ if (hasVersion(r4Binding.valueSet) && hasVersion(r6Binding.valueSet)) {
336
+ const contentDiff = await compareValueSetContent(
337
+ r4Binding.valueSet,
338
+ r6Binding.valueSet,
339
+ options
340
+ );
341
+
342
+ if (contentDiff) {
343
+ finding.contentDifference = contentDiff;
344
+ }
345
+ }
346
+
347
+ // Check if only version differs and no content changes
348
+ const onlyVersionChange = onlyVersionDiffers(r4Binding.valueSet, r6Binding.valueSet);
349
+ const hasContentChanges = finding.contentDifference &&
350
+ !finding.contentDifference.message &&
351
+ (finding.contentDifference.addedCount > 0 ||
352
+ finding.contentDifference.removedCount > 0);
353
+
354
+ // If only version differs and no content changes, treat as strength-change only
355
+ if (onlyVersionChange && !hasContentChanges) {
356
+ findings.push({
357
+ type: 'strength-change',
358
+ path,
359
+ r4Strength: r4Binding.strength,
360
+ r6Strength: r6Binding.strength,
361
+ r4ValueSet: r4Binding.valueSet,
362
+ r6ValueSet: r6Binding.valueSet,
363
+ });
364
+ } else {
365
+ findings.push(finding);
366
+ }
367
+ } else if (strengthChanged) {
368
+ // Only strength changed
369
+ findings.push({
370
+ type: 'strength-change',
371
+ path,
372
+ r4Strength: r4Binding.strength,
373
+ r6Strength: r6Binding.strength,
374
+ r4ValueSet: r4Binding.valueSet,
375
+ r6ValueSet: r6Binding.valueSet,
376
+ });
377
+ } else if (valueSetChanged) {
378
+ // Only valueSet changed
379
+ const finding = {
380
+ type: 'valueset-change',
381
+ path,
382
+ r4ValueSet: r4Binding.valueSet,
383
+ r6ValueSet: r6Binding.valueSet,
384
+ r4Strength: r4Binding.strength,
385
+ r6Strength: r6Binding.strength,
386
+ };
387
+
388
+ // If both have version, compare content
389
+ if (hasVersion(r4Binding.valueSet) && hasVersion(r6Binding.valueSet)) {
390
+ const contentDiff = await compareValueSetContent(
391
+ r4Binding.valueSet,
392
+ r6Binding.valueSet,
393
+ options
394
+ );
395
+
396
+ if (contentDiff) {
397
+ finding.contentDifference = contentDiff;
398
+ }
399
+ }
400
+
401
+ // Skip if only version differs and no content changes
402
+ const onlyVersionChange = onlyVersionDiffers(r4Binding.valueSet, r6Binding.valueSet);
403
+ const hasContentChanges = finding.contentDifference &&
404
+ !finding.contentDifference.message &&
405
+ (finding.contentDifference.addedCount > 0 ||
406
+ finding.contentDifference.removedCount > 0);
407
+
408
+ // Only add finding if:
409
+ // - Version is different AND there are content changes, OR
410
+ // - The base URL is different (not just version)
411
+ if (!onlyVersionChange || hasContentChanges) {
412
+ findings.push(finding);
413
+ }
414
+ }
415
+ }
416
+
417
+ // Check for removed bindings
418
+ for (const [path, r4Elem] of r4Elements) {
419
+ if (!r6Elements.has(path)) {
420
+ findings.push({
421
+ type: 'removed-binding',
422
+ path,
423
+ r4Binding: r4Elem.binding,
424
+ });
425
+ }
426
+ }
427
+
428
+ return findings;
429
+ }
430
+
431
+ /**
432
+ * Check if valueSet URL has a version (pipe notation)
433
+ */
434
+ function hasVersion(valueSetUrl) {
435
+ return valueSetUrl && valueSetUrl.includes('|');
436
+ }
437
+
438
+ /**
439
+ * Check if two ValueSet URLs differ only in version
440
+ */
441
+ function onlyVersionDiffers(url1, url2) {
442
+ if (!url1 || !url2) return false;
443
+
444
+ // Extract base URLs (without version)
445
+ const base1 = url1.split('|')[0];
446
+ const base2 = url2.split('|')[0];
447
+
448
+ // If base URLs are the same, they only differ in version
449
+ return base1 === base2;
450
+ }
451
+
452
+ /**
453
+ * Compare ValueSet content from local package cache
454
+ */
455
+ async function compareValueSetContent(r4ValueSetUrl, r6ValueSetUrl, options = {}) {
456
+ try {
457
+ const r4ValueSet = await loadValueSetFromCache(r4ValueSetUrl, '4.0.1');
458
+ const r6ValueSet = await loadValueSetFromCache(r6ValueSetUrl, '6.0.0-ballot3');
459
+
460
+ if (!r4ValueSet || !r6ValueSet) {
461
+ return { message: 'Could not load ValueSets from cache' };
462
+ }
463
+
464
+ // Extract all codes from both ValueSets
465
+ const r4Codes = await extractCodesFromValueSet(r4ValueSet, '4.0.1');
466
+ const r6Codes = await extractCodesFromValueSet(r6ValueSet, '6.0.0-ballot3');
467
+
468
+ // Compare codes
469
+ const r4CodeSet = new Set(r4Codes.map(c => `${c.system}|${c.code}`));
470
+ const r6CodeSet = new Set(r6Codes.map(c => `${c.system}|${c.code}`));
471
+
472
+ const addedCodes = [];
473
+ const removedCodes = [];
474
+
475
+ // Find added codes
476
+ for (const codeKey of r6CodeSet) {
477
+ if (!r4CodeSet.has(codeKey)) {
478
+ const code = r6Codes.find(c => `${c.system}|${c.code}` === codeKey);
479
+ addedCodes.push(code);
480
+ }
481
+ }
482
+
483
+ // Find removed codes
484
+ for (const codeKey of r4CodeSet) {
485
+ if (!r6CodeSet.has(codeKey)) {
486
+ const code = r4Codes.find(c => `${c.system}|${c.code}` === codeKey);
487
+ removedCodes.push(code);
488
+ }
489
+ }
490
+
491
+ if (addedCodes.length === 0 && removedCodes.length === 0) {
492
+ return null; // No difference in codes
493
+ }
494
+
495
+ return {
496
+ r4TotalCodes: r4Codes.length,
497
+ r6TotalCodes: r6Codes.length,
498
+ addedCodes: addedCodes.slice(0, 20), // Limit to first 20 for readability
499
+ removedCodes: removedCodes.slice(0, 20), // Limit to first 20 for readability
500
+ addedCount: addedCodes.length,
501
+ removedCount: removedCodes.length,
502
+ };
503
+ } catch (error) {
504
+ return { message: `Error comparing ValueSets: ${error.message}` };
505
+ }
506
+ }
507
+
508
+ /**
509
+ * Extract all codes from a ValueSet by processing compose.include
510
+ */
511
+ async function extractCodesFromValueSet(valueSet, fhirVersion) {
512
+ const codes = [];
513
+
514
+ if (!valueSet.compose || !valueSet.compose.include) {
515
+ return codes;
516
+ }
517
+
518
+ for (const include of valueSet.compose.include) {
519
+ const system = include.system;
520
+
521
+ if (!system) {
522
+ continue;
523
+ }
524
+
525
+ // If specific concepts are listed, use those
526
+ if (include.concept && include.concept.length > 0) {
527
+ for (const concept of include.concept) {
528
+ codes.push({
529
+ system,
530
+ code: concept.code,
531
+ display: concept.display,
532
+ });
533
+ }
534
+ } else {
535
+ // Otherwise, try to load the entire CodeSystem
536
+ const codeSystem = await loadCodeSystemFromCache(system, fhirVersion);
537
+ if (codeSystem && codeSystem.concept) {
538
+ for (const concept of codeSystem.concept) {
539
+ addConceptAndChildren(codes, system, concept);
540
+ }
541
+ }
542
+ }
543
+ }
544
+
545
+ // Handle excludes
546
+ if (valueSet.compose.exclude) {
547
+ const excludedCodes = new Set();
548
+
549
+ for (const exclude of valueSet.compose.exclude) {
550
+ const system = exclude.system;
551
+
552
+ if (exclude.concept) {
553
+ for (const concept of exclude.concept) {
554
+ excludedCodes.add(`${system}|${concept.code}`);
555
+ }
556
+ }
557
+ }
558
+
559
+ // Filter out excluded codes
560
+ return codes.filter(c => !excludedCodes.has(`${c.system}|${c.code}`));
561
+ }
562
+
563
+ return codes;
564
+ }
565
+
566
+ /**
567
+ * Recursively add concept and its children to the codes array
568
+ */
569
+ function addConceptAndChildren(codes, system, concept) {
570
+ codes.push({
571
+ system,
572
+ code: concept.code,
573
+ display: concept.display,
574
+ });
575
+
576
+ if (concept.concept && concept.concept.length > 0) {
577
+ for (const child of concept.concept) {
578
+ addConceptAndChildren(codes, system, child);
579
+ }
580
+ }
581
+ }
582
+
583
+ /**
584
+ * Load a CodeSystem from the local FHIR package cache
585
+ */
586
+ async function loadCodeSystemFromCache(codeSystemUrl, fhirVersion) {
587
+ // Extract base URL without version
588
+ const [baseUrl] = codeSystemUrl.split('|');
589
+
590
+ // Determine package path based on FHIR version
591
+ const userProfile = os.homedir();
592
+ let packagePath;
593
+
594
+ if (fhirVersion.startsWith('4.')) {
595
+ packagePath = path.join(userProfile, '.fhir', 'packages', 'hl7.fhir.r4.core#4.0.1', 'package');
596
+ } else if (fhirVersion.startsWith('6.')) {
597
+ packagePath = path.join(userProfile, '.fhir', 'packages', 'hl7.fhir.r6.core#6.0.0-ballot3', 'package');
598
+ } else {
599
+ return null;
600
+ }
601
+
602
+ // Check if package exists
603
+ const exists = await directoryExists(packagePath);
604
+ if (!exists) {
605
+ return null;
606
+ }
607
+
608
+ // Search for CodeSystem file
609
+ const files = await fsp.readdir(packagePath);
610
+
611
+ for (const file of files) {
612
+ if (!file.startsWith('CodeSystem-') || !file.endsWith('.json')) {
613
+ continue;
614
+ }
615
+
616
+ try {
617
+ const filePath = path.join(packagePath, file);
618
+ const content = await fsp.readFile(filePath, 'utf8');
619
+ const data = JSON.parse(content);
620
+
621
+ if (data.resourceType === 'CodeSystem' && data.url === baseUrl) {
622
+ return data;
623
+ }
624
+ } catch (error) {
625
+ // Skip files that can't be read or parsed
626
+ continue;
627
+ }
628
+ }
629
+
630
+ return null;
631
+ }
632
+
633
+ /**
634
+ * Load a ValueSet from the local FHIR package cache
635
+ */
636
+ async function loadValueSetFromCache(valueSetUrl, fhirVersion) {
637
+ // Extract base URL without version
638
+ const [baseUrl, version] = valueSetUrl.split('|');
639
+
640
+ // Determine package path based on FHIR version
641
+ const userProfile = os.homedir();
642
+ let packagePath;
643
+
644
+ if (fhirVersion.startsWith('4.')) {
645
+ packagePath = path.join(userProfile, '.fhir', 'packages', 'hl7.fhir.r4.core#4.0.1', 'package');
646
+ } else if (fhirVersion.startsWith('6.')) {
647
+ packagePath = path.join(userProfile, '.fhir', 'packages', 'hl7.fhir.r6.core#6.0.0-ballot3', 'package');
648
+ } else {
649
+ return null;
650
+ }
651
+
652
+ // Check if package exists
653
+ const exists = await directoryExists(packagePath);
654
+ if (!exists) {
655
+ return null;
656
+ }
657
+
658
+ // Search for ValueSet file
659
+ const files = await fsp.readdir(packagePath);
660
+
661
+ for (const file of files) {
662
+ if (!file.startsWith('ValueSet-') || !file.endsWith('.json')) {
663
+ continue;
664
+ }
665
+
666
+ const filePath = path.join(packagePath, file);
667
+ const content = await fsp.readFile(filePath, 'utf8');
668
+ const data = JSON.parse(content);
669
+
670
+ if (data.resourceType === 'ValueSet' && data.url === baseUrl) {
671
+ return data;
672
+ }
673
+ }
674
+
675
+ return null;
676
+ }
677
+
678
+ /**
679
+ * Identify bindings that are common across all profiles
680
+ */
681
+ function identifyCommonBindings(findings) {
682
+ if (findings.length === 0) {
683
+ return [];
684
+ }
685
+
686
+ // Build a map of finding signatures to count occurrences
687
+ const signatureMap = new Map();
688
+
689
+ for (const profile of findings) {
690
+ const seenSignatures = new Set();
691
+
692
+ for (const finding of profile.findings) {
693
+ // Create a signature for this finding (without the path's resource type prefix)
694
+ const pathWithoutResource = finding.path.replace(/^[^.]+\./, '');
695
+ const signature = createFindingSignature(finding, pathWithoutResource);
696
+
697
+ if (!seenSignatures.has(signature)) {
698
+ seenSignatures.add(signature);
699
+
700
+ if (!signatureMap.has(signature)) {
701
+ signatureMap.set(signature, {
702
+ count: 0,
703
+ finding: { ...finding, path: pathWithoutResource },
704
+ });
705
+ }
706
+ signatureMap.get(signature).count++;
707
+ }
708
+ }
709
+ }
710
+
711
+ // Filter to findings that appear in all profiles
712
+ const commonFindings = [];
713
+ for (const [signature, data] of signatureMap) {
714
+ if (data.count === findings.length) {
715
+ commonFindings.push(data.finding);
716
+ }
717
+ }
718
+
719
+ return commonFindings;
720
+ }
721
+
722
+ /**
723
+ * Create a signature for a finding to identify identical changes
724
+ */
725
+ function createFindingSignature(finding, pathWithoutResource) {
726
+ const parts = [
727
+ finding.type,
728
+ pathWithoutResource,
729
+ finding.r4ValueSet || '',
730
+ finding.r6ValueSet || '',
731
+ finding.r4Strength || '',
732
+ finding.r6Strength || '',
733
+ ];
734
+
735
+ return parts.join('||');
736
+ }
737
+
738
+ /**
739
+ * Remove common bindings from individual profile findings
740
+ */
741
+ function removeCommonBindingsFromProfiles(findings, commonBindings) {
742
+ if (commonBindings.length === 0) {
743
+ return findings;
744
+ }
745
+
746
+ // Create signatures for common bindings
747
+ const commonSignatures = new Set(
748
+ commonBindings.map(f => createFindingSignature(f, f.path))
749
+ );
750
+
751
+ // Filter findings from each profile
752
+ const filtered = findings.map(profile => {
753
+ const filteredFindings = profile.findings.filter(finding => {
754
+ const pathWithoutResource = finding.path.replace(/^[^.]+\./, '');
755
+ const signature = createFindingSignature(finding, pathWithoutResource);
756
+ return !commonSignatures.has(signature);
757
+ });
758
+
759
+ return {
760
+ ...profile,
761
+ findings: filteredFindings,
762
+ };
763
+ }).filter(profile => profile.findings.length > 0); // Remove profiles with no unique findings
764
+
765
+ return filtered;
766
+ }
767
+
768
+ /**
769
+ * Group findings by type
770
+ */
771
+ function groupFindingsByType(findings) {
772
+ const byType = {
773
+ 'strength-and-valueset-change': [],
774
+ 'strength-change': [],
775
+ 'valueset-change': [],
776
+ 'new-binding': [],
777
+ 'removed-binding': [],
778
+ };
779
+
780
+ for (const finding of findings) {
781
+ byType[finding.type].push(finding);
782
+ }
783
+
784
+ return byType;
785
+ }
786
+
787
+ /**
788
+ * Append findings to markdown lines
789
+ */
790
+ function appendFindingsToMarkdown(lines, byType) {
791
+ // Combined strength and valueset changes
792
+ if (byType['strength-and-valueset-change'].length > 0) {
793
+ lines.push('### Binding Strength and ValueSet Changes');
794
+ lines.push('');
795
+
796
+ for (const f of byType['strength-and-valueset-change']) {
797
+ lines.push(`**${f.path}**`);
798
+ lines.push(`- Strength: \`${f.r4Strength}\` → \`${f.r6Strength}\``);
799
+ lines.push(`- R4 ValueSet: ${f.r4ValueSet || 'none'}`);
800
+ lines.push(`- R6 ValueSet: ${f.r6ValueSet || 'none'}`);
801
+
802
+ if (f.contentDifference) {
803
+ lines.push('');
804
+ lines.push('**Content Difference:**');
805
+
806
+ if (f.contentDifference.message) {
807
+ lines.push(`- ${f.contentDifference.message}`);
808
+ } else {
809
+ lines.push(`- R4 Total Codes: ${f.contentDifference.r4TotalCodes}`);
810
+ lines.push(`- R6 Total Codes: ${f.contentDifference.r6TotalCodes}`);
811
+
812
+ if (f.contentDifference.addedCount > 0) {
813
+ lines.push(`- **Added Codes (${f.contentDifference.addedCount}):**`);
814
+ const addedCodes = f.contentDifference.addedCodes || [];
815
+ for (const code of addedCodes) {
816
+ const display = code.display ? ` - ${code.display}` : '';
817
+ lines.push(` - \`${code.code}\`${display} (${code.system})`);
818
+ }
819
+ if (f.contentDifference.addedCount > addedCodes.length) {
820
+ lines.push(` - ... and ${f.contentDifference.addedCount - addedCodes.length} more`);
821
+ }
822
+ }
823
+
824
+ if (f.contentDifference.removedCount > 0) {
825
+ lines.push(`- **Removed Codes (${f.contentDifference.removedCount}):**`);
826
+ const removedCodes = f.contentDifference.removedCodes || [];
827
+ for (const code of removedCodes) {
828
+ const display = code.display ? ` - ${code.display}` : '';
829
+ lines.push(` - \`${code.code}\`${display} (${code.system})`);
830
+ }
831
+ if (f.contentDifference.removedCount > removedCodes.length) {
832
+ lines.push(` - ... and ${f.contentDifference.removedCount - removedCodes.length} more`);
833
+ }
834
+ }
835
+ }
836
+ }
837
+
838
+ lines.push('');
839
+ }
840
+ }
841
+
842
+ // Strength changes
843
+ if (byType['strength-change'].length > 0) {
844
+ lines.push('### Binding Strength Changes');
845
+ lines.push('');
846
+
847
+ for (const f of byType['strength-change']) {
848
+ lines.push(`**${f.path}**`);
849
+ lines.push(`- Strength: \`${f.r4Strength}\` → \`${f.r6Strength}\``);
850
+ if (f.r4ValueSet) {
851
+ lines.push(`- ValueSet (R4): ${f.r4ValueSet}`);
852
+ }
853
+ if (f.r6ValueSet) {
854
+ lines.push(`- ValueSet (R6): ${f.r6ValueSet}`);
855
+ }
856
+ lines.push('');
857
+ }
858
+ }
859
+
860
+ // ValueSet changes
861
+ if (byType['valueset-change'].length > 0) {
862
+ lines.push('### ValueSet Changes');
863
+ lines.push('');
864
+
865
+ for (const f of byType['valueset-change']) {
866
+ lines.push(`**${f.path}**`);
867
+ lines.push(`- R4 ValueSet: ${f.r4ValueSet || 'none'}`);
868
+ lines.push(`- R6 ValueSet: ${f.r6ValueSet || 'none'}`);
869
+
870
+ if (f.r4Strength || f.r6Strength) {
871
+ lines.push(`- Binding Strength: \`${f.r4Strength || 'none'}\` → \`${f.r6Strength || 'none'}\``);
872
+ }
873
+
874
+ if (f.contentDifference) {
875
+ lines.push('');
876
+ lines.push('**Content Difference:**');
877
+
878
+ if (f.contentDifference.message) {
879
+ lines.push(`- ${f.contentDifference.message}`);
880
+ } else {
881
+ lines.push(`- R4 Total Codes: ${f.contentDifference.r4TotalCodes}`);
882
+ lines.push(`- R6 Total Codes: ${f.contentDifference.r6TotalCodes}`);
883
+
884
+ if (f.contentDifference.addedCount > 0) {
885
+ lines.push(`- **Added Codes (${f.contentDifference.addedCount}):**`);
886
+ const addedCodes = f.contentDifference.addedCodes || [];
887
+ for (const code of addedCodes) {
888
+ const display = code.display ? ` - ${code.display}` : '';
889
+ lines.push(` - \`${code.code}\`${display} (${code.system})`);
890
+ }
891
+ if (f.contentDifference.addedCount > addedCodes.length) {
892
+ lines.push(` - ... and ${f.contentDifference.addedCount - addedCodes.length} more`);
893
+ }
894
+ }
895
+
896
+ if (f.contentDifference.removedCount > 0) {
897
+ lines.push(`- **Removed Codes (${f.contentDifference.removedCount}):**`);
898
+ const removedCodes = f.contentDifference.removedCodes || [];
899
+ for (const code of removedCodes) {
900
+ const display = code.display ? ` - ${code.display}` : '';
901
+ lines.push(` - \`${code.code}\`${display} (${code.system})`);
902
+ }
903
+ if (f.contentDifference.removedCount > removedCodes.length) {
904
+ lines.push(` - ... and ${f.contentDifference.removedCount - removedCodes.length} more`);
905
+ }
906
+ }
907
+ }
908
+ }
909
+
910
+ lines.push('');
911
+ }
912
+ }
913
+
914
+ // New bindings
915
+ if (byType['new-binding'].length > 0) {
916
+ lines.push('### New Bindings in R6');
917
+ lines.push('');
918
+
919
+ for (const f of byType['new-binding']) {
920
+ lines.push(`**${f.path}**`);
921
+ if (f.r6Binding?.valueSet) {
922
+ lines.push(`- ValueSet: ${f.r6Binding.valueSet}`);
923
+ }
924
+ if (f.r6Binding?.strength) {
925
+ lines.push(`- Strength: \`${f.r6Binding.strength}\``);
926
+ }
927
+ lines.push('');
928
+ }
929
+ }
930
+
931
+ // Removed bindings
932
+ if (byType['removed-binding'].length > 0) {
933
+ lines.push('### Removed Bindings in R6');
934
+ lines.push('');
935
+
936
+ for (const f of byType['removed-binding']) {
937
+ lines.push(`**${f.path}**`);
938
+ if (f.r4Binding?.valueSet) {
939
+ lines.push(`- ValueSet (R4): ${f.r4Binding.valueSet}`);
940
+ }
941
+ if (f.r4Binding?.strength) {
942
+ lines.push(`- Strength (R4): \`${f.r4Binding.strength}\``);
943
+ }
944
+ lines.push('');
945
+ }
946
+ }
947
+ }
948
+
949
+ /**
950
+ * Generate markdown report for terminology comparison
951
+ */
952
+ function generateTerminologyReport(findings, commonBindings = []) {
953
+ const lines = [];
954
+
955
+ lines.push('# Terminology Binding Comparison Report');
956
+ lines.push('');
957
+ lines.push(`**Generated:** ${new Date().toISOString()}`);
958
+ lines.push(`**Profiles with Differences:** ${findings.length}`);
959
+ if (commonBindings.length > 0) {
960
+ lines.push(`**Common Bindings Across All Profiles:** ${commonBindings.length}`);
961
+ }
962
+ lines.push('');
963
+ lines.push('This report shows differences in terminology bindings between R4 and R6 profiles.');
964
+ lines.push('');
965
+ lines.push('---');
966
+ lines.push('');
967
+
968
+ // Add common bindings section
969
+ if (commonBindings.length > 0) {
970
+ lines.push('## All Resources');
971
+ lines.push('');
972
+ lines.push('The following binding changes occur in **all** profiles:');
973
+ lines.push('');
974
+
975
+ const groupedCommon = groupFindingsByType(commonBindings);
976
+ appendFindingsToMarkdown(lines, groupedCommon);
977
+
978
+ lines.push('---');
979
+ lines.push('');
980
+ }
981
+
982
+ if (findings.length === 0) {
983
+ lines.push('✅ **No profile-specific binding differences found.**');
984
+ lines.push('');
985
+ return lines.join('\n');
986
+ }
987
+
988
+ for (const profile of findings) {
989
+ lines.push(`## ${profile.profileName}`);
990
+ lines.push('');
991
+ lines.push(`- **R4 URL:** ${profile.r4Url}`);
992
+ lines.push(`- **R6 URL:** ${profile.r6Url}`);
993
+ lines.push(`- **Differences:** ${profile.findings.length}`);
994
+ lines.push('');
995
+
996
+ const groupedFindings = groupFindingsByType(profile.findings);
997
+ appendFindingsToMarkdown(lines, groupedFindings);
998
+
999
+ lines.push('---');
1000
+ lines.push('');
1001
+ }
1002
+
1003
+ return lines.join('\n');
1004
+ }
package/src/index.js CHANGED
@@ -7,6 +7,7 @@ import { spawnProcess } from './utils/process.js';
7
7
  import { generateFshFromPackage } from './generate-fsh.js';
8
8
  import { upgradeSushiToR6 } from './upgrade-sushi.js';
9
9
  import { compareProfiles } from './compare-profiles.js';
10
+ import { compareTerminology } from './compare-terminology.js';
10
11
  import { findRemovedResources } from './utils/removed-resources.js';
11
12
  import { createZip } from './utils/zip.js';
12
13
 
@@ -38,41 +39,53 @@ export async function runMigration(config) {
38
39
  if (config.enableGoFSH) {
39
40
  const shouldRunGoFSH = await checkShouldRunGoFSH(resourcesDir);
40
41
  if (shouldRunGoFSH) {
41
- console.log('\n[1/4] Downloading package and generating FSH...');
42
+ console.log('\n[1/5] Downloading package and generating FSH...');
42
43
  await runGoFSH(context);
43
44
  context.steps.push('gofsh');
44
45
  } else {
45
- console.log('\n[1/4] GoFSH - SKIPPED (Resources directory with sushi-config.yaml already exists)');
46
+ console.log('\n[1/5] GoFSH - SKIPPED (Resources directory with sushi-config.yaml already exists)');
46
47
  }
47
48
  } else {
48
- console.log('\n[1/4] GoFSH - DISABLED in config');
49
+ console.log('\n[1/5] GoFSH - DISABLED in config');
49
50
  }
50
51
 
51
52
  // Step 2: Upgrade to R6
52
53
  const shouldRunUpgrade = await checkShouldRunUpgrade(resourcesR6Dir);
53
54
  if (shouldRunUpgrade) {
54
- console.log('\n[2/4] Upgrading to R6...');
55
+ console.log('\n[2/5] Upgrading to R6...');
55
56
  await runUpgradeToR6(context);
56
57
  context.steps.push('upgrade');
57
58
  } else {
58
- console.log('\n[2/4] Upgrade - SKIPPED (ResourcesR6 directory with sushi-config.yaml already exists)');
59
+ console.log('\n[2/5] Upgrade - SKIPPED (ResourcesR6 directory with sushi-config.yaml already exists)');
59
60
  }
60
61
 
61
62
  // Step 3: Compare profiles
62
- console.log('\n[3/4] Comparing R4 vs R6 profiles...');
63
+ console.log('\n[3/5] Comparing R4 vs R6 profiles...');
63
64
  const compareResults = await runProfileComparison(context);
64
65
  context.steps.push('compare');
65
66
 
66
- // Step 4: Generate report with rules
67
- console.log('\n[4/4] Generating migration report...');
67
+ // Step 4: Generat5] Generating migration report...');
68
68
  const removedResources = await findRemovedResources(resourcesDir);
69
69
  const report = await generateReport(context, compareResults, removedResources);
70
70
  context.steps.push('report');
71
71
 
72
+ // Step 5: Compare terminology bindings
73
+ console.log('\n[5/5] Comparing terminology bindings...');
74
+ let terminologyReport = null;
75
+ try {
76
+ terminologyReport = await runTerminologyComparison(context);
77
+ if (terminologyReport) {
78
+ context.steps.push('terminology');
79
+ }
80
+ } catch (error) {
81
+ console.warn(` Terminology comparison failed: ${error.message}`);
82
+ console.warn(' Continuing without terminology report...');
83
+ }
84
+
72
85
  let exportZipPath = null;
73
86
  if (config.exportZip) {
74
87
  console.log('\nGenerating export ZIP...');
75
- exportZipPath = await exportComparisonZip(context, report);
88
+ exportZipPath = await exportComparisonZip(context, report, terminologyReport);
76
89
  context.steps.push('exportZip');
77
90
  }
78
91
 
@@ -153,6 +166,28 @@ async function runProfileComparison(context) {
153
166
  return [];
154
167
  }
155
168
 
169
+ /**
170
+ * Run terminology comparison
171
+ */
172
+ async function runTerminologyComparison(context) {
173
+ const { resourcesDir, resourcesR6Dir, outputDir, config } = context;
174
+
175
+ const options = {
176
+ debug: config.debug || false,
177
+ };
178
+
179
+ const result = await compareTerminology(resourcesDir, resourcesR6Dir, outputDir, options);
180
+
181
+ if (result) {
182
+ console.log(` ${result.profilesWithDifferences} profile(s) with binding differences`);
183
+ console.log(` Total findings: ${result.totalFindings}`);
184
+ console.log(` Markdown report: ${result.path}`);
185
+ console.log(` JSON report: ${result.jsonPath}`);
186
+ }
187
+
188
+ return result;
189
+ }
190
+
156
191
  /**
157
192
  * Get list of profiles that need to be compared
158
193
  */
@@ -255,7 +290,7 @@ async function generateReport(context, compareResults, removedResources = []) {
255
290
  /**
256
291
  * Create a ZIP export with compare HTML files, report, and run config
257
292
  */
258
- async function exportComparisonZip(context, report) {
293
+ async function exportComparisonZip(context, report, terminologyReport = null) {
259
294
  const { compareDir, outputDir, config } = context;
260
295
  const exportFilename = 'diffyr6-publish.zip';
261
296
  const exportPath = path.join(outputDir, exportFilename);
@@ -282,6 +317,26 @@ async function exportComparisonZip(context, report) {
282
317
  mtime: (await fsp.stat(report.path)).mtime,
283
318
  });
284
319
 
320
+ // Add terminology report if available
321
+ if (terminologyReport && terminologyReport.path) {
322
+ const termContent = await fsp.readFile(terminologyReport.path);
323
+ entries.push({
324
+ name: terminologyReport.filename,
325
+ data: termContent,
326
+ mtime: (await fsp.stat(terminologyReport.path)).mtime,
327
+ });
328
+
329
+ // Add terminology JSON if available
330
+ if (terminologyReport.jsonPath) {
331
+ const termJsonContent = await fsp.readFile(terminologyReport.jsonPath);
332
+ entries.push({
333
+ name: terminologyReport.jsonFilename,
334
+ data: termJsonContent,
335
+ mtime: (await fsp.stat(terminologyReport.jsonPath)).mtime,
336
+ });
337
+ }
338
+ }
339
+
285
340
  // Add config used for the run
286
341
  entries.push({
287
342
  name: 'run-config.json',