babelfhir-ts 1.5.2 → 1.5.4

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.
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Backbone slice typing: generates discriminated union types for sliced backbone elements.
3
+ *
4
+ * When a profile defines named slices on a backbone element array (e.g., Observation.component),
5
+ * this module generates per-slice interfaces extending the base backbone type and updates
6
+ * the parent array field to use a union of those interfaces.
7
+ *
8
+ * Example output for BloodPressure.component with systolic/diastolic slices:
9
+ * ```ts
10
+ * export interface BloodPressureComponentSystolic extends ObservationComponent {
11
+ * code: { coding: [{ system: "http://loinc.org"; code: "8480-6" }] };
12
+ * }
13
+ * export interface BloodPressureComponentDiastolic extends ObservationComponent {
14
+ * code: { coding: [{ system: "http://loinc.org"; code: "8462-4" }] };
15
+ * }
16
+ * export interface BloodPressure extends Observation {
17
+ * component?: (BloodPressureComponentSystolic | BloodPressureComponentDiastolic | ObservationComponent)[];
18
+ * }
19
+ * ```
20
+ */
21
+ import { capitalize, sanitizeIdentifier } from '../../core/utils.js';
22
+ import { getRules } from '../../fhir/versionContext.js';
23
+ import { logger } from '../../../logger.js';
24
+ const log = logger.withTag('backbone-slices');
25
+ /**
26
+ * Post-process backbone element slices: generate per-slice interfaces and update
27
+ * the parent array property to a union type.
28
+ */
29
+ export function generateBackboneSliceTypes(ctx) {
30
+ const { interfaceName, baseResource, newFields, baseFields, interfaces, importManager, inferBackboneType } = ctx;
31
+ const rules = getRules();
32
+ // 1. Find all sliced backbone element arrays
33
+ const sliceGroups = collectSliceGroups(newFields, baseFields, baseResource);
34
+ if (sliceGroups.length === 0)
35
+ return;
36
+ for (const group of sliceGroups) {
37
+ const { parentField, sliceFields, childFields, slicingRules } = group;
38
+ const fieldName = parentField.name.split('.').pop();
39
+ // Determine the base backbone type for this field
40
+ let backboneType = inferBackboneType(baseResource, fieldName);
41
+ if (!backboneType) {
42
+ // Try from base fields — only accept concrete FHIR types that we can
43
+ // actually extend (not 'BackboneElement', 'Element', 'any', or unknown types).
44
+ // BackboneElement/Element are too generic: the parent field expects a specific
45
+ // subtype (e.g. ExplanationOfBenefitItemAdjudication) and extending the raw
46
+ // generic type produces TS2430.
47
+ const baseFld = baseFields.find(f => f.name === parentField.name && !f.sliceName);
48
+ const rawType = baseFld?.type || parentField.type;
49
+ if (rawType && rawType !== 'BackboneElement' && rawType !== 'Element' && rules.isFhirType(rawType)) {
50
+ backboneType = rawType;
51
+ }
52
+ }
53
+ if (!backboneType) {
54
+ log.debug(`No backbone type found for ${parentField.name}, skipping slice typing`);
55
+ continue;
56
+ }
57
+ // Ensure the backbone type is imported
58
+ if (rules.isFhirType(backboneType)) {
59
+ importManager.addFhirType(backboneType);
60
+ }
61
+ // 2. Generate per-slice interfaces
62
+ const sliceTypeNames = [];
63
+ for (const sliceField of sliceFields) {
64
+ const sliceName = sliceField.sliceName;
65
+ const sliceTypeName = `${interfaceName}${capitalize(fieldName)}${capitalize(sanitizeIdentifier(sliceName))}`;
66
+ // Collect children for this specific slice
67
+ const sliceElementId = sliceField.elementId || `${parentField.name}:${sliceName}`;
68
+ const sliceChildren = childFields.filter(f => {
69
+ const childId = f.elementId || f.name;
70
+ return childId.startsWith(sliceElementId + '.');
71
+ });
72
+ // Build interface body lines from slice children and the slice's own constraints
73
+ const { lines: bodyLines, referencedFhirTypes } = buildSliceInterfaceBody(sliceField, sliceChildren, sliceElementId, backboneType);
74
+ if (bodyLines.length === 0) {
75
+ // No constrainable properties — skip this slice interface
76
+ log.debug(`No constraints for slice ${sliceName} of ${parentField.name}, skipping`);
77
+ continue;
78
+ }
79
+ // Register any FHIR types used in the slice body
80
+ for (const fhirType of referencedFhirTypes) {
81
+ importManager.addFhirType(fhirType);
82
+ }
83
+ // Emit the slice interface
84
+ const sliceInterface = `export interface ${sliceTypeName} extends ${backboneType} {\n${bodyLines.map(l => ` ${l}`).join('\n')}\n}`;
85
+ // Add before the root interface to maintain declaration order
86
+ const rootIdx = interfaces.findIndex(i => i.startsWith(`export interface ${interfaceName} `) || i.startsWith(`export interface ${interfaceName}<`));
87
+ if (rootIdx >= 0) {
88
+ interfaces.splice(rootIdx, 0, sliceInterface);
89
+ }
90
+ else {
91
+ interfaces.push(sliceInterface);
92
+ }
93
+ sliceTypeNames.push(sliceTypeName);
94
+ }
95
+ if (sliceTypeNames.length === 0)
96
+ continue;
97
+ // 3. Update the parent field's type in the root interface to use the union
98
+ const isOpen = slicingRules !== 'closed';
99
+ const unionMembers = [...sliceTypeNames];
100
+ if (isOpen) {
101
+ unionMembers.push(backboneType);
102
+ }
103
+ const unionType = unionMembers.length === 1
104
+ ? `${unionMembers[0]}[]`
105
+ : `(${unionMembers.join(' | ')})[]`;
106
+ // Find and update the field in the root interface
107
+ updateRootInterfaceField(interfaces, interfaceName, fieldName, parentField.isOptional, unionType, baseResource);
108
+ }
109
+ }
110
+ /**
111
+ * Collect sliced backbone element groups from the fields.
112
+ *
113
+ * Handles two scenarios:
114
+ * 1. The differential includes the unsliced parent field (e.g. Observation.component) —
115
+ * the parent field carries slicing metadata and baseTypeCode.
116
+ * 2. The differential only contains slice entries (e.g. Observation.component:confidence)
117
+ * without redeclaring the parent — common in downstream profiles. In this case the
118
+ * parent field is looked up from baseFields.
119
+ */
120
+ function collectSliceGroups(fields, baseFields, baseResource) {
121
+ const groups = [];
122
+ const seenPaths = new Set();
123
+ // Pass 1: parent field present in newFields
124
+ for (const field of fields) {
125
+ if (field.sliceName)
126
+ continue; // Skip slice entries themselves
127
+ if (!field.isArray)
128
+ continue; // Slicing is only on arrays
129
+ const path = field.name;
130
+ if (seenPaths.has(path))
131
+ continue;
132
+ const sliceFields = fields.filter(f => f.name === path && f.sliceName);
133
+ if (sliceFields.length === 0)
134
+ continue;
135
+ if (!isEligibleBackbonePath(field.baseTypeCode || field.type || '', path, baseResource))
136
+ continue;
137
+ const childFields = gatherChildFields(fields, path);
138
+ const slicingRules = field.slicingRules || 'open';
139
+ seenPaths.add(path);
140
+ groups.push({ parentField: field, sliceFields, childFields, slicingRules });
141
+ }
142
+ // Pass 2: orphaned slice entries whose parent path wasn't found in newFields.
143
+ // The differential may only contain slice entries (e.g. Observation.component:confidence)
144
+ // without the unsliced parent. Resolve the parent from baseFields.
145
+ for (const field of fields) {
146
+ if (!field.sliceName)
147
+ continue;
148
+ const path = field.name;
149
+ if (seenPaths.has(path))
150
+ continue;
151
+ // Find the parent field in baseFields
152
+ const parentField = baseFields.find(f => f.name === path && !f.sliceName);
153
+ if (!parentField)
154
+ continue;
155
+ if (!parentField.isArray)
156
+ continue;
157
+ if (!isEligibleBackbonePath(parentField.baseTypeCode || parentField.type || '', path, baseResource))
158
+ continue;
159
+ const sliceFields = fields.filter(f => f.name === path && f.sliceName);
160
+ if (sliceFields.length === 0)
161
+ continue;
162
+ const childFields = gatherChildFields(fields, path);
163
+ const slicingRules = parentField.slicingRules || 'open';
164
+ seenPaths.add(path);
165
+ groups.push({ parentField, sliceFields, childFields, slicingRules });
166
+ }
167
+ return groups;
168
+ }
169
+ /** Check whether a field path is eligible for backbone slice typing. */
170
+ function isEligibleBackbonePath(baseType, path, baseResource) {
171
+ if (baseType !== 'BackboneElement' && baseType !== 'Element')
172
+ return false;
173
+ const fieldName = path.split('.').pop() || '';
174
+ // Skip extension slices — handled separately by postProcessExtensions
175
+ if (fieldName === 'extension' || fieldName === 'modifierExtension')
176
+ return false;
177
+ // Skip Bundle.entry and similar resource-container slices
178
+ if (fieldName === 'entry')
179
+ return false;
180
+ // Must be a root-level field (direct child of the base resource)
181
+ const parts = path.split('.');
182
+ if (parts.length !== 2)
183
+ return false;
184
+ if (baseResource && parts[0] !== baseResource)
185
+ return false;
186
+ return true;
187
+ }
188
+ /** Gather all child fields for any slice of the given parent path. */
189
+ function gatherChildFields(fields, path) {
190
+ return fields.filter(f => {
191
+ if (f.name === path)
192
+ return false;
193
+ const childId = f.elementId || f.name;
194
+ return childId.startsWith(`${path}:`) || (f.name.startsWith(`${path}.`) && f.name !== path);
195
+ });
196
+ }
197
+ /**
198
+ * Build the body lines for a slice-specific interface.
199
+ */
200
+ function buildSliceInterfaceBody(sliceField, sliceChildren, sliceElementId, _backboneType) {
201
+ const lines = [];
202
+ const referencedFhirTypes = [];
203
+ // Add pattern constraint from the slice field itself (discriminator value)
204
+ if (sliceField.patternConstraint && typeof sliceField.patternConstraint === 'object') {
205
+ const pattern = sliceField.patternConstraint;
206
+ if ('coding' in pattern && Array.isArray(pattern.coding)) {
207
+ // patternCodeableConcept on the slice itself (rare — usually on a child like .code)
208
+ const codingLiteral = formatCodingLiteral(pattern.coding);
209
+ if (codingLiteral) {
210
+ lines.push(`coding: ${codingLiteral};`);
211
+ }
212
+ }
213
+ }
214
+ // Process direct children of the slice
215
+ for (const child of sliceChildren) {
216
+ const childId = child.elementId || child.name;
217
+ const relativePath = childId.substring(sliceElementId.length + 1);
218
+ // Only handle direct children (no dots in relative path)
219
+ if (relativePath.includes('.'))
220
+ continue;
221
+ // Skip slice notation in child paths
222
+ if (relativePath.includes(':'))
223
+ continue;
224
+ const propName = relativePath.replace(/\[x\]$/, '');
225
+ if (child.fixedValue !== undefined) {
226
+ // Fixed value constraint
227
+ const fixedLiteral = JSON.stringify(child.fixedValue);
228
+ lines.push(`${propName}: ${child.isArray ? `[${fixedLiteral}]` : fixedLiteral};`);
229
+ }
230
+ else if (child.patternConstraint && typeof child.patternConstraint === 'object') {
231
+ const pattern = child.patternConstraint;
232
+ if ('coding' in pattern && Array.isArray(pattern.coding)) {
233
+ // patternCodeableConcept
234
+ const codingLiteral = formatCodingLiteral(pattern.coding);
235
+ if (codingLiteral) {
236
+ const objectLiteral = `{ coding: ${codingLiteral} }`;
237
+ lines.push(`${propName}: ${child.isArray ? `[${objectLiteral}]` : objectLiteral};`);
238
+ }
239
+ }
240
+ else if ('code' in pattern && typeof pattern.code === 'string') {
241
+ // patternCoding
242
+ const parts = [];
243
+ if ('system' in pattern && typeof pattern.system === 'string') {
244
+ parts.push(`system: "${pattern.system}"`);
245
+ }
246
+ parts.push(`code: "${pattern.code}"`);
247
+ const objectLiteral = `{ ${parts.join('; ')} }`;
248
+ lines.push(`${propName}: ${child.isArray ? `[${objectLiteral}]` : objectLiteral};`);
249
+ }
250
+ }
251
+ else if (!child.isOptional && child.type) {
252
+ // Required field without constraints — mark as required in the interface.
253
+ // Skip children whose type can't produce a valid extends:
254
+ // - BackboneElement/Element: parent expects a concrete subtype
255
+ // - 'any': unresolvable type (e.g. content references like Parameters.parameter.part)
256
+ // that mapTypeToTS would map to 'string', producing an incompatible field.
257
+ if (child.type === 'BackboneElement' || child.type === 'Element' || child.type === 'any')
258
+ continue;
259
+ const rawType = getRules().mapTypeToTS(child.type);
260
+ const mappedType = sanitizeIdentifier(rawType);
261
+ // Skip profiled type references that would need separate imports
262
+ // (e.g., Composition-uv-ips) — these are better handled by the main processor
263
+ if (mappedType !== rawType && child.isProfiled)
264
+ continue;
265
+ const isArray = child.isArray ? '[]' : '';
266
+ lines.push(`${propName}: ${mappedType}${isArray};`);
267
+ // Track FHIR types that need importing
268
+ if (getRules().isFhirType(mappedType)) {
269
+ referencedFhirTypes.push(mappedType);
270
+ }
271
+ }
272
+ }
273
+ return { lines, referencedFhirTypes };
274
+ }
275
+ /**
276
+ * Format an array of coding objects as a TypeScript literal type.
277
+ */
278
+ function formatCodingLiteral(codings) {
279
+ const entries = [];
280
+ for (const coding of codings) {
281
+ if (!coding || typeof coding !== 'object')
282
+ continue;
283
+ const c = coding;
284
+ const parts = [];
285
+ if (typeof c.system === 'string')
286
+ parts.push(`system: "${c.system}"`);
287
+ if (typeof c.code === 'string')
288
+ parts.push(`code: "${c.code}"`);
289
+ if (parts.length > 0) {
290
+ entries.push(`{ ${parts.join('; ')} }`);
291
+ }
292
+ }
293
+ if (entries.length === 0)
294
+ return null;
295
+ return `[${entries.join(', ')}]`;
296
+ }
297
+ /**
298
+ * Update the root interface to use the union type for a sliced backbone field.
299
+ */
300
+ function updateRootInterfaceField(interfaces, interfaceName, fieldName, isOptional, unionType, baseResource) {
301
+ const optMark = isOptional ? '?' : '';
302
+ const newFieldLine = ` ${fieldName}${optMark}: ${unionType};`;
303
+ // Try to find an existing interface declaration
304
+ const rootIdx = interfaces.findIndex(i => i.startsWith(`export interface ${interfaceName} `) || i.startsWith(`export interface ${interfaceName}<`));
305
+ if (rootIdx >= 0) {
306
+ const iface = interfaces[rootIdx];
307
+ const fieldPattern = new RegExp(`^(\\s+)${fieldName}[?]?:\\s*.+;`, 'm');
308
+ if (fieldPattern.test(iface)) {
309
+ // Replace existing field line
310
+ interfaces[rootIdx] = iface.replace(fieldPattern, newFieldLine);
311
+ }
312
+ else {
313
+ // Field not yet in the interface — inject it before the closing brace
314
+ const closingBrace = iface.lastIndexOf('}');
315
+ if (closingBrace > 0) {
316
+ interfaces[rootIdx] = iface.slice(0, closingBrace) + newFieldLine + '\n' + iface.slice(closingBrace);
317
+ }
318
+ }
319
+ return;
320
+ }
321
+ // Try to find a type alias (e.g., "export type BloodPressure = Observation;")
322
+ const aliasIdx = interfaces.findIndex(i => i.startsWith(`export type ${interfaceName} =`));
323
+ if (aliasIdx >= 0) {
324
+ // Convert type alias to interface with the field
325
+ const aliasMatch = interfaces[aliasIdx].match(/export type \S+ = (\S+);/);
326
+ const baseType = aliasMatch ? aliasMatch[1] : baseResource || '';
327
+ interfaces[aliasIdx] = `export interface ${interfaceName} extends ${baseType} {\n${newFieldLine}\n}`;
328
+ }
329
+ }
@@ -6,6 +6,7 @@ import { ImportManager } from './importManager.js';
6
6
  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
