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
@@ -2,6 +2,20 @@
2
2
  * Extension and ValueSet slice validation strategies.
3
3
  */
4
4
  import { safeAccessor, safeList, sliceElseBranch, detectNestedArray, findArrayAncestor, isArrayPath, FHIR_PRIMITIVE_STRING_TYPES } from './sliceValidatorUtils.js';
5
+ import { stripVersionFromCanonicalUrl } from '../../core/utils.js';
6
+ import { buildSubExtensionScope, renderScopeHops } from './subExtensionScope.js';
7
+ /**
8
+ * Read pre-resolved Extension SD metadata for a canonical.
9
+ *
10
+ * The map is keyed by the version-stripped canonical because a slice names its
11
+ * extension in two spellings — `type.profile` with a `|x.y.z` suffix, the pinned
12
+ * `url` child without one — and either may reach this lookup. Reading with the
13
+ * raw url missed every versioned entry, and a miss is indistinguishable from an
14
+ * unreadable SD, so the emitter silently skipped the slice.
15
+ */
16
+ export function lookupExtensionMetadata(metadata, extensionUrl) {
17
+ return metadata?.get(stripVersionFromCanonicalUrl(extensionUrl));
18
+ }
5
19
  export function generateValueSetValidation(slice, baseName, relPath, errorPath, sliceLabel, varName, min, minText, getValueSetForBinding, valueSetImports, fields, out) {
6
20
  const vsInfo = getValueSetForBinding(slice.binding.uri);
7
21
  const vsInfoFull = vsInfo && !('systemOnly' in vsInfo && vsInfo.systemOnly) ? vsInfo : undefined;
@@ -216,17 +230,12 @@ export function generateExistsExtensionValidation(slice, relPath, errorPath, sli
216
230
  const remainingAccessor = remaining.includes('.') ? remaining.split('.').join('?.') : remaining;
217
231
  out.push(`
218
232
  // Required slice validation for ${relPath}:${sliceLabel} (exists extension discriminator, nested in array)
219
- if (${safeAccessor(arrayParent)} && Array.isArray(${safeAccessor(arrayParent)})) {
220
- for (const _el of ${safeAccessor(arrayParent)}) {
221
- const _arr = elements(at(_el, ${remainingAccessor.split('?.').map(s => `'${s}'`).join(', ')}));
222
- if (Array.isArray(_arr)) {
223
- const ${varName}Matched = _arr.filter((item) =>
224
- elements(at(item, 'extension')).some((ext) => ext.url === "${extensionUrl}")
225
- );
226
- if (${varName}Matched.length < ${min}) {
227
- errors.push("No elements matched required slice: '${errorPath}:${sliceLabel}'");
228
- }
229
- }
233
+ for (const _el of arr(${safeAccessor(arrayParent)})) {
234
+ const ${varName}Matched = elements(at(_el, ${remainingAccessor.split('?.').map(s => `'${s}'`).join(', ')})).filter((item) =>
235
+ elements(at(item, 'extension')).some((ext) => ext.url === "${extensionUrl}")
236
+ );
237
+ if (${varName}Matched.length < ${min}) {
238
+ errors.push("No elements matched required slice: '${errorPath}:${sliceLabel}'");
230
239
  }
231
240
  }`);
232
241
  return;
@@ -278,12 +287,17 @@ export function detectExtensionUrlSlice(slice, relPath, fields) {
278
287
  }
279
288
  export function generateExtensionUrlSliceValidation(slice, relPath, errorPath, sliceLabel, varName, min, extensionUrl, fieldPathMap, arrayFieldPaths, out, ctx) {
280
289
  const baseFieldIsArray = isArrayPath(relPath, fieldPathMap, arrayFieldPaths);
281
- const extMeta = ctx?.extensionMetadata?.get(extensionUrl);
290
+ const extMeta = lookupExtensionMetadata(ctx?.extensionMetadata, extensionUrl);
282
291
  // Narrow extension metadata value types with profile-level constraints.
283
292
  // The profile differential may restrict value[x] types more strictly than
284
293
  // the extension SD (e.g., extension allows [boolean, url] but profile allows only [url]).
294
+ //
295
+ // An empty type list is not a list to narrow: it means the extension is complex and
296
+ // forbids a value outright. The profile still carries the inherited value[x] element
297
+ // with its full base type list, so narrowing would read that as "any of ~50 types
298
+ // allowed" and cancel the prohibition.
285
299
  let effectiveMeta = extMeta;
286
- if (extMeta && ctx?.fields && slice.elementId) {
300
+ if (extMeta && extMeta.valueTypes.length > 0 && ctx?.fields && slice.elementId) {
287
301
  const sliceElemId = slice.elementId.replace(/^[^.]+\./, '');
288
302
  const valueChild = ctx.fields.find(f => {
289
303
  if (!f.elementId || !f.typeOptions?.length)
@@ -296,22 +310,55 @@ export function generateExtensionUrlSliceValidation(slice, relPath, errorPath, s
296
310
  effectiveMeta = { ...extMeta, valueTypes: profileTypes };
297
311
  }
298
312
  }
313
+ // An optional slice asserts no count, but a present instance must still conform.
314
+ // Omitting the assertion rather than emitting `length < 0` keeps a branch that can
315
+ // never fire out of the generated output.
316
+ const countAssertion = min > 0
317
+ ? `
318
+ if (${varName}Matches.length < ${min}) {
319
+ errors.push("Slice '${errorPath}:${sliceLabel}': a matching slice is required, but not found");
320
+ }`
321
+ : '';
322
+ const headline = min > 0 ? 'Required' : 'Optional';
323
+ // With no count to assert and no resolved metadata there is nothing to check, and
324
+ // the block would filter a list and discard it. That happens whenever the
325
+ // extension SD could not be read, so emit only when the slice earns it.
326
+ if (min === 0 && !generateExtensionValueChecks('_probe', extensionUrl, effectiveMeta, ctx, '')) {
327
+ return;
328
+ }
329
+ // A sub-extension of another extension slice needs the parent walked and filtered
330
+ // first; the generic paths below address it from the resource root and cannot.
331
+ const subScope = ctx?.fields
332
+ ? buildSubExtensionScope(slice, ctx.fields)
333
+ : null;
334
+ if (subScope) {
335
+ const scopedElemId = (slice.elementId || '').replace(/^[^.]+\./, '');
336
+ const scopedErrorPath = scopedElemId || `${errorPath}:${sliceLabel}`;
337
+ // The slot the slice lives in: its own last segment, minus the slice qualifier.
338
+ const leafProp = scopedElemId.split('.').pop().split(':')[0];
339
+ const body = renderScopeHops(subScope, (container, indent) => {
340
+ const matches = `${varName}Matches`;
341
+ let inner = `\n${indent}const ${matches} = elements(at(${container}, '${leafProp}')).filter((item) => item.url === "${extensionUrl}");`;
342
+ if (min > 0) {
343
+ inner += `\n${indent}if (${matches}.length < ${min}) {`;
344
+ inner += `\n${indent} errors.push("Slice '${scopedErrorPath}': a matching slice is required, but not found");`;
345
+ inner += `\n${indent}}`;
346
+ }
347
+ inner += generateExtensionValueChecks(matches, extensionUrl, effectiveMeta, ctx, indent);
348
+ return inner;
349
+ });
350
+ out.push(`
351
+ // ${headline} extension slice validation for ${scopedErrorPath} (sub-extension, scoped to its parent slice)${body}`);
352
+ return;
353
+ }
299
354
  const nested = findArrayAncestor(relPath, fieldPathMap, arrayFieldPaths);
300
355
  if (nested) {
301
356
  const { arrayParent, remaining } = nested;
302
357
  const remainingAccessor = remaining.includes('.') ? remaining.split('.').join('?.') : remaining;
303
358
  out.push(`
304
- // Required extension slice validation for ${relPath}:${sliceLabel} (url discriminator, nested in array)
305
- if (${safeAccessor(arrayParent)} && Array.isArray(${safeAccessor(arrayParent)})) {
306
- for (const _el of ${safeAccessor(arrayParent)}) {
307
- const _extArr = elements(at(_el, ${remainingAccessor.split('?.').map(s => `'${s}'`).join(', ')}));
308
- if (Array.isArray(_extArr)) {
309
- const ${varName}Matches = _extArr.filter((item) => item.url === "${extensionUrl}");
310
- if (${varName}Matches.length < ${min}) {
311
- errors.push("Slice '${errorPath}:${sliceLabel}': a matching slice is required, but not found");
312
- }${generateExtensionValueChecks(varName + 'Matches', extensionUrl, effectiveMeta, ctx, ' ')}
313
- }
314
- }
359
+ // ${headline} extension slice validation for ${relPath}:${sliceLabel} (url discriminator, nested in array)
360
+ for (const _el of arr(${safeAccessor(arrayParent)})) {
361
+ const ${varName}Matches = elements(at(_el, ${remainingAccessor.split('?.').map(s => `'${s}'`).join(', ')})).filter((item) => item.url === "${extensionUrl}");${countAssertion}${generateExtensionValueChecks(varName + 'Matches', extensionUrl, effectiveMeta, ctx, ' ')}
315
362
  }`);
316
363
  return;
317
364
  }
@@ -319,41 +366,159 @@ export function generateExtensionUrlSliceValidation(slice, relPath, errorPath, s
319
366
  const pathParts = relPath.split('.');
320
367
  const parentPath = pathParts.length > 1 ? pathParts.slice(0, -1).join('.') : null;
321
368
  const parentGuard = parentPath ? `${safeAccessor(parentPath)} && ` : '';
322
- const elseGuard = parentPath ? `else if (${safeAccessor(parentPath)})` : 'else';
369
+ // The absent branch asserts the slice is missing. For min=0 that is simply false,
370
+ // and on an empty resource the element is never an array, so it fired every time:
371
+ // this is what cost empty parity 613/613 -> 599/613 across mcode, ndh, pas and
372
+ // physical-activity. Two earlier attempts patched only the nested path above and
373
+ // never reached this one.
374
+ const absentBranch = min > 0
375
+ ? ` ${parentPath ? `else if (${safeAccessor(parentPath)})` : 'else'} {
376
+ errors.push("Slice '${errorPath}:${sliceLabel}': a matching slice is required, but not found");
377
+ }`
378
+ : '';
379
+ const arrayCountAssertion = min > 0
380
+ ? `
381
+ if (${varName}Elements.length < ${min}) {
382
+ errors.push("Slice '${errorPath}:${sliceLabel}': a matching slice is required, but not found");
383
+ }`
384
+ : '';
323
385
  out.push(`
324
- // Required extension slice validation for ${relPath}:${sliceLabel} (url discriminator)
386
+ // ${headline} extension slice validation for ${relPath}:${sliceLabel} (url discriminator)
325
387
  if (${parentGuard}Array.isArray(${safeAccessor(relPath)})) {
326
388
  const ${varName}Elements = ${safeList(relPath)}.filter((item) =>
327
389
  item.url === "${extensionUrl}"
328
- );
329
- if (${varName}Elements.length < ${min}) {
330
- errors.push("Slice '${errorPath}:${sliceLabel}': a matching slice is required, but not found");
331
- }${generateExtensionValueChecks(varName + 'Elements', extensionUrl, effectiveMeta, ctx, ' ')}
332
- } ${elseGuard} {
333
- errors.push("Slice '${errorPath}:${sliceLabel}': a matching slice is required, but not found");
334
- }`);
390
+ );${arrayCountAssertion}${generateExtensionValueChecks(varName + 'Elements', extensionUrl, effectiveMeta, ctx, ' ')}
391
+ }${absentBranch}`);
335
392
  }
336
- else {
393
+ else if (min > 0) {
337
394
  out.push(`
338
395
  // Required extension slice validation for ${relPath}:${sliceLabel} (url discriminator)
339
396
  if (!${safeAccessor(relPath)} || (${safeAccessor(relPath)} as { url?: string }).url !== "${extensionUrl}") {
340
397
  errors.push("Slice '${errorPath}:${sliceLabel}': a matching slice is required, but not found");
341
398
  }`);
342
399
  }
400
+ // A non-array optional slice has nothing to assert: its only check was that the
401
+ // slice is present, which min=0 does not require.
343
402
  }
344
403
  // ── Extension value type + binding validation helper ─────────────────────
345
404
  /** Map FHIR type code → JSON property name suffix (e.g., 'code' → 'Code'). */
346
405
  function valuePropertySuffix(typeCode) {
347
406
  return typeCode.charAt(0).toUpperCase() + typeCode.slice(1);
348
407
  }
408
+ /** A ValueSet resolved to an emitted validator function, or nothing to check against. */
409
+ function resolveBindingValueSet(binding, ctx) {
410
+ if (!binding || !ctx)
411
+ return undefined;
412
+ if (binding.strength !== 'required' && binding.strength !== 'extensible')
413
+ return undefined;
414
+ const vsInfo = ctx.getValueSetForBinding(binding.uri);
415
+ // A system-only ValueSet cannot decide membership of a code, only of its system.
416
+ if (!vsInfo || ('systemOnly' in vsInfo && vsInfo.systemOnly))
417
+ return undefined;
418
+ const full = vsInfo;
419
+ ctx.valueSetImports.set(full.sanitizedName, `./valuesets/ValueSet-${full.sanitizedName}.js`);
420
+ return full;
421
+ }
422
+ /**
423
+ * Check a sub-extension's value against its bound ValueSet.
424
+ *
425
+ * `elemVar` is `unknown`, so the value is reached through `at()` and only the shape
426
+ * actually read is asserted. Codings are the case KBV needs — the Payor extension
427
+ * binds `Extension.extension:Kostentraegertyp.value[x]:valueCoding` required — and
428
+ * primitives and CodeableConcepts are handled alongside so one helper covers the
429
+ * shapes an extension value can take.
430
+ */
431
+ function subExtensionBindingCheck(sub, vs, elemVar, indent) {
432
+ const lines = [];
433
+ const notFound = (valueExpr) => `errors.push("The value provided ('" + ${valueExpr} + "') was not found in the value set '${vs.displayName}' (${sub.binding.uri})");`;
434
+ if (sub.valueTypes.includes('Coding')) {
435
+ lines.push(`${indent}const _seCode = at(${elemVar}, 'valueCoding', 'code');`);
436
+ lines.push(`${indent}if (typeof _seCode === 'string' && !${vs.validatorFn}(_seCode)) {`);
437
+ lines.push(`${indent} ${notFound('_seCode')}`);
438
+ lines.push(`${indent}}`);
439
+ }
440
+ else if (sub.valueTypes.includes('CodeableConcept')) {
441
+ lines.push(`${indent}const _seCodings = codings(at(${elemVar}, 'valueCodeableConcept'));`);
442
+ lines.push(`${indent}if (_seCodings.length > 0 && !_seCodings.some((c) => !!c.code && ${vs.validatorFn}(c.code))) {`);
443
+ lines.push(`${indent} errors.push("None of the codings in the extension value are from the value set '${vs.displayName}' (${sub.binding.uri})");`);
444
+ lines.push(`${indent}}`);
445
+ }
446
+ else {
447
+ // Primitive: whichever value* property is present carries the code.
448
+ lines.push(`${indent}const _seEntry = Object.entries(node(${elemVar})).find(([k]) => k.startsWith("value"));`);
449
+ lines.push(`${indent}if (_seEntry && typeof _seEntry[1] === 'string' && !${vs.validatorFn}(_seEntry[1])) {`);
450
+ lines.push(`${indent} ${notFound('_seEntry[1]')}`);
451
+ lines.push(`${indent}}`);
452
+ }
453
+ return lines.join('\n');
454
+ }
455
+ /**
456
+ * Checks on the sub-extensions of a matched complex extension.
457
+ *
458
+ * `_ext` is the matched member; its own `extension` array holds the sub-extensions.
459
+ * Emits the cardinality on `Extension.extension`, the presence of each required
460
+ * slice, and the bound ValueSet of each slice that declares one — all in HL7's
461
+ * wording so parity attributes them to the same fields.
462
+ */
463
+ function generateSubExtensionChecks(extMeta, ctx, indent) {
464
+ const subs = extMeta.subExtensions ?? [];
465
+ const min = extMeta.subExtensionMin ?? 0;
466
+ const lines = [];
467
+ if (min > 0) {
468
+ lines.push(`${indent}const _seAll = elements(at(_ext, 'extension'));`);
469
+ lines.push(`${indent}if (_seAll.length < ${min}) {`);
470
+ lines.push(`${indent} errors.push("Extension.extension: minimum required = ${min}, but only found " + _seAll.length);`);
471
+ lines.push(`${indent}}`);
472
+ }
473
+ for (const sub of subs) {
474
+ const needsPresence = sub.min > 0;
475
+ const vs = resolveBindingValueSet(sub.binding, ctx);
476
+ if (!needsPresence && !vs)
477
+ continue;
478
+ const varName = `_se_${sub.sliceName.replace(/[^a-zA-Z0-9_]/g, '_')}`;
479
+ lines.push(`${indent}const ${varName} = elements(at(_ext, 'extension')).filter((e) => e.url === "${sub.url}");`);
480
+ if (needsPresence) {
481
+ lines.push(`${indent}if (${varName}.length < ${sub.min}) {`);
482
+ lines.push(`${indent} errors.push("Slice 'Extension.extension:${sub.sliceName}': a matching slice is required, but not found");`);
483
+ lines.push(`${indent}}`);
484
+ }
485
+ if (vs) {
486
+ lines.push(`${indent}for (const _seElem of ${varName}) {`);
487
+ lines.push(subExtensionBindingCheck(sub, vs, '_seElem', indent + ' '));
488
+ lines.push(`${indent}}`);
489
+ }
490
+ }
491
+ return lines.length ? '\n' + lines.join('\n') : '';
492
+ }
349
493
  /**
350
494
  * Generate inline JS code that validates extension value type and binding
351
495
  * for each matched extension in the given array variable.
352
496
  * Returns an empty string when no metadata is available.
353
497
  */
354
498
  function generateExtensionValueChecks(matchedArrayVar, extensionUrl, extMeta, ctx, indent) {
355
- if (!extMeta || extMeta.valueTypes.length === 0)
499
+ // No entry means the extension SD could not be read, so nothing can be concluded.
500
+ if (!extMeta)
356
501
  return '';
502
+ // An entry with no permitted types means the opposite of unknown: the extension
503
+ // is complex, pins value[x] to max=0, and carries sub-extensions instead. A value
504
+ // of any type is forbidden, and since no single property can be named — every
505
+ // `value*` variant is disallowed — the check is on the presence of any of them.
506
+ if (extMeta.valueTypes.length === 0) {
507
+ const forbidUrl = ctx?.profileUrl || extensionUrl;
508
+ // HL7 reports one defect under two headings — the cardinality breach and the
509
+ // empty allowed-type list — and parity classifies them into different fields
510
+ // ('value' and 'constraint'), so emitting only the first left `constraint`
511
+ // showing as HL7-only on a finding we had already detected.
512
+ return `
513
+ ${indent}// Complex extension: value[x] is max=0, so no value* property is allowed
514
+ ${indent}for (const _ext of ${matchedArrayVar}) {
515
+ ${indent} const _forbidden = Object.keys(node(_ext)).find((k) => k.startsWith("value"));
516
+ ${indent} if (_forbidden) {
517
+ ${indent} errors.push("Extension.value[x]: max allowed = 0, but found 1 (${forbidUrl})");
518
+ ${indent} errors.push("The Extension '${extensionUrl}' definition allows for the types [] but found type " + _forbidden.slice(5));
519
+ ${indent} }${generateSubExtensionChecks(extMeta, ctx, indent + ' ')}
520
+ ${indent}}`;
521
+ }
357
522
  const lines = [];
358
523
  const allowedProps = extMeta.valueTypes.map(t => `"value${valuePropertySuffix(t)}"`);
359
524
  const allowedSet = `[${allowedProps.join(', ')}]`;