@gefyra/diffyr6-cli 1.1.4 → 1.2.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
@@ -8,6 +8,7 @@ import { generateFshFromPackage } from './generate-fsh.js';
8
8
  import { upgradeSushiToR6 } from './upgrade-sushi.js';
9
9
  import { compareProfiles } from './compare-profiles.js';
10
10
  import { compareTerminology, hasSnapshots, runSushiWithSnapshots } from './compare-terminology.js';
11
+ import { compareSearchParameters } from './compare-searchparameters.js';
11
12
  import { findRemovedResources } from './utils/removed-resources.js';
12
13
  import { createZip } from './utils/zip.js';
13
14
  import { checkForUpdates } from './utils/update-check.js';
@@ -38,42 +39,45 @@ export async function runMigration(config) {
38
39
  outputDir,
39
40
  steps: [],
40
41
  };
42
+
43
+ const totalSteps = 7;
41
44
 
42
45
  // Step 1: GoFSH (if enabled and not already done)
43
46
  if (config.enableGoFSH) {
44
47
  const shouldRunGoFSH = await checkShouldRunGoFSH(resourcesDir);
45
48
  if (shouldRunGoFSH) {
46
- console.log('\n[1/6] Downloading package and generating FSH...');
49
+ console.log(`\n[1/${totalSteps}] Downloading package and generating FSH...`);
47
50
  await runGoFSH(context);
48
51
  context.steps.push('gofsh');
49
52
  } else {
50
- console.log('\n[1/6] GoFSH - SKIPPED (Resources directory with sushi-config.yaml already exists)');
53
+ console.log(`\n[1/${totalSteps}] GoFSH - SKIPPED (Resources directory with sushi-config.yaml already exists)`);
51
54
  }
52
55
  } else {
53
- console.log('\n[1/6] GoFSH - DISABLED in config');
56
+ console.log(`\n[1/${totalSteps}] GoFSH - DISABLED in config`);
54
57
  }
55
58
 
56
59
  // Step 2: Upgrade to R6
57
60
  const shouldRunUpgrade = await checkShouldRunUpgrade(resourcesR6Dir);
58
61
  if (shouldRunUpgrade) {
59
- console.log('\n[2/6] Upgrading to R6...');
62
+ console.log(`\n[2/${totalSteps}] Upgrading to R6...`);
60
63
  await runUpgradeToR6(context);
61
64
  context.steps.push('upgrade');
62
65
  } else {
63
- console.log('\n[2/6] Upgrade - SKIPPED (ResourcesR6 directory with sushi-config.yaml already exists)');
66
+ console.log(`\n[2/${totalSteps}] Upgrade - SKIPPED (ResourcesR6 directory with sushi-config.yaml already exists)`);
64
67
  }
65
68
 
66
69
  // Step 3: Build snapshots for both R4 and R6
67
- console.log('\n[3/6] Building snapshots with SUSHI...');
70
+ console.log(`\n[3/${totalSteps}] Building snapshots with SUSHI...`);
68
71
  await runSnapshotBuild(context);
69
72
  context.steps.push('snapshots');
70
73
 
71
74
  // Step 4: Compare profiles
72
- console.log('\n[4/6] Comparing R4 vs R6 profiles...');
75
+ console.log(`\n[4/${totalSteps}] Comparing R4 vs R6 profiles...`);
73
76
  const compareResults = await runProfileComparison(context);
74
77
  context.steps.push('compare');
75
78
 
76
- // Step 5: Generat6] Generating migration report...');
79
+ // Step 5: Generate migration report
80
+ console.log(`\n[5/${totalSteps}] Generating migration report...`);
77
81
  const removedResources = await findRemovedResources(resourcesDir);
78
82
  const report = await generateReport(context, compareResults, removedResources);
79
83
  context.steps.push('report');
