@gefyra/diffyr6-cli 1.0.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/LICENSE +21 -0
- package/README.md +447 -0
- package/config/README.md +27 -0
- package/config/default-rules.json +135 -0
- package/config/resources-r4-not-in-r6.json +42 -0
- package/package.json +54 -0
- package/src/cli.js +93 -0
- package/src/compare-profiles.js +386 -0
- package/src/config.js +147 -0
- package/src/generate-fsh.js +457 -0
- package/src/index.js +394 -0
- package/src/rules-engine.js +642 -0
- package/src/upgrade-sushi.js +553 -0
- package/src/utils/fs.js +38 -0
- package/src/utils/html.js +28 -0
- package/src/utils/process.js +101 -0
- package/src/utils/removed-resources.js +135 -0
- package/src/utils/sushi-log.js +46 -0
- package/src/utils/validator.js +103 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import fsp from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileExists, directoryExists } from './utils/fs.js';
|
|
4
|
+
import { loadConfig, loadRules } from './config.js';
|
|
5
|
+
import { evaluateRulesForHtmlFiles } from './rules-engine.js';
|
|
6
|
+
import { spawnProcess } from './utils/process.js';
|
|
7
|
+
import { generateFshFromPackage } from './generate-fsh.js';
|
|
8
|
+
import { upgradeSushiToR6 } from './upgrade-sushi.js';
|
|
9
|
+
import { compareProfiles } from './compare-profiles.js';
|
|
10
|
+
import { findRemovedResources } from './utils/removed-resources.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Main entry point - runs the FHIR R4 to R6 migration pipeline
|
|
14
|
+
*/
|
|
15
|
+
export async function runMigration(config) {
|
|
16
|
+
// Resolve paths
|
|
17
|
+
const workdir = config.workdir ? path.resolve(config.workdir) : process.cwd();
|
|
18
|
+
const resourcesDir = path.resolve(workdir, config.resourcesDir);
|
|
19
|
+
const resourcesR6Dir = path.resolve(workdir, config.resourcesR6Dir);
|
|
20
|
+
const compareDir = path.resolve(workdir, config.compareDir);
|
|
21
|
+
const outputDir = path.resolve(workdir, config.outputDir);
|
|
22
|
+
|
|
23
|
+
// Ensure output directory exists
|
|
24
|
+
await fsp.mkdir(outputDir, { recursive: true });
|
|
25
|
+
|
|
26
|
+
const context = {
|
|
27
|
+
config,
|
|
28
|
+
workdir,
|
|
29
|
+
resourcesDir,
|
|
30
|
+
resourcesR6Dir,
|
|
31
|
+
compareDir,
|
|
32
|
+
outputDir,
|
|
33
|
+
steps: [],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Step 1: GoFSH (if enabled and not already done)
|
|
37
|
+
if (config.enableGoFSH) {
|
|
38
|
+
const shouldRunGoFSH = await checkShouldRunGoFSH(resourcesDir);
|
|
39
|
+
if (shouldRunGoFSH) {
|
|
40
|
+
console.log('\n[1/4] Downloading package and generating FSH...');
|
|
41
|
+
await runGoFSH(context);
|
|
42
|
+
context.steps.push('gofsh');
|
|
43
|
+
} else {
|
|
44
|
+
console.log('\n[1/4] GoFSH - SKIPPED (Resources directory with sushi-config.yaml already exists)');
|
|
45
|
+
}
|
|
46
|
+
} else {
|
|
47
|
+
console.log('\n[1/4] GoFSH - DISABLED in config');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Step 2: Upgrade to R6
|
|
51
|
+
const shouldRunUpgrade = await checkShouldRunUpgrade(resourcesR6Dir);
|
|
52
|
+
if (shouldRunUpgrade) {
|
|
53
|
+
console.log('\n[2/4] Upgrading to R6...');
|
|
54
|
+
await runUpgradeToR6(context);
|
|
55
|
+
context.steps.push('upgrade');
|
|
56
|
+
} else {
|
|
57
|
+
console.log('\n[2/4] Upgrade - SKIPPED (ResourcesR6 directory with sushi-config.yaml already exists)');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Step 3: Compare profiles
|
|
61
|
+
console.log('\n[3/4] Comparing R4 vs R6 profiles...');
|
|
62
|
+
const compareResults = await runProfileComparison(context);
|
|
63
|
+
context.steps.push('compare');
|
|
64
|
+
|
|
65
|
+
// Step 4: Generate report with rules
|
|
66
|
+
console.log('\n[4/4] Generating migration report...');
|
|
67
|
+
const removedResources = await findRemovedResources(resourcesDir);
|
|
68
|
+
const report = await generateReport(context, compareResults, removedResources);
|
|
69
|
+
context.steps.push('report');
|
|
70
|
+
|
|
71
|
+
console.log(`\n✓ Migration complete!`);
|
|
72
|
+
console.log(` Report: ${report.path}`);
|
|
73
|
+
console.log(` Total Score: ${report.score}`);
|
|
74
|
+
console.log(` Findings: ${report.findingsCount}`);
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
success: true,
|
|
78
|
+
steps: context.steps,
|
|
79
|
+
report: report.path,
|
|
80
|
+
score: report.score,
|
|
81
|
+
findingsCount: report.findingsCount,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Check if GoFSH should run (Resources dir doesn't exist or is empty)
|
|
87
|
+
*/
|
|
88
|
+
async function checkShouldRunGoFSH(resourcesDir) {
|
|
89
|
+
const sushiConfigPath = path.join(resourcesDir, 'sushi-config.yaml');
|
|
90
|
+
return !(await fileExists(sushiConfigPath));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Check if upgrade should run (ResourcesR6 dir doesn't exist or is empty)
|
|
95
|
+
*/
|
|
96
|
+
async function checkShouldRunUpgrade(resourcesR6Dir) {
|
|
97
|
+
const sushiConfigPath = path.join(resourcesR6Dir, 'sushi-config.yaml');
|
|
98
|
+
return !(await fileExists(sushiConfigPath));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Run GoFSH to generate FSH from package
|
|
103
|
+
*/
|
|
104
|
+
async function runGoFSH(context) {
|
|
105
|
+
const { config, resourcesDir } = context;
|
|
106
|
+
const packageSpec = config.packageVersion
|
|
107
|
+
? `${config.packageId}#${config.packageVersion}`
|
|
108
|
+
: config.packageId;
|
|
109
|
+
|
|
110
|
+
await generateFshFromPackage(packageSpec, resourcesDir);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Run SUSHI upgrade to R6
|
|
115
|
+
*/
|
|
116
|
+
async function runUpgradeToR6(context) {
|
|
117
|
+
const { resourcesDir, config } = context;
|
|
118
|
+
const sushiExecutable = config.sushiExecutable || 'sushi -s';
|
|
119
|
+
await upgradeSushiToR6(resourcesDir, sushiExecutable);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Run profile comparison
|
|
124
|
+
*/
|
|
125
|
+
async function runProfileComparison(context) {
|
|
126
|
+
const { config, resourcesDir, resourcesR6Dir, compareDir, workdir } = context;
|
|
127
|
+
|
|
128
|
+
// Ensure compare directory exists
|
|
129
|
+
await fsp.mkdir(compareDir, { recursive: true });
|
|
130
|
+
|
|
131
|
+
const options = {
|
|
132
|
+
jarPath: config.validatorJarPath || null,
|
|
133
|
+
fhirVersion: '4.0',
|
|
134
|
+
debug: config.debug || false,
|
|
135
|
+
workingDirectory: workdir,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const result = await compareProfiles(resourcesDir, resourcesR6Dir, compareDir, options);
|
|
139
|
+
console.log(` Compared ${result.comparedCount} profile pair(s)`);
|
|
140
|
+
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Get list of profiles that need to be compared
|
|
146
|
+
*/
|
|
147
|
+
async function getProfilesToCompare(resourcesDir, resourcesR6Dir, compareDir, compareMode) {
|
|
148
|
+
const r4Profiles = await listProfiles(resourcesDir);
|
|
149
|
+
const r6Profiles = await listProfiles(resourcesR6Dir);
|
|
150
|
+
|
|
151
|
+
// Find common profiles
|
|
152
|
+
const commonProfiles = r4Profiles.filter(p => r6Profiles.includes(p));
|
|
153
|
+
|
|
154
|
+
if (compareMode === 'full') {
|
|
155
|
+
return commonProfiles;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Incremental mode: only compare missing files
|
|
159
|
+
const existing = await listExistingCompareFiles(compareDir);
|
|
160
|
+
return commonProfiles.filter(profile => {
|
|
161
|
+
const expectedFile = `sd-${profile}-${profile}.html`;
|
|
162
|
+
return !existing.includes(expectedFile);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* List profile names from a resources directory
|
|
168
|
+
*/
|
|
169
|
+
async function listProfiles(resourcesDir) {
|
|
170
|
+
const resourcesPath = path.join(resourcesDir, 'fsh-generated', 'resources');
|
|
171
|
+
const exists = await directoryExists(resourcesPath);
|
|
172
|
+
if (!exists) {
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const files = await fsp.readdir(resourcesPath);
|
|
177
|
+
const profiles = [];
|
|
178
|
+
|
|
179
|
+
for (const file of files) {
|
|
180
|
+
if (!file.startsWith('StructureDefinition-') || !file.endsWith('.json')) {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const filePath = path.join(resourcesPath, file);
|
|
184
|
+
const content = await fsp.readFile(filePath, 'utf8');
|
|
185
|
+
const data = JSON.parse(content);
|
|
186
|
+
|
|
187
|
+
if (data.resourceType === 'StructureDefinition' && data.id) {
|
|
188
|
+
profiles.push(data.id);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return profiles;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* List existing compare HTML files
|
|
197
|
+
*/
|
|
198
|
+
async function listExistingCompareFiles(compareDir) {
|
|
199
|
+
const exists = await directoryExists(compareDir);
|
|
200
|
+
if (!exists) {
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const files = await fsp.readdir(compareDir);
|
|
205
|
+
return files.filter(f => f.endsWith('.html'));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Generate markdown report with rules evaluation
|
|
210
|
+
*/
|
|
211
|
+
async function generateReport(context, compareResults, removedResources = []) {
|
|
212
|
+
const { compareDir, outputDir, config } = context;
|
|
213
|
+
|
|
214
|
+
// Load rules
|
|
215
|
+
const rules = await loadRules(config.rulesConfigPath);
|
|
216
|
+
|
|
217
|
+
// Read all HTML files from compare directory
|
|
218
|
+
const htmlFiles = await readCompareHtmlFiles(compareDir);
|
|
219
|
+
|
|
220
|
+
// Evaluate rules
|
|
221
|
+
const findings = evaluateRulesForHtmlFiles(htmlFiles, rules);
|
|
222
|
+
|
|
223
|
+
// Calculate total score
|
|
224
|
+
const totalScore = findings.reduce((sum, f) => sum + (f.value || 0), 0);
|
|
225
|
+
|
|
226
|
+
// Generate markdown
|
|
227
|
+
const timestamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-');
|
|
228
|
+
const reportFilename = `migration-report-${timestamp}.md`;
|
|
229
|
+
const reportPath = path.join(outputDir, reportFilename);
|
|
230
|
+
|
|
231
|
+
const markdown = generateMarkdown(findings, totalScore, rules, removedResources);
|
|
232
|
+
await fsp.writeFile(reportPath, markdown, 'utf8');
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
path: reportPath,
|
|
236
|
+
score: totalScore,
|
|
237
|
+
findingsCount: findings.length,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Read all HTML comparison files
|
|
243
|
+
*/
|
|
244
|
+
async function readCompareHtmlFiles(compareDir) {
|
|
245
|
+
const exists = await directoryExists(compareDir);
|
|
246
|
+
if (!exists) {
|
|
247
|
+
return [];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const files = await fsp.readdir(compareDir);
|
|
251
|
+
const htmlFiles = [];
|
|
252
|
+
|
|
253
|
+
for (const file of files) {
|
|
254
|
+
if (!file.endsWith('.html')) {
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
const filePath = path.join(compareDir, file);
|
|
258
|
+
const content = await fsp.readFile(filePath, 'utf8');
|
|
259
|
+
htmlFiles.push({
|
|
260
|
+
filename: file,
|
|
261
|
+
content,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return htmlFiles;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Generate markdown report from findings
|
|
270
|
+
*/
|
|
271
|
+
function generateMarkdown(findings, totalScore, rules, removedResources = []) {
|
|
272
|
+
const lines = [];
|
|
273
|
+
|
|
274
|
+
lines.push('# FHIR R4 to R6 Migration Report');
|
|
275
|
+
lines.push('');
|
|
276
|
+
lines.push(`**Generated:** ${new Date().toISOString()}`);
|
|
277
|
+
lines.push(`**Total Findings:** ${findings.length}`);
|
|
278
|
+
lines.push(`**Migration Score:** ${totalScore}`);
|
|
279
|
+
lines.push(`**Resources Removed in R6:** ${removedResources.length}`);
|
|
280
|
+
lines.push('');
|
|
281
|
+
lines.push('---');
|
|
282
|
+
lines.push('');
|
|
283
|
+
|
|
284
|
+
// Removed Resources Section
|
|
285
|
+
lines.push('## ⚠️ Resources Removed in R6');
|
|
286
|
+
lines.push('');
|
|
287
|
+
|
|
288
|
+
if (removedResources.length > 0) {
|
|
289
|
+
lines.push('The following resources/profiles exist in R4 but were completely removed in R6:');
|
|
290
|
+
lines.push('');
|
|
291
|
+
|
|
292
|
+
for (const { profile, resource } of removedResources) {
|
|
293
|
+
lines.push(`- **${profile}** (${resource})`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
lines.push('');
|
|
297
|
+
lines.push('> **Critical:** These resources cannot be migrated automatically. You must redesign data capture using alternative R6 resources.');
|
|
298
|
+
} else {
|
|
299
|
+
lines.push('✅ **No profiles found that are based on resource types removed in R6.**');
|
|
300
|
+
lines.push('');
|
|
301
|
+
lines.push('Your R4 profiles do not use any of the 38 resource types that were removed in FHIR R6 (such as Media, CatalogEntry, DocumentManifest, etc.).');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
lines.push('');
|
|
305
|
+
lines.push('---');
|
|
306
|
+
lines.push('');
|
|
307
|
+
|
|
308
|
+
// Group by profile
|
|
309
|
+
const byProfile = new Map();
|
|
310
|
+
for (const finding of findings) {
|
|
311
|
+
const profile = extractProfileName(finding.file);
|
|
312
|
+
if (!byProfile.has(profile)) {
|
|
313
|
+
byProfile.set(profile, []);
|
|
314
|
+
}
|
|
315
|
+
byProfile.get(profile).push(finding);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Sort profiles by name
|
|
319
|
+
const sortedProfiles = Array.from(byProfile.keys()).sort();
|
|
320
|
+
|
|
321
|
+
for (const profile of sortedProfiles) {
|
|
322
|
+
const profileFindings = byProfile.get(profile);
|
|
323
|
+
const profileScore = profileFindings.reduce((sum, f) => sum + (f.value || 0), 0);
|
|
324
|
+
|
|
325
|
+
lines.push(`## ${profile}`);
|
|
326
|
+
lines.push('');
|
|
327
|
+
lines.push(`**Score:** ${profileScore} | **Findings:** ${profileFindings.length}`);
|
|
328
|
+
lines.push('');
|
|
329
|
+
|
|
330
|
+
// Group by rule group
|
|
331
|
+
const byGroup = new Map();
|
|
332
|
+
for (const finding of profileFindings) {
|
|
333
|
+
const group = finding.group || 'Other';
|
|
334
|
+
if (!byGroup.has(group)) {
|
|
335
|
+
byGroup.set(group, []);
|
|
336
|
+
}
|
|
337
|
+
byGroup.get(group).push(finding);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Sort groups by groupOrder
|
|
341
|
+
const sortedGroups = Array.from(byGroup.keys()).sort((a, b) => {
|
|
342
|
+
const findingsA = byGroup.get(a);
|
|
343
|
+
const findingsB = byGroup.get(b);
|
|
344
|
+
const orderA = findingsA[0]?.groupOrder ?? Number.MAX_SAFE_INTEGER;
|
|
345
|
+
const orderB = findingsB[0]?.groupOrder ?? Number.MAX_SAFE_INTEGER;
|
|
346
|
+
return orderA - orderB;
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
for (const group of sortedGroups) {
|
|
350
|
+
const groupFindings = byGroup.get(group);
|
|
351
|
+
|
|
352
|
+
lines.push(`### ${group}`);
|
|
353
|
+
lines.push('');
|
|
354
|
+
|
|
355
|
+
// Find description from first finding
|
|
356
|
+
const description = groupFindings[0]?.description;
|
|
357
|
+
if (description) {
|
|
358
|
+
lines.push(`*${description}*`);
|
|
359
|
+
lines.push('');
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Sort findings by rank
|
|
363
|
+
const sortedFindings = groupFindings.sort((a, b) => {
|
|
364
|
+
const rankA = a.rank ?? Number.MAX_SAFE_INTEGER;
|
|
365
|
+
const rankB = b.rank ?? Number.MAX_SAFE_INTEGER;
|
|
366
|
+
return rankA - rankB;
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
for (const finding of sortedFindings) {
|
|
370
|
+
lines.push(`- ${finding.text} *(Score: ${finding.value || 0})*`);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
lines.push('');
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
lines.push('---');
|
|
378
|
+
lines.push('');
|
|
379
|
+
lines.push(`**Final Migration Score:** ${totalScore}`);
|
|
380
|
+
lines.push('');
|
|
381
|
+
lines.push('*Lower scores indicate fewer migration challenges. Review high-scoring sections carefully.*');
|
|
382
|
+
lines.push('');
|
|
383
|
+
|
|
384
|
+
return lines.join('\n');
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Extract profile name from filename
|
|
389
|
+
*/
|
|
390
|
+
function extractProfileName(filename) {
|
|
391
|
+
// sd-ProfileName-ProfileNameR6.html -> ProfileName
|
|
392
|
+
const match = filename.match(/^(?:sd-)?(.+?)(?:-\w+)?\.html$/);
|
|
393
|
+
return match ? match[1] : filename.replace('.html', '');
|
|
394
|
+
}
|