babelfhir-ts 1.3.10 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/README.md +59 -1
  2. package/out/src/cli/installCommand.js +52 -1
  3. package/out/src/cli/updateCommand.js +444 -0
  4. package/out/src/generator/emitters/interface/interfaceFieldProcessor.js +4 -6
  5. package/out/src/generator/emitters/interface/interfaceGenerator.js +2 -1
  6. package/out/src/generator/emitters/interface/postProcessExtensions.js +44 -19
  7. package/out/src/generator/emitters/interface/processNestedField.js +12 -4
  8. package/out/src/generator/emitters/validator/sliceBackboneValidation.js +11 -7
  9. package/out/src/generator/emitters/validator/sliceValidatorGenerator.js +8 -4
  10. package/out/src/generator/emitters/validator/sliceValidatorUtils.js +69 -0
  11. package/out/src/generator/emitters/validator/validatorFieldBuilders.js +78 -1
  12. package/out/src/generator/emitters/validator/validatorGenerator.js +5 -4
  13. package/out/src/generator/emitters/valueset/valueSetGenerator.js +15 -1
  14. package/out/src/generator/generationHelpers.js +58 -3
  15. package/out/src/generator/index.js +28 -11
  16. package/out/src/generator/parser/packageParser.js +20 -6
  17. package/out/src/generator/parser/sdFetcher.js +14 -14
  18. package/out/src/generator/parser/sdParser.js +4 -2
  19. package/out/src/generator/parser/txClient.js +59 -4
  20. package/out/src/generator/parser/vsParser.js +6 -2
  21. package/out/src/generator/sdProcessor.js +1 -1
  22. package/out/src/generator/sdProcessorHelpers.js +2 -1
  23. package/out/src/main.js +90 -13
  24. package/package.json +1 -1
  25. /package/out/src/generator/fhir/r4/{base/backport-subscription.json → backport-subscription.json} +0 -0
  26. /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,65 @@ 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(s) for display terms (e.g., de or de,fr,en).