@@ -81,9 +85,9 @@ export async function runMigration(config) {
81
85
  // Step 6: Compare terminology bindings
82
86
  let terminologyReport = null;
83
87
  if (config.skipTerminologyReport) {
84
- console.log('\n[6/6] Terminology comparison - SKIPPED (skipTerminologyReport is enabled)');
88
+ console.log(`\n[6/${totalSteps}] Terminology comparison - SKIPPED (skipTerminologyReport is enabled)`);
85
89
  } else {
86
- console.log('\n[6/6] Comparing terminology bindings...');
90
+ console.log(`\n[6/${totalSteps}] Comparing terminology bindings...`);
87
91
  try {
88
92
  terminologyReport = await runTerminologyComparison(context);
89
93
  if (terminologyReport) {
@@ -95,10 +99,26 @@ export async function runMigration(config) {
95
99
  }
96
100
  }
97
101
 
102
+ let searchParameterReport = null;
103
+ if (config.skipSearchParameterReport) {
104
+ console.log(`\n[7/${totalSteps}] SearchParameter report - SKIPPED (skipSearchParameterReport is enabled)`);
105
+ } else {
106
+ console.log(`\n[7/${totalSteps}] Analyzing removed search parameters...`);
107
+ try {
108
+ searchParameterReport = await runSearchParameterComparison(context);
109
+ if (searchParameterReport) {
110
+ context.steps.push('searchParameters');
111
+ }
112
+ } catch (error) {
113
+ console.warn(` SearchParameter analysis failed: ${error.message}`);
114
+ console.warn(' Continuing without search parameter report...');
115
+ }
116
+ }
117
+
98
118
  let exportZipPath = null;
99
119
  if (config.exportZip) {
100
120
  console.log('\nGenerating export ZIP...');
101
- exportZipPath = await exportComparisonZip(context, report, terminologyReport);
121
+ exportZipPath = await exportComparisonZip(context, report, terminologyReport, searchParameterReport);
102
122
  context.steps.push('exportZip');
103
123
  }
104
124
 
@@ -106,6 +126,12 @@ export async function runMigration(config) {
106
126
  console.log(` Report: ${report.path}`);
107
127
  console.log(` Total Score: ${report.score}`);
108
128
  console.log(` Findings: ${report.findingsCount}`);
129
+ if (terminologyReport?.path) {
130
+ console.log(` Terminology report: ${terminologyReport.path}`);
131
+ }
132
+ if (searchParameterReport?.path) {
133
+ console.log(` SearchParameter report: ${searchParameterReport.path}`);
134
+ }
109
135
  if (exportZipPath) {
110
136
  console.log(` Export ZIP: ${exportZipPath}`);
111
137
  }
@@ -239,6 +265,25 @@ async function runTerminologyComparison(context) {
239
265
  return result;
240
266
  }
241
267
 
268
+ async function runSearchParameterComparison(context) {
269
+ const { resourcesDir, outputDir, config } = context;
270
+
271
+ const options = {
272
+ debug: config.debug || false,
273
+ };
274
+
275
+ const result = await compareSearchParameters(resourcesDir, outputDir, options);
276
+
277
+ if (result) {
278
+ console.log(` Removed search parameter matches: ${result.matchCount}`);
279
+ console.log(` Affected CapabilityStatements: ${result.affectedCpsCount}`);
280
+ console.log(` Markdown report: ${result.path}`);
281
+ console.log(` JSON report: ${result.jsonPath}`);
282
+ }
283
+
284
+ return result;
285
+ }
286
+
242
287
  /**
243
288
  * Get list of profiles that need to be compared
244
289
  */
@@ -341,7 +386,7 @@ async function generateReport(context, compareResults, removedResources = []) {
341
386
  /**
342
387
  * Create a ZIP export with compare HTML files, report, and run config
343
388
  */
344
- async function exportComparisonZip(context, report, terminologyReport = null) {
389
+ async function exportComparisonZip(context, report, terminologyReport = null, searchParameterReport = null) {
345
390
  const { compareDir, outputDir, config } = context;
346
391
  const exportFilename = 'diffyr6-publish.zip';
347
392
  const exportPath = path.join(outputDir, exportFilename);
@@ -368,22 +413,43 @@ async function exportComparisonZip(context, report, terminologyReport = null) {
368
413
  mtime: (await fsp.stat(report.path)).mtime,
369
414
  });
370
415
 
416
+ const terminologyReportForZip = await resolveReportForZip(outputDir, 'terminology-report', terminologyReport);
417
+ const searchParameterReportForZip = await resolveReportForZip(outputDir, 'searchparameter-report', searchParameterReport);
418
+
371
419
  // Add terminology report if available
372
- if (terminologyReport && terminologyReport.path) {
373
- const termContent = await fsp.readFile(terminologyReport.path);
420
+ if (terminologyReportForZip?.path) {
421
+ const termContent = await fsp.readFile(terminologyReportForZip.path);
374
422
  entries.push({
375
- name: terminologyReport.filename,
423
+ name: terminologyReportForZip.filename,
376
424
  data: termContent,
377
- mtime: (await fsp.stat(terminologyReport.path)).mtime,
425
+ mtime: (await fsp.stat(terminologyReportForZip.path)).mtime,
378
426
  });
379
427
 
380
428
  // Add terminology JSON if available
381
- if (terminologyReport.jsonPath) {
382
- const termJsonContent = await fsp.readFile(terminologyReport.jsonPath);
429
+ if (terminologyReportForZip.jsonPath) {
430
+ const termJsonContent = await fsp.readFile(terminologyReportForZip.jsonPath);
383
431
  entries.push({
384
- name: terminologyReport.jsonFilename,
432
+ name: terminologyReportForZip.jsonFilename,
385
433
  data: termJsonContent,
386
- mtime: (await fsp.stat(terminologyReport.jsonPath)).mtime,
434
+ mtime: (await fsp.stat(terminologyReportForZip.jsonPath)).mtime,
435
+ });
436
+ }
437
+ }
438
+
439
+ if (searchParameterReportForZip?.path) {
440
+ const searchParamContent = await fsp.readFile(searchParameterReportForZip.path);
441
+ entries.push({
442
+ name: searchParameterReportForZip.filename,
443
+ data: searchParamContent,
444
+ mtime: (await fsp.stat(searchParameterReportForZip.path)).mtime,
445
+ });
446
+
447
+ if (searchParameterReportForZip.jsonPath) {
448
+ const searchParamJsonContent = await fsp.readFile(searchParameterReportForZip.jsonPath);
449
+ entries.push({
450
+ name: searchParameterReportForZip.jsonFilename,
451
+ data: searchParamJsonContent,
452
+ mtime: (await fsp.stat(searchParameterReportForZip.jsonPath)).mtime,
387
453
  });
388
454
  }
389
455
  }
@@ -399,6 +465,57 @@ async function exportComparisonZip(context, report, terminologyReport = null) {
399
465
  return exportPath;
400
466
  }
401
467
 
468
+ async function resolveReportForZip(outputDir, prefix, currentReport = null) {
469
+ if (currentReport?.path && await fileExists(currentReport.path)) {
470
+ return currentReport;
471
+ }
472
+
473
+ const latestReport = await findLatestExistingReport(outputDir, prefix);
474
+ return latestReport;
475
+ }
476
+
477
+ async function findLatestExistingReport(outputDir, prefix) {
478
+ const exists = await directoryExists(outputDir);
479
+ if (!exists) {
480
+ return null;
481
+ }
482
+
483
+ const entries = await fsp.readdir(outputDir).catch(() => []);
484
+ const reportFiles = entries.filter(name => new RegExp(`^${escapeRegex(prefix)}-.+\\.(md|json)$`, 'i').test(name));
485
+ if (reportFiles.length === 0) {
486
+ return null;
487
+ }
488
+
489
+ const groups = new Map();
490
+ for (const filename of reportFiles) {
491
+ const ext = path.extname(filename).toLowerCase();
492
+ const stem = filename.slice(0, -ext.length);
493
+ const filePath = path.join(outputDir, filename);
494
+ const stat = await fsp.stat(filePath).catch(() => null);
495
+ if (!stat?.isFile()) {
496
+ continue;
497
+ }
498
+
499
+ const existing = groups.get(stem) || { stem, latestMtimeMs: 0 };
500
+ existing.latestMtimeMs = Math.max(existing.latestMtimeMs, stat.mtimeMs);
501
+ if (ext === '.md') {
502
+ existing.path = filePath;
503
+ existing.filename = filename;
504
+ } else if (ext === '.json') {
505
+ existing.jsonPath = filePath;
506
+ existing.jsonFilename = filename;
507
+ }
508
+ groups.set(stem, existing);
509
+ }
510
+
511
+ const sorted = Array.from(groups.values()).sort((a, b) => b.latestMtimeMs - a.latestMtimeMs);
512
+ return sorted[0] || null;
513
+ }
514
+
515
+ function escapeRegex(value) {
516
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
517
+ }
518
+
402
519
  async function listExportHtmlFiles(compareDir) {
403
520
  const exists = await directoryExists(compareDir);
404
521
  if (!exists) {
@@ -565,7 +565,7 @@ function evaluateCondition(condition, row, lookup) {
565
565
  }
566
566
  const cellValue = row.values[columnAlias] || '';
567
567
  const expected = resolveExpectedValue(condition, row, lookup);
568
- const operator = (condition.operator || '').toLowerCase();
568
+ const operator = normalizeOperator(condition.operator || '');
569
569
  const caseSensitive = Boolean(condition.caseSensitive);
570
570
  if (operator === 'equals') {
571
571
  return compareEquals(cellValue, expected, caseSensitive);
@@ -576,9 +576,23 @@ function evaluateCondition(condition, row, lookup) {
576
576
  if (operator === 'contains') {
577
577
  return compareContains(cellValue, expected, caseSensitive);
578
578
  }
579
+ if (operator === 'typesubsetof') {
580
+ return typeListIsSubset(cellValue, expected);
581
+ }
582
+ if (operator === '!typesubsetof') {
583
+ return !typeListIsSubset(cellValue, expected);
584
+ }
579
585
  return false;
580
586
  }
581
587
 
588
+ function normalizeOperator(operator) {
589
+ const value = String(operator).trim().toLowerCase();
590
+ if (value === 'nottypesubsetof' || value === 'not-typesubsetof') {
591
+ return '!typesubsetof';
592
+ }
593
+ return value;
594
+ }
595
+
582
596
  function resolveExpectedValue(condition, row, lookup) {
583
597
  if (condition.valueColumn) {
584
598
  const alias = resolveColumnAlias(condition.valueColumn, lookup);
@@ -611,6 +625,102 @@ function compareContains(left, right, caseSensitive) {
611
625
  return String(left).toLowerCase().includes(String(right).toLowerCase());
612
626
  }
613
627
 
628
+ function normalizeTypeName(name) {
629
+ return String(name)
630
+ .trim()
631
+ .replace(/[- ]?r[46]$/i, '')
632
+ .toLowerCase();
633
+ }
634
+
635
+ function normalizeReferenceGroup(referenceStr) {
636
+ const match = String(referenceStr)
637
+ .trim()
638
+ .match(/^reference\s*\((.*)\)$/i);
639
+ if (!match) {
640
+ return `reference:${normalizeTypeName(referenceStr)}`;
641
+ }
642
+ const normalizedTargets = match[1]
643
+ .split('|')
644
+ .map((token) => normalizeTypeName(token))
645
+ .filter(Boolean)
646
+ .sort();
647
+ return `reference:${normalizedTargets.join('|')}`;
648
+ }
649
+
650
+ function parseTypeList(typeStr) {
651
+ const types = new Set();
652
+ const references = [];
653
+ let current = '';
654
+ let depth = 0;
655
+
656
+ const pushCurrent = () => {
657
+ const token = current.trim();
658
+ current = '';
659
+ if (!token) {
660
+ return;
661
+ }
662
+ if (token.toLowerCase().startsWith('reference')) {
663
+ references.push(parseReferenceTargets(token));
664
+ return;
665
+ }
666
+ const normalized = normalizeTypeName(token);
667
+ if (normalized) {
668
+ types.add(normalized);
669
+ }
670
+ };
671
+
672
+ for (const char of String(typeStr)) {
673
+ if (char === '(') {
674
+ depth += 1;
675
+ current += char;
676
+ continue;
677
+ }
678
+ if (char === ')') {
679
+ depth = Math.max(0, depth - 1);
680
+ current += char;
681
+ continue;
682
+ }
683
+ if (char === ',' && depth === 0) {
684
+ pushCurrent();
685
+ continue;
686
+ }
687
+ current += char;
688
+ }
689
+
690
+ pushCurrent();
691
+ return { types, references };
692
+ }
693
+
694
+ function parseReferenceTargets(referenceStr) {
695
+ const normalized = normalizeReferenceGroup(referenceStr);
696
+ const [, targetList = ''] = normalized.split(':');
697
+ return new Set(targetList.split('|').filter(Boolean));
698
+ }
699
+
700
+ function typeListIsSubset(leftStr, rightStr) {
701
+ const leftTypes = parseTypeList(leftStr);
702
+ const rightTypes = parseTypeList(rightStr);
703
+ for (const type of leftTypes.types) {
704
+ if (!rightTypes.types.has(type)) {
705
+ return false;
706
+ }
707
+ }
708
+ for (const leftReference of leftTypes.references) {
709
+ const hasSuperset = rightTypes.references.some((rightReference) => {
710
+ for (const target of leftReference) {
711
+ if (!rightReference.has(target)) {
712
+ return false;
713
+ }
714
+ }
715
+ return true;
716
+ });
717
+ if (!hasSuperset) {
718
+ return false;
719
+ }
720
+ }
721
+ return true;
722
+ }
723
+
614
724
  function renderTemplate(template, variables) {
615
725
  const rendered = template.replace(/{{\s*([^}]+)\s*}}/g, (_, key) => {
616
726
  const resolved = resolveVariableValue(key.trim(), variables);