@gefyra/diffyr6-cli 1.1.4 → 1.2.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gefyra/diffyr6-cli",
3
- "version": "1.1.4",
3
+ "version": "1.2.0",
4
4
  "description": "FHIR R4 to R6 migration pipeline runner with automated profile comparison and rule-based analysis",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -0,0 +1,328 @@
1
+ import fsp from 'fs/promises';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ const __filename = fileURLToPath(import.meta.url);
6
+ const __dirname = path.dirname(__filename);
7
+
8
+ export async function compareSearchParameters(resourcesDir, outputDir, options = {}) {
9
+ const { debug = false } = options;
10
+
11
+ if (debug) {
12
+ console.log(' Scanning CapabilityStatements for removed search parameters...');
13
+ }
14
+
15
+ const removedParams = await loadRemovedSearchParameters();
16
+ const capabilityStatements = await collectCapabilityStatements(resourcesDir);
17
+ const usedParams = extractUsedSearchParameters(capabilityStatements);
18
+ const matches = findRemovedMatches(usedParams, removedParams);
19
+
20
+ const timestamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-');
21
+ const reportFilename = `searchparameter-report-${timestamp}.md`;
22
+ const reportPath = path.join(outputDir, reportFilename);
23
+ const jsonFilename = `searchparameter-report-${timestamp}.json`;
24
+ const jsonPath = path.join(outputDir, jsonFilename);
25
+
26
+ const markdown = generateSearchParameterReport(matches);
27
+ await fsp.writeFile(reportPath, markdown, 'utf8');
28
+
29
+ const affectedCapabilityStatements = new Set(
30
+ matches.map(match => match.capabilityStatement.id || match.capabilityStatement.url || match.capabilityStatement.sourceFile)
31
+ );
32
+ const jsonData = {
33
+ generated: new Date().toISOString(),
34
+ totalRemovedMatches: matches.length,
35
+ affectedCapabilityStatements: affectedCapabilityStatements.size,
36
+ matches,
37
+ };
38
+ await fsp.writeFile(jsonPath, JSON.stringify(jsonData, null, 2), 'utf8');
39
+
40
+ return {
41
+ path: reportPath,
42
+ filename: reportFilename,
43
+ jsonPath,
44
+ jsonFilename,
45
+ matchCount: matches.length,
46
+ affectedCpsCount: affectedCapabilityStatements.size,
47
+ };
48
+ }
49
+
50
+ async function loadRemovedSearchParameters() {
51
+ const configPath = path.resolve(__dirname, '..', 'config', 'searchparameters-r4-not-in-r6.json');
52
+ const content = await fsp.readFile(configPath, 'utf8');
53
+ const data = JSON.parse(content);
54
+ return data.searchParameters || [];
55
+ }
56
+
57
+ async function collectCapabilityStatements(resourcesDir) {
58
+ const files = await collectJsonFiles(resourcesDir);
59
+ const capabilityStatements = [];
60
+ const baseDir = path.resolve(resourcesDir);
61
+
62
+ for (const filePath of files) {
63
+ const raw = await fsp.readFile(filePath, 'utf8').catch(() => '');
64
+ if (!raw) {
65
+ continue;
66
+ }
67
+
68
+ let data;
69
+ try {
70
+ data = JSON.parse(raw);
71
+ } catch {
72
+ continue;
73
+ }
74
+
75
+ if (data?.resourceType !== 'CapabilityStatement') {
76
+ continue;
77
+ }
78
+
79
+ capabilityStatements.push({
80
+ id: data.id || '',
81
+ url: data.url || '',
82
+ name: data.name || data.title || data.id || path.basename(filePath, '.json'),
83
+ sourceFile: path.relative(baseDir, filePath) || path.basename(filePath),
84
+ data,
85
+ });
86
+ }
87
+
88
+ return capabilityStatements;
89
+ }
90
+
91
+ function extractUsedSearchParameters(capabilityStatements) {
92
+ const used = [];
93
+
94
+ for (const capabilityStatement of capabilityStatements) {
95
+ const restEntries = Array.isArray(capabilityStatement.data.rest) ? capabilityStatement.data.rest : [];
96
+
97
+ for (const rest of restEntries) {
98
+ const resources = Array.isArray(rest.resource) ? rest.resource : [];
99
+
100
+ for (const resource of resources) {
101
+ const resourceType = typeof resource.type === 'string' ? resource.type : '';
102
+ const searchParams = Array.isArray(resource.searchParam) ? resource.searchParam : [];
103
+
104
+ for (const searchParam of searchParams) {
105
+ const name = normalizeString(searchParam.name || searchParam.code);
106
+ const definition = normalizeString(searchParam.definition);
107
+
108
+ if (!name && !definition) {
109
+ continue;
110
+ }
111
+
112
+ used.push({
113
+ name,
114
+ definition,
115
+ resourceType,
116
+ capabilityStatementId: capabilityStatement.id,
117
+ capabilityStatementUrl: capabilityStatement.url,
118
+ capabilityStatementName: capabilityStatement.name,
119
+ sourceFile: capabilityStatement.sourceFile,
120
+ });
121
+ }
122
+ }
123
+ }
124
+ }
125
+
126
+ return used;
127
+ }
128
+
129
+ function findRemovedMatches(usedParams, removedParams) {
130
+ const removedByUrl = new Map();
131
+ const removedByBaseAndCode = new Map();
132
+
133
+ for (const removed of removedParams) {
134
+ const url = normalizeString(removed.url);
135
+ if (url) {
136
+ removedByUrl.set(url, removed);
137
+ }
138
+
139
+ const bases = Array.isArray(removed.base) ? removed.base : [];
140
+ for (const base of bases) {
141
+ const key = buildBaseCodeKey(base, removed.code || removed.name);
142
+ if (!key) {
143
+ continue;
144
+ }
145
+ if (!removedByBaseAndCode.has(key)) {
146
+ removedByBaseAndCode.set(key, []);
147
+ }
148
+ removedByBaseAndCode.get(key).push(removed);
149
+ }
150
+ }
151
+
152
+ const seen = new Set();
153
+ const matches = [];
154
+
155
+ for (const used of usedParams) {
156
+ let removed = null;
157
+ let matchedBy = '';
158
+
159
+ if (used.definition && removedByUrl.has(used.definition)) {
160
+ removed = removedByUrl.get(used.definition);
161
+ matchedBy = 'definition';
162
+ } else if (used.resourceType && used.name) {
163
+ const fallbackMatches = removedByBaseAndCode.get(buildBaseCodeKey(used.resourceType, used.name)) || [];
164
+ if (fallbackMatches.length > 0) {
165
+ removed = fallbackMatches[0];
166
+ matchedBy = 'name+base';
167
+ }
168
+ }
169
+
170
+ if (!removed) {
171
+ continue;
172
+ }
173
+
174
+ const matchKey = [
175
+ removed.url || removed.id || removed.code,
176
+ used.capabilityStatementId || used.capabilityStatementUrl || used.sourceFile,
177
+ used.resourceType,
178
+ used.name,
179
+ used.definition,
180
+ ].join('::').toLowerCase();
181
+
182
+ if (seen.has(matchKey)) {
183
+ continue;
184
+ }
185
+ seen.add(matchKey);
186
+
187
+ matches.push({
188
+ removedSearchParameter: {
189
+ id: removed.id || '',
190
+ name: removed.name || '',
191
+ code: removed.code || '',
192
+ url: removed.url || '',
193
+ base: Array.isArray(removed.base) ? removed.base : [],
194
+ type: removed.type || '',
195
+ description: removed.description || '',
196
+ },
197
+ capabilityStatement: {
198
+ id: used.capabilityStatementId || '',
199
+ url: used.capabilityStatementUrl || '',
200
+ name: used.capabilityStatementName || '',
201
+ sourceFile: used.sourceFile,
202
+ },
203
+ resourceType: used.resourceType,
204
+ searchParameter: {
205
+ name: used.name,
206
+ definition: used.definition,
207
+ },
208
+ matchedBy,
209
+ });
210
+ }
211
+
212
+ return matches.sort(compareMatches);
213
+ }
214
+
215
+ function generateSearchParameterReport(matches) {
216
+ const lines = [];
217
+ const generated = new Date().toISOString();
218
+ const affectedCapabilityStatements = new Set(
219
+ matches.map(match => match.capabilityStatement.id || match.capabilityStatement.url || match.capabilityStatement.sourceFile)
220
+ );
221
+
222
+ lines.push('# Search Parameter Report');
223
+ lines.push('');
224
+ lines.push(`**Generated:** ${generated}`);
225
+ lines.push(`**Removed search parameter matches:** ${matches.length}`);
226
+ lines.push(`**Affected CapabilityStatements:** ${affectedCapabilityStatements.size}`);
227
+ lines.push('');
228
+ lines.push('---');
229
+ lines.push('');
230
+
231
+ if (matches.length === 0) {
232
+ lines.push('No CapabilityStatements were found that reference search parameters removed in FHIR R6.');
233
+ lines.push('');
234
+ return lines.join('\n');
235
+ }
236
+
237
+ const grouped = new Map();
238
+ for (const match of matches) {
239
+ const key = match.removedSearchParameter.url || `${match.removedSearchParameter.code}::${match.resourceType}`;
240
+ if (!grouped.has(key)) {
241
+ grouped.set(key, []);
242
+ }
243
+ grouped.get(key).push(match);
244
+ }
245
+
246
+ for (const key of Array.from(grouped.keys()).sort()) {
247
+ const group = grouped.get(key);
248
+ const first = group[0];
249
+ const removed = first.removedSearchParameter;
250
+
251
+ lines.push(`## ${removed.code || removed.name || removed.id}`);
252
+ lines.push('');
253
+ lines.push(`**Resource Type:** ${(removed.base || []).join(', ') || first.resourceType || 'Unknown'}`);
254
+ lines.push(`**Definition:** ${removed.url || 'n/a'}`);
255
+ lines.push(`**Matches:** ${group.length}`);
256
+ if (removed.description) {
257
+ lines.push(`**Description:** ${removed.description}`);
258
+ }
259
+ lines.push('');
260
+ lines.push('Affected CapabilityStatements:');
261
+ lines.push('');
262
+
263
+ for (const match of group) {
264
+ const cpsLabel =
265
+ match.capabilityStatement.name ||
266
+ match.capabilityStatement.id ||
267
+ match.capabilityStatement.url ||
268
+ path.basename(match.capabilityStatement.sourceFile);
269
+ lines.push(`- **${cpsLabel}**`);
270
+ lines.push(` Resource: ${match.resourceType || 'Unknown'}`);
271
+ lines.push(` Parameter: ${match.searchParameter.name || 'n/a'}`);
272
+ lines.push(` Match: ${match.matchedBy}`);
273
+ if (match.searchParameter.definition) {
274
+ lines.push(` Definition: ${match.searchParameter.definition}`);
275
+ }
276
+ lines.push(` Source: ${match.capabilityStatement.sourceFile}`);
277
+ }
278
+
279
+ lines.push('');
280
+ }
281
+
282
+ return lines.join('\n');
283
+ }
284
+
285
+ async function collectJsonFiles(dir) {
286
+ const results = [];
287
+ const entries = await fsp.readdir(dir, { withFileTypes: true }).catch(() => []);
288
+
289
+ for (const entry of entries) {
290
+ const entryPath = path.join(dir, entry.name);
291
+ if (entry.isDirectory()) {
292
+ const nested = await collectJsonFiles(entryPath);
293
+ results.push(...nested);
294
+ continue;
295
+ }
296
+ if (entry.isFile() && entry.name.toLowerCase().endsWith('.json')) {
297
+ results.push(entryPath);
298
+ }
299
+ }
300
+
301
+ return results;
302
+ }
303
+
304
+ function buildBaseCodeKey(base, code) {
305
+ const normalizedBase = normalizeString(base);
306
+ const normalizedCode = normalizeString(code);
307
+ if (!normalizedBase || !normalizedCode) {
308
+ return '';
309
+ }
310
+ return `${normalizedBase}::${normalizedCode}`;
311
+ }
312
+
313
+ function normalizeString(value) {
314
+ return typeof value === 'string' ? value.trim() : '';
315
+ }
316
+
317
+ function compareMatches(a, b) {
318
+ return (
319
+ compareStrings(a.removedSearchParameter.code, b.removedSearchParameter.code) ||
320
+ compareStrings(a.resourceType, b.resourceType) ||
321
+ compareStrings(a.capabilityStatement.name || a.capabilityStatement.id, b.capabilityStatement.name || b.capabilityStatement.id) ||
322
+ compareStrings(a.searchParameter.name, b.searchParameter.name)
323
+ );
324
+ }
325
+
326
+ function compareStrings(a, b) {
327
+ return (a || '').localeCompare(b || '');
328
+ }
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.2';
9
+ export const CONFIG_VERSION = '1.0.3';
10
10
 
