@gefyra/diffyr6-cli 1.0.0 → 1.0.2

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.
@@ -1,386 +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
- }
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
+ }