@gefyra/diffyr6-cli 1.0.0 → 1.0.1

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/src/index.js CHANGED
@@ -1,394 +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
- }
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
+ }