11
11
  /**
12
12
  * Default configuration values
@@ -26,6 +26,7 @@ export const DEFAULT_CONFIG = {
26
26
  compareMode: 'incremental',
27
27
  exportZip: true,
28
28
  skipTerminologyReport: false,
29
+ skipSearchParameterReport: false,
29
30
  };
30
31
 
31
32
  /**
@@ -68,17 +69,31 @@ function migrateConfig(config) {
68
69
  if (config.skipTerminologyReport === undefined) {
69
70
  config.skipTerminologyReport = false;
70
71
  }
72
+ if (config.skipSearchParameterReport === undefined) {
73
+ config.skipSearchParameterReport = false;
74
+ }
71
75
  return config;
72
76
  }
73
77
 
74
78
  const [major, minor, patch] = config.configVersion.split('.').map(Number);
75
79
 
76
- // Migrate from 1.0.0 or 1.0.1 to 1.0.2
80
+ // Migrate from 1.0.0 or 1.0.1 to 1.0.2/1.0.3
77
81
  if (major === 1 && minor === 0 && (patch === 0 || patch === 1)) {
78
82
  console.log(` Migrating config from ${config.configVersion} to ${CONFIG_VERSION}...`);
79
83
  if (config.skipTerminologyReport === undefined) {
80
84
  config.skipTerminologyReport = false;
81
85
  }
86
+ if (config.skipSearchParameterReport === undefined) {
87
+ config.skipSearchParameterReport = false;
88
+ }
89
+ config.configVersion = CONFIG_VERSION;
90
+ }
91
+
92
+ if (major === 1 && minor === 0 && patch === 2) {
93
+ console.log(` Migrating config from ${config.configVersion} to ${CONFIG_VERSION}...`);
94
+ if (config.skipSearchParameterReport === undefined) {
95
+ config.skipSearchParameterReport = false;
96
+ }
82
97
  config.configVersion = CONFIG_VERSION;
83
98
  }
84
99
 
@@ -142,6 +157,10 @@ function validateConfig(config) {
142
157
  if (typeof config.skipTerminologyReport !== 'boolean') {
143
158
  errors.push('skipTerminologyReport must be a boolean');
144
159
  }
160
+
161
+ if (typeof config.skipSearchParameterReport !== 'boolean') {
162
+ errors.push('skipSearchParameterReport must be a boolean');
163
+ }
145
164
 
146
165
  if (errors.length > 0) {
147
166
  throw new Error(`Invalid configuration:\n${errors.map(e => ` - ${e}`).join('\n')}`);
@@ -166,7 +185,8 @@ export async function createExampleConfig(outputPath) {
166
185
  workdir: null,
167
186
  compareMode: 'incremental',
168
187
  exportZip: true,
169
- skipTerminologyReport: false
188
+ skipTerminologyReport: false,
189
+ skipSearchParameterReport: false
170
190
  };
171
191
 
172
192
  await fsp.writeFile(
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) {