@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/LICENSE +21 -0
- package/README.md +447 -0
- package/config/README.md +27 -0
- package/config/default-rules.json +135 -0
- package/config/resources-r4-not-in-r6.json +42 -0
- package/package.json +54 -0
- package/src/cli.js +93 -0
- package/src/compare-profiles.js +386 -0
- package/src/config.js +147 -0
- package/src/generate-fsh.js +457 -0
- package/src/index.js +394 -0
- package/src/rules-engine.js +642 -0
- package/src/upgrade-sushi.js +553 -0
- package/src/utils/fs.js +38 -0
- package/src/utils/html.js +28 -0
- package/src/utils/process.js +101 -0
- package/src/utils/removed-resources.js +135 -0
- package/src/utils/sushi-log.js +46 -0
- package/src/utils/validator.js +103 -0
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
|
+
}
|