@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 +15 -1
- package/package.json +1 -1
- package/src/compare-terminology.js +1004 -0
- package/src/index.js +65 -10
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
|
|
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
|
@@ -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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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:
|
|
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',
|