@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.
- package/README.md +15 -1
- package/config/default-rules.json +14 -0
- package/package.json +1 -1
- package/src/compare-terminology.js +976 -0
- package/src/config.js +47 -3
- package/src/index.js +117 -11
- package/src/upgrade-sushi.js +69 -2
- package/src/utils/update-check.js +128 -0
|
@@ -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
|
+
}
|