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