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
@@ -1,163 +1,82 @@
1
1
  import { logger } from '../../../logger.js';
2
2
  import { sanitizeValueSetName } from '../valueset/valueSetGenerator.js';
3
3
  import { generateSliceValidations } from './sliceValidatorGenerator.js';
4
+ import { generateClosedSlicingValidations } from './closedSlicingValidation.js';
4
5
  import { fhirpathModelSlug } from '../../fhir/versionContext.js';
5
6
  import { buildRequiredConstraints, buildMaxCardinalityConstraints, buildForbiddenFieldConstraintEntries, buildPatternValidations, buildChoiceTypeNarrowingValidations, } from './validatorConstraintBuilders.js';
6
- import { buildNestedRequiredValidations, buildFixedValueValidations, buildProhibitedFieldValidations, buildPrimitiveFormatValidations, buildStructuralTypeValidations, } from './validatorFieldBuilders.js';
7
+ import { buildNestedRequiredValidations, buildFixedValueValidations, buildProhibitedFieldValidations, buildPrimitiveFormatValidations, buildStructuralTypeValidations, buildInlineResourceTypeValidations, INLINE_RESOURCE_SLOTS, } from './validatorFieldBuilders.js';
7
8
  import { buildBindingValidations } from './validatorBindingBuilder.js';
8
- import { generateBundleRefValidation, generateContainedRefValidation, generateExtensionStructuralValidation, generateBundledAggregationValidation } from './validatorTemplates.js';
9
+ import { generateBundleRefValidation, generateContainedRefValidation, generateExtensionStructuralValidation, generateAggregationRefValidation, buildReferenceTargetTypeValidation, buildBundleEntryResolution } from './validatorTemplates.js';
10
+ import { HELPER_NAMES } from './validatorRuntime.js';
11
+ import { quoteFhirPathPath, normalizeCollectionTypeFilters, isBaseElementInvariant } from './validatorExpressions.js';
9
12
  const log = logger.withTag('validator');
