babelfhir-ts 1.5.21 → 1.6.1

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 (57) hide show
  1. package/README.md +6 -3
  2. package/out/src/generator/core/constants.js +2 -1
  3. package/out/src/generator/core/extensionSdReader.js +4 -1
  4. package/out/src/generator/core/utils.js +91 -9
  5. package/out/src/generator/emitters/class/classGenerator.js +14 -8
  6. package/out/src/generator/emitters/class/classGeneratorHelpers.js +22 -9
  7. package/out/src/generator/emitters/class/classHeuristics.js +4 -2
  8. package/out/src/generator/emitters/class/fieldPatternExtractor.js +17 -13
  9. package/out/src/generator/emitters/class/sliceElementDefaults.js +21 -6
  10. package/out/src/generator/emitters/class/sliceElementGenerator.js +3 -3
  11. package/out/src/generator/emitters/interface/backboneSliceTyping.js +21 -13
  12. package/out/src/generator/emitters/interface/importManager.js +1 -1
  13. package/out/src/generator/emitters/interface/interfaceFieldProcessor.js +15 -12
  14. package/out/src/generator/emitters/interface/interfaceFieldUtils.js +6 -3
  15. package/out/src/generator/emitters/interface/interfaceGenerator.js +14 -9
  16. package/out/src/generator/emitters/interface/postProcessExtensions.js +32 -16
  17. package/out/src/generator/emitters/interface/processFlatField.js +9 -7
  18. package/out/src/generator/emitters/interface/processNestedField.js +9 -10
  19. package/out/src/generator/emitters/namingsystem/namingSystemGenerator.js +136 -0
  20. package/out/src/generator/emitters/prefab/prefabEmitter.js +2 -15
  21. package/out/src/generator/emitters/prefab/prefabRenderer.js +2 -19
  22. package/out/src/generator/emitters/prefab/prefabTypeMapper.js +2 -0
  23. package/out/src/generator/emitters/validator/closedSlicingValidation.js +5 -2
  24. package/out/src/generator/emitters/validator/fhirpathStubInstaller.js +7 -1
  25. package/out/src/generator/emitters/validator/sliceBackboneValidation.js +27 -12
  26. package/out/src/generator/emitters/validator/sliceDelegation.js +8 -6
  27. package/out/src/generator/emitters/validator/sliceExtensionValidation.js +3 -3
  28. package/out/src/generator/emitters/validator/sliceValidatorGenerator.js +41 -41
  29. package/out/src/generator/emitters/validator/sliceValidatorUtils.js +8 -3
  30. package/out/src/generator/emitters/validator/subExtensionScope.js +10 -2
  31. package/out/src/generator/emitters/validator/validatorBindingBuilder.js +4 -3
  32. package/out/src/generator/emitters/validator/validatorBindingLeafEmitters.js +5 -5
  33. package/out/src/generator/emitters/validator/validatorConstraintBuilders.js +8 -6
  34. package/out/src/generator/emitters/validator/validatorExpressions.js +44 -0
  35. package/out/src/generator/emitters/validator/validatorFieldBuilders.js +34 -17
  36. package/out/src/generator/emitters/validator/validatorGenerator.js +43 -47
  37. package/out/src/generator/emitters/validator/validatorRuntime.js +13 -2
  38. package/out/src/generator/emitters/validator/validatorTemplates.js +4 -1
  39. package/out/src/generator/emitters/valueset/valueSetGenerator.js +6 -10
  40. package/out/src/generator/emitters/zod/zodRefinementBuilder.js +48 -8
  41. package/out/src/generator/emitters/zod/zodSchemaGenerator.js +51 -24
  42. package/out/src/generator/fhir/corePackageResolver.js +2 -3
  43. package/out/src/generator/fhir/corePackageResolver.ts +2 -3
  44. package/out/src/generator/generationHelpers.js +59 -7
  45. package/out/src/generator/index.js +94 -49
  46. package/out/src/generator/parser/packageManager.js +4 -4
  47. package/out/src/generator/parser/packageParser.js +116 -64
  48. package/out/src/generator/parser/sdFetcher.js +5 -3
  49. package/out/src/generator/parser/sdParser.js +1 -1
  50. package/out/src/generator/parser/sliceAggregator.js +13 -10
  51. package/out/src/generator/parser/sliceAggregatorComposition.js +11 -2
  52. package/out/src/generator/parser/vsParser.js +6 -3
  53. package/out/src/generator/sdProcessor.js +30 -21
  54. package/out/src/generator/sdProcessorRequiredFields.js +3 -2
  55. package/out/src/main.js +5 -0
  56. package/package.json +13 -10
  57. package/parity-matrix.json +6 -0