+ import { generateBackboneSliceTypes } from './backboneSliceTyping.js';
9
10
  const log = logger.withTag('interfaces');
10
11
  export function generateInterfaces(interfaceName, newFields, baseResource, baseFields = [], valueSets, resourceType, existingProfiles, fhirChildTypeMap, profileIdToName, profileUrlToName, isLogicalModel = false) {
11
12
  const debug = (...args) => log.debug(...args);
@@ -243,6 +244,17 @@ export function generateInterfaces(interfaceName, newFields, baseResource, baseF
243
244
  interfaces.push(`export interface ${interfaceName} {}`);
244
245
  }
245
246
  }
247
+ // Post-pass: generate discriminated union types for sliced backbone elements.
248
+ // Must run after type alias generation so it can convert aliases to interfaces if needed.
249
+ generateBackboneSliceTypes({
250
+ interfaceName,
251
+ baseResource,
252
+ newFields,
253
+ baseFields,
254
+ interfaces,
255
+ importManager,
256
+ inferBackboneType,
257
+ });
246
258
  // Generate import statements using ImportManager
247
259
  const importStatements = importManager.generateImportStatements();
248
260
  let output = `${importStatements}\n${interfaces.join("\n\n")}`;
@@ -1,6 +1,8 @@
1
1
  /**
2
- * Zod refinement builder for pattern constraints, slice cardinalities, and FHIRPath invariants.
2
+ * Zod refinement builder for pattern constraints, slice cardinalities, FHIRPath invariants,
3
+ * and required ValueSet bindings.
3
4
  */