10
13
  /**
11
- * Returns the content of ValidatorOptions.ts — the shared runtime options type
12
- * plus the element-probing helpers that every generated validateX() imports.
14
+ * A constraint declared on a slice applies only to that slice's members.
13
15
  *
14
- * The helpers exist so validators can read arbitrary child elements off an
15
- * open-world FHIR resource *without* widening to `any`. `any` would disable
16
- * checking for every downstream access, which contradicts the type safety this
17
- * generator promises; `at()`/`arr()` return `unknown`, so the emitted code must
18
- * still narrow before it can do anything with a value.
19
- */
20
- export function generateValidatorOptionsContent() {
21
- return `/** Runtime options for generated FHIR validators. */
22
- export interface ValidatorOptions {
23
- /** Terminology server URL for memberOf() evaluation (e.g. "https://tx.fhir.org/r4"). */
24
- terminologyUrl?: string;
25
- /** FHIR server URL for resolve() evaluation (e.g. "https://hapi.fhir.org/baseR4"). */
26
- fhirServerUrl?: string;
27
- /** HTTP headers keyed by server URL — used for auth with terminology or FHIR servers. */
28
- httpHeaders?: Record<string, string>;
29
- /** AbortSignal for cancelling long-running async evaluations. */
30
- signal?: AbortSignal;
31
- /** Debug tracing callback — invoked for every FHIRPath trace() call. */
32
- traceFn?: (value: unknown, label: string) => void;
33
- }
34
-
35
- /** Property bag view of a FHIR element. Reads yield \`unknown\`, never \`any\`. */
36
- export type FhirNode = { readonly [key: string]: unknown };
37
-
38
- /**
39
- * View an arbitrary value as a property bag, for key enumeration
40
- * (\`Object.entries(node(x))\`) and repeated child reads.
41
- *
42
- * Non-objects become \`{}\` so callers never need a null check.
43
- */
44
- export function node(value: unknown): FhirNode {
45
- return typeof value === 'object' && value !== null ? (value as FhirNode) : {};
46
- }
47
-
48
- /**
49
- * Read a (possibly nested) child element off an arbitrary FHIR value.
50
- *
51
- * Returns \`undefined\` as soon as the path leaves an object, so it is safe on
52
- * absent, null, primitive and array-valued intermediates alike. This is the one
53
- * place the untyped boundary is crossed, replacing a cast at every call site.
54
- *
55
- * @example at(resource, 'channel', 'payload', 'extension')
56
- */
57
- export function at(value: unknown, ...path: readonly string[]): unknown {
58
- let current = value;
59
- for (const key of path) {
60
- if (typeof current !== 'object' || current === null) return undefined;
61
- current = (current as FhirNode)[key];
62
- }
63
- return current;
64
- }
65
-
66
- /**
67
- * View an element as an iterable list of children.
68
- *
69
- * Exactly equivalent to the \`if (Array.isArray(x)) for (const c of x)\` guard it
70
- * replaces: non-array values (absent, null, or a scalar) yield no iterations, so
71
- * substituting this helper does not change which elements get validated.
72
- */
73
- export function arr(value: unknown): readonly unknown[] {
74
- return Array.isArray(value) ? value : [];
75
- }
76
-
77
- /** A coding as read by the emitted slice, pattern and binding checks. */
78
- export interface CodingShape {
79
- code?: string;
80
- system?: string;
81
- display?: string;
82
- version?: string;
83
- }
84
-
85
- /**
86
- * The properties emitted element checks actually read off a FHIR element.
87
- *
88
- * Deliberately structural and all-optional: slice discriminators match on
89
- * \`url\`/\`code\`/\`system\`, pattern checks walk \`coding\`, and choice-type checks
90
- * look for \`value*\` keys. Typing the element this way keeps every access checked,
91
- * where a \`(item: any)\` callback parameter would switch checking off entirely.
92
- */
93
- export interface ElementShape {
94
- /** Codings when the element is itself a CodeableConcept. */
95
- coding?: CodingShape[];
96
- /**
97
- * Everything else reads as \`unknown\`.
98
- *
99
- * Discriminator properties are deliberately *not* declared with concrete types:
100
- * a FHIR element's \`code\` may be a \`code\` primitive on one profile and a
101
- * \`CodeableConcept\` on another, so committing to \`string\` would break the
102
- * \`.coding\` reads (and vice versa). \`unknown\` still supports \`===\` comparisons
103
- * and \`typeof\` narrowing; use \`at()\` / \`codings()\` to go deeper.
104
- */
105
- readonly [key: string]: unknown;
106
- }
107
-
108
- /**
109
- * Like {@link arr} but typed for element inspection, so emitted callbacks need no
110
- * parameter annotation and stay type-checked.
16
+ * The scope is derived from field.name, which carries no slice identity, so every
17
+ * slice's invariants were being applied to the whole base element. US Core pins
18
+ * Practitioner.identifier:NPI to ten digits and :NCSBN to eight, so a conformant
19
+ * NPI necessarily failed the NCSBN rule and we reported an error HL7 does not.
111
20
  *
112
- * Non-arrays yield no iterations, matching the \`Array.isArray\` guard this
113
- * replaces.
21
+ * Returns a predicate matching the value the slice pins, so the constraint reaches
22
+ * only its own members. Slices discriminated by something other than a scalar
23
+ * (a CodeableConcept's coding array, say) yield undefined and are left alone
24
+ * rather than guessed at.
114
25
  */
115
- export function elements(value: unknown): readonly ElementShape[] {
116
- return Array.isArray(value) ? (value as ElementShape[]) : [];
117
- }
118
-
119
- /**
120
- * View a value as a list of codings.
121
- *
122
- * Accepts either a CodeableConcept (reads \`.coding\`) or an already-bare array of
123
- * Codings, which is what slice discriminators need since the same discriminator
124
- * path can be typed either way across profiles. Anything else yields \`[]\`.
125
- */
126
- export function codings(value: unknown): readonly CodingShape[] {
127
- if (Array.isArray(value)) return value as CodingShape[];
128
- const nested = at(value, 'coding');
129
- if (Array.isArray(nested)) return nested as CodingShape[];
130
- // A lone Coding. Elements typed Coding 0..1 are neither an array nor a
131
- // CodeableConcept — AuditEvent.entity.type in R4 is the common case — and used
132
- // to fall through to [], so every slice discriminated on one matched nothing.
133
- // That silently disabled the whole within-slice block: the IHE audit profiles
134
- // reported "No elements matched required slice" while their max/min child
135
- // constraints were never evaluated at all.
136
- if (value !== null && typeof value === 'object') {
137
- const lone = value as CodingShape;
138
- if (lone.system !== undefined || lone.code !== undefined) return [lone];
139
- }
140
- return [];
26
+ function buildSliceConstraintFilter(field) {
27
+ const elementId = field.elementId || '';
28
+ if (!elementId.split('.').some(segment => segment.includes(':')))
29
+ return undefined;
30
+ const pattern = field.patternConstraint;
31
+ if (!pattern || typeof pattern !== 'object')
32
+ return undefined;
33
+ for (const [key, value] of Object.entries(pattern)) {
34
+ if (typeof value === 'string')
35
+ return `${key} = '${value.replace(/'/g, "\\'")}'`;
36
+ }
37
+ return undefined;
141
38
  }
