babelfhir-ts 1.5.19 → 1.5.21

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 (38) hide show
  1. package/out/src/cli/updateCommand.js +19 -12
  2. package/out/src/generator/core/constants.js +19 -0
  3. package/out/src/generator/core/extensionSdReader.js +99 -0
  4. package/out/src/generator/core/utils.js +97 -3
  5. package/out/src/generator/emitters/class/classTemplate.js +8 -2
  6. package/out/src/generator/emitters/class/sliceElementDefaults.js +5 -3
  7. package/out/src/generator/emitters/client/clientGenerator.js +22 -6
  8. package/out/src/generator/emitters/client/smartAuthGenerator.js +4 -1
  9. package/out/src/generator/emitters/interface/backboneSliceTyping.js +15 -4
  10. package/out/src/generator/emitters/interface/importManager.js +28 -2
  11. package/out/src/generator/emitters/interface/interfaceFieldProcessor.js +21 -16
  12. package/out/src/generator/emitters/interface/interfaceFieldUtils.js +16 -0
  13. package/out/src/generator/emitters/interface/postProcessExtensions.js +1 -0
  14. package/out/src/generator/emitters/validator/closedSlicingValidation.js +146 -0
  15. package/out/src/generator/emitters/validator/sliceDelegation.js +450 -0
  16. package/out/src/generator/emitters/validator/sliceExtensionValidation.js +200 -35
  17. package/out/src/generator/emitters/validator/sliceValidatorGenerator.js +130 -299
  18. package/out/src/generator/emitters/validator/sliceValidatorUtils.js +89 -16
  19. package/out/src/generator/emitters/validator/subExtensionScope.js +100 -0
  20. package/out/src/generator/emitters/validator/validatorBindingBuilder.js +113 -237
  21. package/out/src/generator/emitters/validator/validatorBindingLeafEmitters.js +13 -4
  22. package/out/src/generator/emitters/validator/validatorConstraintBuilders.js +104 -63
  23. package/out/src/generator/emitters/validator/validatorExpressions.js +54 -0
  24. package/out/src/generator/emitters/validator/validatorFieldBuilders.js +492 -46
  25. package/out/src/generator/emitters/validator/validatorGenerator.js +170 -169
  26. package/out/src/generator/emitters/validator/validatorRuntime.js +279 -0
  27. package/out/src/generator/emitters/validator/validatorTemplates.js +186 -54
  28. package/out/src/generator/emitters/zod/zodRefinementBuilder.js +29 -19
  29. package/out/src/generator/emitters/zod/zodSchemaGenerator.js +16 -1
  30. package/out/src/generator/index.js +21 -11
  31. package/out/src/generator/parser/packageParser.js +1 -8
  32. package/out/src/generator/parser/sdParser.js +1 -0
  33. package/out/src/generator/sdProcessor.js +303 -30
  34. package/out/src/generator/sdProcessorHelpers.js +1 -1
  35. package/out/src/generator/sdProcessorRequiredFields.js +120 -0
  36. package/out/src/main.js +37 -11
  37. package/package.json +3 -1
  38. package/parity-matrix.json +18 -18
@@ -10,7 +10,8 @@ import path from 'path';
10
10
  import { generateClass } from './emitters/class/classGenerator.js';
11
11
  import { generateInterfaces } from './emitters/interface/interfaceGenerator.js';
12
12
  import { fetchStructureDefinition, parseStructureDefinition } from './parser/sdParser.js';
13
- import { writeInterfaceAndValidatorToFile, mergeFields, alignFields, writeToFile, getBaseResource, sanitizeIdentifier, toPascalCase, capitalize, stripVersionFromCanonicalUrl } from './core/utils.js';
13
+ import { writeInterfaceAndValidatorToFile, mergeFields, alignFields, writeToFile, getBaseResource, sanitizeIdentifier, toPascalCase, stripVersionFromCanonicalUrl } from './core/utils.js';
14
+ import { extensionElements, collectSubExtensionSlices, readSubExtensionMin, readBinding } from './core/extensionSdReader.js';
14
15
  import { generateValidateProfileFunction } from './emitters/validator/validatorGenerator.js';
15
16
  import { generateZodSchema } from './emitters/zod/zodSchemaGenerator.js';
16
17
  import { extractFieldPatternsAndMetadata } from './emitters/class/fieldPatternExtractor.js';
@@ -18,14 +19,176 @@ import { logger } from '../logger.js';
18
19
  import { ctx as versionCtx, fhirModuleSpecifier } from './fhir/versionContext.js';
19
20
  import { registerProfile, addDiagnostic } from './core/sdDiagnostics.js';
20
21
  import { resetFetchFailureTracking, getFetchFailureCount, getFetchFailureWarning, recordFetchFailure, resolveComplexTypeChildType, ensureRandomSupportFile, ensureValidatorOptionsFile, } from './sdProcessorHelpers.js';
