babelfhir-ts 1.3.10 → 1.4.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/README.md +57 -1
- package/out/src/cli/installCommand.js +52 -1
- package/out/src/cli/updateCommand.js +361 -0
- package/out/src/generator/emitters/interface/interfaceFieldProcessor.js +4 -6
- package/out/src/generator/emitters/interface/interfaceGenerator.js +2 -1
- package/out/src/generator/emitters/interface/postProcessExtensions.js +44 -19
- package/out/src/generator/emitters/interface/processNestedField.js +12 -4
- package/out/src/generator/emitters/validator/sliceBackboneValidation.js +11 -7
- package/out/src/generator/emitters/validator/sliceValidatorGenerator.js +8 -4
- package/out/src/generator/emitters/validator/sliceValidatorUtils.js +69 -0
- package/out/src/generator/emitters/validator/validatorFieldBuilders.js +78 -1
- package/out/src/generator/emitters/validator/validatorGenerator.js +5 -4
- package/out/src/generator/generationHelpers.js +2 -2
- package/out/src/generator/index.js +27 -11
- package/out/src/generator/parser/packageParser.js +20 -6
- package/out/src/generator/parser/sdFetcher.js +14 -14
- package/out/src/generator/parser/sdParser.js +4 -2
- package/out/src/generator/parser/txClient.js +9 -4
- package/out/src/generator/sdProcessor.js +1 -1
- package/out/src/generator/sdProcessorHelpers.js +2 -1
- package/out/src/main.js +88 -13
- package/package.json +1 -1
- /package/out/src/generator/fhir/r4/{base/backport-subscription.json → backport-subscription.json} +0 -0
- /package/out/src/generator/fhir/r4/{base/well-known-system-codes.json → well-known-system-codes.json} +0 -0
package/README.md
CHANGED
|
@@ -202,7 +202,63 @@ const { errors, warnings } = await patient.validate();
|
|
|
202
202
|
|
|
203
203
|
<!-- CLI_HELP_START -->
|
|
204
204
|
```
|
|
205
|
-
|
|
205
|
+
BabelFHIR-TS: Generate TypeScript interfaces from FHIR StructureDefinitions
|
|
206
|
+
|
|
207
|
+
Usage:
|
|
208
|
+
babelfhir-ts [options] [<input> [output]]
|
|
209
|
+
babelfhir-ts install [--package] <pkg[@version]|path> [--registry <url>] [options]
|
|
210
|
+
babelfhir-ts update [<pkg@version>] [--recursive] [options]
|
|
211
|
+
|
|
212
|
+
Arguments:
|
|
213
|
+
input Input can be:
|
|
214
|
+
- Canonical URL of a FHIR profile (http://... or https://...)
|
|
215
|
+
- Directory containing FHIR packages (.tgz/.zip files)
|
|
216
|
+
- Single FHIR package (.tgz/.zip file)
|
|
217
|
+
- Single StructureDefinition (.json file)
|
|
218
|
+
- Directory containing StructureDefinition files
|
|
219
|
+
output Output directory or archive name (optional)
|
|
220
|
+
|
|
221
|
+
Commands:
|
|
222
|
+
install Download, process, and npm install package as dependency
|
|
223
|
+
update Regenerate all installed packages (or a specific one) with current babelfhir-ts
|
|
224
|
+
|
|
225
|
+
Options:
|
|
226
|
+
-h, --help Show this help message
|
|
227
|
+
-v, --version Show version number
|
|
228
|
+
--log <dest> Log destination: console (default) or file
|
|
229
|
+
--log-level <level> Log verbosity: error, warn, info (default), or debug
|
|
230
|
+
--cache-dir <path> Custom cache directory (default: ~/.fhir/packages for FHIR packages, .cache for working files)
|
|
231
|
+
--no-cache Delete .cache working folder after generation (does not affect shared ~/.fhir/packages)
|
|
232
|
+
--no-classes Only generate interfaces and types (skip class generation)
|
|
233
|
+
--no-client Skip FHIR client generation (client generated by default)
|
|
234
|
+
--schema <format> Generate schema files alongside outputs (supported: zod)
|
|
235
|
+
--dicomweb Generate DICOMweb helpers typed to ImagingStudy profiles in the IG
|
|
236
|
+
--recursive (update only) Recursively search subdirectories for lib/ folders
|
|
237
|
+
--outDir <dir> Output directory (alias for second positional argument)
|
|
238
|
+
--fhir-version <ver> FHIR version to target: r4, r4b, or r5 (auto-detected from package if omitted)
|
|
239
|
+
--package <pkg[@version]> Download FHIR package from registry and process it (latest if no version)
|
|
240
|
+
--registry <url> FHIR package registry URL (default: https://packages.simplifier.net)
|
|
241
|
+
--tx-server <url> Terminology server URL for ValueSet expansion (e.g., https://tx.fhir.org/r4)
|
|
242
|
+
When set, expands ValueSets without explicit codes using $expand operation
|
|
243
|
+
--display-language <lang> BCP-47 language for display terms (e.g., de, fr). Passed to $expand displayLanguage
|
|
244
|
+
|
|
245
|
+
Examples:
|
|
246
|
+
babelfhir-ts # Process ./input to ./output
|
|
247
|
+
babelfhir-ts http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient # Generate from profile URL
|
|
248
|
+
babelfhir-ts package.tgz # Process package to current directory
|
|
249
|
+
babelfhir-ts package.tgz modified-package.tgz # Embed interfaces in package
|
|
250
|
+
babelfhir-ts profiles/ generated/ # Process directory to directory
|
|
251
|
+
babelfhir-ts --package hl7.fhir.us.core@8.0.0 # Download and process from default registry
|
|
252
|
+
babelfhir-ts --package hl7.fhir.us.core@8.0.0 output/ # Download and output to directory
|
|
253
|
+
babelfhir-ts --package pkg@version --log console --log-level debug # With verbose logging
|
|
254
|
+
babelfhir-ts install de.gematik.isik-basismodul # Download latest, process, and install
|
|
255
|
+
babelfhir-ts install de.gematik.isik-basismodul@3.1.0 # Download specific version
|
|
256
|
+
babelfhir-ts install ./package.tgz # Install from local package file
|
|
257
|
+
babelfhir-ts install hl7.fhir.us.core@8.0.0 --registry <url> # Install from custom registry
|
|
258
|
+
babelfhir-ts install --package hl7.fhir.us.core@8.0.0 --registry <url> # Alternative syntax
|
|
259
|
+
babelfhir-ts update # Regenerate all packages in ./lib
|
|
260
|
+
babelfhir-ts update hl7.fhir.us.core@8.0.0 # Regenerate a specific package
|
|
261
|
+
babelfhir-ts update --recursive # Regenerate packages in all subdirectories
|
|
206
262
|
```
|
|
207
263
|
<!-- CLI_HELP_END -->
|
|
208
264
|
|
|
@@ -63,6 +63,9 @@ export function buildInstallArgs(opts) {
|
|
|
63
63
|
const deps = projectPkg.dependencies || {};
|
|
64
64
|
for (const [, value] of Object.entries(deps)) {
|
|
65
65
|
if (typeof value === 'string' && (value === tgzRelPath || value === packagePath)) {
|
|
66
|
+
// Dep already points to the tgz. Use a plain `install` (no add)
|
|
67
|
+
// to avoid `bun add` creating duplicate keys in package.json.
|
|
68
|
+
// The .bun cache nuke (clearBunCache) ensures bun re-extracts.
|
|
66
69
|
return { args: ['install'] };
|
|
67
70
|
}
|
|
68
71
|
}
|
|
@@ -72,7 +75,7 @@ export function buildInstallArgs(opts) {
|
|
|
72
75
|
deps[name] = tgzRelPath;
|
|
73
76
|
projectPkg.dependencies = deps;
|
|
74
77
|
return {
|
|
75
|
-
args: [
|
|
78
|
+
args: [...pmArgs, tgzRelPath],
|
|
76
79
|
updatedPackageJson: JSON.stringify(projectPkg, null, 2) + '\n',
|
|
77
80
|
};
|
|
78
81
|
}
|
|
@@ -120,6 +123,49 @@ function npmInstall(packagePath) {
|
|
|
120
123
|
});
|
|
121
124
|
});
|
|
122
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* Bun caches extracted tgz content in `node_modules/.bun/<pkg>@<path-hash>/`.
|
|
128
|
+
* The cache key is derived from the file *path*, not its content, so replacing a
|
|
129
|
+
* tgz at the same path silently serves stale content — even with `--force`.
|
|
130
|
+
* This function removes those cache entries so the next `bun add` re-extracts.
|
|
131
|
+
* In monorepos the .bun cache lives at the workspace root, not in the app subdir,
|
|
132
|
+
* so we walk up the directory tree to find it (same as detectPackageManager).
|
|
133
|
+
* No-op when bun is not the package manager or no .bun cache dir is found.
|
|
134
|
+
*/
|
|
135
|
+
function clearBunCache(packageName) {
|
|
136
|
+
const pm = detectPackageManager();
|
|
137
|
+
if (!pm.cmd.includes('bun'))
|
|
138
|
+
return;
|
|
139
|
+
// Walk up to find node_modules/.bun — in monorepos it's at the workspace root.
|
|
140
|
+
let bunCacheDir = null;
|
|
141
|
+
let dir = process.cwd();
|
|
142
|
+
while (true) {
|
|
143
|
+
const candidate = path.join(dir, 'node_modules', '.bun');
|
|
144
|
+
if (fs.existsSync(candidate)) {
|
|
145
|
+
bunCacheDir = candidate;
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
const parent = path.dirname(dir);
|
|
149
|
+
if (parent === dir)
|
|
150
|
+
break;
|
|
151
|
+
dir = parent;
|
|
152
|
+
}
|
|
153
|
+
if (!bunCacheDir)
|
|
154
|
+
return;
|
|
155
|
+
// Entries look like: <pkg>@<encoded-path>+<hash>
|
|
156
|
+
// The package name may contain dots/scopes — match by startsWith.
|
|
157
|
+
const prefix = `${packageName}@`;
|
|
158
|
+
let removed = 0;
|
|
159
|
+
for (const entry of fs.readdirSync(bunCacheDir, { withFileTypes: true })) {
|
|
160
|
+
if (entry.isDirectory() && entry.name.startsWith(prefix)) {
|
|
161
|
+
fs.rmSync(path.join(bunCacheDir, entry.name), { recursive: true, force: true });
|
|
162
|
+
removed++;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (removed > 0) {
|
|
166
|
+
console.log(`Cleared ${removed} stale bun cache entr${removed === 1 ? 'y' : 'ies'} for ${packageName}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
123
169
|
export async function handleInstallCommand(opts) {
|
|
124
170
|
const { packageToInstall, registry, generationFlags, downloadPackage } = opts;
|
|
125
171
|
// Keep cache by default — dependency packages are large (hl7.fhir.r4.core ~50 MB)
|
|
@@ -171,6 +217,11 @@ export async function handleInstallCommand(opts) {
|
|
|
171
217
|
cwd: generatedDir,
|
|
172
218
|
prefix: 'package'
|
|
173
219
|
}, ['.']);
|
|
220
|
+
// Bun caches extracted tgz content keyed by file path, not content hash.
|
|
221
|
+
// When the tgz is regenerated at the same path, bun serves stale content.
|
|
222
|
+
// Workaround: nuke the .bun cache entry so bun is forced to re-extract.
|
|
223
|
+
// See: https://github.com/oven-sh/bun/issues/29372
|
|
224
|
+
clearBunCache(packageName);
|
|
174
225
|
console.log(`Installing ${packageName}...`);
|
|
175
226
|
await npmInstall(tarballPath);
|
|
176
227
|
// Clean up extracted package tree (but keep the tarball under ./lib)
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `babelfhir-ts update` command.
|
|
3
|
+
*
|
|
4
|
+
* Discovers generated tgz packages under ./lib (or recursively), regenerates
|
|
5
|
+
* them with the current babelfhir-ts version, repacks the tgz, and reinstalls.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* babelfhir-ts update # update all in current project
|
|
9
|
+
* babelfhir-ts update --recursive # walk subdirectories too
|
|
10
|
+
* babelfhir-ts update <package-name@version> # update a single package
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'fs';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import crypto from 'crypto';
|
|
15
|
+
import { generateIntoPackageDirect, resetFetchFailureTracking } from '../generator/index.js';
|
|
16
|
+
import { getFhirPackagesCacheDir } from '../generator/core/cacheConfig.js';
|
|
17
|
+
import { resetDiagnostics, buildQualityReport, formatReportSummary } from '../generator/core/sdDiagnostics.js';
|
|
18
|
+
import { detectPackageManager } from './installCommand.js';
|
|
19
|
+
import { spawn } from 'child_process';
|
|
20
|
+
// ── Public entry point ──────────────────────────────────────────────────────
|
|
21
|
+
export async function handleUpdateCommand(opts) {
|
|
22
|
+
const { packageFilter, recursive, generationFlags, registry, downloadPackage, confirm } = opts;
|
|
23
|
+
const startDir = process.cwd();
|
|
24
|
+
// 1. Discover tgz files
|
|
25
|
+
const libDirs = discoverLibDirs(startDir, recursive ?? false);
|
|
26
|
+
if (libDirs.length === 0) {
|
|
27
|
+
if (packageFilter) {
|
|
28
|
+
return await promptInstallRedirect(packageFilter, confirm);
|
|
29
|
+
}
|
|
30
|
+
console.log('No lib/ directories found. Nothing to update.');
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
// 2. Read metadata from each tgz
|
|
34
|
+
const allTgz = await discoverTgzMetadata(libDirs);
|
|
35
|
+
if (allTgz.length === 0) {
|
|
36
|
+
if (packageFilter) {
|
|
37
|
+
return await promptInstallRedirect(packageFilter, confirm);
|
|
38
|
+
}
|
|
39
|
+
console.log('No generated packages found in lib/ directories.');
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
// 3. Filter if a specific package was requested
|
|
43
|
+
const targets = packageFilter ? filterBySpec(allTgz, packageFilter) : allTgz;
|
|
44
|
+
if (targets.length === 0 && packageFilter) {
|
|
45
|
+
console.log(`Package "${packageFilter}" is not installed.`);
|
|
46
|
+
if (allTgz.length > 0) {
|
|
47
|
+
console.log('Installed packages:');
|
|
48
|
+
for (const t of allTgz)
|
|
49
|
+
console.log(` ${t.igName}@${t.igVersion}`);
|
|
50
|
+
}
|
|
51
|
+
return await promptInstallRedirect(packageFilter, confirm);
|
|
52
|
+
}
|
|
53
|
+
console.log(`Found ${targets.length} package${targets.length === 1 ? '' : 's'} to update:\n`);
|
|
54
|
+
for (const t of targets)
|
|
55
|
+
console.log(` ${t.igName}@${t.igVersion} → ${path.relative(startDir, t.tgzPath)}`);
|
|
56
|
+
console.log('');
|
|
57
|
+
// 4. Regenerate each package
|
|
58
|
+
let updated = 0;
|
|
59
|
+
let failed = 0;
|
|
60
|
+
const updatedPackages = [];
|
|
61
|
+
for (const target of targets) {
|
|
62
|
+
try {
|
|
63
|
+
await regeneratePackage(target, generationFlags, registry, downloadPackage);
|
|
64
|
+
updatedPackages.push({ generatedName: target.generatedName, tgzPath: target.tgzPath });
|
|
65
|
+
updated++;
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
failed++;
|
|
69
|
+
console.error(`✗ Failed to update ${target.igName}@${target.igVersion}: ${err.message}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// 5. Patch bun.lock integrity hashes for regenerated packages (avoids full lockfile delete)
|
|
73
|
+
if (updatedPackages.length > 0) {
|
|
74
|
+
patchBunLockIntegrity(updatedPackages);
|
|
75
|
+
}
|
|
76
|
+
// 6. Single npm install at the end
|
|
77
|
+
if (updated > 0) {
|
|
78
|
+
console.log('\nReinstalling dependencies...');
|
|
79
|
+
await runInstall();
|
|
80
|
+
}
|
|
81
|
+
// 7. Summary
|
|
82
|
+
console.log('');
|
|
83
|
+
if (updated > 0)
|
|
84
|
+
console.log(`✓ Updated ${updated} package${updated === 1 ? '' : 's'}`);
|
|
85
|
+
if (failed > 0)
|
|
86
|
+
console.error(`✗ ${failed} package${failed === 1 ? '' : 's'} failed`);
|
|
87
|
+
return {};
|
|
88
|
+
}
|
|
89
|
+
// ── Install redirect prompt ─────────────────────────────────────────────────
|
|
90
|
+
async function promptInstallRedirect(packageFilter, confirm) {
|
|
91
|
+
const promptFn = confirm ?? defaultConfirm;
|
|
92
|
+
const yes = await promptFn(`Would you like to install ${packageFilter} instead? (Y/n) `);
|
|
93
|
+
if (yes)
|
|
94
|
+
return { redirectInstall: packageFilter };
|
|
95
|
+
return {};
|
|
96
|
+
}
|
|
97
|
+
/** Default Y/N prompt reading from stdin. */
|
|
98
|
+
function defaultConfirm(question) {
|
|
99
|
+
return new Promise((resolve) => {
|
|
100
|
+
process.stdout.write(question);
|
|
101
|
+
const rl = (async () => {
|
|
102
|
+
const { createInterface } = await import('readline');
|
|
103
|
+
return createInterface({ input: process.stdin, output: process.stdout });
|
|
104
|
+
})();
|
|
105
|
+
rl.then(r => {
|
|
106
|
+
r.question('', (answer) => {
|
|
107
|
+
r.close();
|
|
108
|
+
resolve(!answer || answer.toLowerCase().startsWith('y'));
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
// ── Discovery ───────────────────────────────────────────────────────────────
|
|
114
|
+
/** Find all lib/ directories starting from startDir. Non-recursive returns only startDir/lib. */
|
|
115
|
+
function discoverLibDirs(startDir, recursive) {
|
|
116
|
+
const dirs = [];
|
|
117
|
+
const libDir = path.join(startDir, 'lib');
|
|
118
|
+
if (fs.existsSync(libDir) && fs.statSync(libDir).isDirectory()) {
|
|
119
|
+
dirs.push(libDir);
|
|
120
|
+
}
|
|
121
|
+
if (recursive) {
|
|
122
|
+
walkForLibDirs(startDir, dirs, 0);
|
|
123
|
+
}
|
|
124
|
+
return [...new Set(dirs)]; // deduplicate
|
|
125
|
+
}
|
|
126
|
+
/** Recursively walk looking for lib/ dirs (max depth 5, skip node_modules/.git) */
|
|
127
|
+
function walkForLibDirs(dir, result, depth) {
|
|
128
|
+
if (depth > 5)
|
|
129
|
+
return;
|
|
130
|
+
const SKIP = new Set(['node_modules', '.git', '.cache', '.bun', 'dist', 'build']);
|
|
131
|
+
let entries;
|
|
132
|
+
try {
|
|
133
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
for (const entry of entries) {
|
|
139
|
+
if (!entry.isDirectory() || SKIP.has(entry.name))
|
|
140
|
+
continue;
|
|
141
|
+
const full = path.join(dir, entry.name);
|
|
142
|
+
if (entry.name === 'lib') {
|
|
143
|
+
result.push(full);
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
walkForLibDirs(full, result, depth + 1);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** Read metadata from all .tgz files in the given lib directories. */
|
|
151
|
+
async function discoverTgzMetadata(libDirs) {
|
|
152
|
+
const results = [];
|
|
153
|
+
const { list } = await import('tar');
|
|
154
|
+
for (const libDir of libDirs) {
|
|
155
|
+
const files = fs.readdirSync(libDir).filter(f => f.endsWith('.tgz'));
|
|
156
|
+
for (const file of files) {
|
|
157
|
+
const tgzPath = path.join(libDir, file);
|
|
158
|
+
const meta = await readTgzMetadata(tgzPath, list);
|
|
159
|
+
if (meta)
|
|
160
|
+
results.push(meta);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return results;
|
|
164
|
+
}
|
|
165
|
+
/** Extract package.json from a tgz and read fhir.ig + fhir.version metadata. */
|
|
166
|
+
async function readTgzMetadata(tgzPath, listFn) {
|
|
167
|
+
let pkgJsonContent = '';
|
|
168
|
+
// Stream through the tgz looking for package/package.json
|
|
169
|
+
await listFn({
|
|
170
|
+
file: tgzPath,
|
|
171
|
+
onReadEntry: (entry) => {
|
|
172
|
+
const entryPath = entry.path.replace(/\\/g, '/');
|
|
173
|
+
if (entryPath === 'package/package.json' || entryPath === 'package.json') {
|
|
174
|
+
const chunks = [];
|
|
175
|
+
entry.on('data', (chunk) => chunks.push(chunk));
|
|
176
|
+
entry.on('end', () => { pkgJsonContent = Buffer.concat(chunks).toString('utf8'); });
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
entry.resume(); // skip other entries
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
if (!pkgJsonContent)
|
|
184
|
+
return null;
|
|
185
|
+
try {
|
|
186
|
+
const pkg = JSON.parse(pkgJsonContent);
|
|
187
|
+
const igName = pkg?.fhir?.ig;
|
|
188
|
+
const igVersion = pkg?.fhir?.version;
|
|
189
|
+
if (!igName || !igVersion)
|
|
190
|
+
return null;
|
|
191
|
+
return {
|
|
192
|
+
tgzPath,
|
|
193
|
+
generatedName: pkg.name || '',
|
|
194
|
+
igName,
|
|
195
|
+
igVersion,
|
|
196
|
+
...(pkg?.fhir?.txServer && { txServer: pkg.fhir.txServer }),
|
|
197
|
+
...(pkg?.fhir?.displayLanguage && { displayLanguage: pkg.fhir.displayLanguage }),
|
|
198
|
+
...(pkg?.fhir?.dicomweb && { dicomweb: true }),
|
|
199
|
+
...(pkg?.fhir?.noClient && { noClient: true }),
|
|
200
|
+
...(pkg?.fhir?.noClasses && { noClasses: true }),
|
|
201
|
+
...(pkg?.fhir?.schema && { schema: pkg.fhir.schema }),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// ── Filtering ───────────────────────────────────────────────────────────────
|
|
209
|
+
/** Filter metadata list by a package spec like "hl7.fhir.us.core" or "hl7.fhir.us.core@8.0.0" */
|
|
210
|
+
function filterBySpec(all, spec) {
|
|
211
|
+
const atIdx = spec.lastIndexOf('@');
|
|
212
|
+
if (atIdx > 0) {
|
|
213
|
+
const name = spec.slice(0, atIdx);
|
|
214
|
+
const version = spec.slice(atIdx + 1);
|
|
215
|
+
return all.filter(t => t.igName === name && t.igVersion === version);
|
|
216
|
+
}
|
|
217
|
+
// No version — match by name only
|
|
218
|
+
return all.filter(t => t.igName === spec);
|
|
219
|
+
}
|
|
220
|
+
// ── Regeneration ────────────────────────────────────────────────────────────
|
|
221
|
+
async function regeneratePackage(target, generationFlags, registry, downloadPackage) {
|
|
222
|
+
const spec = `${target.igName}@${target.igVersion}`;
|
|
223
|
+
console.log(`\nUpdating ${spec}...`);
|
|
224
|
+
// Locate the source package: check FHIR cache first, download if needed
|
|
225
|
+
const sourcePath = await resolveSourcePackage(target.igName, target.igVersion, registry, downloadPackage);
|
|
226
|
+
// Merge generation flags from package metadata (CLI flags take priority)
|
|
227
|
+
const mergedFlags = { ...generationFlags };
|
|
228
|
+
if (target.txServer && !mergedFlags.txServer) {
|
|
229
|
+
mergedFlags.txServer = target.txServer;
|
|
230
|
+
console.log(` Using tx-server from package metadata: ${target.txServer}`);
|
|
231
|
+
}
|
|
232
|
+
if (target.displayLanguage && !mergedFlags.displayLanguage) {
|
|
233
|
+
mergedFlags.displayLanguage = target.displayLanguage;
|
|
234
|
+
console.log(` Using display-language from package metadata: ${target.displayLanguage}`);
|
|
235
|
+
}
|
|
236
|
+
if (target.dicomweb && mergedFlags.dicomweb === undefined)
|
|
237
|
+
mergedFlags.dicomweb = true;
|
|
238
|
+
if (target.noClient && mergedFlags.noClient === undefined)
|
|
239
|
+
mergedFlags.noClient = true;
|
|
240
|
+
if (target.noClasses && mergedFlags.noClasses === undefined)
|
|
241
|
+
mergedFlags.noClasses = true;
|
|
242
|
+
if (target.schema && !mergedFlags.schema)
|
|
243
|
+
mergedFlags.schema = target.schema;
|
|
244
|
+
resetFetchFailureTracking();
|
|
245
|
+
resetDiagnostics();
|
|
246
|
+
// Generate
|
|
247
|
+
const { generatedDir, cleanup } = await generateIntoPackageDirect(sourcePath, mergedFlags);
|
|
248
|
+
try {
|
|
249
|
+
// Repack the tgz at the same path
|
|
250
|
+
const { create } = await import('tar');
|
|
251
|
+
await create({ gzip: true, file: target.tgzPath, cwd: generatedDir, prefix: 'package' }, ['.']);
|
|
252
|
+
// Clear bun cache for this package (same as install command)
|
|
253
|
+
clearBunCacheForPackage(target.generatedName);
|
|
254
|
+
console.log(`✓ Regenerated: ${path.basename(target.tgzPath)}`);
|
|
255
|
+
const report = buildQualityReport();
|
|
256
|
+
const summary = formatReportSummary(report);
|
|
257
|
+
if (summary.trim())
|
|
258
|
+
console.log(summary);
|
|
259
|
+
}
|
|
260
|
+
finally {
|
|
261
|
+
cleanup();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/** Find the source .tgz in the FHIR cache, or download it. */
|
|
265
|
+
async function resolveSourcePackage(igName, igVersion, registry, downloadPackage) {
|
|
266
|
+
const cacheDir = getFhirPackagesCacheDir();
|
|
267
|
+
const tgzPath = path.join(cacheDir, `${igName}-${igVersion}.tgz`);
|
|
268
|
+
const extractedDir = path.join(cacheDir, `${igName}@${igVersion}`);
|
|
269
|
+
// If extracted dir exists (most common case after first install), return the tgz path
|
|
270
|
+
if (fs.existsSync(extractedDir) && fs.readdirSync(extractedDir).length > 0) {
|
|
271
|
+
// The tgz may or may not exist — generateIntoPackageDirect can work with either
|
|
272
|
+
if (fs.existsSync(tgzPath))
|
|
273
|
+
return tgzPath;
|
|
274
|
+
// If only extracted dir exists, we need to find or create a tgz
|
|
275
|
+
// Try to repack from extracted dir
|
|
276
|
+
const { create } = await import('tar');
|
|
277
|
+
await create({ gzip: true, file: tgzPath, cwd: extractedDir }, ['.']);
|
|
278
|
+
return tgzPath;
|
|
279
|
+
}
|
|
280
|
+
// Not cached — download
|
|
281
|
+
console.log(` Package not in cache, downloading ${igName}@${igVersion}...`);
|
|
282
|
+
return downloadPackage(`${igName}@${igVersion}`, registry);
|
|
283
|
+
}
|
|
284
|
+
// ── Bun lockfile integrity patching ──────────────────────────────────────────
|
|
285
|
+
/**
|
|
286
|
+
* Surgically update SHA-512 integrity hashes in bun.lock for regenerated tgz files.
|
|
287
|
+
*
|
|
288
|
+
* Bun stores integrity hashes in bun.lock (text format) as the third element
|
|
289
|
+
* of each package entry array: `["pkg@path", {deps}, "sha512-<base64>"]`.
|
|
290
|
+
* When a tgz is regenerated, the old hash becomes stale and `bun install`
|
|
291
|
+
* fails with "Integrity check failed". Instead of deleting the entire lockfile
|
|
292
|
+
* (which re-resolves ALL packages), we compute the new hash and patch only
|
|
293
|
+
* the affected entries.
|
|
294
|
+
*
|
|
295
|
+
* @see https://github.com/oven-sh/bun/issues/29372
|
|
296
|
+
*/
|
|
297
|
+
export function patchBunLockIntegrity(updatedPackages) {
|
|
298
|
+
const pm = detectPackageManager();
|
|
299
|
+
if (!pm.cmd.includes('bun'))
|
|
300
|
+
return;
|
|
301
|
+
const lockPath = path.resolve('bun.lock');
|
|
302
|
+
if (!fs.existsSync(lockPath))
|
|
303
|
+
return;
|
|
304
|
+
let lockContent = fs.readFileSync(lockPath, 'utf8');
|
|
305
|
+
let patched = 0;
|
|
306
|
+
for (const { generatedName, tgzPath } of updatedPackages) {
|
|
307
|
+
// Compute new SHA-512 integrity hash
|
|
308
|
+
const tgzBytes = fs.readFileSync(tgzPath);
|
|
309
|
+
const hash = crypto.createHash('sha512').update(tgzBytes).digest('base64');
|
|
310
|
+
const newIntegrity = `sha512-${hash}`;
|
|
311
|
+
// Match all entries for this package in bun.lock:
|
|
312
|
+
// "hl7.fhir.uv.ips-generated": [..., "sha512-oldHash=="],
|
|
313
|
+
// "workspace/hl7.fhir.uv.ips-generated": [..., "sha512-oldHash=="],
|
|
314
|
+
const pattern = new RegExp(`("(?:[^"]*\\/)?${escapeRegExp(generatedName)}"\\s*:\\s*\\[.*?)(sha512-[A-Za-z0-9+/]+=*)`, 'g');
|
|
315
|
+
lockContent = lockContent.replace(pattern, (_, prefix, _oldHash) => {
|
|
316
|
+
patched++;
|
|
317
|
+
return `${prefix}${newIntegrity}`;
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
if (patched > 0) {
|
|
321
|
+
fs.writeFileSync(lockPath, lockContent, 'utf8');
|
|
322
|
+
console.log(`Patched ${patched} integrity hash${patched === 1 ? '' : 'es'} in bun.lock`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
function escapeRegExp(s) {
|
|
326
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
327
|
+
}
|
|
328
|
+
// ── Bun cache workaround ────────────────────────────────────────────────────
|
|
329
|
+
function clearBunCacheForPackage(packageName) {
|
|
330
|
+
const pm = detectPackageManager();
|
|
331
|
+
if (!pm.cmd.includes('bun'))
|
|
332
|
+
return;
|
|
333
|
+
let dir = process.cwd();
|
|
334
|
+
while (true) {
|
|
335
|
+
const candidate = path.join(dir, 'node_modules', '.bun');
|
|
336
|
+
if (fs.existsSync(candidate)) {
|
|
337
|
+
const prefix = `${packageName}@`;
|
|
338
|
+
for (const entry of fs.readdirSync(candidate, { withFileTypes: true })) {
|
|
339
|
+
if (entry.isDirectory() && entry.name.startsWith(prefix)) {
|
|
340
|
+
fs.rmSync(path.join(candidate, entry.name), { recursive: true, force: true });
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
const parent = path.dirname(dir);
|
|
346
|
+
if (parent === dir)
|
|
347
|
+
break;
|
|
348
|
+
dir = parent;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
// ── Package manager install ─────────────────────────────────────────────────
|
|
352
|
+
function runInstall() {
|
|
353
|
+
return new Promise((resolve, reject) => {
|
|
354
|
+
const pm = detectPackageManager();
|
|
355
|
+
const pmName = path.basename(pm.cmd).replace(/\.(cmd|exe)$/, '');
|
|
356
|
+
console.log(`Running ${pmName} install...`);
|
|
357
|
+
const child = spawn(pm.cmd, ['install'], { stdio: 'inherit', shell: true });
|
|
358
|
+
child.on('exit', (code) => code === 0 ? resolve() : reject(new Error(`${pmName} install failed (exit ${code})`)));
|
|
359
|
+
child.on('error', (err) => reject(new Error(`Failed to run ${pmName}: ${err.message}`)));
|
|
360
|
+
});
|
|
361
|
+
}
|
|
@@ -414,11 +414,6 @@ export function processFields(ctx, fields, parentInterfaceName, parentFieldType,
|
|
|
414
414
|
treatAsNested = false;
|
|
415
415
|
}
|
|
416
416
|
else if (!isLogicalModelForNested) {
|
|
417
|
-
// Normally we flatten direct children of the base resource (e.g., DocumentReference.content)
|
|
418
|
-
// so we don't re-define backbone interfaces unnecessarily. However, if there are
|
|
419
|
-
// profiled descendants (e.g., DocumentReference.content.attachment with a profiled Attachment),
|
|
420
|
-
// we MUST keep this as a distinct nested interface so we can override those descendants while
|
|
421
|
-
// still extending the original backbone element type (e.g., DocumentReferenceContent).
|
|
422
417
|
const hasProfiledDescendant = fields.some(f => f.name.startsWith(field.name + '.') && f.isProfiled);
|
|
423
418
|
// Also keep as nested when the element has typed extension slices (e.g., Encounter.hospitalization.extension)
|
|
424
419
|
const hasChildExtSlices = fields.some(f => f.name === `${field.name}.extension` && f.sliceName && (f.profileUrls || []).length > 0);
|
|
@@ -428,8 +423,11 @@ export function processFields(ctx, fields, parentInterfaceName, parentFieldType,
|
|
|
428
423
|
else {
|
|
429
424
|
// Special case: primitive direct base child with child extension slices (e.g., gender)
|
|
430
425
|
// should NOT be modeled as a nested interface; the extension must live on sidecar _field Element.
|
|
426
|
+
// Use field.type first — baseFieldByLastSegment can return wrong type due to
|
|
427
|
+
// name collisions (e.g., 'text' resolves to Questionnaire.text=Narrative
|
|
428
|
+
// instead of Questionnaire.item.text=string).
|
|
431
429
|
const baseFld = baseFieldByLastSegment.get(fieldName);
|
|
432
|
-
const mappedBase = mapTypeToTS((
|
|
430
|
+
const mappedBase = mapTypeToTS((field.type || baseFld?.type || 'string'));
|
|
433
431
|
if (interfaceName.includes('address') && interfaceName.includes('0_2') && fieldName === 'line') {
|
|
434
432
|
logger.debug('[CHECK treatAsNested for line]', { hasChildExtSlices, isPrimitiveType: isPrimitiveType(mappedBase), mappedBase, baseFld: baseFld?.type, fieldType: field.type });
|
|
435
433
|
}
|
|
@@ -7,7 +7,7 @@ import { getRules, ctx as versionCtx } from '../../fhir/versionContext.js';
|
|
|
7
7
|
import { postProcessExtensions, safetyNetRootExtensions } from './postProcessExtensions.js';
|
|
8
8
|
import { processFields } from './interfaceFieldProcessor.js';
|
|
9
9
|
const log = logger.withTag('interfaces');
|
|
10
|
-
export function generateInterfaces(interfaceName, newFields, baseResource, baseFields = [], valueSets, resourceType, existingProfiles, fhirChildTypeMap, profileIdToName, profileUrlToName) {
|
|
10
|
+
export function generateInterfaces(interfaceName, newFields, baseResource, baseFields = [], valueSets, resourceType, existingProfiles, fhirChildTypeMap, profileIdToName, profileUrlToName, isLogicalModel = false) {
|
|
11
11
|
const debug = (...args) => log.debug(...args);
|
|
12
12
|
// Use FHIR rules from version context
|
|
13
13
|
const rules = getRules();
|
|
@@ -151,6 +151,7 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
|
|
|
151
151
|
resourceType, existingProfiles, fhirChildTypeMap, profileUrlToName,
|
|
152
152
|
interfaces, localInterfaceNames, writtenLines, generatedAliasTypes,
|
|
153
153
|
importManager, baseFieldByLastSegment, baseFieldAnyArray,
|
|
154
|
+
isLogicalModel,
|
|
154
155
|
debug, addTypeImport, getBaseTypeForField, findInterfaceIndex,
|
|
155
156
|
inferBackboneType, applyBindingConstraint,
|
|
156
157
|
};
|
|
@@ -289,35 +289,60 @@ export function postProcessExtensions(params) {
|
|
|
289
289
|
}
|
|
290
290
|
const union = ['Extension', ...aliases].join(' | ');
|
|
291
291
|
const lvl2Name = `${lvl1Name}${capitalize(seg2)}`;
|
|
292
|
-
// Try to get base field type for seg2, then infer extend type
|
|
292
|
+
// Try to get base field type for seg2, then infer extend type.
|
|
293
|
+
// First check actual field type from newFields (path-aware) to avoid
|
|
294
|
+
// baseFieldByLastSegment collisions (e.g., 'text' → Questionnaire.text=Narrative
|
|
295
|
+
// instead of Questionnaire.item.text=string).
|
|
296
|
+
const actualField2 = newFields.find(f => {
|
|
297
|
+
const parts = f.name.split('.');
|
|
298
|
+
return parts.length >= 3 && parts[parts.length - 1] === seg2 && parts[parts.length - 2] === seg1;
|
|
299
|
+
});
|
|
293
300
|
const baseFld2 = baseFieldByLastSegment.get(seg2);
|
|
294
|
-
let extendT2 = baseFld2?.type;
|
|
301
|
+
let extendT2 = actualField2?.type || baseFld2?.type;
|
|
295
302
|
// Fallback to specific mappings if needed
|
|
296
303
|
if (!extendT2 || extendT2 === 'any' || extendT2 === 'BackboneElement') {
|
|
297
304
|
if (baseResource === 'Encounter' && seg1 === 'hospitalization' && seg2 === 'dischargeDisposition') {
|
|
298
305
|
extendT2 = 'CodeableConcept';
|
|
299
306
|
}
|
|
300
307
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
308
|
+
// If the actual field type is primitive, use Element sidecar pattern (_text, _prefix)
|
|
309
|
+
// instead of promoting to a complex interface. This avoids turning string fields
|
|
310
|
+
// into Narrative-extending interfaces when they have rendering extensions.
|
|
311
|
+
const extendT2TS = mapTypeToTS(extendT2 || 'string');
|
|
312
|
+
if (isPrimitiveLike(extendT2TS)) {
|
|
313
|
+
const elementIface = `${lvl1Name}${capitalize(seg2)}Element`;
|
|
314
|
+
importManager.addFhirType('Element');
|
|
315
|
+
if (!interfaces.some(i => i.startsWith(`export interface ${elementIface}`))) {
|
|
316
|
+
interfaces.push(`export interface ${elementIface} extends Element {\n extension?: (${union})[];\n}`);
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
appendToInterface(elementIface, `extension?: (${union})[];`);
|
|
320
|
+
}
|
|
321
|
+
const opt2 = actualField2 ? (actualField2.isOptional ? '?' : '') : (baseFld2 ? (baseFld2.isOptional ? '?' : '') : '?');
|
|
322
|
+
appendToInterface(lvl1Name, `_${seg2}${opt2}: ${elementIface};`);
|
|
306
323
|
}
|
|
307
324
|
else {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
325
|
+
if (extendT2 && isFhirType(extendT2))
|
|
326
|
+
addTypeImport(extendT2);
|
|
327
|
+
const extendsClause2 = (extendT2 && !isPrimitiveType(extendT2)) ? ` extends ${extendT2}` : '';
|
|
328
|
+
if (!interfaces.some(i => i.startsWith(`export interface ${lvl2Name}`))) {
|
|
329
|
+
interfaces.push(`export interface ${lvl2Name}${extendsClause2} {\n extension?: (${union})[];\n}`);
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
appendToInterface(lvl2Name, `extension?: (${union})[];`);
|
|
333
|
+
}
|
|
334
|
+
// Add property on level 1 interface - preserve arrayness from base
|
|
335
|
+
let isArr2 = baseFld2?.isArray;
|
|
336
|
+
if (isArr2 === undefined && extendT1) {
|
|
337
|
+
// Use FHIR type knowledge for the parent type (extendT1 is the type of lvl1)
|
|
338
|
+
isArr2 = isFhirFieldArray(extendT1, seg2);
|
|
339
|
+
}
|
|
340
|
+
const arrSuffix = isArr2 ? '[]' : '';
|
|
341
|
+
if (seg1 === 'code' || seg2 === 'coding') {
|
|
342
|
+
logger.debug('[DEBUG lvl2 property]', { seg1, seg2, baseFld2IsArray: baseFld2?.isArray, extendT1, isFhirCheck: isArr2, arrSuffix, lvl1Name, lvl2Name });
|
|
343
|
+
}
|
|
344
|
+
appendToInterface(lvl1Name, `${seg2}: ${lvl2Name}${arrSuffix};`);
|
|
319
345
|
}
|
|
320
|
-
appendToInterface(lvl1Name, `${seg2}: ${lvl2Name}${arrSuffix};`);
|
|
321
346
|
}
|
|
322
347
|
}
|
|
323
348
|
}
|