@@ -1,4 +1,4 @@
1
- import { choicePropertyName, narrowedChoiceType } from '../../core/utils.js';
1
+ import { choicePropertyName, narrowedChoiceType, stripSliceSuffix } from '../../core/utils.js';
2
2
  /**
3
3
  * The element slots FHIR types as `Resource`, i.e. the ones that hold an inline
4
4
  * resource rather than a reference to one: DomainResource.contained,
@@ -22,7 +22,7 @@ const ABSTRACT_RESOURCE_TYPES = new Set(['Resource', 'DomainResource']);
22
22
  * HL7 uses, so this cannot flag a conformant resource.
23
23
  */
24
24
  function buildNestedObjectTypeValidations(fields, arrayFieldPaths, result) {
25
- const isArrayPath = (p) => arrayFieldPaths.has(p) || arrayFieldPaths.has(p.split(':')[0]);
25
+ const isArrayPath = (p) => arrayFieldPaths.has(p) || arrayFieldPaths.has(stripSliceSuffix(p));
26
26
  const seen = new Set();
27
27
  for (const field of fields) {
28
28
  if (field.sliceName || field.isArray)
@@ -63,6 +63,7 @@ function buildNestedObjectTypeValidations(fields, arrayFieldPaths, result) {
63
63
  const body = `${indent}const _soValue = at(${expr}, '${leaf}');
64
64
  ${indent}if (_soValue !== undefined && _soValue !== null && typeof _soValue !== 'object') {
65
65
  ${indent} errors.push("The property ${leaf} must be an Object, not a Primitive property (at ${rel})");
66
+ ${indent} _structuralFailures.add('${rel}');
66
67
  ${indent}}`;
67
68
  result.push(`
68
69
  // Structural type check: ${rel} must be an Object
@@ -160,8 +161,9 @@ export function buildInlineResourceTypeValidations(fields, profileUrl) {
160
161
  * is one identifier among several, so requiring its children of every identifier
161
162
  * would report a conformant resource.
162
163
  */
164
+ /** Undefined for an empty path, which callers treat as "nothing to check". */
163
165
  function leafFieldName(parts) {
164
- return parts[parts.length - 1];
166
+ return parts.at(-1);
165
167
  }
166
168
  function resolveChoiceSliceSegments(elementIdRel) {
167
169
  const segments = elementIdRel.split('.');
@@ -172,7 +174,7 @@ function resolveChoiceSliceSegments(elementIdRel) {
172
174
  continue;
173
175
  }
174
176
  const [base, sliceName] = segment.split(':');
175
- if (!base.endsWith('[x]') || !sliceName)
177
+ if (!base || !base.endsWith('[x]') || !sliceName)
176
178
  return undefined;
177
179
  const baseProp = base.slice(0, -'[x]'.length);
178
180
  if (!sliceName.startsWith(baseProp) || sliceName === baseProp)
@@ -236,17 +238,20 @@ export function buildNestedRequiredValidations(fields, arrayFieldPaths) {
236
238
  // Only an unambiguous choice yields one property name. A multi-typed choice
237
239
  // may legitimately carry any variant, and picking one would flag a conformant
238
240
  // resource — which is what the previous depth guard was protecting against.
239
- if (uniqueTypes.length !== 1)
241
+ const [onlyType] = uniqueTypes;
242
+ if (uniqueTypes.length !== 1 || !onlyType)
240
243
  return;
241
- const choiceSuffix = uniqueTypes[0].charAt(0).toUpperCase() + uniqueTypes[0].slice(1);
244
+ const choiceSuffix = onlyType.charAt(0).toUpperCase() + onlyType.slice(1);
242
245
  rel = relFull.replace(/^[^.]+\./, '').replace(/\[x\]/, choiceSuffix);
243
246
  }
244
247
  const resolvedParts = rel.split('.');
245
- const isArrayPath = (p) => arrayFieldPaths.has(p) || arrayFieldPaths.has(p.split(':')[0]);
248
+ const isArrayPath = (p) => arrayFieldPaths.has(p) || arrayFieldPaths.has(stripSliceSuffix(p));
246
249
  // Check which intermediate parts are arrays
247
250
  const intermediateParts = resolvedParts.slice(1, -1);
248
251
  const hasIntermediateArray = intermediateParts.some((_part, idx) => isArrayPath(resolvedParts.slice(0, idx + 2).join('.')));
249
252
  const arrayParent = resolvedParts[0];
253
+ if (!arrayParent)
254
+ return;
250
255
  const topIsArray = isArrayPath(arrayParent);
251
256
  // An array is needed somewhere above the leaf, but it need not be the first
252
257
  // segment: the flat branches below index straight into resource.<arrayParent>,
@@ -275,17 +280,22 @@ export function buildNestedRequiredValidations(fields, arrayFieldPaths) {
275
280
  }`);
276
281
  return;
277
282
  }
278
- const leafField = resolvedParts[resolvedParts.length - 1];
283
+ const leafField = resolvedParts.at(-1);
284
+ if (!leafField)
285
+ return;
279
286
  // Check if the leaf field itself is an array — if so, don't flag Array values as absent
280
287
  const fullLeafPath = resolvedParts.join('.');
281
- const leafIsArray = arrayFieldPaths.has(fullLeafPath) || arrayFieldPaths.has(fullLeafPath.split(':')[0]);
288
+ const leafIsArray = arrayFieldPaths.has(fullLeafPath) || arrayFieldPaths.has(stripSliceSuffix(fullLeafPath));
282
289
  if (hasIntermediateArray) {
283
290
  // Build nested for-loops for paths with intermediate arrays
284
291
  const levels = [];
285
292
  for (let i = 0; i < resolvedParts.length - 1; i++) {
293
+ const name = resolvedParts[i];
294
+ if (!name)
295
+ continue;
286
296
  const pathToHere = resolvedParts.slice(0, i + 1).join('.');
287
- const isArr = arrayFieldPaths.has(pathToHere) || arrayFieldPaths.has(pathToHere.split(':')[0]);
288
- levels.push({ name: resolvedParts[i], isArray: isArr });
297
+ const isArr = arrayFieldPaths.has(pathToHere) || arrayFieldPaths.has(stripSliceSuffix(pathToHere));
298
+ levels.push({ name, isArray: isArr });
289
299
  }
290
300
  const lines = [];
291
301
  let indent = ' ';
@@ -295,6 +305,8 @@ export function buildNestedRequiredValidations(fields, arrayFieldPaths) {
295
305
  let isTyped = true;
296
306
  for (let i = 0; i < levels.length; i++) {
297
307
  const level = levels[i];
308
+ if (!level)
309
+ continue;
298
310
  const child = isTyped ? `${currentVar}.${level.name}` : `at(${currentVar}, '${level.name}')`;
299
311
  if (level.isArray) {
300
312
  const loopVar = `_nrEl${i}`;
@@ -393,7 +405,7 @@ function emitNestedFixedValue(result, rel, field, arrayFieldPaths, profileUrl, b
393
405
  .some(candidate => candidate.split('.').some(segment => segment.includes(':')));
394
406
  if (hasSliceQualifier)
395
407
  return;
396
- const isArrayPath = (p) => arrayFieldPaths.has(p) || arrayFieldPaths.has(p.split(':')[0]);
408
+ const isArrayPath = (p) => arrayFieldPaths.has(p) || arrayFieldPaths.has(stripSliceSuffix(p));
397
409
  const segments = rel.split('.');
398
410
  const parents = segments.slice(0, -1);
399
411
  const leaf = segments[segments.length - 1];
@@ -547,9 +559,9 @@ export function buildProhibitedFieldValidations(fields, arrayFieldPaths, fieldPa
547
559
  const resolvedParts = eidRel.split('.').map(p => {
548
560
  if (p.includes(':')) {
549
561
  const [basePart, sliceName] = p.split(':', 2);
550
- if (basePart.includes('[x]') && sliceName)
562
+ if (basePart?.includes('[x]') && sliceName)
551
563
  return sliceName;
552
- return basePart;
564
+ return basePart ?? p;
553
565
  }
554
566
  return p.replace(/\[x\]$/, '');
555
567
  });
@@ -573,9 +585,13 @@ export function buildProhibitedFieldValidations(fields, arrayFieldPaths, fieldPa
573
585
  }
574
586
  else if (pathParts.length >= 2) {
575
587
  const arrayParent = pathParts[0];
576
- if (!arrayFieldPaths.has(arrayParent) && !arrayFieldPaths.has(arrayParent.split(':')[0]))
588
+ if (!arrayParent)
589
+ return;
590
+ if (!arrayFieldPaths.has(arrayParent) && !arrayFieldPaths.has(stripSliceSuffix(arrayParent)))
591
+ return;
592
+ const leafField = pathParts.at(-1);
593
+ if (!leafField)
577
594
  return;
578
- const leafField = pathParts[pathParts.length - 1];
579
595
  const fullPath = `${resourceType}.${rel}`;
580
596
  // `at()` walks the dotted path and yields `unknown`, so the prohibited
581
597
  // field is probed without widening the element to `any`.
@@ -703,7 +719,7 @@ const CONTAINER_TYPES = new Set([
703
719
  * `constraint` looking like an HL7-only finding.
704
720
  */
705
721
  function buildArrayNestedPrimitiveKindValidations(fields, arrayFieldPaths, result) {
706
- const isArrayPath = (p) => arrayFieldPaths.has(p) || arrayFieldPaths.has(p.split(':')[0]);
722
+ const isArrayPath = (p) => arrayFieldPaths.has(p) || arrayFieldPaths.has(stripSliceSuffix(p));
707
723
  const seen = new Set();
708
724
  for (const field of fields) {
709
725
  if (field.sliceName || field.isArray)
@@ -846,6 +862,7 @@ export function buildStructuralTypeValidations(fields, arrayFieldPaths) {
846
862
  if (_stValue !== undefined && _stValue !== null && typeof _stValue !== 'object') {
847
863
  const _stType = at(resource, 'resourceType');
848
864
  errors.push("The property ${rel} must be an Object, not a Primitive property (at " + (typeof _stType === 'string' ? _stType : "Resource") + ".${rel})");
865
+ _structuralFailures.add('${rel}');
849
866
  }
850
867
  }`);
851
868
  }
@@ -1,3 +1,4 @@
1
+ import { stripSliceSuffix, stripVersionFromCanonicalUrl } from '../../core/utils.js';
1
2
  import { logger } from '../../../logger.js';
2
3
  import { sanitizeValueSetName } from '../valueset/valueSetGenerator.js';
3
4
  import { generateSliceValidations } from './sliceValidatorGenerator.js';
@@ -8,7 +9,7 @@ import { buildNestedRequiredValidations, buildFixedValueValidations, buildProhib
8
9
  import { buildBindingValidations } from './validatorBindingBuilder.js';
9
10
  import { generateBundleRefValidation, generateContainedRefValidation, generateExtensionStructuralValidation, generateAggregationRefValidation, buildReferenceTargetTypeValidation, buildBundleEntryResolution } from './validatorTemplates.js';
10
11
  import { HELPER_NAMES } from './validatorRuntime.js';
11
- import { quoteFhirPathPath, normalizeCollectionTypeFilters, isBaseElementInvariant } from './validatorExpressions.js';
12
+ import { isBaseElementInvariant, scopeConstraintExpression } from './validatorExpressions.js';
12
13
  const log = logger.withTag('validator');
13
14
  /**
14
15
  * A constraint declared on a slice applies only to that slice's members.
@@ -52,6 +53,8 @@ function sliceScopedBindingPath(rel, field, fields) {
52
53
  if (!sliceMatch)
53
54
  return rel;
54
55
  const sliceId = sliceMatch[1];
56
+ if (!sliceId)
57
+ return rel;
55
58
  const pinned = fields.find(f => {
56
59
  if (!f.elementId || !f.elementId.startsWith(sliceId + '.'))
57
60
  return false;
@@ -63,7 +66,9 @@ function sliceScopedBindingPath(rel, field, fields) {
63
66
  const discriminatorProp = pinned.elementId.substring(sliceId.length + 1);
64
67
  if (!discriminatorProp || discriminatorProp.includes('.'))
65
68
  return rel;
66
- const coding = pinned.patternConstraint.coding[0];
69
+ const [coding] = pinned.patternConstraint.coding;
70
+ if (!coding)
71
+ return rel;
67
72
  const system = typeof coding.system === 'string' ? coding.system : undefined;
68
73
  const code = typeof coding.code === 'string' ? coding.code : undefined;
69
74
  if (!code)
@@ -71,9 +76,9 @@ function sliceScopedBindingPath(rel, field, fields) {
71
76
  const terms = [system ? `system='${system}'` : '', `code='${code}'`].filter(Boolean).join(' and ');
72
77
  const segments = rel.split('.');
73
78
  // rel is <base>.<leaf>; the where() goes on the repeating base element.
74
- if (segments.length < 2)
79
+ const leaf = segments.length >= 2 ? segments.pop() : undefined;
80
+ if (!leaf)
75
81
  return rel;
76
- const leaf = segments.pop();
77
82
  return `${segments.join('.')}.where(${discriminatorProp}.coding.where(${terms}).exists()).${leaf}`;
78
83
  }
79
84
  export function generateValidateProfileFunction(interfaceName, fields, valueSets, profileUrl, baseResourceType, profileUrlToName, extensionMetadata, baseDefinition, profileUrlToType, datatypeProfilePins) {
@@ -110,7 +115,7 @@ export function generateValidateProfileFunction(interfaceName, fields, valueSets
110
115
  const relPath = fullPath.replace(/^[^.]+\./, "");
111
116
  arrayFieldPaths.add(relPath);
112
117
  if (relPath.includes(':')) {
113
- arrayFieldPaths.add(relPath.split(':')[0]);
118
+ arrayFieldPaths.add(stripSliceSuffix(relPath));
114
119
  }
115
120
  }
116
121
  });
@@ -126,7 +131,7 @@ export function generateValidateProfileFunction(interfaceName, fields, valueSets
126
131
  let isNestedInArray = false;
127
132
  for (let j = 0; j < i; j++) {
128
133
  const parentPath = parts.slice(0, j + 1).join('.');
129
- const basePath = parentPath.split(':')[0];
134
+ const basePath = stripSliceSuffix(parentPath);
130
135
  if (arrayFieldPaths.has(parentPath) || arrayFieldPaths.has(basePath)) {
131
136
  isNestedInArray = true;
132
137
  break;
@@ -169,7 +174,7 @@ export function generateValidateProfileFunction(interfaceName, fields, valueSets
169
174
  let vs = valueSets.get(bindingUri);
170
175
  let resolvedUri = bindingUri;
171
176
  if (!vs && bindingUri.includes('|')) {
172
- resolvedUri = bindingUri.split('|')[0];
177
+ resolvedUri = stripVersionFromCanonicalUrl(bindingUri);
173
178
  vs = valueSets.get(resolvedUri);
174
179
  }
175
180
  if (!vs)
@@ -225,7 +230,7 @@ export function generateValidateProfileFunction(interfaceName, fields, valueSets
225
230
  if (!fieldPath)
226
231
  return;
227
232
  fieldPath = sliceScopedBindingPath(fieldPath, f, fields);
228
- const vsUri = (f.binding.uri || '').split('|')[0];
233
+ const vsUri = stripVersionFromCanonicalUrl(f.binding.uri || '');
229
234
  const vsDisplayName = vsUri.split('/').pop() || vsUri;
230
235
  constraintsWithContext.push({
231
236
  constraint: {
@@ -407,55 +412,40 @@ export function generateValidateProfileFunction(interfaceName, fields, valueSets
407
412
  const lastSegment = path.split('.').pop() || '';
408
413
  return !!lastSegment && relativeFieldNames.has(`${path}.${lastSegment}`);
409
414
  };
415
+ // Paths a structural type check can actually record, read back from the code
416
+ // just emitted rather than re-deriving which fields qualify — two copies of
417
+ // that predicate would drift, and a guard on a path nothing records is a dead
418
+ // conditional in every generated validator.
419
+ const structuralFailurePaths = new Set([...structuralTypeValidations.join('').matchAll(/_structuralFailures\.add\('([^']+)'\)/g)].map(m => m[1]));
420
+ const hasStructuralChecks = structuralFailurePaths.size > 0;
410
421
  // ── Generate FHIRPath evaluation code ───────────────────────────────────
411
422
  const validationLogic = filteredConstraints
412
423
  .map((item, index) => {
413
424
  const constraint = item.constraint;
414
425
  const fieldPath = item.fieldPath;
415
- let escapedExpression = normalizeCollectionTypeFilters(constraint.expression || '')
416
- .replace(/\s*\n\s*/g, ' ')
417
- .replace(/\\/g, '\\\\')
418
- .replace(/"/g, '\\"');
426
+ // Shared with the Zod emitter so both evaluate the identical expression.
427
+ const escapedExpression = scopeConstraintExpression(constraint.expression || '', fieldPath, {
428
+ isRecursiveElement,
429
+ sliceFilter: item.sliceFilter,
430
+ });
419
431
  const escapedHuman = (constraint.human || '').replace(/"/g, '\\"').replace(/[\r\n]+/g, ' ');
420
- // Scope constraints to their fieldPath if needed
421
- // If fieldPath exists and the expression doesn't already navigate FROM it,
422
- // wrap with .all() so the constraint evaluates relative to each element.
423
- // Only check the START of the expression — built constraints (e.g.,
424
- // "action.exists()") always begin with the fieldPath, while field-level
425
- // constraints are relative to the element and start with child names.
426
- // Checking for fieldPath anywhere in the expression causes false positives
427
- // for recursive elements (e.g., "resource.exists() != action.exists()"
428
- // on fieldPath "action" — the expression contains "action" but only as a
429
- // child property reference, not a root navigation).
430
- //
431
- // NOTE: resource-root constraints (fieldPath === baseResourceType) also
432
- // need .all() wrapping. FHIRPath `implies` returns empty when the
433
- // antecedent is missing, and .all() correctly converts that to false —
434
- // matching the HL7 / Firely validator behavior which fires the constraint.
435
- if (fieldPath) {
436
- const escaped = fieldPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
437
- const fieldPathNavigationPattern = new RegExp(`^${escaped}\\s*\\.`);
438
- const fieldPathStandalonePattern = new RegExp(`^${escaped}(?:[^.\\w]|$)`);
439
- const expressionRefersToField = fieldPathNavigationPattern.test(escapedExpression) || fieldPathStandalonePattern.test(escapedExpression);
440
- if (!expressionRefersToField) {
441
- const resourceTypePattern = /^[A-Z][a-zA-Z]+\./;
442
- if (!resourceTypePattern.test(escapedExpression)) {
443
- const walkRecursion = isRecursiveElement(fieldPath)
444
- && !isBaseElementInvariant(constraint.expression || '');
445
- const navigation = item.sliceFilter
446
- ? `${quoteFhirPathPath(fieldPath)}.where(${item.sliceFilter})`
447
- : quoteFhirPathPath(fieldPath);
448
- const scope = walkRecursion ? `repeat(${navigation})` : navigation;
449
- escapedExpression = `${scope}.all(${escapedExpression})`;
450
- }
451
- }
452
- }
453
432
  const needsTerminology = /\bmemberOf\b/.test(constraint.expression || '');
454
433
  let evaluateBlock = `
455
434
  const result${index} = await fhirpath.evaluate(resource, "${escapedExpression}", { resource, rootResource: resource }, fhirpath_model, fhirpathOptions);
456
435
  if (!result${index}.every(Boolean)) {
457
436
  ${constraint.severity === "error" ? "errors" : "warnings"}.push("Constraint violation: ${escapedHuman}");
458
437
  }`;
438
+ // One defect, one diagnostic. An element that failed its structural type
439
+ // check cannot satisfy an invariant that inspects its children, so
440
+ // evaluating them here adds a second error for the same root cause — the
441
+ // ele-1 ("must have a @value or children") cascade behind
442
+ // ClinicalUseDefinition*, where HL7 reports only the type error. HL7 is
443
+ // the reference implementation and reports the root cause once.
444
+ if (fieldPath && structuralFailurePaths.has(fieldPath)) {
445
+ evaluateBlock = `
446
+ if (!_structuralFailures.has('${fieldPath}')) {${evaluateBlock}
447
+ }`;
448
+ }
459
449
  // Gate memberOf() behind terminologyUrl. For resolve(), use try/catch
460
450
  // instead of a hard gate: FHIRPath `and` short-circuits, so expressions
461
451
  // like `(A and resolve().is(X)) or (B and focus.exists().not())` can still
@@ -545,6 +535,12 @@ ${body}
545
535
  fhirpathOptionsLines.push(' async: true as const,', ' ...(options?.terminologyUrl ? { terminologyUrl: options.terminologyUrl } : {}),', ' ...(options?.fhirServerUrl ? { fhirServerUrl: options.fhirServerUrl } : {}),');
546
536
  }
547
537
  const fhirpathOptionsBlock = `const fhirpathOptions = {\n${fhirpathOptionsLines.join('\n')}\n };`;
538
+ // Paths whose value is the wrong JSON kind. Populated by the structural type
539
+ // checks, which the assembled body now runs before any invariant so the guard
540
+ // has something to read.
541
+ const structuralFailureDecl = hasStructuralChecks
542
+ ? 'const _structuralFailures = new Set<string>();\n '
543
+ : '';
548
544
  let valueSetImportStatements = '';
549
545
  if (valueSetImports.size > 0) {
550
546
  valueSetImportStatements = Array.from(valueSetImports.entries())
@@ -562,8 +558,8 @@ ${body}
562
558
  code: `${fhirpathImport}${optionsImport}${valueSetImportStatements}${validatorImportStatements}export async function validate${interfaceName}(resource: ${interfaceName}, options?: ValidatorOptions): Promise<{ errors: string[]; warnings: string[] }> {
563
559
  const errors: string[] = [];
564
560
  const warnings: string[] = [];
565
- ${fhirpathOptionsBlock}
566
- ${bundleEntryResolution}${referenceTargetTypeValidation}${validationLogic}${fixedPatternValidations.join('')}${nestedRequiredValidations.join('')}${fixedValueValidations.join('')}${structuralTypeValidations.join('')}${prohibitedFieldValidations.join('')}${bindingValidations.join('')}${primitiveFormatValidations.join('')}${choiceNarrowingValidations.join('')}${sliceValidations.join('')}${closedSlicingValidations.join('')}${datatypeProfileDelegations.join('')}${nonDomainTextCheck}${extensionStructuralValidation}${containedRefValidation}${bundledAggregationValidation}${bundleRefValidation}
561
+ ${structuralFailureDecl}${fhirpathOptionsBlock}
562
+ ${bundleEntryResolution}${referenceTargetTypeValidation}${structuralTypeValidations.join('')}${validationLogic}${fixedPatternValidations.join('')}${nestedRequiredValidations.join('')}${fixedValueValidations.join('')}${prohibitedFieldValidations.join('')}${bindingValidations.join('')}${primitiveFormatValidations.join('')}${choiceNarrowingValidations.join('')}${sliceValidations.join('')}${closedSlicingValidations.join('')}${datatypeProfileDelegations.join('')}${nonDomainTextCheck}${extensionStructuralValidation}${containedRefValidation}${bundledAggregationValidation}${bundleRefValidation}
567
563
  return { errors, warnings };
568
564
  }`,
569
565
  valueSetImports
@@ -26,8 +26,19 @@ export interface ValidatorOptions {
26
26
  terminologyUrl?: string;
27
27
  /** FHIR server URL for resolve() evaluation (e.g. "https://hapi.fhir.org/baseR4"). */
28
28
  fhirServerUrl?: string;
29
- /** HTTP headers keyed by server URL — used for auth with terminology or FHIR servers. */
30
- httpHeaders?: Record<string, string>;
29
+ /**
30
+ * HTTP headers for auth with terminology or FHIR servers, keyed by server base
31
+ * URL, each mapping header name to value:
32
+ *
33
+ * { "https://tx.fhir.org/r4": { Authorization: "Bearer …" } }
34
+ *
35
+ * Two levels, not one. The comment always said "keyed by server URL" while the
36
+ * type said \`Record<string, string>\`, so a caller typing against it wrote
37
+ * \`{ Authorization: "Bearer …" }\` — which fhirpath reads as base URL
38
+ * "Authorization" with a string where a header map belongs, and the header is
39
+ * never sent. fhirpath 5.1.0 corrected the same mistake in its own declaration.
40
+ */
41
+ httpHeaders?: Record<string, Record<string, string>>;
31
42
  /** AbortSignal for cancelling long-running async evaluations. */
32
43
  signal?: AbortSignal;
33
44
  /** Debug tracing callback — invoked for every FHIRPath trace() call. */
@@ -1,3 +1,4 @@
1
+ import { stripVersionFromCanonicalUrl } from '../../core/utils.js';
1
2
  /**
2
3
  * Emit the reference target-type check for one profile.
3
4
  *
@@ -13,7 +14,7 @@
13
14
  */
14
15
  export function buildReferenceTargetTypeValidation(fields, profileUrlToType) {
15
16
  const resolveTargetType = (url) => {
16
- const bare = url.split('|')[0];
17
+ const bare = stripVersionFromCanonicalUrl(url);
17
18
  const lastSegment = bare.split('/').pop() || '';
18
19
  if (/^[A-Z][A-Za-z]+$/.test(lastSegment))
19
20
  return lastSegment;
@@ -328,6 +329,8 @@ ${indent} errors.push('${message}' + ${refVar});
328
329
  ${indent}}`;
329
330
  }
330
331
  const segment = segments[index];
332
+ if (segment === undefined)
333
+ return '';
331
334
  if (isArray[index]) {
332
335
  const itemVar = `_aggItem${id}${index}`;
333
336
  return `
@@ -2,6 +2,7 @@
2
2
  * ValueSet Generator
3
3
  * Generates TypeScript files for FHIR ValueSets with runtime validation support
4
4
  */
5
+ import { pascalCaseIdentifier } from '../../core/utils.js';
5
6
  /**
6
7
  * Assign a unique identifier to every ValueSet that will be emitted.
7
8
  *
@@ -148,16 +149,11 @@ export function generateValueSetTypeScript(valueSet, options) {
148
149
  * Sanitize ValueSet name for use as TypeScript identifier
149
150
  */
150
151
  export function sanitizeValueSetName(name) {
151
- // Remove common prefixes
152
- let sanitized = name.replace(/^ValueSet[-_]?/i, '');
153
- // Split on non-alphanumeric and capitalize each part
154
- const parts = sanitized.split(/[^a-zA-Z0-9]+/).filter(Boolean);
155
- sanitized = parts.map(p => p.charAt(0).toUpperCase() + p.slice(1)).join('');
156
- // Ensure it starts with a letter
157
- if (/^[0-9]/.test(sanitized)) {
158
- sanitized = 'VS' + sanitized;
159
- }
160
- return sanitized || 'UnnamedValueSet';
152
+ return pascalCaseIdentifier(name, {
153
+ stripPrefix: /^ValueSet[-_]?/i,
154
+ digitPrefix: 'VS',
155
+ fallback: 'UnnamedValueSet',
156
+ });
161
157
  }
162
158
  /**
163
159
  * Generate a ValueSet registry file that exports all ValueSets
@@ -3,6 +3,8 @@
3
3
  * and required ValueSet bindings.
4
4
  */
5
5
  import { sanitizeValueSetName } from '../valueset/valueSetGenerator.js';
6
+ import { scopeConstraintExpression } from '../validator/validatorExpressions.js';
7
+ import { stripSliceSuffix, stripVersionFromCanonicalUrl } from '../../core/utils.js';
6
8
  /** Escape double-quote characters in refinement messages */
7
9
  function escStr(s) {
8
10
  return s.replace(/"/g, '\\"');
@@ -11,15 +13,19 @@ function escStr(s) {
11
13
  * Build refinement chains for pattern constraints, required slice cardinalities,
12
14
  * and FHIRPath invariants. Returns refinement code strings plus tracked constraint metadata.
13
15
  */
14
- export function buildRefinements(allFields, rootPrefix, valueSets) {
16
+ export function buildRefinements(allFields, rootPrefix, valueSets,
17
+ /** See generateZodSchema's `emitInvariants`: opt-in, for the per-parse cost. */
18
+ emitInvariants = false) {
15
19
  const refinements = [];
20
+ const invariantEvaluators = [];
16
21
  const bindingVsImports = new Map();
17
22
  // Build set of direct (root-level) field names for safety checks
18
23
  const directFieldNames = new Set();
19
24
  for (const field of allFields) {
20
25
  const parts = field.name.split('.');
21
- if (parts.length === 2 && parts[0] === rootPrefix) {
22
- directFieldNames.add(parts[1].split(':')[0]); // strip slice suffix
26
+ const [root, direct] = parts;
27
+ if (parts.length === 2 && root === rootPrefix && direct) {
28
+ directFieldNames.add(stripSliceSuffix(direct));
23
29
  }
24
30
  }
25
31
  // 1. Pattern constraints (patternCodeableConcept, patternCoding)
@@ -31,7 +37,7 @@ export function buildRefinements(allFields, rootPrefix, valueSets) {
31
37
  continue;
32
38
  if (rel.includes('.') || rel.endsWith('.coding'))
33
39
  continue;
34
- if (!directFieldNames.has(rel.split(':')[0]))
40
+ if (!directFieldNames.has(stripSliceSuffix(rel)))
35
41
  continue;
36
42
  const pattern = field.patternConstraint;
37
43
  // patternCodeableConcept
@@ -84,7 +90,7 @@ export function buildRefinements(allFields, rootPrefix, valueSets) {
84
90
  continue;
85
91
  if (relPath.includes('[x]'))
86
92
  continue;
87
- if (!directFieldNames.has(relPath.split(':')[0]))
93
+ if (!directFieldNames.has(stripSliceSuffix(relPath)))
88
94
  continue;
89
95
  for (const slice of slices) {
90
96
  const min = typeof slice.min === 'number' ? slice.min : (slice.isOptional ? 0 : 1);
@@ -163,10 +169,42 @@ export function buildRefinements(allFields, rootPrefix, valueSets) {
163
169
  unsupportedConstraints.push(meta);
164
170
  }
165
171
  else {
166
- // Supported but not yet emitted — future FHIRPath→JS translation point
167
172
  supportedConstraints.push(meta);
168
173
  }
169
174
  }
175
+ // Emit the supported invariants.
176
+ //
177
+ // These were tracked and never emitted, on the premise that FHIRPath
178
+ // evaluation is async while Zod refinements are synchronous. Neither half
179
+ // holds: fhirpath.js returns an array and only returns a Promise when async
180
+ // mode is opted into, which it needs solely for memberOf()/resolve() — and
181
+ // those are already in `unsupportedConstraints` above. So every constraint
182
+ // here evaluates synchronously inside a plain refinement, and `safeParse`
183
+ // keeps working for consumers.
184
+ //
185
+ // The expression goes through the same scoping the validator uses, and the
186
+ // message repeats its wording, so the two agree per profile rather than
187
+ // reporting the same defect under different text.
188
+ supportedConstraints.forEach((meta, i) => {
189
+ if (!emitInvariants)
190
+ return;
191
+ const scoped = scopeConstraintExpression(meta.expression, meta.fieldPath);
192
+ if (!scoped)
193
+ return;
194
+ const human = escStr(meta.human).replace(/[\r\n]+/g, ' ');
195
+ const fn = `_inv${i}`;
196
+ // Compiled once at module load, not per parse. `evaluate` re-parses the
197
+ // expression on every call, which cost ~0.3ms each: 69 invariants made a
198
+ // C4BBOrganization parse 242x slower on an empty resource and 964x on a
199
+ // populated one. `compile` parses once and returns a reusable evaluator.
200
+ //
201
+ // traceFn is silenced deliberately: fhirpath's trace() defaults to writing
202
+ // to the console, so a schema parse would print "TRACE:[…]" out of a
203
+ // published package.
204
+ invariantEvaluators.push(`const ${fn} = fhirpath.compile("${scoped}", fhirpath_model, { preciseMath: true, traceFn: () => {} });`);
205
+ refinements.push(`.refine(d => { try { const _r = ${fn}(d, { resource: d, rootResource: d }); return Array.isArray(_r) ? _r.every(Boolean) : true; } catch { return true; } },`
206
+ + ` { message: "Constraint violation: ${human}", when: () => true })`);
207
+ });
170
208
  // 4. Meta sub-field constraints
171
209
  // When a profile constrains meta.lastUpdated, meta.profile, etc. with min>=1,
172
210
  // emit refinements that check for their presence. Uses `when: () => true` so
@@ -201,6 +239,8 @@ export function buildRefinements(allFields, rootPrefix, valueSets) {
201
239
  if (field.binding?.strength !== 'required')
202
240
  continue;
203
241
  const rel = parts[1];
242
+ if (!rel)
243
+ continue;
204
244
  // Skip choice-type placeholders (code[x], value[x], etc.) — the binding applies
205
245
  // to the resolved variant (e.g. codeCodeableConcept) which we cannot reliably
206
246
  // identify here. Emitting d.code[x] would produce invalid TypeScript.
@@ -216,7 +256,7 @@ export function buildRefinements(allFields, rootPrefix, valueSets) {
216
256
  vsDisplayName = field.binding.uri?.split('/').pop() || 'ValueSet';
217
257
  }
218
258
  else if (field.binding.uri && valueSets) {
219
- const vsUri = field.binding.uri.split('|')[0];
259
+ const vsUri = stripVersionFromCanonicalUrl(field.binding.uri);
220
260
  const vs = valueSets.get(vsUri) || valueSets.get(field.binding.uri);
221
261
  if (vs && vs.concepts.length > 0 && vs.concepts.length <= 200) {
222
262
  codes = vs.concepts.map(c => c.code);
@@ -253,5 +293,5 @@ export function buildRefinements(allFields, rootPrefix, valueSets) {
253
293
  }
254
294
  }
255
295
  }
256
- return { refinements, supportedConstraints, unsupportedConstraints, valueSetImports: bindingVsImports };
296
+ return { refinements, supportedConstraints, unsupportedConstraints, valueSetImports: bindingVsImports, invariantEvaluators };
257
297
  }