142
-
39
+ /** Coded types whose membership a terminology server can decide. */
40
+ const CODED_BINDING_TYPES = new Set(['code', 'Coding', 'CodeableConcept']);
143
41
  /**
144
- * Hand an untyped element to a validator expecting a concrete profile type.
145
- *
146
- * Cross-profile delegation reaches its target through \`at()\`, which yields
147
- * \`unknown\`; the callee wants e.g. \`USCorePatient\`. \`T\` is inferred from the
148
- * parameter position, so the conversion needs no type import and no
149
- * \`Parameters<typeof validateX>[0]\` — the latter breaks outright in packages that
150
- * define a FHIR resource named \`Parameters\`, which shadows the utility type, and
151
- * it rejects array-valued fields whose element type is the delegate's.
42
+ * Scope a binding declared on a slice's child to that slice's members.
152
43
  *
153
- * Widening happens here once rather than at each call site.
44
+ * Task.input is sliced by `value@type` with each slice pinning a different type
45
+ * coding, so an unscoped check would apply one slice's ValueSet to every input —
46
+ * the same mistake that made US Core's NPI fail the NCSBN rule. The predicate is
47
+ * built from the sibling element that carries the pin.
154
48
  */
155
- export function asResource<T>(value: unknown): T {
156
- return value as T;
157
- }
158
- `;
49
+ function sliceScopedBindingPath(rel, field, fields) {
50
+ const elementId = field.elementId || '';
51
+ const sliceMatch = /^(.*?:[^.]+)\./.exec(elementId);
52
+ if (!sliceMatch)
53
+ return rel;
54
+ const sliceId = sliceMatch[1];
55
+ const pinned = fields.find(f => {
56
+ if (!f.elementId || !f.elementId.startsWith(sliceId + '.'))
57
+ return false;
58
+ const pattern = f.patternConstraint;
59
+ return !!pattern && Array.isArray(pattern.coding) && pattern.coding.length > 0;
60
+ });
61
+ if (!pinned?.elementId)
62
+ return rel;
63
+ const discriminatorProp = pinned.elementId.substring(sliceId.length + 1);
64
+ if (!discriminatorProp || discriminatorProp.includes('.'))
65
+ return rel;
66
+ const coding = pinned.patternConstraint.coding[0];
67
+ const system = typeof coding.system === 'string' ? coding.system : undefined;
68
+ const code = typeof coding.code === 'string' ? coding.code : undefined;
69
+ if (!code)
70
+ return rel;
71
+ const terms = [system ? `system='${system}'` : '', `code='${code}'`].filter(Boolean).join(' and ');
72
+ const segments = rel.split('.');
73
+ // rel is <base>.<leaf>; the where() goes on the repeating base element.
74
+ if (segments.length < 2)
75
+ return rel;
76
+ const leaf = segments.pop();
77
+ return `${segments.join('.')}.where(${discriminatorProp}.coding.where(${terms}).exists()).${leaf}`;
159
78
  }
160
- export function generateValidateProfileFunction(interfaceName, fields, valueSets, profileUrl, baseResourceType, profileUrlToName, extensionMetadata, baseDefinition, profileUrlToType) {
79
+ export function generateValidateProfileFunction(interfaceName, fields, valueSets, profileUrl, baseResourceType, profileUrlToName, extensionMetadata, baseDefinition, profileUrlToType, datatypeProfilePins) {
161
80
  const valueSetImports = new Map();
162
81
  const validatorImports = new Map();
163
82
  // ── Collect field-level constraints ──────────────────────────────────────
@@ -176,9 +95,10 @@ export function generateValidateProfileFunction(interfaceName, fields, valueSets
176
95
  else {
177
96
  fieldPath = rawName.replace(/^[^.]+\./, "").replace(/\[x\]/g, "");
178
97
  }
98
+ const sliceFilter = buildSliceConstraintFilter(field);
179
99
  field.constraints.forEach((constraint) => {
180
100
  if (constraint && constraint.expression) {
181
- constraintsWithContext.push({ constraint, fieldPath, isChoiceType });
101
+ constraintsWithContext.push({ constraint, fieldPath, isChoiceType, sliceFilter });
182
102
  }
183
103
  });
184
104
  });
@@ -268,7 +188,7 @@ export function generateValidateProfileFunction(interfaceName, fields, valueSets
268
188
  constraintsWithContext.push(...buildRequiredConstraints(fields, arrayFieldPaths, fieldPathMap, fieldParentMap), ...buildMaxCardinalityConstraints(fields, arrayFieldPaths, fieldParentMap), ...buildForbiddenFieldConstraintEntries(fields));
269
189
  const patternValidations = buildPatternValidations(fields, arrayFieldPaths, fieldPathMap);
270
190
  const nestedRequiredValidations = buildNestedRequiredValidations(fields, arrayFieldPaths);
271
- const fixedValueValidations = buildFixedValueValidations(fields, arrayFieldPaths);
191
+ const fixedValueValidations = buildFixedValueValidations(fields, arrayFieldPaths, profileUrl, baseResourceType);
272
192
  const prohibitedFieldValidations = buildProhibitedFieldValidations(fields, arrayFieldPaths, fieldParentMap, baseResourceType, interfaceName);
273
193
  const bindingValidations = buildBindingValidations(fields, fieldParentMap, arrayFieldPaths, getValueSetForBinding, valueSetImports);
274
194
  // ── Synthesize memberOf() constraints for code-type non-enumerable bindings ─
@@ -284,7 +204,10 @@ export function generateValidateProfileFunction(interfaceName, fields, valueSets
284
204
  if (f.sliceName)
285
205
  return false;
286
206
  const type = f.type || f.baseTypeCode;
287
- if (type !== 'code')
207
+ // Coding and CodeableConcept are decidable by the server too; restricting this
208
+ // to `code` left a required binding on a CodeableConcept unchecked entirely,
209
+ // and the system-presence fallback passes anything that carries a system.
210
+ if (!type || !CODED_BINDING_TYPES.has(type))
288
211
  return false;
289
212
  const vsInfo = getValueSetForBinding(f.binding.uri);
290
213
  // Only emit when VS is unresolved or systemOnly (no enumerated codes)
@@ -292,9 +215,16 @@ export function generateValidateProfileFunction(interfaceName, fields, valueSets
292
215
  })
293
216
  .forEach(f => {
294
217
  const relFull = f.name || '';
295
- const fieldPath = relFull.replace(/^[^.]+\./, '').replace(/\[x\]/g, '');
218
+ const type = f.type || f.baseTypeCode || '';
219
+ let fieldPath = relFull.replace(/^[^.]+\./, '');
220
+ // A choice element's JSON property carries the type suffix, so stripping
221
+ // "[x]" alone would name a property that does not exist.
222
+ fieldPath = fieldPath.includes('[x]')
223
+ ? fieldPath.replace(/\[x\]/, type.charAt(0).toUpperCase() + type.slice(1))
224
+ : fieldPath;
296
225
  if (!fieldPath)
297
226
  return;
227
+ fieldPath = sliceScopedBindingPath(fieldPath, f, fields);
298
228
  const vsUri = (f.binding.uri || '').split('|')[0];
299
229
  const vsDisplayName = vsUri.split('/').pop() || vsUri;
300
230
  constraintsWithContext.push({
@@ -310,12 +240,18 @@ export function generateValidateProfileFunction(interfaceName, fields, valueSets
310
240
  const sliceValidations = generateSliceValidations({
311
241
  fields, fieldPathMap, arrayFieldPaths, getValueSetForBinding,
312
242
  valueSetImports, profileUrl, profileName: interfaceName, baseResourceType,
313
- profileUrlToName, validatorImports, extensionMetadata, profileUrlToType,
243
+ profileUrlToName, validatorImports, extensionMetadata, profileUrlToType, datatypeProfilePins,
314
244
  });
245
+ const closedSlicingValidations = generateClosedSlicingValidations(fields, profileUrl, baseResourceType);
315
246
  const primitiveFormatValidations = buildPrimitiveFormatValidations(fields);
316
- const structuralTypeValidations = buildStructuralTypeValidations(fields, arrayFieldPaths);
247
+ const structuralTypeValidations = [
248
+ ...buildStructuralTypeValidations(fields, arrayFieldPaths),
249
+ ...buildInlineResourceTypeValidations(fields, profileUrl),
250
+ ];
317
251
  const choiceNarrowingValidations = buildChoiceTypeNarrowingValidations(fields, profileUrl);
318
252
  const bundleRefValidation = generateBundleRefValidation(baseResourceType);
253
+ const referenceTargetTypeValidation = buildReferenceTargetTypeValidation(fields, profileUrlToType);
254
+ const bundleEntryResolution = buildBundleEntryResolution(baseResourceType);
319
255
  const containedRefValidation = generateContainedRefValidation(baseResourceType);
320
256
  const extensionStructuralValidation = generateExtensionStructuralValidation();
321
257
  // ── Bundled aggregation reference checks ─────────────────────────────────
@@ -329,8 +265,19 @@ export function generateValidateProfileFunction(interfaceName, fields, valueSets
329
265
  // Skip slices — base field already covers them
330
266
  if (field.sliceName)
331
267
  continue;
332
- const hasBundled = field.typeOptions?.some(t => t.code === 'Reference' && t.aggregation?.includes('bundled'));
333
- if (!hasBundled)
268
+ const aggregations = new Set((field.typeOptions ?? [])
269
+ .filter(t => t.code === 'Reference')
270
+ .flatMap(t => t.aggregation ?? []));
271
+ let kind;
272
+ if (aggregations.has('bundled')) {
273
+ kind = 'bundled';
274
+ }
275
+ else if (aggregations.has('contained') && !aggregations.has('referenced')) {
276
+ // Only restrict when a contained reference is the sole option — a field
277
+ // that also permits 'referenced' accepts an ordinary relative reference.
278
+ kind = 'contained';
279
+ }
280
+ if (!kind)
334
281
  continue;
335
282
  // Strip resource-type prefix (e.g. "Condition.encounter" → "encounter")
336
283
  const dotIdx = field.name.indexOf('.');
@@ -342,10 +289,10 @@ export function generateValidateProfileFunction(interfaceName, fields, valueSets
342
289
  const partial = field.name.substring(0, dotIdx) + '.' + segments.slice(0, i + 1).join('.');
343
290
  return fieldArrayMap.get(partial) ?? false;
344
291
  });
345
- bundledPaths.push({ segments, isArray });
292
+ bundledPaths.push({ segments, isArray, kind });
346
293
  }
347
294
  }
348
- const bundledAggregationValidation = generateBundledAggregationValidation(bundledPaths);
295
+ const bundledAggregationValidation = generateAggregationRefValidation(bundledPaths);
349
296
  // ── Profiled datatype field delegation ──────────────────────────────────
350
297
  // When a non-sliced field references a profiled datatype (e.g., Bundle.signature
351
298
  // with profile cdex-signature), delegate validation to the generated validator.
@@ -372,12 +319,25 @@ export function generateValidateProfileFunction(interfaceName, fields, valueSets
372
319
  if (!delegateName || delegateName === interfaceName)
373
320
  continue;
374
321
  validatorImports.set(delegateName, `./${delegateName}.js`);
322
+ // An inline-resource slot narrowed to a concrete resource type is not a
323
+ // datatype: the value carries a resourceType, and handing an untyped or
324
+ // wrong-typed blob to a resource profile's validator reports that profile's
325
+ // required fields against something that is not one. HL7 rejects the
326
+ // resource as untyped and never descends, so it reports none of them.
327
+ // field.type is often the profile alias (dtr-questionnaire-adapt) rather than
328
+ // a resource type name, so resolve the type the profile targets.
329
+ const isResourceTypeName = (name) => !!name && /^[A-Z][A-Za-z]+$/.test(name);
330
+ const expectedResourceType = [profileUrlToType?.get(pUrl), field.baseTypeCode, fieldType]
331
+ .find(isResourceTypeName);
332
+ const inlineResourceGuard = INLINE_RESOURCE_SLOTS.has(relPath) && expectedResourceType
333
+ ? `\n if (at(_dtEl, 'resourceType') !== '${expectedResourceType}') continue;`
334
+ : '';
375
335
  if (field.isArray) {
376
336
  datatypeProfileDelegations.push(`
