babelfhir-ts 1.5.3 → 1.5.5

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.
@@ -30,7 +30,7 @@ export function generateBackboneSliceTypes(ctx) {
30
30
  const { interfaceName, baseResource, newFields, baseFields, interfaces, importManager, inferBackboneType } = ctx;
31
31
  const rules = getRules();
32
32
  // 1. Find all sliced backbone element arrays
33
- const sliceGroups = collectSliceGroups(newFields, baseResource);
33
+ const sliceGroups = collectSliceGroups(newFields, baseFields, baseResource);
34
34
  if (sliceGroups.length === 0)
35
35
  return;
36
36
  for (const group of sliceGroups) {
@@ -70,7 +70,7 @@ export function generateBackboneSliceTypes(ctx) {
70
70
  return childId.startsWith(sliceElementId + '.');
71
71
  });
72
72
  // Build interface body lines from slice children and the slice's own constraints
73
- const { lines: bodyLines, referencedFhirTypes } = buildSliceInterfaceBody(sliceField, sliceChildren, sliceElementId, backboneType);
73
+ const { lines: bodyLines, referencedFhirTypes } = buildSliceInterfaceBody(sliceField, sliceChildren, sliceElementId, backboneType, baseFields, parentField.name);
74
74
  if (bodyLines.length === 0) {
75
75
  // No constrainable properties — skip this slice interface
76
76
  log.debug(`No constraints for slice ${sliceName} of ${parentField.name}, skipping`);
@@ -109,10 +109,18 @@ export function generateBackboneSliceTypes(ctx) {
109
109
  }
110
110
  /**
111
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.
112
119
  */
113
- function collectSliceGroups(fields, baseResource) {
120
+ function collectSliceGroups(fields, baseFields, baseResource) {
114
121
  const groups = [];
115
122
  const seenPaths = new Set();
123
+ // Pass 1: parent field present in newFields
116
124
  for (const field of fields) {
117
125
  if (field.sliceName)
118
126
  continue; // Skip slice entries themselves
@@ -121,52 +129,75 @@ function collectSliceGroups(fields, baseResource) {
121
129
  const path = field.name;
122
130
  if (seenPaths.has(path))
123
131
  continue;
124
- // Check if there are named slices at this path
125
132
  const sliceFields = fields.filter(f => f.name === path && f.sliceName);
126
133
  if (sliceFields.length === 0)
127
134
  continue;
128
- // Must be a true backbone element — only process fields whose base FHIR type
129
- // is BackboneElement or Element (inline nested types). Skip complex DataTypes
130
- // like Identifier, CodeableConcept, ContactPoint, etc. — those have slices
131
- // handled by the main type mapping and profiled type reference system.
132
- const baseType = field.baseTypeCode || field.type || '';
133
- if (baseType !== 'BackboneElement' && baseType !== 'Element')
135
+ if (!isEligibleBackbonePath(field.baseTypeCode || field.type || '', path, baseResource))
134
136
  continue;
135
- // Skip extension slices — handled separately by postProcessExtensions
136
- const fieldName = path.split('.').pop() || '';
137
- if (fieldName === 'extension' || fieldName === 'modifierExtension')
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)
138
147
  continue;
139
- // Skip Bundle.entry and similar resource-container slices — these use profiled
140
- // type references that are handled by the external profile resolution system
141
- if (fieldName === 'entry')
148
+ const path = field.name;
149
+ if (seenPaths.has(path))
142
150
  continue;
143
- // Must be a root-level field (direct child of the base resource)
144
- const parts = path.split('.');
145
- if (parts.length !== 2)
151
+ // Find the parent field in baseFields
152
+ const parentField = baseFields.find(f => f.name === path && !f.sliceName);
153
+ if (!parentField)
146
154
  continue;
147
- if (baseResource && parts[0] !== baseResource)
155
+ if (!parentField.isArray)
148
156
  continue;
149
- // Gather all child fields for any slice of this element
150
- const childFields = fields.filter(f => {
151
- if (f.name === path)
152
- return false; // Skip the parent itself
153
- const childId = f.elementId || f.name;
154
- // Match children that belong to ANY slice of this element
155
- return childId.startsWith(`${path}:`) || (f.name.startsWith(`${path}.`) && f.name !== path);
156
- });
157
- // Determine slicing rules from the parent field's metadata
158
- // The slicing rules come from the SD's element.slicing.rules
159
- // We default to 'open' since that's the FHIR default
160
- const slicingRules = field.slicingRules || 'open';
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';
161
164
  seenPaths.add(path);
162
- groups.push({ parentField: field, sliceFields, childFields, slicingRules });
165
+ groups.push({ parentField, sliceFields, childFields, slicingRules });
163
166
  }
164
167
  return groups;
165
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
+ }
166
197
  /**
167
198
  * Build the body lines for a slice-specific interface.
168
199
  */
169
- function buildSliceInterfaceBody(sliceField, sliceChildren, sliceElementId, _backboneType) {
200
+ function buildSliceInterfaceBody(sliceField, sliceChildren, sliceElementId, _backboneType, baseFields, parentPath) {
170
201
  const lines = [];
171
202
  const referencedFhirTypes = [];
172
203
  // Add pattern constraint from the slice field itself (discriminator value)
@@ -180,16 +211,58 @@ function buildSliceInterfaceBody(sliceField, sliceChildren, sliceElementId, _bac
180
211
  }
181
212
  }
182
213
  }
183
- // Process direct children of the slice
214
+ // Pre-scan: identify choice-type parent elements (value[x]) that have
215
+ // sub-slices (value[x]:valueQuantity, etc.). These parents carry only slicing
216
+ // metadata and should not be emitted as typed properties.
217
+ // Match only direct choice-type slices like "value[x]:valueString" — NOT deeper
218
+ // elements like "value[x].extension:questionnaireDisplay" where the colon is
219
+ // on a different segment.
220
+ const choiceTypeParents = new Set();
221
+ const choiceTypeSlices = [];
222
+ const choiceSlicePattern = /^([^.]+\[x]):([^.]+)$/; // e.g. "value[x]:valueString"
184
223
  for (const child of sliceChildren) {
185
224
  const childId = child.elementId || child.name;
186
225
  const relativePath = childId.substring(sliceElementId.length + 1);
187
- // Only handle direct children (no dots in relative path)
188
- if (relativePath.includes('.'))
226
+ const match = choiceSlicePattern.exec(relativePath);
227
+ if (match) {
228
+ choiceTypeSlices.push(child);
229
+ choiceTypeParents.add(match[1]); // e.g. "value[x]"
230
+ }
231
+ }
232
+ // Collect deeply nested fixed values for object literal synthesis.
233
+ // e.g. code.coding.code = "confidence" → code: { coding: [{ code: "confidence" }] }
234
+ const nestedFixedByProp = new Map();
235
+ // Process children of the slice
236
+ for (const child of sliceChildren) {
237
+ const childId = child.elementId || child.name;
238
+ const relativePath = childId.substring(sliceElementId.length + 1);
239
+ // Nested children (dots in relative path) — collect fixed values for synthesis
240
+ if (relativePath.includes('.')) {
241
+ if (child.fixedValue !== undefined) {
242
+ const segments = relativePath.split('.');
243
+ const rawPropName = segments[0];
244
+ const propName = rawPropName.replace(/\[x\]$/, '');
245
+ // Skip nested values under:
246
+ // - choice-type elements ([x]) — handled by direct child or sub-slices
247
+ // - slice notation (part:LineItem.name) — deeper-level constraints
248
+ // - extension/modifierExtension — handled by postProcessExtensions
249
+ if (!rawPropName.includes('[x]') && !rawPropName.includes(':')
250
+ && propName !== 'extension' && propName !== 'modifierExtension') {
251
+ const nestedPath = segments.slice(1).join('.');
252
+ if (!nestedFixedByProp.has(propName))
253
+ nestedFixedByProp.set(propName, new Map());
254
+ nestedFixedByProp.get(propName).set(nestedPath, String(child.fixedValue));
255
+ }
256
+ }
189
257
  continue;
190
- // Skip slice notation in child paths
258
+ }
259
+ // Choice-type sub-slices (value[x]:valueXxx) are processed separately below
191
260
  if (relativePath.includes(':'))
192
261
  continue;
262
+ // Skip choice-type parents that have sub-slices — they carry only slicing
263
+ // metadata and their inherited type (e.g. Quantity) is misleading
264
+ if (choiceTypeParents.has(relativePath))
265
+ continue;
193
266
  const propName = relativePath.replace(/\[x\]$/, '');
194
267
  if (child.fixedValue !== undefined) {
195
268
  // Fixed value constraint
@@ -239,6 +312,43 @@ function buildSliceInterfaceBody(sliceField, sliceChildren, sliceElementId, _bac
239
312
  }
240
313
  }
241
314
  }
315
+ // Emit choice-type sub-slice properties (e.g. value[x]:valueQuantity → valueQuantity: Quantity)
316
+ for (const child of choiceTypeSlices) {
317
+ if (!child.sliceName || !child.type)
318
+ continue;
319
+ if (child.type === 'BackboneElement' || child.type === 'Element' || child.type === 'any')
320
+ continue;
321
+ const rawType = getRules().mapTypeToTS(child.type);
322
+ const mappedType = sanitizeIdentifier(rawType);
323
+ if (mappedType !== rawType && child.isProfiled)
324
+ continue;
325
+ const optMark = child.isOptional ? '?' : '';
326
+ lines.push(`${child.sliceName}${optMark}: ${mappedType};`);
327
+ if (getRules().isFhirType(mappedType)) {
328
+ referencedFhirTypes.push(mappedType);
329
+ }
330
+ }
331
+ // Emit synthesized nested object literals from deeply nested fixed values.
332
+ // Skip properties already emitted by the direct child or choice-type loops
333
+ // to prevent TS2717 (duplicate property) and TS2430 (incompatible extends).
334
+ const emittedProps = new Set(lines.map(l => l.match(/^(\w+)[?]?:/)?.[1]).filter(Boolean));
335
+ for (const [propName, paths] of nestedFixedByProp) {
336
+ if (emittedProps.has(propName))
337
+ continue;
338
+ // Check if this property is an array — look in both slice children and
339
+ // base fields. Synthesized literals for array properties need wrapping
340
+ // in [] to avoid TS2430 (incompatible extends).
341
+ const propFullPath = `${parentPath}.${propName}`;
342
+ const isArrayProp = sliceChildren.some(c => {
343
+ const cId = c.elementId || c.name;
344
+ const rel = cId.substring(sliceElementId.length + 1);
345
+ return rel === propName && c.isArray;
346
+ }) || baseFields.some(f => f.name === propFullPath && f.isArray);
347
+ const literal = synthesizeNestedLiteral(paths);
348
+ if (literal) {
349
+ lines.push(`${propName}: ${isArrayProp ? `[${literal}]` : literal};`);
350
+ }
351
+ }
242
352
  return { lines, referencedFhirTypes };
243
353
  }
244
354
  /**
@@ -263,6 +373,39 @@ function formatCodingLiteral(codings) {
263
373
  return null;
264
374
  return `[${entries.join(', ')}]`;
265
375
  }
376
+ /**
377
+ * Synthesize a nested object literal from deeply nested fixed values.
378
+ *
379
+ * Given a map of nested paths → values (e.g. { "coding.code" → "confidence" }),
380
+ * produces an object literal like `{ coding: [{ code: "confidence" }] }`.
381
+ *
382
+ * Special handling: `coding` segments are wrapped in array brackets because
383
+ * `CodeableConcept.coding` is always an array in FHIR.
384
+ */
385
+ function synthesizeNestedLiteral(paths) {
386
+ const codingFields = new Map();
387
+ const scalarFields = [];
388
+ for (const [path, value] of paths) {
389
+ if (path.startsWith('coding.')) {
390
+ codingFields.set(path.substring('coding.'.length), value);
391
+ }
392
+ else if (!path.includes('.')) {
393
+ scalarFields.push([path, value]);
394
+ }
395
+ // Deeper nesting (3+ levels) is uncommon; skip gracefully
396
+ }
397
+ const parts = [];
398
+ if (codingFields.size > 0) {
399
+ const codingParts = [...codingFields.entries()].map(([k, v]) => `${k}: "${v}"`);
400
+ parts.push(`coding: [{ ${codingParts.join('; ')} }]`);
401
+ }
402
+ for (const [field, value] of scalarFields) {
403
+ parts.push(`${field}: "${value}"`);
404
+ }
405
+ if (parts.length === 0)
406
+ return null;
407
+ return `{ ${parts.join('; ')} }`;
408
+ }
266
409
  /**
267
410
  * Update the root interface to use the union type for a sliced backbone field.
268
411
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "babelfhir-ts",
3
- "version": "1.5.3",
3
+ "version": "1.5.5",
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",