244
+ Single value replaces concept displays. Comma-separated values
245
+ also generate a multi-language display map with getDisplay() helper.
246
+
247
+ Examples:
248
+ babelfhir-ts # Process ./input to ./output
249
+ babelfhir-ts http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient # Generate from profile URL
250
+ babelfhir-ts package.tgz # Process package to current directory
251
+ babelfhir-ts package.tgz modified-package.tgz # Embed interfaces in package
252
+ babelfhir-ts profiles/ generated/ # Process directory to directory
253
+ babelfhir-ts --package hl7.fhir.us.core@8.0.0 # Download and process from default registry
254
+ babelfhir-ts --package hl7.fhir.us.core@8.0.0 output/ # Download and output to directory
255
+ babelfhir-ts --package pkg@version --log console --log-level debug # With verbose logging
256
+ babelfhir-ts install de.gematik.isik-basismodul # Download latest, process, and install
257
+ babelfhir-ts install de.gematik.isik-basismodul@3.1.0 # Download specific version
258
+ babelfhir-ts install ./package.tgz # Install from local package file
259
+ babelfhir-ts install hl7.fhir.us.core@8.0.0 --registry <url> # Install from custom registry
260
+ babelfhir-ts install --package hl7.fhir.us.core@8.0.0 --registry <url> # Alternative syntax
261
+ babelfhir-ts update # Regenerate all packages in ./lib
262
+ babelfhir-ts update hl7.fhir.us.core@8.0.0 # Regenerate a specific package
263
+ babelfhir-ts update --recursive # Regenerate packages in all subdirectories
206
264
  ```
207
265
  <!-- CLI_HELP_END -->
208
266
 
@@ -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: ['install'],
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,444 @@
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 os from 'os';
14
+ import path from 'path';
15
+ import crypto from 'crypto';
16
+ import { generateIntoPackageDirect, resetFetchFailureTracking } from '../generator/index.js';
17
+ import { getFhirPackagesCacheDir } from '../generator/core/cacheConfig.js';
18
+ import { resetDiagnostics, buildQualityReport, formatReportSummary } from '../generator/core/sdDiagnostics.js';
19
+ import { detectPackageManager } from './installCommand.js';
20
+ import { spawn } from 'child_process';
21
+ // ── Public entry point ──────────────────────────────────────────────────────
22
+ export async function handleUpdateCommand(opts) {
23
+ const { packageFilter, recursive, generationFlags, registry, downloadPackage, confirm } = opts;
24
+ const startDir = process.cwd();
25
+ // 1. Discover tgz files
26
+ const libDirs = discoverLibDirs(startDir, recursive ?? false);
27
+ if (libDirs.length === 0) {
28
+ if (packageFilter) {
29
+ return await promptInstallRedirect(packageFilter, confirm);
30
+ }
31
+ console.log('No lib/ directories found. Nothing to update.');
32
+ return {};
33
+ }
34
+ // 2. Read metadata from each tgz
35
+ const allTgz = await discoverTgzMetadata(libDirs);
36
+ if (allTgz.length === 0) {
37
+ if (packageFilter) {
38
+ return await promptInstallRedirect(packageFilter, confirm);
39
+ }
40
+ console.log('No generated packages found in lib/ directories.');
41
+ return {};
42
+ }
43
+ // 3. Filter if a specific package was requested
44
+ const targets = packageFilter ? filterBySpec(allTgz, packageFilter) : allTgz;
45
+ if (targets.length === 0 && packageFilter) {
46
+ console.log(`Package "${packageFilter}" is not installed.`);
47
+ if (allTgz.length > 0) {
48
+ console.log('Installed packages:');
49
+ for (const t of allTgz)
50
+ console.log(` ${t.igName}@${t.igVersion}`);
51
+ }
52
+ return await promptInstallRedirect(packageFilter, confirm);
53
+ }
54
+ console.log(`Found ${targets.length} package${targets.length === 1 ? '' : 's'} to update:\n`);
55
+ for (const t of targets)
56
+ console.log(` ${t.igName}@${t.igVersion} → ${path.relative(startDir, t.tgzPath)}`);
57
+ console.log('');
58
+ // 4. Regenerate each package
59
+ let updated = 0;
60
+ let failed = 0;
61
+ const updatedPackages = [];
62
+ for (const target of targets) {
63
+ try {
64
+ await regeneratePackage(target, generationFlags, registry, downloadPackage);
65
+ updatedPackages.push({ generatedName: target.generatedName, tgzPath: target.tgzPath });
66
+ updated++;
67
+ }
68
+ catch (err) {
69
+ failed++;
70
+ console.error(`✗ Failed to update ${target.igName}@${target.igVersion}: ${err.message}`);
71
+ }
72
+ }
73
+ // 5. Patch bun.lock integrity hashes for regenerated packages (avoids full lockfile delete)
74
+ if (updatedPackages.length > 0) {
75
+ patchBunLockIntegrity(updatedPackages);
76
+ }
77
+ // 5b. Remove stale node_modules/<pkg> so bun re-extracts from the updated tgz
78
+ if (updatedPackages.length > 0) {
79
+ removeStaleNodeModules(updatedPackages.map((p) => p.generatedName));
80
+ }
81
+ // 6. Single npm install at the end
82
+ if (updated > 0) {
83
+ console.log('\nReinstalling dependencies...');
84
+ await runInstall();
85
+ }
86
+ // 7. Summary
87
+ console.log('');
88
+ if (updated > 0)
89
+ console.log(`✓ Updated ${updated} package${updated === 1 ? '' : 's'}`);
90
+ if (failed > 0)
91
+ console.error(`✗ ${failed} package${failed === 1 ? '' : 's'} failed`);
92
+ return {};
93
+ }
94
+ // ── Install redirect prompt ─────────────────────────────────────────────────
95
+ async function promptInstallRedirect(packageFilter, confirm) {
96
+ const promptFn = confirm ?? defaultConfirm;
97
+ const yes = await promptFn(`Would you like to install ${packageFilter} instead? (Y/n) `);
98
+ if (yes)
99
+ return { redirectInstall: packageFilter };
100
+ return {};
101
+ }
102
+ /** Default Y/N prompt reading from stdin. */
103
+ function defaultConfirm(question) {
104
+ return new Promise((resolve) => {
105
+ process.stdout.write(question);
106
+ const rl = (async () => {
107
+ const { createInterface } = await import('readline');
108
+ return createInterface({ input: process.stdin, output: process.stdout });
109
+ })();
110
+ rl.then(r => {
111
+ r.question('', (answer) => {
112
+ r.close();
113
+ resolve(!answer || answer.toLowerCase().startsWith('y'));
114
+ });
115
+ });
116
+ });
117
+ }
118
+ // ── Discovery ───────────────────────────────────────────────────────────────
119
+ /** Find all lib/ directories starting from startDir. Non-recursive returns only startDir/lib. */
120
+ function discoverLibDirs(startDir, recursive) {
121
+ const dirs = [];
122
+ const libDir = path.join(startDir, 'lib');
123
+ if (fs.existsSync(libDir) && fs.statSync(libDir).isDirectory()) {
124
+ dirs.push(libDir);
125
+ }
126
+ if (recursive) {
127
+ walkForLibDirs(startDir, dirs, 0);
128
+ }
129
+ return [...new Set(dirs)]; // deduplicate
130
+ }
131
+ /** Recursively walk looking for lib/ dirs (max depth 5, skip node_modules/.git) */
132
+ function walkForLibDirs(dir, result, depth) {
133
+ if (depth > 5)
134
+ return;
135
+ const SKIP = new Set(['node_modules', '.git', '.cache', '.bun', 'dist', 'build']);
136
+ let entries;
137
+ try {
138
+ entries = fs.readdirSync(dir, { withFileTypes: true });
139
+ }
140
+ catch {
141
+ return;
142
+ }
143
+ for (const entry of entries) {
144
+ if (!entry.isDirectory() || SKIP.has(entry.name))
145
+ continue;
146
+ const full = path.join(dir, entry.name);
147
+ if (entry.name === 'lib') {
148
+ result.push(full);
149
+ }
150
+ else {
151
+ walkForLibDirs(full, result, depth + 1);
152
+ }
153
+ }
154
+ }
155
+ /** Read metadata from all .tgz files in the given lib directories. */
156
+ async function discoverTgzMetadata(libDirs) {
157
+ const results = [];
158
+ const { list } = await import('tar');
159
+ for (const libDir of libDirs) {
160
+ const files = fs.readdirSync(libDir).filter(f => f.endsWith('.tgz'));
161
+ for (const file of files) {
162
+ const tgzPath = path.join(libDir, file);
163
+ const meta = await readTgzMetadata(tgzPath, list);
164
+ if (meta)
165
+ results.push(meta);
166
+ }
167
+ }
168
+ return results;
169
+ }
170
+ /** Extract package.json from a tgz and read fhir.ig + fhir.version metadata. */
171
+ async function readTgzMetadata(tgzPath, listFn) {
172
+ let pkgJsonContent = '';
173
+ // Stream through the tgz looking for package/package.json
174
+ await listFn({
175
+ file: tgzPath,
176
+ onReadEntry: (entry) => {
177
+ const entryPath = entry.path.replace(/\\/g, '/');
178
+ if (entryPath === 'package/package.json' || entryPath === 'package.json') {
179
+ const chunks = [];
180
+ entry.on('data', (chunk) => chunks.push(chunk));
181
+ entry.on('end', () => { pkgJsonContent = Buffer.concat(chunks).toString('utf8'); });
182
+ }
183
+ else {
184
+ entry.resume(); // skip other entries
185
+ }
186
+ },
187
+ });
188
+ if (!pkgJsonContent)
189
+ return null;
190
+ try {
191
+ const pkg = JSON.parse(pkgJsonContent);
192
+ const igName = pkg?.fhir?.ig;
193
+ const igVersion = pkg?.fhir?.version;
194
+ if (!igName || !igVersion)
195
+ return null;
196
+ return {
197
+ tgzPath,
198
+ generatedName: pkg.name || '',
199
+ igName,
200
+ igVersion,
201
+ ...(pkg?.fhir?.txServer && { txServer: pkg.fhir.txServer }),
202
+ ...(pkg?.fhir?.displayLanguage && { displayLanguage: pkg.fhir.displayLanguage }),
203
+ ...(pkg?.fhir?.dicomweb && { dicomweb: true }),
204
+ ...(pkg?.fhir?.noClient && { noClient: true }),
205
+ ...(pkg?.fhir?.noClasses && { noClasses: true }),
206
+ ...(pkg?.fhir?.schema && { schema: pkg.fhir.schema }),
207
+ };
208
+ }
209
+ catch {
210
+ return null;
211
+ }
212
+ }
213
+ // ── Filtering ───────────────────────────────────────────────────────────────
214
+ /** Filter metadata list by a package spec like "hl7.fhir.us.core" or "hl7.fhir.us.core@8.0.0" */
215
+ function filterBySpec(all, spec) {
216
+ const atIdx = spec.lastIndexOf('@');
217
+ if (atIdx > 0) {
218
+ const name = spec.slice(0, atIdx);
219
+ const version = spec.slice(atIdx + 1);
220
+ return all.filter(t => t.igName === name && t.igVersion === version);
221
+ }
222
+ // No version — match by name only
223
+ return all.filter(t => t.igName === spec);
224
+ }
225
+ // ── Regeneration ────────────────────────────────────────────────────────────
226
+ async function regeneratePackage(target, generationFlags, registry, downloadPackage) {
227
+ const spec = `${target.igName}@${target.igVersion}`;
228
+ console.log(`\nUpdating ${spec}...`);
229
+ // Locate the source package: check FHIR cache first, download if needed
230
+ const sourcePath = await resolveSourcePackage(target.igName, target.igVersion, registry, downloadPackage);
231
+ // Merge generation flags from package metadata (CLI flags take priority)
232
+ const mergedFlags = { ...generationFlags };
233
+ if (target.txServer && !mergedFlags.txServer) {
234
+ mergedFlags.txServer = target.txServer;
235
+ console.log(` Using tx-server from package metadata: ${target.txServer}`);
236
+ }
237
+ if (target.displayLanguage && !mergedFlags.displayLanguage) {
238
+ mergedFlags.displayLanguage = target.displayLanguage;
239
+ console.log(` Using display-language from package metadata: ${target.displayLanguage}`);
240
+ }
241
+ if (target.dicomweb && mergedFlags.dicomweb === undefined)
242
+ mergedFlags.dicomweb = true;
243
+ if (target.noClient && mergedFlags.noClient === undefined)
244
+ mergedFlags.noClient = true;
245
+ if (target.noClasses && mergedFlags.noClasses === undefined)
246
+ mergedFlags.noClasses = true;
247
+ if (target.schema && !mergedFlags.schema)
248
+ mergedFlags.schema = target.schema;
249
+ resetFetchFailureTracking();
250
+ resetDiagnostics();
251
+ // Generate
252
+ const { generatedDir, cleanup } = await generateIntoPackageDirect(sourcePath, mergedFlags);
253
+ try {
254
+ // Repack the tgz at the same path
255
+ const { create } = await import('tar');
256
+ await create({ gzip: true, file: target.tgzPath, cwd: generatedDir, prefix: 'package' }, ['.']);
257
+ // Clear bun cache for this package (same as install command)
258
+ clearBunCacheForPackage(target.generatedName);
259
+ console.log(`✓ Regenerated: ${path.basename(target.tgzPath)}`);
260
+ const report = buildQualityReport();
261
+ const summary = formatReportSummary(report);
262
+ if (summary.trim())
263
+ console.log(summary);
264
+ }
265
+ finally {
266
+ cleanup();
267
+ }
268
+ }
269
+ /** Find the source .tgz in the FHIR cache, or download it. */
270
+ async function resolveSourcePackage(igName, igVersion, registry, downloadPackage) {
271
+ const cacheDir = getFhirPackagesCacheDir();
272
+ const tgzPath = path.join(cacheDir, `${igName}-${igVersion}.tgz`);
273
+ const extractedDir = path.join(cacheDir, `${igName}@${igVersion}`);
274
+ // If extracted dir exists (most common case after first install), return the tgz path
275
+ if (fs.existsSync(extractedDir) && fs.readdirSync(extractedDir).length > 0) {
276
+ // The tgz may or may not exist — generateIntoPackageDirect can work with either
277
+ if (fs.existsSync(tgzPath))
278
+ return tgzPath;
279
+ // If only extracted dir exists, we need to find or create a tgz
280
+ // Try to repack from extracted dir
281
+ const { create } = await import('tar');
282
+ await create({ gzip: true, file: tgzPath, cwd: extractedDir }, ['.']);
283
+ return tgzPath;
284
+ }
285
+ // Not cached — download
286
+ console.log(` Package not in cache, downloading ${igName}@${igVersion}...`);
287
+ return downloadPackage(`${igName}@${igVersion}`, registry);
288
+ }
289
+ // ── Bun lockfile integrity patching ──────────────────────────────────────────
290
+ /**
291
+ * Surgically update SHA-512 integrity hashes in bun.lock for regenerated tgz files.
292
+ *
293
+ * Bun stores integrity hashes in bun.lock (text format) as the third element
294
+ * of each package entry array: `["pkg@path", {deps}, "sha512-<base64>"]`.
295
+ * When a tgz is regenerated, the old hash becomes stale and `bun install`
296
+ * fails with "Integrity check failed". Instead of deleting the entire lockfile
297
+ * (which re-resolves ALL packages), we compute the new hash and patch only
298
+ * the affected entries.
299
+ *
300
+ * @see https://github.com/oven-sh/bun/issues/29372
301
+ */
302
+ export function patchBunLockIntegrity(updatedPackages) {
303
+ const pm = detectPackageManager();
304
+ if (!pm.cmd.includes('bun'))
305
+ return;
306
+ // Walk up to find bun.lock (in monorepos it's at the workspace root, not cwd)
307
+ let lockPath = null;
308
+ let dir = process.cwd();
309
+ while (true) {
310
+ const candidate = path.join(dir, 'bun.lock');
311
+ if (fs.existsSync(candidate)) {
312
+ lockPath = candidate;
313
+ break;
314
+ }
315
+ const parent = path.dirname(dir);
316
+ if (parent === dir)
317
+ break;
318
+ dir = parent;
319
+ }
320
+ if (!lockPath)
321
+ return;
322
+ let lockContent = fs.readFileSync(lockPath, 'utf8');
323
+ let patched = 0;
324
+ for (const { generatedName, tgzPath } of updatedPackages) {
325
+ // Compute new SHA-512 integrity hash
326
+ const tgzBytes = fs.readFileSync(tgzPath);
327
+ const hash = crypto.createHash('sha512').update(tgzBytes).digest('base64');
328
+ const newIntegrity = `sha512-${hash}`;
329
+ // Match all entries for this package in bun.lock:
330
+ // "hl7.fhir.uv.ips-generated": [..., "sha512-oldHash=="],
331
+ // "workspace/hl7.fhir.uv.ips-generated": [..., "sha512-oldHash=="],
332
+ const pattern = new RegExp(`("(?:[^"]*\\/)?${escapeRegExp(generatedName)}"\\s*:\\s*\\[.*?)(sha512-[A-Za-z0-9+/]+=*)`, 'g');
333
+ lockContent = lockContent.replace(pattern, (_, prefix, _oldHash) => {
334
+ patched++;
335
+ return `${prefix}${newIntegrity}`;
336
+ });
337
+ }
338
+ if (patched > 0) {
339
+ fs.writeFileSync(lockPath, lockContent, 'utf8');
340
+ console.log(`Patched ${patched} integrity hash${patched === 1 ? '' : 'es'} in bun.lock`);
341
+ }
342
+ }
343
+ function escapeRegExp(s) {
344
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
345
+ }
346
+ // ── Bun cache workaround ────────────────────────────────────────────────────
347
+ function clearBunCacheForPackage(packageName) {
348
+ const pm = detectPackageManager();
349
+ if (!pm.cmd.includes('bun'))
350
+ return;
351
+ let dir = process.cwd();
352
+ while (true) {
353
+ const candidate = path.join(dir, 'node_modules', '.bun');
354
+ if (fs.existsSync(candidate)) {
355
+ const prefix = `${packageName}@`;
356
+ for (const entry of fs.readdirSync(candidate, { withFileTypes: true })) {
357
+ if (entry.isDirectory() && entry.name.startsWith(prefix)) {
358
+ fs.rmSync(path.join(candidate, entry.name), { recursive: true, force: true });
359
+ }
360
+ }
361
+ break;
362
+ }
363
+ const parent = path.dirname(dir);
364
+ if (parent === dir)
365
+ break;
366
+ dir = parent;
367
+ }
368
+ }
369
+ /**
370
+ * Remove node_modules/<pkg> directories, bun cached extractions, and bun global
371
+ * cache entries for each updated package so bun re-extracts from the fresh tgz.
372
+ * Finds the workspace root first, then cleans all node_modules dirs from cwd up.
373
+ */
374
+ function removeStaleNodeModules(packageNames) {
375
+ // Find the workspace root: highest ancestor with a package.json
376
+ let root = process.cwd();
377
+ let search = root;
378
+ while (true) {
379
+ const parent = path.dirname(search);
380
+ if (parent === search)
381
+ break;
382
+ if (fs.existsSync(path.join(parent, 'package.json')))
383
+ root = parent;
384
+ search = parent;
385
+ }
386
+ // Walk from cwd up to (and including) the root, cleaning each node_modules
387
+ let dir = process.cwd();
388
+ while (true) {
389
+ const nm = path.join(dir, 'node_modules');
390
+ for (const name of packageNames) {
391
+ // Remove the symlink / junction / directory itself (lstat detects dangling junctions too)
392
+ const pkgDir = path.join(nm, name);
393
+ let pkgExists = false;
394
+ try {
395
+ fs.lstatSync(pkgDir);
396
+ pkgExists = true;
397
+ }
398
+ catch { /* does not exist */ }
399
+ if (pkgExists) {
400
+ fs.rmSync(pkgDir, { recursive: true, force: true });
401
+ }
402
+ // Remove bun's content-addressed cache entries (node_modules/.bun/<pkg>@*)
403
+ const bunDir = path.join(nm, '.bun');
404
+ if (fs.existsSync(bunDir)) {
405
+ const prefix = `${name}@`;
406
+ try {
407
+ for (const entry of fs.readdirSync(bunDir, { withFileTypes: true })) {
408
+ if (entry.isDirectory() && entry.name.startsWith(prefix)) {
409
+ fs.rmSync(path.join(bunDir, entry.name), { recursive: true, force: true });
410
+ }
411
+ }
412
+ }
413
+ catch { /* ignore read errors */ }
414
+ }
415
+ }
416
+ if (dir === root)
417
+ break;
418
+ const parent = path.dirname(dir);
419
+ if (parent === dir)
420
+ break;
421
+ dir = parent;
422
+ }
423
+ // Clear bun's global install cache (~/.bun/install/cache/<pkg>)
424
+ const bunGlobalCache = path.join(os.homedir(), '.bun', 'install', 'cache');
425
+ if (fs.existsSync(bunGlobalCache)) {
426
+ for (const name of packageNames) {
427
+ const cached = path.join(bunGlobalCache, name);
428
+ if (fs.existsSync(cached)) {
429
+ fs.rmSync(cached, { recursive: true, force: true });
430
+ }
431
+ }
432
+ }
433
+ }
434
+ // ── Package manager install ─────────────────────────────────────────────────
435
+ function runInstall() {
436
+ return new Promise((resolve, reject) => {
437
+ const pm = detectPackageManager();
438
+ const pmName = path.basename(pm.cmd).replace(/\.(cmd|exe)$/, '');
439
+ console.log(`Running ${pmName} install...`);
440
+ const child = spawn(pm.cmd, ['install'], { stdio: 'inherit', shell: true });
441
+ child.on('exit', (code) => code === 0 ? resolve() : reject(new Error(`${pmName} install failed (exit ${code})`)));
442
+ child.on('error', (err) => reject(new Error(`Failed to run ${pmName}: ${err.message}`)));
443
+ });
444
+ }
@@ -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((baseFld?.type || field.type || 'string'));
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
  };