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.
Files changed (24) hide show
  1. package/README.md +57 -1
  2. package/out/src/cli/installCommand.js +52 -1
  3. package/out/src/cli/updateCommand.js +361 -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/generationHelpers.js +2 -2
  14. package/out/src/generator/index.js +27 -11
  15. package/out/src/generator/parser/packageParser.js +20 -6
  16. package/out/src/generator/parser/sdFetcher.js +14 -14
  17. package/out/src/generator/parser/sdParser.js +4 -2
  18. package/out/src/generator/parser/txClient.js +9 -4
  19. package/out/src/generator/sdProcessor.js +1 -1
  20. package/out/src/generator/sdProcessorHelpers.js +2 -1
  21. package/out/src/main.js +88 -13
  22. package/package.json +1 -1
  23. /package/out/src/generator/fhir/r4/{base/backport-subscription.json → backport-subscription.json} +0 -0
  24. /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: ['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,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((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
  };
@@ -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
- if (extendT2 && isFhirType(extendT2))
302
- addTypeImport(extendT2);
303
- const extendsClause2 = (extendT2 && !isPrimitiveType(extendT2)) ? ` extends ${extendT2}` : '';
304
- if (!interfaces.some(i => i.startsWith(`export interface ${lvl2Name}`))) {
305
- interfaces.push(`export interface ${lvl2Name}${extendsClause2} {\n extension?: (${union})[];\n}`);
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
- appendToInterface(lvl2Name, `extension?: (${union})[];`);
309
- }
310
- // Add property on level 1 interface - preserve arrayness from base
311
- let isArr2 = baseFld2?.isArray;
312
- if (isArr2 === undefined && extendT1) {
313
- // Use FHIR type knowledge for the parent type (extendT1 is the type of lvl1)
314
- isArr2 = isFhirFieldArray(extendT1, seg2);
315
- }
316
- const arrSuffix = isArr2 ? '[]' : '';
317
- if (seg1 === 'code' || seg2 === 'coding') {
318
- logger.debug('[DEBUG lvl2 property]', { seg1, seg2, baseFld2IsArray: baseFld2?.isArray, extendT1, isFhirCheck: isArr2, arrSuffix, lvl1Name, lvl2Name });
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
  }