21
- import { extractAndDeduplicateRequiredFields } from './sdProcessorRequiredFields.js';
22
+ import { extractAndDeduplicateRequiredFields, resolveChoiceSegments } from './sdProcessorRequiredFields.js';
22
23
  // Re-export public API consumed by index.ts / generationHelpers.ts
23
24
  export { resetFetchFailureTracking, getFetchFailureCount, getFetchFailureWarning };
24
25
  const log = logger.withTag('generator');
26
+ /**
27
+ * Fill in what a profile's differential never states, from the base resource.
28
+ *
29
+ * A differential element says only what it *changes*. KBV constrains
30
+ * `Medication.amount.numerator.value` to max=0 and says nothing about its type, so
31
+ * the merged field read `any` and every type-aware check skipped it — HL7 reported
32
+ * "The value 'Example value' is not a valid decimal" where we were silent. The same
33
+ * silence on `max` left `Medication.ingredient` non-repeating despite being 0..* in
34
+ * the base, so nothing beneath it was ever walked.
35
+ *
36
+ * The base resource snapshot supplies the top of each path (`Medication.amount` is a
37
+ * Ratio); below that it stops, because a resource never expands its datatypes'
38
+ * children. Those come from the datatype tables — `Ratio.numerator` is a Quantity,
39
+ * `Quantity.value` a decimal — walked down from the deepest ancestor whose type is
40
+ * already known. An unresolvable hop leaves the field alone rather than guessing.
41
+ *
42
+ * Only unstated properties are filled; anything the differential declares still wins.
43
+ */
44
+ async function fillUnstatedFromBaseResource(fields, resourceType) {
45
+ if (!resourceType)
46
+ return;
47
+ let baseSd;
48
+ try {
49
+ baseSd = await fetchStructureDefinition(`http://hl7.org/fhir/StructureDefinition/${resourceType}`);
50
+ }
51
+ catch {
52
+ return;
53
+ }
54
+ const baseElements = baseSd?.snapshot?.element;
55
+ if (!baseElements?.length)
56
+ return;
57
+ const baseByPath = new Map();
58
+ for (const el of baseElements) {
59
+ const id = el.id ?? el.path;
60
+ if (!id)
61
+ continue;
62
+ const rel = id.replace(/^[^.]+\./, '');
63
+ if (!rel || rel === id || rel.includes(':'))
64
+ continue;
65
+ baseByPath.set(rel, { type: el.type?.[0]?.code, max: el.max });
66
+ }
67
+ // Container types name no datatype to walk into, so they seed nothing.
68
+ const knownType = new Map();
69
+ for (const [rel, meta] of baseByPath) {
70
+ if (meta.type && meta.type !== 'BackboneElement' && meta.type !== 'Element') {
71
+ knownType.set(rel, meta.type);
72
+ }
73
+ }
74
+ const unstated = (type) => !type || type === 'any';
75
+ // Shallow paths first, so a deeper one can walk down from an ancestor this pass
76
+ // has just resolved.
77
+ const ordered = [...fields].sort((a, b) => (a.name || '').split('.').length - (b.name || '').split('.').length);
78
+ for (const field of ordered) {
79
+ const rel = (field.name || '').replace(/^[^.]+\./, '');
80
+ if (!rel)
81
+ continue;
82
+ const base = baseByPath.get(rel);
83
+ if (base) {
84
+ if (unstated(field.type) && base.type)
85
+ field.type = base.type;
86
+ if (field.max === undefined && base.max !== undefined) {
87
+ const unbounded = base.max === '*';
88
+ const parsed = unbounded ? Infinity : Number.parseInt(base.max, 10);
89
+ if (unbounded || Number.isFinite(parsed)) {
90
+ field.max = parsed;
91
+ field.isArray = unbounded || parsed > 1;
92
+ }
93
+ }
94
+ if (!unstated(field.type))
95
+ knownType.set(rel, field.type);
96
+ continue;
97
+ }
98
+ if (!unstated(field.type)) {
99
+ knownType.set(rel, field.type);
100
+ continue;
101
+ }
102
+ const segments = rel.split('.');
103
+ let current;
104
+ let next = 0;
105
+ for (let depth = segments.length - 1; depth >= 1; depth--) {
106
+ const ancestorType = knownType.get(segments.slice(0, depth).join('.'));
107
+ if (ancestorType) {
108
+ current = ancestorType;
109
+ next = depth;
110
+ break;
111
+ }
112
+ }
113
+ if (!current)
114
+ continue;
115
+ for (let i = next; i < segments.length && current; i++) {
116
+ current = resolveComplexTypeChildType(current, segments[i]);
117
+ }
118
+ if (current) {
119
+ field.type = current;
120
+ knownType.set(rel, current);
121
+ }
122
+ }
123
+ }
124
+ /**
125
+ * Fill in extension-slice canonicals a derived profile does not restate.
126
+ *
127
+ * An extension slice is recognised at runtime by the canonical its `type.profile`
128
+ * names. A derived profile that only tightens cardinality omits it:
129
+ * KBV_PR_ERP_Medication_PZN redeclares `extension:Kategorie` and
130
+ * `extension:Normgroesse` with no profile, while its base KBV_PR_Base_Medication
131
+ * gives them KBV_EX_Base_Medication_Type and .../normgroesse. Two of four slices
132
+ * were therefore unidentifiable, and the closed-slicing check on
133
+ * `Medication.extension` could not build an allowed set — so it stayed silent where
134
+ * HL7 reports "does not match any known slice … and slicing is CLOSED".
135
+ *
136
+ * Walks the baseDefinition chain, nearest ancestor first, and stops at the first
137
+ * that names a canonical. Depth-bounded: a malformed chain must not loop.
138
+ */
139
+ async function fillSliceProfileUrlsFromBaseProfiles(fields, baseDefinition) {
140
+ const needing = fields.filter(f => f.sliceName && !f.profileUrls?.length && f.elementId);
141
+ if (needing.length === 0)
142
+ return;
143
+ const seen = new Set();
144
+ let canonical = baseDefinition;
145
+ for (let depth = 0; depth < 5 && canonical; depth++) {
146
+ const key = stripVersionFromCanonicalUrl(canonical);
147
+ if (seen.has(key))
148
+ break;
149
+ seen.add(key);
150
+ let baseSd;
151
+ try {
152
+ baseSd = await fetchStructureDefinition(canonical);
153
+ }
154
+ catch {
155
+ return;
156
+ }
157
+ if (!baseSd)
158
+ return;
159
+ const byElementId = new Map();
160
+ for (const el of (baseSd.snapshot?.element ?? baseSd.differential?.element ?? [])) {
161
+ const id = el.id;
162
+ if (!id)
163
+ continue;
164
+ const profiles = (el.type ?? []).flatMap(t => t.profile ?? []);
165
+ if (profiles.length)
166
+ byElementId.set(id, profiles);
167
+ }
168
+ for (const field of needing) {
169
+ if (field.profileUrls?.length)
170
+ continue;
171
+ const inherited = byElementId.get(field.elementId);
172
+ if (inherited?.length)
173
+ field.profileUrls = inherited;
174
+ }
175
+ if (needing.every(f => f.profileUrls?.length))
176
+ return;
177
+ canonical = baseSd.baseDefinition;
178
+ }
179
+ }
25
180
  // ── Extension metadata resolution for validator emission ──────────────────
