@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/src/config.js CHANGED
@@ -6,7 +6,7 @@ import { pathExists } from './utils/fs.js';
6
6
  const __filename = fileURLToPath(import.meta.url);
7
7
  const __dirname = path.dirname(__filename);
8
8
 
9
- export const CONFIG_VERSION = '1.0.1';
9
+ export const CONFIG_VERSION = '1.0.2';
10
10
 
11
11
  /**
12
12
  * Default configuration values
@@ -25,6 +25,7 @@ export const DEFAULT_CONFIG = {
25
25
  workdir: null,
26
26
  compareMode: 'incremental',
27
27
  exportZip: true,
28
+ skipTerminologyReport: false,
28
29
  };
29
30
 
30
31
  /**
@@ -32,7 +33,17 @@ export const DEFAULT_CONFIG = {
32
33
  */
33
34
  export async function loadConfig(configPath) {
34
35
  const raw = await fsp.readFile(configPath, 'utf8');
35
- const config = JSON.parse(raw);
36
+ let config = JSON.parse(raw);
37
+ const originalVersion = config.configVersion;
38
+
39
+ // Migrate config if needed
40
+ config = migrateConfig(config);
41
+
42
+ // Write back to file if migration occurred
43
+ if (config.configVersion !== originalVersion) {
44
+ await fsp.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
45
+ console.log(` Config file updated to version ${config.configVersion}`);
46
+ }
36
47
 
37
48
  // Validate config version
38
49
  validateConfigVersion(config);
@@ -46,6 +57,34 @@ export async function loadConfig(configPath) {
46
57
  return merged;
47
58
  }
48
59
 
60
+ /**
61
+ * Migrates configuration from older versions to the current version
62
+ */
63
+ function migrateConfig(config) {
64
+ if (!config.configVersion) {
65
+ // Very old config without version - add all new fields
66
+ console.log(' Migrating config from pre-1.0.0 to current version...');
67
+ config.configVersion = CONFIG_VERSION;
68
+ if (config.skipTerminologyReport === undefined) {
69
+ config.skipTerminologyReport = false;
70
+ }
71
+ return config;
72
+ }
73
+
74
+ const [major, minor, patch] = config.configVersion.split('.').map(Number);
75
+
76
+ // Migrate from 1.0.0 or 1.0.1 to 1.0.2
77
+ if (major === 1 && minor === 0 && (patch === 0 || patch === 1)) {
78
+ console.log(` Migrating config from ${config.configVersion} to ${CONFIG_VERSION}...`);
79
+ if (config.skipTerminologyReport === undefined) {
80
+ config.skipTerminologyReport = false;
81
+ }
82
+ config.configVersion = CONFIG_VERSION;
83
+ }
84
+
85
+ return config;
86
+ }
87
+
49
88
  /**
50
89
  * Validates the configuration version
51
90
  */
@@ -99,6 +138,10 @@ function validateConfig(config) {
99
138
  if (typeof config.exportZip !== 'boolean') {
100
139
  errors.push('exportZip must be a boolean');
101
140
  }
141
+
142
+ if (typeof config.skipTerminologyReport !== 'boolean') {
143
+ errors.push('skipTerminologyReport must be a boolean');
144
+ }
102
145
 
103
146
  if (errors.length > 0) {
104
147
  throw new Error(`Invalid configuration:\n${errors.map(e => ` - ${e}`).join('\n')}`);
@@ -122,7 +165,8 @@ export async function createExampleConfig(outputPath) {
122
165
  validatorJarPath: null,
123
166
  workdir: null,
124
167
  compareMode: 'incremental',
125
- exportZip: true
168
+ exportZip: true,
169
+ skipTerminologyReport: false
126
170
  };
127
171
 
128
172
  await fsp.writeFile(
package/src/index.js CHANGED
@@ -7,13 +7,18 @@ import { spawnProcess } from './utils/process.js';
7
7
  import { generateFshFromPackage } from './generate-fsh.js';
8
8
  import { upgradeSushiToR6 } from './upgrade-sushi.js';
9
9
  import { compareProfiles } from './compare-profiles.js';
10
+ import { compareTerminology, hasSnapshots, runSushiWithSnapshots } from './compare-terminology.js';
10
11
  import { findRemovedResources } from './utils/removed-resources.js';
11
12
  import { createZip } from './utils/zip.js';
13
+ import { checkForUpdates } from './utils/update-check.js';
12
14
 
13
15
  /**
14
16
  * Main entry point - runs the FHIR R4 to R6 migration pipeline
15
17
  */
16
18
  export async function runMigration(config) {
19
+ // Check for updates
20
+ await checkForUpdates();
21
+
17
22
  // Resolve paths
18
23
  const workdir = config.workdir ? path.resolve(config.workdir) : process.cwd();
19
24
  const resourcesDir = path.resolve(workdir, config.resourcesDir);
@@ -38,41 +43,62 @@ export async function runMigration(config) {
38
43
  if (config.enableGoFSH) {
39
44
  const shouldRunGoFSH = await checkShouldRunGoFSH(resourcesDir);
40
45
  if (shouldRunGoFSH) {
41
- console.log('\n[1/4] Downloading package and generating FSH...');
46
+ console.log('\n[1/6] Downloading package and generating FSH...');
42
47
  await runGoFSH(context);
43
48
  context.steps.push('gofsh');
44
49
  } else {
45
- console.log('\n[1/4] GoFSH - SKIPPED (Resources directory with sushi-config.yaml already exists)');
50
+ console.log('\n[1/6] GoFSH - SKIPPED (Resources directory with sushi-config.yaml already exists)');
46
51
  }
47
52
  } else {
48
- console.log('\n[1/4] GoFSH - DISABLED in config');
53
+ console.log('\n[1/6] GoFSH - DISABLED in config');
49
54
  }
50
55
 
51
56
  // Step 2: Upgrade to R6
52
57
  const shouldRunUpgrade = await checkShouldRunUpgrade(resourcesR6Dir);
53
58
  if (shouldRunUpgrade) {
54
- console.log('\n[2/4] Upgrading to R6...');
59
+ console.log('\n[2/6] Upgrading to R6...');
55
60
  await runUpgradeToR6(context);
56
61
  context.steps.push('upgrade');
57
62
  } else {
58
- console.log('\n[2/4] Upgrade - SKIPPED (ResourcesR6 directory with sushi-config.yaml already exists)');
63
+ console.log('\n[2/6] Upgrade - SKIPPED (ResourcesR6 directory with sushi-config.yaml already exists)');
59
64
  }
60
65
 
61
- // Step 3: Compare profiles
62
- console.log('\n[3/4] Comparing R4 vs R6 profiles...');
66
+ // Step 3: Build snapshots for both R4 and R6
67
+ console.log('\n[3/6] Building snapshots with SUSHI...');
68
+ await runSnapshotBuild(context);
69
+ context.steps.push('snapshots');
70
+
71
+ // Step 4: Compare profiles
72
+ console.log('\n[4/6] Comparing R4 vs R6 profiles...');
63
73
  const compareResults = await runProfileComparison(context);
64
74
  context.steps.push('compare');
65
75
 
66
- // Step 4: Generate report with rules
67
- console.log('\n[4/4] Generating migration report...');
76
+ // Step 5: Generat6] Generating migration report...');
68
77
  const removedResources = await findRemovedResources(resourcesDir);
69
78
  const report = await generateReport(context, compareResults, removedResources);
70
79
  context.steps.push('report');
71
80
 
81
+ // Step 6: Compare terminology bindings
82
+ let terminologyReport = null;
83
+ if (config.skipTerminologyReport) {
84
+ console.log('\n[6/6] Terminology comparison - SKIPPED (skipTerminologyReport is enabled)');
85
+ } else {
86
+ console.log('\n[6/6] Comparing terminology bindings...');
87
+ try {
88
+ terminologyReport = await runTerminologyComparison(context);
89
+ if (terminologyReport) {
90
+ context.steps.push('terminology');
91
+ }
92
+ } catch (error) {
93
+ console.warn(` Terminology comparison failed: ${error.message}`);
94
+ console.warn(' Continuing without terminology report...');
95
+ }
96
+ }
97
+
72
98
  let exportZipPath = null;
73
99
  if (config.exportZip) {
74
100
  console.log('\nGenerating export ZIP...');
75
- exportZipPath = await exportComparisonZip(context, report);
101
+ exportZipPath = await exportComparisonZip(context, report, terminologyReport);
76
102
  context.steps.push('exportZip');
77
103
  }
78
104
 
@@ -131,6 +157,44 @@ async function runUpgradeToR6(context) {
131
157
  await upgradeSushiToR6(resourcesDir, sushiExecutable);
132
158
  }
133
159
 
160
+ /**
161
+ * Build snapshots for both R4 and R6 projects with sushi -s
162
+ */
163
+ async function runSnapshotBuild(context) {
164
+ const { resourcesDir, resourcesR6Dir, config } = context;
165
+ const debug = config.debug || false;
166
+
167
+ console.log(' Checking for existing snapshots...');
168
+
169
+ const resourcesDirHasSnapshots = await hasSnapshots(resourcesDir);
170
+ const resourcesR6DirHasSnapshots = await hasSnapshots(resourcesR6Dir);
171
+
172
+ if (resourcesDirHasSnapshots && resourcesR6DirHasSnapshots) {
173
+ console.log(' Snapshots already exist in both directories, skipping SUSHI build');
174
+ return;
175
+ }
176
+
177
+ console.log(' Building snapshots with SUSHI...');
178
+
179
+ try {
180
+ if (!resourcesDirHasSnapshots) {
181
+ await runSushiWithSnapshots(resourcesDir, debug);
182
+ } else {
183
+ console.log(` Skipping ${path.basename(resourcesDir)} (snapshots already exist)`);
184
+ }
185
+
186
+ if (!resourcesR6DirHasSnapshots) {
187
+ await runSushiWithSnapshots(resourcesR6Dir, debug);
188
+ } else {
189
+ console.log(` Skipping ${path.basename(resourcesR6Dir)} (snapshots already exist)`);
190
+ }
191
+
192
+ console.log(' Snapshots built successfully');
193
+ } catch (error) {
194
+ throw new Error(`Failed to build snapshots: ${error.message}`);
195
+ }
196
+ }
197
+
134
198
  /**
135
199
  * Run profile comparison
136
200
  */
@@ -153,6 +217,28 @@ async function runProfileComparison(context) {
153
217
  return [];
154
218
  }
155
219
 
220
+ /**
221
+ * Run terminology comparison
222
+ */
223
+ async function runTerminologyComparison(context) {
224
+ const { resourcesDir, resourcesR6Dir, outputDir, config } = context;
225
+
226
+ const options = {
227
+ debug: config.debug || false,
228
+ };
229
+
230
+ const result = await compareTerminology(resourcesDir, resourcesR6Dir, outputDir, options);
231
+
232
+ if (result) {
233
+ console.log(` ${result.profilesWithDifferences} profile(s) with binding differences`);
234
+ console.log(` Total findings: ${result.totalFindings}`);
235
+ console.log(` Markdown report: ${result.path}`);
236
+ console.log(` JSON report: ${result.jsonPath}`);
237
+ }
238
+
239
+ return result;
240
+ }
241
+
156
242
  /**
157
243
  * Get list of profiles that need to be compared
158
244
  */
@@ -255,7 +341,7 @@ async function generateReport(context, compareResults, removedResources = []) {
255
341
  /**
256
342
  * Create a ZIP export with compare HTML files, report, and run config
257
343
  */
258
- async function exportComparisonZip(context, report) {
344
+ async function exportComparisonZip(context, report, terminologyReport = null) {
259
345
  const { compareDir, outputDir, config } = context;
260
346
  const exportFilename = 'diffyr6-publish.zip';
261
347
  const exportPath = path.join(outputDir, exportFilename);
@@ -282,6 +368,26 @@ async function exportComparisonZip(context, report) {
282
368
  mtime: (await fsp.stat(report.path)).mtime,
283
369
  });
284
370
 
371
+ // Add terminology report if available
372
+ if (terminologyReport && terminologyReport.path) {
373
+ const termContent = await fsp.readFile(terminologyReport.path);
374
+ entries.push({
375
+ name: terminologyReport.filename,
376
+ data: termContent,
377
+ mtime: (await fsp.stat(terminologyReport.path)).mtime,
378
+ });
379
+
380
+ // Add terminology JSON if available
381
+ if (terminologyReport.jsonPath) {
382
+ const termJsonContent = await fsp.readFile(terminologyReport.jsonPath);
383
+ entries.push({
384
+ name: terminologyReport.jsonFilename,
385
+ data: termJsonContent,
386
+ mtime: (await fsp.stat(terminologyReport.jsonPath)).mtime,
387
+ });
388
+ }
389
+ }
390
+
285
391
  // Add config used for the run
286
392
  entries.push({
287
393
  name: 'run-config.json',
@@ -7,7 +7,7 @@ import { parseSushiLog } from './utils/sushi-log.js';
7
7
 
8
8
  const SOURCE_VERSION = '4.0.1';
9
9
  const TARGET_VERSION = '6.0.0-ballot3';
10
- const MAX_ITERATIONS = 25;
10
+ const MAX_ITERATIONS = 10;
11
11
  const SNOMED_CT_ERROR_TEXT = 'Resolved value "SNOMED_CT" is not a valid URI';
12
12
 
13
13
  /**
@@ -248,7 +248,11 @@ async function runSushiUntilSuccess(targetDir, sushiExecutable) {
248
248
  }
249
249
  console.log(` Commented out lines in ${modifications} file(s)`);
250
250
  }
251
- throw new Error(`SUSHI failed after ${MAX_ITERATIONS} attempts`);
251
+ throw new Error(
252
+ `SUSHI failed after ${MAX_ITERATIONS} iterations. ` +
253
+ `Please fix the remaining SUSHI errors manually in the ResourcesR6 directory ` +
254
+ `and then run 'sushi -s' to build the snapshots.`
255
+ );
252
256
  }
253
257
 
254
258
  async function runSushi(executable, targetDir) {
@@ -371,6 +375,69 @@ function expandContainsBlockLines(lines, lineNumbers) {
371
375
 
372
376
  const currentLine = lines[idx].trim();
373
377
 
378
+ // Handle multi-line string literals (e.g., ^comment, ^description, etc.)
379
+ // Check if line contains = followed by a quote that's not closed
380
+ const hasStringAssignment = currentLine.match(/=\s*(""")?(")?/);
381
+ if (hasStringAssignment) {
382
+ const tripleQuote = hasStringAssignment[1]; // """
383
+ const singleQuote = hasStringAssignment[2]; // "
384
+
385
+ if (tripleQuote) {
386
+ // Triple-quoted string - find closing """
387
+ const firstTriplePos = currentLine.indexOf('"""');
388
+ const secondTriplePos = currentLine.indexOf('"""', firstTriplePos + 3);
389
+
390
+ if (secondTriplePos === -1) {
391
+ // No closing """ on this line - include following lines
392
+ let nextIdx = idx + 1;
393
+ while (nextIdx < lines.length) {
394
+ expanded.add(nextIdx + 1);
395
+ if (lines[nextIdx].includes('"""')) {
396
+ break;
397
+ }
398
+ nextIdx++;
399
+ // Safety limit to prevent infinite loops
400
+ if (nextIdx - idx > 100) break;
401
+ }
402
+ }
403
+ } else if (singleQuote) {
404
+ // Single-quoted string - check if it's closed on the same line
405
+ const firstQuotePos = currentLine.indexOf('"');
406
+ let secondQuotePos = -1;
407
+
408
+ // Find closing quote that's not escaped
409
+ for (let i = firstQuotePos + 1; i < currentLine.length; i++) {
410
+ if (currentLine[i] === '"' && currentLine[i - 1] !== '\\') {
411
+ secondQuotePos = i;
412
+ break;
413
+ }
414
+ }
415
+
416
+ if (secondQuotePos === -1) {
417
+ // No closing " on this line - include following lines
418
+ let nextIdx = idx + 1;
419
+ while (nextIdx < lines.length) {
420
+ expanded.add(nextIdx + 1);
421
+ // Check for unescaped closing quote
422
+ const nextLine = lines[nextIdx];
423
+ let foundClosing = false;
424
+ for (let i = 0; i < nextLine.length; i++) {
425
+ if (nextLine[i] === '"' && (i === 0 || nextLine[i - 1] !== '\\')) {
426
+ foundClosing = true;
427
+ break;
428
+ }
429
+ }
430
+ if (foundClosing) {
431
+ break;
432
+ }
433
+ nextIdx++;
434
+ // Safety limit to prevent infinite loops
435
+ if (nextIdx - idx > 100) break;
436
+ }
437
+ }
438
+ }
439
+ }
440
+
374
441
  if (currentLine.includes(' contains') || currentLine.endsWith(' and')) {
375
442
  let nextIdx = idx + 1;
376
443
  while (nextIdx < lines.length) {
@@ -0,0 +1,128 @@
1
+ import https from 'https';
2
+ import { fileURLToPath } from 'url';
3
+ import { readFileSync } from 'fs';
4
+ import path from 'path';
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = path.dirname(__filename);
8
+
9
+ /**
10
+ * Gets the current package version from package.json
11
+ */
12
+ function getCurrentVersion() {
13
+ const packageJsonPath = path.join(__dirname, '..', '..', 'package.json');
14
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
15
+ return packageJson.version;
16
+ }
17
+
18
+ /**
19
+ * Fetches the latest version from NPM registry
20
+ */
21
+ function getLatestVersion(packageName) {
22
+ return new Promise((resolve, reject) => {
23
+ const options = {
24
+ hostname: 'registry.npmjs.org',
25
+ port: 443,
26
+ path: `/${packageName}`,
27
+ method: 'GET',
28
+ headers: {
29
+ 'Accept': 'application/json',
30
+ },
31
+ timeout: 3000,
32
+ };
33
+
34
+ const req = https.request(options, (res) => {
35
+ let data = '';
36
+
37
+ res.on('data', (chunk) => {
38
+ data += chunk;
39
+ });
40
+
41
+ res.on('end', () => {
42
+ try {
43
+ if (res.statusCode === 200) {
44
+ const json = JSON.parse(data);
45
+ resolve(json['dist-tags']?.latest || null);
46
+ } else {
47
+ resolve(null);
48
+ }
49
+ } catch (error) {
50
+ resolve(null);
51
+ }
52
+ });
53
+ });
54
+
55
+ req.on('error', () => {
56
+ resolve(null);
57
+ });
58
+
59
+ req.on('timeout', () => {
60
+ req.destroy();
61
+ resolve(null);
62
+ });
63
+
64
+ req.end();
65
+ });
66
+ }
67
+
68
+ /**
69
+ * Compares two semantic versions
70
+ * Returns true if newVersion is greater than currentVersion
71
+ */
72
+ function isNewerVersion(currentVersion, newVersion) {
73
+ if (!currentVersion || !newVersion) return false;
74
+
75
+ const current = currentVersion.split('.').map(Number);
76
+ const latest = newVersion.split('.').map(Number);
77
+
78
+ for (let i = 0; i < 3; i++) {
79
+ if (latest[i] > current[i]) return true;
80
+ if (latest[i] < current[i]) return false;
81
+ }
82
+
83
+ return false;
84
+ }
85
+
86
+ /**
87
+ * Creates a boxed update notification message
88
+ */
89
+ function createUpdateNotification(currentVersion, latestVersion, packageName) {
90
+ const lines = [
91
+ '',
92
+ '╔════════════════════════════════════════════════════════════════╗',
93
+ '║ ║',
94
+ '║ UPDATE AVAILABLE ║',
95
+ '║ ║',
96
+ `║ Current version: ${currentVersion.padEnd(43)} ║`,
97
+ `║ Latest version: ${latestVersion.padEnd(43)} ║`,
98
+ '║ ║',
99
+ '║ Run one of the following commands to update: ║',
100
+ '║ ║',
101
+ `║ npm update -g ${packageName.padEnd(42)} ║`,
102
+ `║ npm install -g ${packageName}@latest`.padEnd(65) + '║',
103
+ '║ ║',
104
+ '╚════════════════════════════════════════════════════════════════╝',
105
+ '',
106
+ ];
107
+
108
+ return lines.join('\n');
109
+ }
110
+
111
+ /**
112
+ * Checks for updates and displays notification if available
113
+ * This function is non-blocking and will not throw errors
114
+ */
115
+ export async function checkForUpdates() {
116
+ try {
117
+ const packageName = '@gefyra/diffyr6-cli';
118
+ const currentVersion = getCurrentVersion();
119
+ const latestVersion = await getLatestVersion(packageName);
120
+
121
+ if (latestVersion && isNewerVersion(currentVersion, latestVersion)) {
122
+ const notification = createUpdateNotification(currentVersion, latestVersion, packageName);
123
+ console.log(notification);
124
+ }
125
+ } catch (error) {
126
+ // Silently fail - update check should never block the main functionality
127
+ }
128
+ }