5
+ import { sanitizeValueSetName } from '../valueset/valueSetGenerator.js';
4
6
  /** Escape double-quote characters in refinement messages */
5
7
  function escStr(s) {
6
8
  return s.replace(/"/g, '\\"');
@@ -9,8 +11,9 @@ function escStr(s) {
9
11
  * Build refinement chains for pattern constraints, required slice cardinalities,
10
12
  * and FHIRPath invariants. Returns refinement code strings plus tracked constraint metadata.
11
13
  */
12
- export function buildRefinements(allFields, rootPrefix) {
14
+ export function buildRefinements(allFields, rootPrefix, valueSets) {
13
15
  const refinements = [];
16
+ const bindingVsImports = new Map();
14
17
  // Build set of direct (root-level) field names for safety checks
15
18
  const directFieldNames = new Set();
16
19
  for (const field of allFields) {
@@ -176,5 +179,69 @@ export function buildRefinements(allFields, rootPrefix) {
176
179
  refinements.push(`.refine(d => (d as any).meta?.${subField} !== undefined, { message: "meta.${subField} must be present", when: () => true })`);
177
180
  }
178
181
  }
179
- return { refinements, supportedConstraints, unsupportedConstraints };
182
+ // 5. Required ValueSet bindings on CodeableConcept / Coding fields
183
+ // When a field has binding.strength === 'required' and we have resolved codes,
184
+ // emit a refinement that checks at least one coding.code is in the allowed set.
185
+ for (const field of allFields) {
186
+ const parts = field.name.split('.');
187
+ if (parts.length !== 2 || parts[0] !== rootPrefix)
188
+ continue;
189
+ if (field.sliceName)
190
+ continue;
191
+ const type = field.type || field.baseTypeCode || '';
192
+ if (type !== 'CodeableConcept' && type !== 'Coding')
193
+ continue;
194
+ if (field.binding?.strength !== 'required')
195
+ continue;
196
+ const rel = parts[1];
197
+ // Skip choice-type placeholders (code[x], value[x], etc.) — the binding applies
198
+ // to the resolved variant (e.g. codeCodeableConcept) which we cannot reliably
199
+ // identify here. Emitting d.code[x] would produce invalid TypeScript.
200
+ if (rel.includes('[x]'))
201
+ continue;
202
+ if (!directFieldNames.has(rel))
203
+ continue;
204
+ // Resolve codes: prefer field.binding.codes, then fall back to parsed ValueSets
205
+ let codes;
206
+ let vsDisplayName = '';
207
+ if (field.binding.codes && field.binding.codes.length > 0) {
208
+ codes = field.binding.codes.map(c => c.code);
209
+ vsDisplayName = field.binding.uri?.split('/').pop() || 'ValueSet';
210
+ }
211
+ else if (field.binding.uri && valueSets) {
212
+ const vsUri = field.binding.uri.split('|')[0];
213
+ const vs = valueSets.get(vsUri) || valueSets.get(field.binding.uri);
214
+ if (vs && vs.concepts.length > 0 && vs.concepts.length <= 200) {
215
+ codes = vs.concepts.map(c => c.code);
216
+ vsDisplayName = vs.name || vsUri.split('/').pop() || 'ValueSet';
217
+ // Import the ValueSet type so the codes are available at runtime
218
+ const sanitizedName = sanitizeValueSetName(vs.name);
219
+ bindingVsImports.set(sanitizedName, `./valuesets/ValueSet-${sanitizedName}.js`);
220
+ }
221
+ }
222
+ if (!codes || codes.length === 0)
223
+ continue;
224
+ const codesLiteral = `[${codes.map(c => JSON.stringify(c)).join(', ')}]`;
225
+ const msgBase = `None of the codings provided are in the value set '${escStr(vsDisplayName)}' (${escStr(field.binding.uri || '')})`;
226
+ if (type === 'CodeableConcept') {
227
+ if (field.isArray) {
228
+ // Array of CodeableConcept: each entry must have at least one valid coding
229
+ refinements.push(`.refine(d => { const arr = (d as any).${rel}; if (!Array.isArray(arr)) return true; const allowed = ${codesLiteral}; return arr.every((cc: any) => cc?.coding?.some((c: any) => allowed.includes(c?.code))); }, { message: "${escStr(msgBase)}", when: () => true })`);
230
+ }
231
+ else {
232
+ // Single CodeableConcept: coding must include at least one valid code
233
+ refinements.push(`.refine(d => { const cc = (d as any).${rel}; if (!cc) return true; const allowed = ${codesLiteral}; return cc.coding?.some((c: any) => allowed.includes(c?.code)); }, { message: "${escStr(msgBase)}", when: () => true })`);
234
+ }
235
+ }
236
+ else {
237
+ // Coding field
238
+ if (field.isArray) {
239
+ refinements.push(`.refine(d => { const arr = (d as any).${rel}; if (!Array.isArray(arr)) return true; const allowed = ${codesLiteral}; return arr.every((c: any) => allowed.includes(c?.code)); }, { message: "${escStr(msgBase)}", when: () => true })`);
240
+ }
241
+ else {
242
+ refinements.push(`.refine(d => { const c = (d as any).${rel}; if (!c) return true; const allowed = ${codesLiteral}; return allowed.includes(c.code); }, { message: "${escStr(msgBase)}", when: () => true })`);
243
+ }
244
+ }
245
+ }
246
+ return { refinements, supportedConstraints, unsupportedConstraints, valueSetImports: bindingVsImports };
180
247
  }
@@ -249,13 +249,19 @@ export function generateZodSchema(interfaceName, newFields, baseResource, baseFi
249
249
  for (const [vsTypeName, vsPath] of valueSetImports) {
250
250
  lines.push(`import { ${vsTypeName} } from "${vsPath}";`);
251
251
  }
252
- // Collect refinements (pattern constraints, slice cardinality, FHIRPath invariants)
252
+ // Collect refinements (pattern constraints, slice cardinality, FHIRPath invariants, required bindings)
253
253
  // Only emit refinements for Resource profiles (not DataType profiles like Identifier, Coding)
254
254
  // because DataType random() methods don't produce pattern-aware data.
255
255
  const isDataTypeProfile = getZodPackageTypes().has(rootPrefix);
256
256
  const refinementResult = isDataTypeProfile
257
- ? { refinements: [], supportedConstraints: [], unsupportedConstraints: [] }
258
- : buildRefinements(allFields, rootPrefix);
257
+ ? { refinements: [], supportedConstraints: [], unsupportedConstraints: [], valueSetImports: new Map() }
258
+ : buildRefinements(allFields, rootPrefix, valueSets);
259
+ // Merge ValueSet imports from binding refinements
260
+ for (const [name, importPath] of refinementResult.valueSetImports) {
261
+ if (!valueSetImports.has(name)) {
262
+ valueSetImports.set(name, importPath);
263
+ }
264
+ }
259
265
  lines.push('');
260
266
  // Emit schema
261
267
  const schemaName = `${interfaceName}Schema`;
@@ -26,6 +26,20 @@ function findRepoRoot() {
26
26
  }
27
27
  return path.resolve(selfDir, '..', '..', '..', '..');
28
28
  }
29
+ /** Recursively copy a directory's contents, handling nested subdirectories. */
30
+ function copyDirRecursive(src, dest) {
31
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
32
+ const srcPath = path.join(src, entry.name);
33
+ const destPath = path.join(dest, entry.name);
34
+ if (entry.isDirectory()) {
35
+ fs.mkdirSync(destPath, { recursive: true });
36
+ copyDirRecursive(srcPath, destPath);
37
+ }
38
+ else {
39
+ fs.copyFileSync(srcPath, destPath);
40
+ }
41
+ }
42
+ }
29
43
  export function installBaseZodTypes(outputDir) {
30
44
  const slug = versionSlug();
31
45
  const pkgDir = path.join(outputDir, 'node_modules', '@babelfhir-ts', 'zod');
@@ -42,9 +56,7 @@ export function installBaseZodTypes(outputDir) {
42
56
  // Copy all runtime dist files into dist/<slug>/ to match package.json exports
43
57
  const distSlugDir = path.join(pkgDir, 'dist', slug);
44
58
  fs.mkdirSync(distSlugDir, { recursive: true });
45
- for (const file of fs.readdirSync(runtimeDist)) {
46
- fs.copyFileSync(path.join(runtimeDist, file), path.join(distSlugDir, file));
47
- }
59
+ copyDirRecursive(runtimeDist, distSlugDir);
48
60
  return;
49
61
  }
50
62
  // Fallback: generate stubs with both types (.d.ts) AND runtime JS (.js)
@@ -26,7 +26,7 @@ export function buildProfileRegistries(structureDefinitions, fhirInterfaceNames)
26
26
  for (const sd of structureDefinitions) {
27
27
  const rawInterfaceName = sd.name || sd.id || 'UnnamedInterface';
28
28
  let name = toPascalCase(rawInterfaceName);
29
- if (fhirInterfaceNames.includes(name)) {
29
+ if (fhirInterfaceNames.includes(name) && sd.derivation !== 'specialization') {
30
30
  name = `${name}Extension`;
31
31
  }
32
32
  if (name) {
@@ -85,7 +85,7 @@ export async function processStructureDefinition(sd, ctx) {
85
85
  const rawInterfaceName = sd.name || sd.id || 'UnnamedInterface';
86
86
  let interfaceName = toPascalCase(rawInterfaceName);
87
87
  registerProfile(sd.url || '', interfaceName);
88
- if (versionCtx().interfaceNames.includes(interfaceName)) {
88
+ if (versionCtx().interfaceNames.includes(interfaceName) && sd.derivation !== 'specialization') {
89
89
  logger.log(`Interface name '${interfaceName}' conflicts with FHIR base type, adding 'Extension' suffix`);
90
90
  interfaceName = `${interfaceName}Extension`;
91
91
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "babelfhir-ts",
3
- "version": "1.5.2",
3
+ "version": "1.5.4",
4
4
  "description": "BabelFHIR-TS: generate TypeScript interfaces, validators, and helper classes from FHIR R4/R4B/R5 StructureDefinitions (profiles) directly inside package archives.",
5
5
  "type": "module",
6
6
  "main": "out/src/main.js",