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.
- package/out/src/generator/emitters/interface/backboneSliceTyping.js +329 -0
- package/out/src/generator/emitters/interface/interfaceGenerator.js +12 -0
- package/out/src/generator/emitters/zod/zodRefinementBuilder.js +70 -3
- package/out/src/generator/emitters/zod/zodSchemaGenerator.js +9 -3
- package/out/src/generator/emitters/zod/zodStubInstaller.js +15 -3
- package/out/src/generator/generationHelpers.js +1 -1
- package/out/src/generator/sdProcessor.js +1 -1
- package/package.json +1 -1
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|