377
337
  // Profiled datatype delegation: ${relPath} → ${delegateName}
378
338
  if (resource.${relPath} && Array.isArray(resource.${relPath})) {
379
- for (const _dtEl of resource.${relPath}) {
380
- const _sub = await validate${delegateName}(asResource(_dtEl), options);
339
+ for (const _dtEl of resource.${relPath}) {${inlineResourceGuard}
340
+ const _sub = await validate${delegateName}(asResource(_dtEl), _delegateOptions);
381
341
  errors.push(..._sub.errors);
382
342
  warnings.push(..._sub.warnings);
383
343
  }
@@ -387,7 +347,7 @@ export function generateValidateProfileFunction(interfaceName, fields, valueSets
387
347
  datatypeProfileDelegations.push(`
388
348
  // Profiled datatype delegation: ${relPath} → ${delegateName}
389
349
  if (resource.${relPath}) {
390
- const _sub = await validate${delegateName}(asResource(resource.${relPath}), options);
350
+ const _sub = await validate${delegateName}(asResource(resource.${relPath}), _delegateOptions);
391
351
  errors.push(..._sub.errors);
392
352
  warnings.push(..._sub.warnings);
393
353
  }`);
@@ -397,7 +357,13 @@ export function generateValidateProfileFunction(interfaceName, fields, valueSets
397
357
  }
398
358
  }
399
359
  // ── Deduplicate & filter constraints ────────────────────────────────────
400
- const uniqueConstraints = Array.from(new Map(constraintsWithContext.map((item) => [item.constraint.expression + "|" + item.fieldPath, item])).values());
360
+ const uniqueConstraints = Array.from(new Map(
361
+ // The slice filter is part of the identity: two slices of the same element can
362
+ // carry the same expression with different pinned values, and collapsing them
363
+ // would keep only one slice's rule.
364
+ constraintsWithContext.map((item) => [
365
+ `${item.constraint.expression}|${item.fieldPath}|${item.sliceFilter ?? ''}`, item,
366
+ ])).values());
401
367
  const filteredConstraints = uniqueConstraints.filter(c => {
402
368
  const expr = c.constraint.expression || "";
403
369
  const fieldPath = c.fieldPath || "";
@@ -408,6 +374,16 @@ export function generateValidateProfileFunction(interfaceName, fields, valueSets
408
374
  // Filter $this on choice-type fields — fhirpath scoping differs from HL7.
409
375
  if (c.isChoiceType && /\$this\b/.test(expr))
410
376
  return false;
377
+ // txt-1/txt-2 (htmlChecks) are not filtered: htmlChecks is supplied through
378
+ // fhirpath's userInvocationTable, and as() over a collection is normalised to
379
+ // ofType() below.
380
+ //
381
+ // dom-3 is implemented natively in generateContainedRefValidation instead.
382
+ // Its FHIRPath form builds the fragment as '#'+id, so an absent id makes the
383
+ // predicate empty and the invariant passes where HL7 reports the resource as
384
+ // unreferenced. Keeping both would double-report the ones that do fire.
385
+ if (/'#'\s*\+\s*id\b/.test(expr))
386
+ return false;
411
387
  // For nested fields (depth > 1), filter only base FHIR invariants whose
412
388
  // evaluation diverges between fhirpath.js and HL7:
413
389
  // ele-1: hasValue() or (children().count() > id.count())
@@ -415,19 +391,28 @@ export function generateValidateProfileFunction(interfaceName, fields, valueSets
415
391
  // Profile-specific constraints at nested depth are allowed through —
416
392
  // the .all() wrapping handles scoping correctly for these.
417
393
  if (fieldPath && fieldPath.split('.').length > 1) {
418
- const isEle1 = /^hasValue\(\)\s+or\s+\(children\(\)\.count\(\)\s*>\s*id\.count\(\)\)$/.test(expr);
419
- const isExt1 = /^extension\.exists\(\)\s*!=\s*value\.exists\(\)$/.test(expr);
420
- if (isEle1 || isExt1)
394
+ if (isBaseElementInvariant(expr))
421
395
  return false;
422
396
  }
423
397
  return true;
424
398
  });
399
+ // An invariant on a recursive BackboneElement (Composition.section,
400
+ // Questionnaire.item, …) applies to every occurrence, not only the top-level
401
+ // ones. `<path>.all(…)` visits one level, so a nested element that violates the
402
+ // invariant goes unreported while HL7 flags it — cmp-1 on a depth-2
403
+ // Composition.section is the case that surfaced this. repeat() walks the whole
404
+ // recursion, which is the idiom FHIR's own invariants use.
405
+ const relativeFieldNames = new Set(fields.map(f => (f.name || '').replace(/^[^.]+\./, '')).filter(Boolean));
406
+ const isRecursiveElement = (path) => {
407
+ const lastSegment = path.split('.').pop() || '';
408
+ return !!lastSegment && relativeFieldNames.has(`${path}.${lastSegment}`);
409
+ };
425
410
  // ── Generate FHIRPath evaluation code ───────────────────────────────────
426
411
  const validationLogic = filteredConstraints
427
412
  .map((item, index) => {
428
413
  const constraint = item.constraint;
429
414
  const fieldPath = item.fieldPath;
430
- let escapedExpression = (constraint.expression || '')
415
+ let escapedExpression = normalizeCollectionTypeFilters(constraint.expression || '')
431
416
  .replace(/\s*\n\s*/g, ' ')
432
417
  .replace(/\\/g, '\\\\')
433
418
  .replace(/"/g, '\\"');
@@ -455,13 +440,19 @@ export function generateValidateProfileFunction(interfaceName, fields, valueSets
455
440
  if (!expressionRefersToField) {
456
441
  const resourceTypePattern = /^[A-Z][a-zA-Z]+\./;
457
442
  if (!resourceTypePattern.test(escapedExpression)) {
458
- escapedExpression = `${fieldPath}.all(${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})`;
459
450
  }
460
451
  }
461
452
  }
462
453
  const needsTerminology = /\bmemberOf\b/.test(constraint.expression || '');
463
454
  let evaluateBlock = `
464
- const result${index} = await fhirpath.evaluate(resource, "${escapedExpression}", { resource }, fhirpath_model, fhirpathOptions);
455
+ const result${index} = await fhirpath.evaluate(resource, "${escapedExpression}", { resource, rootResource: resource }, fhirpath_model, fhirpathOptions);
465
456
  if (!result${index}.every(Boolean)) {
466
457
  ${constraint.severity === "error" ? "errors" : "warnings"}.push("Constraint violation: ${escapedHuman}");
467
458
  }`;
@@ -490,7 +481,7 @@ export function generateValidateProfileFunction(interfaceName, fields, valueSets
490
481
  })
491
482
  .join("\n");
492
483
  // ── Assemble final output ───────────────────────────────────────────────
493
- const hasConstraints = filteredConstraints.length > 0 || sliceValidations.length > 0
484
+ const hasConstraints = filteredConstraints.length > 0 || sliceValidations.length > 0 || closedSlicingValidations.length > 0
494
485
  || patternValidations.length > 0 || fixedValueValidations.length > 0
495
486
  || bindingValidations.length > 0 || prohibitedFieldValidations.length > 0
496
487
  || bundleRefValidation.length > 0 || primitiveFormatValidations.length > 0
@@ -510,14 +501,22 @@ export function generateValidateProfileFunction(interfaceName, fields, valueSets
510
501
  ? `\n // Non-DomainResource: text is not a valid property\n if (at(resource, 'text') !== undefined) {\n errors.push("Unrecognized property 'text'");\n }`
511
502
  : '';
512
503
  if (!hasConstraints) {
504
+ const body = `${bundleEntryResolution}${referenceTargetTypeValidation}${structuralTypeValidations.join('')}${nonDomainTextCheck}${extensionStructuralValidation}${containedRefValidation}${bundledAggregationValidation}`;
505
+ // This path skips the shared helper import, so pull in exactly the helpers the
506
+ // body actually calls — importing all of them would trip no-unused-vars, and
507
+ // importing none breaks bodies that use at()/arr() (aggregation, text checks).
508
+ const usedHelpers = HELPER_NAMES.filter(name => new RegExp(`\\b${name}\\(`).test(body));
509
+ const helperImport = usedHelpers.length > 0
510
+ ? `import { ${usedHelpers.join(', ')} } from './ValidatorOptions.js';\n`
511
+ : '';
513
512
  return {
514
513
  code: `import type { ValidatorOptions } from './ValidatorOptions.js';
515
-
514
+ ${helperImport}
516
515
  export async function validate${interfaceName}(resource: ${interfaceName}, options?: ValidatorOptions): Promise<{ errors: string[]; warnings: string[] }> {
517
516
  const errors: string[] = [];
518
517
  const warnings: string[] = [];
519
518
  void options;
520
- ${structuralTypeValidations.length > 0 ? `\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const _bRes = resource as Record<string, any>;` : ''}${structuralTypeValidations.join('')}${nonDomainTextCheck}${extensionStructuralValidation}${containedRefValidation}${bundledAggregationValidation}
519
+ ${body}
521
520
  return { errors, warnings };
522
521
  }`,
523
522
  valueSetImports
@@ -533,9 +532,11 @@ ${structuralTypeValidations.length > 0 ? `\n // eslint-disable-next-line @types
533
532
  // `at`/`arr` are value imports: they replace the `as any` element probing that
534
533
  // would otherwise appear throughout the emitted body.
535
534
  const optionsImport = 'import type { ValidatorOptions } from \'./ValidatorOptions.js\';\n'
536
- + 'import { at, arr, node, elements, codings, asResource } from \'./ValidatorOptions.js\';\n\n';
535
+ + `import { ${HELPER_NAMES.join(', ')}, fhirpathUserFunctions } from './ValidatorOptions.js';\n\n`;
537
536
  const fhirpathOptionsLines = [
538
537
  ' preciseMath: true,',
538
+ // Supplies htmlChecks(), which fhirpath.js does not implement.
539
+ ' userInvocationTable: fhirpathUserFunctions,',
539
540
  ' traceFn: options?.traceFn ?? (() => {}),',
540
541
  ' ...(options?.signal ? { signal: options.signal } : {}),',
541
542
  ' ...(options?.httpHeaders ? { httpHeaders: options.httpHeaders } : {}),',
@@ -562,7 +563,7 @@ ${structuralTypeValidations.length > 0 ? `\n // eslint-disable-next-line @types
562
563
  const errors: string[] = [];
563
564
  const warnings: string[] = [];
564
565
  ${fhirpathOptionsBlock}
565
- ${validationLogic}${fixedPatternValidations.join('')}${nestedRequiredValidations.join('')}${fixedValueValidations.join('')}${(bindingValidations.length > 0 || prohibitedFieldValidations.length > 0 || primitiveFormatValidations.length > 0 || choiceNarrowingValidations.length > 0 || structuralTypeValidations.length > 0) ? `\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const _bRes = resource as Record<string, any>;` : ''}${structuralTypeValidations.join('')}${prohibitedFieldValidations.join('')}${bindingValidations.join('')}${primitiveFormatValidations.join('')}${choiceNarrowingValidations.join('')}${sliceValidations.join('')}${datatypeProfileDelegations.join('')}${nonDomainTextCheck}${extensionStructuralValidation}${containedRefValidation}${bundledAggregationValidation}${bundleRefValidation}
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}
566
567
  return { errors, warnings };
567
568
  }`,
568
569
  valueSetImports