26
181
  /**
27
182
  * Scan fields for extension slices with profile URLs, resolve their
28
183
  * StructureDefinitions, and extract value[x] type + binding metadata.
184
+ *
185
+ * Keyed by the *version-stripped* canonical. A slice names its extension twice and
186
+ * the two forms disagree: `type.profile` carries a version suffix
187
+ * (`…/sdc-questionnaire-contextExpression|4.0.0`) while the pinned `url` child
188
+ * carries the bare canonical. Keying on whichever arrived first left the other
189
+ * lookup empty, and an absent entry means "SD unreadable, conclude nothing" — so
190
+ * DTRStdQuestionnaire's contextExpression emitted no validation at all.
191
+ * Callers must strip before reading; use `lookupExtensionMetadata`.
29
192
  */
30
193
  async function resolveExtensionMetadata(fields) {
31
194
  const extUrls = new Set();
@@ -41,11 +204,35 @@ async function resolveExtensionMetadata(fields) {
41
204
  return undefined;
42
205
  const map = new Map();
43
206
  await Promise.all([...extUrls].map(async (url) => {
207
+ // Fetch by the url as written — the version suffix selects the right SD — but
208
+ // key the result by the stripped form so either spelling finds it.
209
+ const key = stripVersionFromCanonicalUrl(url);
44
210
  try {
45
211
  const extSd = await fetchStructureDefinition(url);
46
- if (!extSd?.snapshot?.element)
212
+ const els = extensionElements(extSd);
213
+ if (!els)
214
+ return;
215
+ const valueEl = els.find(el => el.path === 'Extension.value[x]' || el.id === 'Extension.value[x]');
216
+ const subExtensions = collectSubExtensionSlices(els);
217
+ const subExtensionMin = readSubExtensionMin(els);
218
+ // A complex extension carries sub-extensions instead of a value, and forbids a
219
+ // value outright (ext-1: extension.exists() != value.exists()). Recorded as an
220
+ // empty type list — different from no entry at all, which would mean the SD
221
+ // could not be read and nothing can be concluded.
222
+ //
223
+ // Recognised two ways because published SDs state it two ways: value[x] pinned
224
+ // to max=0 (sdc-questionnaire-contextExpression), or a required
225
+ // Extension.extension with no value[x] element at all
226
+ // (KBV_EX_ERP_PracticeSupply_Payor, whose differential omits value[x] entirely —
227
+ // the earlier `if (!valueEl) return` dropped it before anything was read).
228
+ if (valueEl?.max === '0' || (subExtensionMin ?? 0) >= 1) {
229
+ map.set(key, {
230
+ valueTypes: [],
231
+ subExtensionMin,
232
+ subExtensions: subExtensions.length ? subExtensions : undefined,
233
+ });
47
234
  return;
48
- const valueEl = extSd.snapshot.element.find((el) => (el.path === 'Extension.value[x]' || el.id === 'Extension.value[x]'));
235
+ }
49
236
  if (!valueEl)
50
237
  return;
51
238
  const types = (valueEl.type || []).map((t) => t.code).filter(Boolean);
@@ -54,28 +241,96 @@ async function resolveExtensionMetadata(fields) {
54
241
  const entry = { valueTypes: types };
55
242
  // Check binding on value[x] first, then fall back to typed slices
56
243
  // (e.g., Extension.valueCode may carry the binding instead of Extension.value[x])
57
- let binding = valueEl.binding;
58
- if (!binding?.valueSet && types.length === 1) {
244
+ let binding = readBinding(valueEl);
245
+ if (!binding && types.length === 1) {
59
246
  const suffix = types[0].charAt(0).toUpperCase() + types[0].slice(1);
60
- const sliceEl = extSd.snapshot.element.find((el) => el.id === `Extension.value[x]:value${suffix}` ||
247
+ const sliceEl = els.find(el => el.id === `Extension.value[x]:value${suffix}` ||
61
248
  el.path === `Extension.value${suffix}` ||
62
249
  el.id === `Extension.value${suffix}`);
63
- if (sliceEl?.binding?.valueSet) {
64
- binding = sliceEl.binding;
65
- }
250
+ binding = readBinding(sliceEl);
66
251
  }
67
- if (binding?.strength && binding?.valueSet) {
68
- const vsUri = typeof binding.valueSet === 'string'
69
- ? binding.valueSet
70
- : binding.valueSet?.reference;
71
- if (vsUri) {
72
- entry.binding = { strength: binding.strength, uri: vsUri.split('|')[0] };
252
+ if (binding)
253
+ entry.binding = binding;
254
+ map.set(key, entry);
255
+ }
256
+ catch {
257
+ // Extension SD not resolvable — skip
258
+ }
259
+ }));
260
+ return map.size > 0 ? map : undefined;
261
+ }
262
+ /**
263
+ * Resolve what a datatype profile pins, for slices discriminated by
264
+ * `{profile, $this}`.
265
+ *
266
+ * Such a slice names a profile instead of a value, so membership can only be decided
267
+ * by reading the profile. sdc slices Questionnaire.useContext against
268
+ * sdc-usagecontext-questionnaire-library, which pins UsageContext.code to
269
+ * usage-context-type|workflow — that pin is the discriminator in practice.
270
+ *
271
+ * Extension slices are excluded: they are already handled by resolveExtensionMetadata
272
+ * and are matched on url, not on a pinned value.
273
+ */
274
+ async function resolveDatatypeProfilePins(fields) {
275
+ const urls = new Set();
276
+ for (const f of fields) {
277
+ if (!f.sliceName || !f.profileUrls?.length)
278
+ continue;
279
+ const basePath = (f.name || '').replace(/^[^.]+\./, '').split(':')[0];
280
+ if (basePath.endsWith('extension') || basePath.endsWith('modifierExtension'))
281
+ continue;
282
+ urls.add(f.profileUrls[0]);
283
+ }
284
+ if (urls.size === 0)
285
+ return undefined;
286
+ const map = new Map();
287
+ await Promise.all([...urls].map(async (url) => {
288
+ try {
289
+ const sd = await fetchStructureDefinition(url);
290
+ // Only datatype profiles: a resource profile's members are matched by
291
+ // resourceType, which the type-discriminator path already covers.
292
+ if (!sd?.snapshot?.element || sd.kind !== 'complex-type')
293
+ return;
294
+ for (const el of sd.snapshot.element) {
295
+ const id = typeof el.id === 'string' ? el.id : '';
296
+ const relPath = id.includes('.') ? id.substring(id.indexOf('.') + 1) : '';
297
+ // Skip the root and anything below a nested choice, which needs a concrete
298
+ // property name the pin does not carry.
299
+ if (!relPath || relPath.includes('[x]'))
300
+ continue;
301
+ const coding = el.patternCoding ?? el.fixedCoding;
302
+ if (coding && typeof coding === 'object') {
303
+ const c = coding;
304
+ if (typeof c.code === 'string') {
305
+ map.set(url, {
306
+ path: relPath,
307
+ shape: 'coding',
308
+ system: typeof c.system === 'string' ? c.system : undefined,
309
+ code: c.code,
310
+ });
311
+ return;
312
+ }
313
+ }
314
+ const concept = el.patternCodeableConcept ?? el.fixedCodeableConcept;
315
+ const nested = concept && typeof concept === 'object'
316
+ ? concept.coding
317
+ : undefined;
318
+ if (Array.isArray(nested) && nested.length > 0) {
319
+ const c = nested[0];
320
+ if (typeof c.code === 'string') {
321
+ map.set(url, {
322
+ path: relPath,
323
+ shape: 'codeableConcept',
324
+ system: typeof c.system === 'string' ? c.system : undefined,
325
+ code: c.code,
326
+ });
327
+ return;
328
+ }
73
329
  }
74
330
  }
75
- map.set(url, entry);
76
331
  }
77
332
  catch {
78
- // Extension SD not resolvable — skip
333
+ // Profile not resolvable — leave the slice unmatched rather than guess.
79
334
  }
80
335
  }));
81
336
  return map.size > 0 ? map : undefined;
@@ -111,7 +366,10 @@ export async function processStructureDefinition(sd, ctx) {
111
366
  // ── External profile resolution ──────────────────────────────────
112
367
  for (const [typeName, profileInfo] of interfaceResult.referencedExternalProfiles) {
113
368
  const normalizedTypeName = toPascalCase(sanitizeIdentifier(typeName));
114
- const isUsedHere = interfaceContent.includes(`import { ${normalizedTypeName} } from "./${normalizedTypeName}"`) ||
369
+ // Tolerates the `type` modifier the interface emitter writes; the second
370
+ // clause already did, but leaving the first matching only the bare form
371
+ // makes the two disagree for no reason.
372
+ const isUsedHere = new RegExp(`import(?:\\s+type)?\\s+\\{\\s*${normalizedTypeName}\\s*\\}\\s+from\\s+"\\./${normalizedTypeName}"`).test(interfaceContent) ||
115
373
  (interfaceContent.includes(`from "./${normalizedTypeName}"`) && interfaceContent.includes(`{ ${normalizedTypeName} }`));
116
374
  if (!isUsedHere)
117
375
  continue;
@@ -189,9 +447,13 @@ export async function processStructureDefinition(sd, ctx) {
189
447
  let finalInterfaceContent = interfaceContent;
190
448
  for (const [originalName, actualName] of profileIdToName?.entries() || []) {
191
449
  if (originalName !== actualName) {
192
- const oldImport = `import { ${originalName} } from "./${originalName}"`;
193
- const newImport = `import { ${actualName} } from "./${actualName}"`;
194
- finalInterfaceContent = finalInterfaceContent.replace(oldImport, newImport);
450
+ // Matched by pattern, not by a literal string: the emitted form carries a
451
+ // `type` modifier, and a literal that omitted it renamed the *usages*
452
+ // while leaving the import pointing at the old module — which generated
453
+ // `Cannot find module './BodySite'` alongside `Cannot find name
454
+ // 'BodyStructureReference'` in the same file.
455
+ const importRegex = new RegExp(`import(\\s+type)?\\s+\\{\\s*${originalName}\\s*\\}\\s+from\\s+"\\./${originalName}"`, 'g');
456
+ finalInterfaceContent = finalInterfaceContent.replace(importRegex, (_m, typeModifier) => `import${typeModifier ?? ''} { ${actualName} } from "./${actualName}"`);
195
457
  const typeUsageRegex = new RegExp(`(:\\s+|Array<|\\|\\s+|&\\s+|extends\\s+)${originalName}(?=\\s*[;,>\\|&\\)\\n\\[\\s{])`, 'g');
196
458
  finalInterfaceContent = finalInterfaceContent.replace(typeUsageRegex, `$1${actualName}`);
197
459
  }
@@ -211,9 +473,12 @@ export async function processStructureDefinition(sd, ctx) {
211
473
  finalInterfaceContent = dedupedLines.join('\n');
212
474
  // ── Required fields ──────────────────────────────────────────────
213
475
  const merged = mergeFields(aligned.alignedNewFields, parsed.oldFields);
476
+ await fillUnstatedFromBaseResource(merged, sd.type);
477
+ await fillSliceProfileUrlsFromBaseProfiles(merged, sd.baseDefinition);
214
478
  // ── Pre-resolve Extension SDs for validator emission ─────────────
215
479
  const extensionMetadata = await resolveExtensionMetadata(merged);
216
- const validateResult = generateValidateProfileFunction(interfaceName, merged, valueSets, sd.url, sd.type, profileUrlToName, extensionMetadata, sd.baseDefinition, profileUrlToType);
480
+ const datatypeProfilePins = await resolveDatatypeProfilePins(merged);
481
+ const validateResult = generateValidateProfileFunction(interfaceName, merged, valueSets, sd.url, sd.type, profileUrlToName, extensionMetadata, sd.baseDefinition, profileUrlToType, datatypeProfilePins);
217
482
  const validateFn = validateResult.code;
218
483
  const candidateRequiredFields = [...aligned.alignedNewFields, ...parsed.oldFields];
219
484
  const profileDiffFieldNames = new Set(aligned.alignedNewFields.map(f => f.name));
@@ -251,12 +516,15 @@ export async function processStructureDefinition(sd, ctx) {
251
516
  continue;
252
517
  const parentFieldName = parts[1].split(':')[0];
253
518
  let relativePath = parts.slice(2).map((p) => p.split(':')[0]).join('.');
254
- if (relativePath.includes('[x]') && f.type && f.type !== 'any' && f.type !== 'unknown') {
255
- const fType = f.type;
256
- relativePath = relativePath.replace(/(\w+)\[x\]/g, (_match, baseName) => {
257
- const suffix = fType === 'code' ? 'Code' : capitalize(fType);
258
- return `${baseName}${suffix}`;
259
- });
519
+ if (relativePath.includes('[x]')) {
520
+ const resolvedPath = resolveChoiceSegments(f, parts, candidateRequiredFields);
521
+ // An intermediate choice we cannot narrow to one type has no property name.
522
+ // Dropping the requirement is the only honest outcome: naming it after the
523
+ // leaf's type invents a sibling property that no profile permits, and the
524
+ // random generator would then emit data both validators reject.
525
+ if (!resolvedPath)
526
+ continue;
527
+ relativePath = resolvedPath;
260
528
  }
261
529
  let profileUrl = f.typeOptions?.[0]?.profileUrls?.[0];
262
530
  if (!profileUrl && (relativePath === 'identifier' || relativePath.startsWith('identifier.'))) {
@@ -280,7 +548,12 @@ export async function processStructureDefinition(sd, ctx) {
280
548
  for (let i = 0; i < relativePathParts.length; i++) {
281
549
  const intermediatePath = [root, parentFieldName, ...relativePathParts.slice(0, i + 1)].join('.');
282
550
  if (fieldIsArrayMap.get(intermediatePath)) {
283
- arraySegments.push(relativePathParts[i]);
551
+ // The relative prefix, not the bare segment name: a name repeating at two
552
+ // depths would otherwise share one verdict.
553
+ // MedicinalProductDefinition.name.part is 0..*, while the `part` string
554
+ // inside it is 1..1, and matching on the name alone wrapped the string in
555
+ // an array too.
556
+ arraySegments.push(relativePathParts.slice(0, i + 1).join('.'));
284
557
  }
285
558
  }
286
559
  let fieldType = f.type;
@@ -5,7 +5,7 @@ import path from 'path';
5
5
  import fs from 'fs';
6
6
  import { logger } from '../logger.js';
7
7
  import { generateRandomFiles } from './emitters/class/randomSupportGenerator.js';
8
- import { generateValidatorOptionsContent } from './emitters/validator/validatorGenerator.js';
8
+ import { generateValidatorOptionsContent } from './emitters/validator/validatorRuntime.js';
9
9
  import { ctx } from './fhir/versionContext.js';
10
10
  // ── Fetch failure tracking ─────────────────────────────────────────
11
11
  let failedFetchCount = 0;
@@ -127,3 +127,123 @@ export function extractAndDeduplicateRequiredFields(candidateRequiredFields, sdI
127
127
  }
128
128
  return requiredFields;
129
129
  }
130
+ /**
131
+ * The type a choice element should be named after.
132
+ *
133
+ * `typeOptions[0].code` is preferred over `field.type` because a choice property
134
+ * is named for the base type, never for a profile on it: R4 declares
135
+ * `Coverage.costToBeneficiary.value[x]` as `Quantity` profiled to SimpleQuantity,
136
+ * and the property is `valueQuantity`. Reading `field.type` yielded
137
+ * `valueSimpleQuantity`, which no FHIR resource has.
138
+ */
139
+ function firstType(field) {
140
+ const type = field?.typeOptions?.[0]?.code || field?.type;
141
+ if (!type || type === 'any' || type === 'unknown')
142
+ return undefined;
143
+ return type;
144
+ }
145
+ /**
146
+ * A concrete type for an element that must be named unambiguously.
147
+ *
148
+ * Undefined when more than one type is permitted: a leaf may pick any of them and
149
+ * still be valid data, but an element with children may not, since the children
150
+ * exist under one type only.
151
+ */
152
+ function singleType(field) {
153
+ if (!field)
154
+ return undefined;
155
+ if (field.typeOptions && field.typeOptions.length > 1)
156
+ return undefined;
157
+ return firstType(field);
158
+ }
159
+ /** `SimpleQuantity` -> `SimpleQuantity`, `code` -> `Code`: the choice-name suffix. */
160
+ function choiceSuffix(type) {
161
+ return type === 'code' ? 'Code' : type.charAt(0).toUpperCase() + type.slice(1);
162
+ }
163
+ /**
164
+ * The one type an intermediate choice segment has been narrowed to.
165
+ *
166
+ * The parser strips slice qualifiers from field names, so a sliced choice yields
167
+ * several entries under the same id: the open base element carrying every
168
+ * permitted type, plus one narrowed entry per slice. Only the narrowed entries
169
+ * name a property, and they must agree — two slices pinning `value[x]` to
170
+ * different types share no single property name, so the requirement cannot be
171
+ * expressed and the caller drops it.
172
+ *
173
+ * The trailing `[x]` is also tried stripped, because a profile that pins the
174
+ * choice may record it under the bare name.
175
+ */
176
+ /**
177
+ * Narrowed types by field name, built once per field list.
178
+ *
179
+ * The scan used to run per `[x]` segment, which is O(fields × segments): us-core
180
+ * has thousands of both, and generation crossed the 300s subprocess timeout in CI
181
+ * (`spawnSync /bin/sh ETIMEDOUT`) while every package small enough to finish still
182
+ * passed. Keyed on the array itself so callers need not thread an index through.
183
+ */
184
+ const narrowedTypeIndexes = new WeakMap();
185
+ function narrowedTypeIndex(candidateFields) {
186
+ const cached = narrowedTypeIndexes.get(candidateFields);
187
+ if (cached)
188
+ return cached;
189
+ const index = new Map();
190
+ for (const candidate of candidateFields) {
191
+ const type = singleType(candidate);
192
+ if (!type || !candidate.name)
193
+ continue;
194
+ let types = index.get(candidate.name);
195
+ if (!types)
196
+ index.set(candidate.name, (types = new Set()));
197
+ types.add(type);
198
+ }
199
+ narrowedTypeIndexes.set(candidateFields, index);
200
+ return index;
201
+ }
202
+ function narrowedSegmentType(id, candidateFields) {
203
+ const index = narrowedTypeIndex(candidateFields);
204
+ const bare = id.replace(/\[x\]$/, '');
205
+ // Both spellings, unioned: the same element appears under `value[x]` and, once a
206
+ // profile pins it, under `value`. Disagreement still names nothing.
207
+ const narrowed = new Set([
208
+ ...(index.get(id) ?? []),
209
+ ...(bare === id ? [] : index.get(bare) ?? []),
210
+ ]);
211
+ return narrowed.size === 1 ? [...narrowed][0] : undefined;
212
+ }
213
+ /**
214
+ * Resolve every `x[x]` segment of a nested requirement path to its concrete
215
+ * property name, using the type of *that* segment.
216
+ *
217
+ * The leaf's type is the field's own; an intermediate `[x]` must be looked up by
218
+ * its element id, because a profile that narrows `value[x]` to one type still
219
+ * carries children beneath it. Reading the leaf's type there produced names like
220
+ * `valueSimpleQuantity.origin` for `Observation.component:ekgLeads.value[x].origin`
221
+ * — four invented sibling properties in place of one `valueSampledData` object,
222
+ * all of which the reference validator rejects as unrecognized.
223
+ *
224
+ * @param parts `field.name` split on `.`, slice qualifiers intact.
225
+ * @returns the dotted path relative to the parent field, or undefined when an
226
+ * intermediate choice cannot be narrowed to a single type.
227
+ */
228
+ export function resolveChoiceSegments(field, parts, candidateFields) {
229
+ const segments = parts.slice(2);
230
+ const resolved = [];
231
+ for (let i = 0; i < segments.length; i++) {
232
+ const segment = segments[i].split(':')[0];
233
+ const choice = /^(\w+)\[x\]$/.exec(segment);
234
+ if (!choice) {
235
+ resolved.push(segment);
236
+ continue;
237
+ }
238
+ // A leaf carries no children, so any permitted type yields valid data; an
239
+ // intermediate segment must be the one its children actually belong to.
240
+ const isLeaf = i === segments.length - 1;
241
+ const type = isLeaf
242
+ ? firstType(field)
243
+ : narrowedSegmentType(parts.slice(0, 2 + i + 1).join('.'), candidateFields);
244
+ if (!type)
245
+ return undefined;
246
+ resolved.push(choice[1] + choiceSuffix(type));
247
+ }
248
+ return resolved.join('.');
249
+ }
package/out/src/main.js CHANGED
@@ -3,6 +3,7 @@ import { generateClient } from "./generator/emitters/client/clientGenerator.js";
3
3
  import { extractPackage } from "./generator/parser/packageParser.js";
4
4
  import { getCacheConfig, getFhirPackagesCacheDir, clearAllCaches, ensureCacheDir, setCacheConfig } from "./generator/core/cacheConfig.js";
5
5
  import { fetchArrayBuffer, fetchJson } from "./generator/core/fetchUtils.js";
6
+ import { FHIR_REGISTRIES, PACKAGE_DOWNLOAD_ATTEMPTS } from "./generator/core/constants.js";
6
7
  import { handleInstallCommand } from "./cli/installCommand.js";
7
8
  import { handleUpdateCommand } from "./cli/updateCommand.js";
8
9
  import { buildQualityReport, formatReportSummary, resetDiagnostics } from "./generator/core/sdDiagnostics.js";
@@ -177,18 +178,43 @@ async function downloadPackage(packageSpec, registry) {
177
178
  return tgzPath; // Return path to tgz (may not exist, but packageDir does)
178
179
  }
179
180
  console.log(`Downloading ${packageName}@${version} from ${registry}...`);
180
- // Simplifier API endpoint: GET /package/{id}/{version}
181
- const url = `${registry.replace(/\/$/, '')}/${packageName}/${version}`;
182
- try {
183
- const response = await fetchArrayBuffer(url);
184
- fs.writeFileSync(tgzPath, Buffer.from(response.data));
185
- console.log(`✓ Downloaded package`);
186
- return tgzPath;
187
- }
188
- catch (error) {
189
- const message = error instanceof Error ? error.message : String(error);
190
- throw new Error(`Failed to download package: ${message}. URL: ${url}`, { cause: error });
181
+ // Try the requested registry first, then the other known mirrors. A single
182
+ // transient `fetch failed` used to abort the whole generation: a parity run
183
+ // downloads ~30 packages, and four of them failed this way in one run —
184
+ // including one whose version had not changed. The registries serve identical
185
+ // tarballs, so a retry and a mirror are both cheap and safe.
186
+ const candidates = [
187
+ registry.replace(/\/$/, ''),
188
+ ...FHIR_REGISTRIES.filter(r => r !== registry.replace(/\/$/, '')),
189
+ ];
190
+ const attempts = [];
191
+ for (const base of candidates) {
192
+ // Registry endpoint: GET /{id}/{version}
193
+ const url = `${base}/${packageName}/${version}`;
194
+ for (let attempt = 1; attempt <= PACKAGE_DOWNLOAD_ATTEMPTS; attempt++) {
195
+ try {
196
+ const response = await fetchArrayBuffer(url);
197
+ fs.writeFileSync(tgzPath, Buffer.from(response.data));
198
+ console.log(`✓ Downloaded package`);
199
+ return tgzPath;
200
+ }
201
+ catch (error) {
202
+ const message = error instanceof Error ? error.message : String(error);
203
+ attempts.push(`${url} (attempt ${attempt}): ${message}`);
204
+ // A 404 means this mirror does not carry the version — no point
205
+ // retrying it, move to the next registry.
206
+ if (/\b404\b|not found/i.test(message))
207
+ break;
208
+ if (attempt < PACKAGE_DOWNLOAD_ATTEMPTS) {
209
+ const backoffMs = 1000 * 2 ** (attempt - 1);
210
+ console.log(` retrying in ${backoffMs}ms (${message})`);
211
+ await new Promise(resolve => setTimeout(resolve, backoffMs));
212
+ }
213
+ }
214
+ }
191
215
  }
216
+ throw new Error(`Failed to download package ${packageName}@${version} from ${candidates.length} registry/registries:\n `
217
+ + attempts.join('\n '));
192
218
  }
193
219
  export function parseCliArgs(argv) {
194
220
  const out = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "babelfhir-ts",
3
- "version": "1.5.19",
3
+ "version": "1.5.21",
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",
@@ -40,6 +40,7 @@
40
40
  "generate:clients:check": "tsx scripts/generate-client-packages.ts --check",
41
41
  "generate:parity-matrix": "tsx scripts/generate-parity-matrix.ts",
42
42
  "generate:parity-matrix:check": "tsx scripts/generate-parity-matrix.ts --check",
43
+ "check:ig-versions": "tsx scripts/check-ig-versions.ts",
43
44
  "generate:check": "npm run generate && npm run typecheck && npm run typecheck:output && npm run lint:output",
44
45
  "generate:check:prefab": "npm run generate -- --prefab && npm run typecheck && npm run typecheck:output && npm run lint:output",
45
46
  "generate:check:package": "tsx scripts/generate-check-package.ts",
@@ -85,6 +86,7 @@
85
86
  },
86
87
  "devDependencies": {
87
88
  "@eslint/js": "^10.0.1",
89
+ "@max-health/config": "github:max-health-inc/config",
88
90
  "@maxhealth.tech/prefab": "^0.3.5",
89
91
  "@types/node": "^25.9.5",
90
92
  "@types/semver": "^7.7.1",