@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/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@gefyra/diffyr6-cli",
3
+ "version": "1.0.0",
4
+ "description": "FHIR R4 to R6 migration pipeline runner with automated profile comparison and rule-based analysis",
5
+ "main": "src/index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "fhir-r6-migrate": "src/cli.js"
9
+ },
10
+ "exports": {
11
+ ".": "./src/index.js",
12
+ "./config": "./src/config.js",
13
+ "./rules-engine": "./src/rules-engine.js"
14
+ },
15
+ "engines": {
16
+ "node": ">=18.0.0"
17
+ },
18
+ "scripts": {
19
+ "test": "echo \"Tests will be added later\" && exit 0"
20
+ },
21
+ "keywords": [
22
+ "fhir",
23
+ "r4",
24
+ "r6",
25
+ "migration",
26
+ "profile",
27
+ "comparison",
28
+ "gofsh",
29
+ "sushi",
30
+ "healthcare",
31
+ "hl7"
32
+ ],
33
+ "author": "Jonas Schön <js@gefyra.de>",
34
+ "license": "MIT",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "https://github.com/Gefyra/fhir-r6-migration-runner.git"
38
+ },
39
+ "publishConfig": {
40
+ "registry": "https://registry.npmjs.org"
41
+ },
42
+ "peerDependencies": {
43
+ "fsh-sushi": "^3.0.0",
44
+ "gofsh": "^2.5.0"
45
+ },
46
+ "peerDependenciesMeta": {
47
+ "fsh-sushi": {
48
+ "optional": true
49
+ },
50
+ "gofsh": {
51
+ "optional": true
52
+ }
53
+ }
54
+ }
package/src/cli.js ADDED
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runMigration } from './index.js';
4
+ import { loadConfig, createExampleConfig } from './config.js';
5
+ import path from 'path';
6
+ import { fileURLToPath } from 'url';
7
+
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = path.dirname(__filename);
10
+
11
+ async function main() {
12
+ const args = process.argv.slice(2);
13
+
14
+ // Handle --help
15
+ if (args.includes('--help') || args.includes('-h')) {
16
+ printHelp();
17
+ return;
18
+ }
19
+
20
+ // Handle --version
21
+ if (args.includes('--version') || args.includes('-v')) {
22
+ const pkg = await import('../package.json', { assert: { type: 'json' } });
23
+ console.log(pkg.default.version);
24
+ return;
25
+ }
26
+
27
+ // Handle --init
28
+ if (args.includes('--init')) {
29
+ const outputPath = args[args.indexOf('--init') + 1] || 'migration-config.json';
30
+ await createExampleConfig(outputPath);
31
+ console.log(`✓ Created example config at ${outputPath}`);
32
+ return;
33
+ }
34
+
35
+ // Load config
36
+ let configPath = 'migration-config.json';
37
+ const configIndex = args.indexOf('--config');
38
+ if (configIndex !== -1 && args[configIndex + 1]) {
39
+ configPath = args[configIndex + 1];
40
+ }
41
+
42
+ console.log('FHIR R4 to R6 Migration Runner');
43
+ console.log('==============================');
44
+ console.log('');
45
+ console.log(`Loading config from: ${configPath}`);
46
+
47
+ try {
48
+ const config = await loadConfig(configPath);
49
+ const result = await runMigration(config);
50
+
51
+ if (result.success) {
52
+ process.exit(0);
53
+ } else {
54
+ process.exit(1);
55
+ }
56
+ } catch (error) {
57
+ console.error('\n❌ Error:', error.message);
58
+ process.exit(1);
59
+ }
60
+ }
61
+
62
+ function printHelp() {
63
+ console.log(`
64
+ FHIR R4 to R6 Migration Runner
65
+
66
+ Usage:
67
+ fhir-r6-migrate [options]
68
+
69
+ Options:
70
+ --config <path> Path to configuration file (default: migration-config.json)
71
+ --init [path] Create an example configuration file
72
+ --version, -v Show version number
73
+ --help, -h Show this help message
74
+
75
+ Examples:
76
+ # Run migration with default config
77
+ fhir-r6-migrate
78
+
79
+ # Run with custom config
80
+ fhir-r6-migrate --config my-config.json
81
+
82
+ # Create example config
83
+ fhir-r6-migrate --init
84
+
85
+ Configuration:
86
+ See README.md for detailed configuration options and examples.
87
+ `);
88
+ }
89
+
90
+ main().catch(error => {
91
+ console.error('Fatal error:', error);
92
+ process.exit(1);
93
+ });
@@ -0,0 +1,386 @@
1
+ import fsp from 'fs/promises';
2
+ import path from 'path';
3
+ import { createAnimator, spawnProcess } from './utils/process.js';
4
+ import { ensureValidator } from './utils/validator.js';
5
+
6
+ const MAGNIFIER_FRAMES = [
7
+ '🔎 ~~~~~',
8
+ ' 🔎 ~~~~~',
9
+ ' 🔎 ~~~~~',
10
+ ' 🔎 ~~~~~',
11
+ ' 🔎 ~~~~~',
12
+ ' 🔍 ~~~~~',
13
+ ' 🔍 ~~~~~',
14
+ ' 🔍 ~~~~~',
15
+ ' 🔍 ~~~~~',
16
+ '🔍 ~~~~~',
17
+ ];
18
+
19
+ /**
20
+ * Compares R4 and R6 FHIR profiles using the validator CLI
21
+ */
22
+ export async function compareProfiles(r4Dir, r6Dir, destDir, options = {}) {
23
+ const {
24
+ jarPath = 'validator_cli.jar',
25
+ fhirVersion = '4.0',
26
+ debug = false,
27
+ workingDirectory = process.cwd(),
28
+ } = options;
29
+
30
+ await ensureDirectory(r4Dir, 'R4 directory');
31
+ await ensureDirectory(r6Dir, 'R6 directory');
32
+
33
+ // Ensure validator JAR exists (auto-download if jarPath is null)
34
+ const resolvedJarPath = await ensureValidator(jarPath, workingDirectory);
35
+
36
+ await fsp.mkdir(destDir, { recursive: true });
37
+
38
+ const r4Defs = (await collectStructureDefinitions(r4Dir)).sort((a, b) =>
39
+ a.url.localeCompare(b.url)
40
+ );
41
+ const r6Defs = (await collectStructureDefinitions(r6Dir)).sort((a, b) =>
42
+ a.url.localeCompare(b.url)
43
+ );
44
+
45
+ if (r4Defs.length === 0) {
46
+ throw new Error(`No StructureDefinitions found in ${r4Dir}`);
47
+ }
48
+ if (r6Defs.length === 0) {
49
+ throw new Error(`No StructureDefinitions found in ${r6Dir}`);
50
+ }
51
+
52
+ const pairs = buildComparisonPairs(r4Defs, r6Defs);
53
+ if (pairs.length === 0) {
54
+ throw new Error('No matching profile pairs found between R4 and R6');
55
+ }
56
+
57
+ console.log(`Found ${pairs.length} profile pair(s) to compare`);
58
+
59
+ const igPaths = [
60
+ path.join(r4Dir, 'fsh-generated', 'resources'),
61
+ path.join(r6Dir, 'fsh-generated', 'resources'),
62
+ ];
63
+
64
+ // Filter to only existing IG paths
65
+ const validIgPaths = [];
66
+ for (const igPath of igPaths) {
67
+ const stat = await fsp.stat(igPath).catch(() => null);
68
+ if (stat && stat.isDirectory()) {
69
+ validIgPaths.push(igPath);
70
+ }
71
+ }
72
+
73
+ if (validIgPaths.length === 0) {
74
+ console.warn('Warning: No fsh-generated/resources directories found for IG paths');
75
+ }
76
+
77
+ const existingFiles = await collectExistingComparisonFiles(destDir);
78
+
79
+ for (let i = 0; i < pairs.length; i += 1) {
80
+ const pair = pairs[i];
81
+ const label = `[${i + 1}/${pairs.length}] ${pair.displayName}`;
82
+ console.log(`\n${label}`);
83
+ const comparisonFile = buildComparisonFileName(pair.left.url, pair.right.url);
84
+ if (comparisonFile && existingFiles.has(comparisonFile)) {
85
+ console.log(` Skipping ${pair.displayName} (${comparisonFile} exists)`);
86
+ continue;
87
+ }
88
+ await runValidatorCompare({
89
+ jarPath: resolvedJarPath,
90
+ destDir,
91
+ version: fhirVersion,
92
+ leftUrl: pair.left.url,
93
+ rightUrl: pair.right.url,
94
+ workingDirectory,
95
+ spinnerLabel: `Comparing ${pair.displayName}...`,
96
+ igPaths: validIgPaths,
97
+ debug,
98
+ });
99
+ if (comparisonFile) {
100
+ existingFiles.add(comparisonFile);
101
+ }
102
+ console.log(` Done: ${pair.displayName}`);
103
+ }
104
+
105
+ return {
106
+ comparedCount: pairs.length,
107
+ skippedCount: pairs.filter((p) => {
108
+ const fileName = buildComparisonFileName(p.left.url, p.right.url);
109
+ return fileName && existingFiles.has(fileName);
110
+ }).length,
111
+ };
112
+ }
113
+
114
+ async function ensureDirectory(dirPath, label) {
115
+ const stat = await fsp.stat(dirPath).catch(() => null);
116
+ if (!stat || !stat.isDirectory()) {
117
+ throw new Error(`${label} not found: ${dirPath}`);
118
+ }
119
+ }
120
+
121
+
122
+
123
+ async function collectStructureDefinitions(rootDir) {
124
+ const resourcesDir = path.join(rootDir, 'fsh-generated', 'resources');
125
+ const stat = await fsp.stat(resourcesDir).catch(() => null);
126
+ if (!stat || !stat.isDirectory()) {
127
+ return [];
128
+ }
129
+ const files = await collectJsonFiles(resourcesDir);
130
+ const results = [];
131
+ for (const filePath of files) {
132
+ let data;
133
+ try {
134
+ data = JSON.parse(await fsp.readFile(filePath, 'utf8'));
135
+ } catch {
136
+ continue;
137
+ }
138
+ if (!data || data.resourceType !== 'StructureDefinition' || !data.url) {
139
+ continue;
140
+ }
141
+ results.push({
142
+ url: data.url,
143
+ id: data.id || '',
144
+ name: data.name || '',
145
+ filePath,
146
+ });
147
+ }
148
+ return results;
149
+ }
150
+
151
+ async function collectJsonFiles(dir) {
152
+ const results = [];
153
+ async function walk(current) {
154
+ const entries = await fsp.readdir(current, { withFileTypes: true }).catch(() => []);
155
+ for (const entry of entries) {
156
+ const entryPath = path.join(current, entry.name);
157
+ if (entry.isDirectory()) {
158
+ await walk(entryPath);
159
+ } else if (entry.isFile() && entry.name.toLowerCase().endsWith('.json')) {
160
+ results.push(entryPath);
161
+ }
162
+ }
163
+ }
164
+ await walk(dir);
165
+ return results;
166
+ }
167
+
168
+ function buildComparisonPairs(r4Defs, r6Defs) {
169
+ const lookup = buildR4Lookup(r4Defs);
170
+ const pairs = [];
171
+ for (const right of r6Defs) {
172
+ const left = findMatchingR4Definition(right.url, lookup);
173
+ if (!left) {
174
+ console.warn(` No R4 match for ${right.url}, skipping`);
175
+ continue;
176
+ }
177
+ const displayName = extractLastSegment(right.url) || right.name || right.id || right.url;
178
+ pairs.push({
179
+ left,
180
+ right,
181
+ displayName,
182
+ });
183
+ }
184
+ return pairs;
185
+ }
186
+
187
+ function buildR4Lookup(definitions) {
188
+ const byCanonical = new Map();
189
+ const bySegment = new Map();
190
+ for (const def of definitions) {
191
+ const canonicalKey = def.url.toLowerCase();
192
+ byCanonical.set(canonicalKey, def);
193
+ const segment = extractLastSegment(def.url).toLowerCase();
194
+ if (!bySegment.has(segment)) {
195
+ bySegment.set(segment, []);
196
+ }
197
+ bySegment.get(segment).push(def);
198
+ }
199
+ return { byCanonical, bySegment };
200
+ }
201
+
202
+ function findMatchingR4Definition(r6Url, lookup) {
203
+ if (!r6Url) {
204
+ return null;
205
+ }
206
+ const canonicalKey = r6Url.toLowerCase();
207
+ if (lookup.byCanonical.has(canonicalKey)) {
208
+ return lookup.byCanonical.get(canonicalKey);
209
+ }
210
+
211
+ const lastSegment = extractLastSegment(r6Url);
212
+ const variants = generateCanonicalVariants(r6Url, lastSegment);
213
+ for (const candidate of variants) {
214
+ const key = candidate.toLowerCase();
215
+ if (lookup.byCanonical.has(key)) {
216
+ return lookup.byCanonical.get(key);
217
+ }
218
+ }
219
+
220
+ const segmentVariants = generateSegmentVariants(lastSegment);
221
+ for (const seg of segmentVariants) {
222
+ const entries = lookup.bySegment.get(seg);
223
+ if (entries && entries.length === 1) {
224
+ return entries[0];
225
+ }
226
+ }
227
+
228
+ return null;
229
+ }
230
+
231
+ function generateCanonicalVariants(url, lastSegment) {
232
+ if (!lastSegment) {
233
+ return [];
234
+ }
235
+ const variants = new Set();
236
+
237
+ const replacedR6WithR4 = lastSegment.replace(/-?r6$/i, '-R4');
238
+ if (replacedR6WithR4 !== lastSegment) {
239
+ variants.add(replaceLastSegment(url, replacedR6WithR4));
240
+ }
241
+
242
+ const removedR6 = lastSegment.replace(/-?r6$/i, '');
243
+ if (removedR6 !== lastSegment) {
244
+ variants.add(replaceLastSegment(url, removedR6));
245
+ }
246
+
247
+ if (!/-?r4$/i.test(lastSegment)) {
248
+ variants.add(replaceLastSegment(url, `${lastSegment}-R4`));
249
+ variants.add(replaceLastSegment(url, `${lastSegment}R4`));
250
+ } else {
251
+ variants.add(replaceLastSegment(url, lastSegment.replace(/-?r4$/i, '')));
252
+ }
253
+ return [...variants].filter(Boolean);
254
+ }
255
+
256
+ function generateSegmentVariants(segment) {
257
+ if (!segment) {
258
+ return [];
259
+ }
260
+ const variants = new Set();
261
+ const lower = segment.toLowerCase();
262
+ variants.add(lower);
263
+
264
+ if (lower.endsWith('-r6')) {
265
+ variants.add(lower.replace(/-r6$/, '-r4'));
266
+ variants.add(lower.replace(/-r6$/, ''));
267
+ }
268
+
269
+ if (lower.endsWith('r6')) {
270
+ variants.add(lower.replace(/r6$/, 'r4'));
271
+ variants.add(lower.replace(/r6$/, ''));
272
+ }
273
+
274
+ if (lower.endsWith('-r4')) {
275
+ variants.add(lower.replace(/-r4$/, ''));
276
+ } else if (lower.endsWith('r4')) {
277
+ variants.add(lower.replace(/r4$/, ''));
278
+ } else {
279
+ variants.add(`${lower}-r4`);
280
+ variants.add(`${lower}r4`);
281
+ }
282
+ return [...variants].filter(Boolean);
283
+ }
284
+
285
+ function extractLastSegment(url) {
286
+ if (!url) {
287
+ return '';
288
+ }
289
+ const hashIndex = url.lastIndexOf('#');
290
+ const slashIndex = url.lastIndexOf('/');
291
+ const index = Math.max(hashIndex, slashIndex);
292
+ return index >= 0 ? url.slice(index + 1) : url;
293
+ }
294
+
295
+ function replaceLastSegment(url, newSegment) {
296
+ if (!url) {
297
+ return '';
298
+ }
299
+ const hashIndex = url.lastIndexOf('#');
300
+ const slashIndex = url.lastIndexOf('/');
301
+ const index = Math.max(hashIndex, slashIndex);
302
+ if (index === -1) {
303
+ return newSegment;
304
+ }
305
+ return `${url.slice(0, index + 1)}${newSegment}`;
306
+ }
307
+
308
+ async function collectExistingComparisonFiles(destDir) {
309
+ const entries = await fsp.readdir(destDir).catch(() => []);
310
+ return new Set(entries.filter((name) => name.toLowerCase().endsWith('.html')));
311
+ }
312
+
313
+ function buildComparisonFileName(leftUrl, rightUrl) {
314
+ const leftSegment = sanitizeSegment(extractLastSegment(leftUrl));
315
+ const rightSegment = sanitizeSegment(extractLastSegment(rightUrl));
316
+ if (!leftSegment || !rightSegment) {
317
+ return null;
318
+ }
319
+ return `sd-${leftSegment}-${rightSegment}.html`;
320
+ }
321
+
322
+ function sanitizeSegment(value) {
323
+ return (value || '').replace(/[^A-Za-z0-9_-]/g, '');
324
+ }
325
+
326
+ async function runValidatorCompare({
327
+ jarPath,
328
+ destDir,
329
+ version,
330
+ leftUrl,
331
+ rightUrl,
332
+ workingDirectory,
333
+ spinnerLabel,
334
+ igPaths = [],
335
+ debug,
336
+ }) {
337
+ const args = [
338
+ '-Djava.awt.headless=true',
339
+ '-jar',
340
+ jarPath,
341
+ '-compare',
342
+ '-dest',
343
+ destDir,
344
+ '-version',
345
+ version,
346
+ '-right',
347
+ rightUrl,
348
+ '-left',
349
+ leftUrl,
350
+ ];
351
+ igPaths.filter(Boolean).forEach((igPath) => {
352
+ args.push('-ig', igPath);
353
+ });
354
+
355
+ let animator = null;
356
+ if (!debug) {
357
+ animator = createAnimator(spinnerLabel, { frames: MAGNIFIER_FRAMES });
358
+ animator.start();
359
+ } else {
360
+ console.log(` ${spinnerLabel}`);
361
+ }
362
+
363
+ try {
364
+ const spawnOptions = debug ? { stdio: 'inherit' } : {};
365
+ const result = await spawnProcess('java', args, workingDirectory, spawnOptions);
366
+ if (debug && result) {
367
+ // Output already shown via inherit, no need to print again
368
+ }
369
+ } finally {
370
+ if (animator) {
371
+ animator.stop();
372
+ }
373
+ }
374
+ }
375
+
376
+ function printProcessOutput({ stdout, stderr }, leftUrl, rightUrl) {
377
+ const header = `--- Validator output for ${rightUrl} vs ${leftUrl} ---`;
378
+ console.log(header);
379
+ if (stdout && stdout.trim()) {
380
+ console.log(stdout.trimEnd());
381
+ }
382
+ if (stderr && stderr.trim()) {
383
+ console.error(stderr.trimEnd());
384
+ }
385
+ console.log('-'.repeat(header.length));
386
+ }
package/src/config.js ADDED
@@ -0,0 +1,147 @@
1
+ import fsp from 'fs/promises';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import { pathExists } from './utils/fs.js';
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = path.dirname(__filename);
8
+
9
+ export const CONFIG_VERSION = '1.0.0';
10
+
11
+ /**
12
+ * Default configuration values
13
+ */
14
+ export const DEFAULT_CONFIG = {
15
+ configVersion: CONFIG_VERSION,
16
+ packageId: null,
17
+ packageVersion: 'current',
18
+ enableGoFSH: true,
19
+ resourcesDir: 'Resources',
20
+ resourcesR6Dir: 'ResourcesR6',
21
+ compareDir: 'compare',
22
+ outputDir: 'output',
23
+ rulesConfigPath: null,
24
+ validatorJarPath: null,
25
+ workdir: null,
26
+ compareMode: 'incremental',
27
+ };
28
+
29
+ /**
30
+ * Loads and validates a configuration file
31
+ */
32
+ export async function loadConfig(configPath) {
33
+ const raw = await fsp.readFile(configPath, 'utf8');
34
+ const config = JSON.parse(raw);
35
+
36
+ // Validate config version
37
+ validateConfigVersion(config);
38
+
39
+ // Merge with defaults
40
+ const merged = { ...DEFAULT_CONFIG, ...config };
41
+
42
+ // Validate required fields
43
+ validateConfig(merged);
44
+
45
+ return merged;
46
+ }
47
+
48
+ /**
49
+ * Validates the configuration version
50
+ */
51
+ function validateConfigVersion(config) {
52
+ if (!config.configVersion) {
53
+ throw new Error(
54
+ `Missing 'configVersion' field in config. Expected version: ${CONFIG_VERSION}`
55
+ );
56
+ }
57
+
58
+ const [major, minor] = config.configVersion.split('.').map(Number);
59
+ const [expectedMajor] = CONFIG_VERSION.split('.').map(Number);
60
+
61
+ if (major !== expectedMajor) {
62
+ throw new Error(
63
+ `Incompatible config version: ${config.configVersion}. Expected major version: ${expectedMajor}`
64
+ );
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Validates the configuration object
70
+ */
71
+ function validateConfig(config) {
72
+ const errors = [];
73
+
74
+ if (!config.packageId && config.enableGoFSH) {
75
+ errors.push('packageId is required when enableGoFSH is true');
76
+ }
77
+
78
+ if (!config.resourcesDir) {
79
+ errors.push('resourcesDir is required');
80
+ }
81
+
82
+ if (!config.resourcesR6Dir) {
83
+ errors.push('resourcesR6Dir is required');
84
+ }
85
+
86
+ if (!config.compareDir) {
87
+ errors.push('compareDir is required');
88
+ }
89
+
90
+ if (!config.outputDir) {
91
+ errors.push('outputDir is required');
92
+ }
93
+
94
+ if (config.compareMode && !['incremental', 'full'].includes(config.compareMode)) {
95
+ errors.push('compareMode must be either "incremental" or "full"');
96
+ }
97
+
98
+ if (errors.length > 0) {
99
+ throw new Error(`Invalid configuration:\n${errors.map(e => ` - ${e}`).join('\n')}`);
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Creates an example configuration file
105
+ */
106
+ export async function createExampleConfig(outputPath) {
107
+ const example = {
108
+ configVersion: CONFIG_VERSION,
109
+ packageId: 'de.basisprofil.r4#1.5.0',
110
+ packageVersion: '1.5.0',
111
+ enableGoFSH: true,
112
+ resourcesDir: 'Resources',
113
+ resourcesR6Dir: 'ResourcesR6',
114
+ compareDir: 'compare',
115
+ outputDir: 'output',
116
+ rulesConfigPath: null,
117
+ validatorJarPath: null,
118
+ workdir: null,
119
+ compareMode: 'incremental'
120
+ };
121
+
122
+ await fsp.writeFile(
123
+ outputPath,
124
+ JSON.stringify(example, null, 2),
125
+ 'utf8'
126
+ );
127
+ }
128
+
129
+ /**
130
+ * Loads the default rules configuration
131
+ */
132
+ export async function loadDefaultRules() {
133
+ const rulesPath = path.join(__dirname, '..', 'config', 'default-rules.json');
134
+ const raw = await fsp.readFile(rulesPath, 'utf8');
135
+ return JSON.parse(raw);
136
+ }
137
+
138
+ /**
139
+ * Loads rules from a custom path or falls back to default
140
+ */
141
+ export async function loadRules(customPath) {
142
+ if (customPath && await pathExists(customPath)) {
143
+ const raw = await fsp.readFile(customPath, 'utf8');
144
+ return JSON.parse(raw);
145
+ }
146
+ return loadDefaultRules();
147
+ }