babelfhir-ts 1.6.7 → 1.6.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -46,7 +46,7 @@
46
46
  - **Type-safe extension handling** with proper slicing and nested extension support
47
47
  - **Random data builders** for testing and development (when class generation is enabled)
48
48
  - **Zero manual mapping**—consume any FHIR package or Implementation Guide directly from registries
49
- - **Fast and lightweight**—the CLI pulls no FHIRPath engine of its own; generated packages declare `fhirpath` as a *peer* dependency (`>=4.9.1 <6`), so the host app owns the version
49
+ - **Fast and lightweight**—the CLI pulls no FHIRPath engine of its own; generated packages declare `fhirpath` as a *peer* dependency (`>=5.2.0 <6`), so the host app owns the version
50
50
  - **Type-safe FHIR client** — generated client extends [`@babelfhir-ts/client-r4`](https://www.npmjs.com/package/@babelfhir-ts/client-r4) / [`client-r4b`](https://www.npmjs.com/package/@babelfhir-ts/client-r4b) / [`client-r5`](https://www.npmjs.com/package/@babelfhir-ts/client-r5) with profile-specific methods (e.g., `.uSCorePatientProfile()`, `.pASClaim()`) on top of base resource accessors
51
51
  - **Install any FHIR profile as a node module**—use `babelfhir-ts install` to add Implementation Guides (FHIR Packages) directly to your project
52
52
 
@@ -86,8 +86,17 @@ export const EMITTED_RANGES = {
86
86
  * Capped below 6.0.0: majors do break consumers here. 5.x added an `exports`
87
87
  * map, which is why the emitted import carries an explicit
88
88
  * `/fhir-context/<model>/index.js` filename.
89
+ *
90
+ * The floor moved to 5.2.0 with htmlChecks(). R4 writes txt-1 and txt-2 as
91
+ * `htmlChecks()` on Narrative.div, and an unimplemented function throws out of the
92
+ * whole evaluation rather than returning false — so on an older engine a resource
93
+ * carrying a narrative lost every other finding too, not just the narrative check.
94
+ * This repo supplied the function through fhirpath's userInvocationTable until
95
+ * 5.2.0 implemented it (HL7/fhirpath.js#147). The supplied copy is gone, so an
96
+ * engine below 5.2.0 would take those findings down with it: the floor is what
97
+ * keeps that from reaching a consumer silently.
89
98
  */
90
- fhirpath: '>=4.9.1 <6.0.0',
99
+ fhirpath: '>=5.2.0 <6.0.0',
91
100
  /**
92
101
  * Schema runtime for `--schema zod` output.
93
102
  *
@@ -111,8 +120,29 @@ export const EMITTED_RANGES = {
111
120
  * drop them.
112
121
  */
113
122
  prefab: '^0.3.0',
114
- /** Generated FHIR client base package (`@babelfhir-ts/client-<slug>`). */
115
- client: '^0.2.0',
123
+ /**
124
+ * Generated FHIR client base package (`@babelfhir-ts/client-<slug>`).
125
+ *
126
+ * The emitted client is a thin profile-typed layer over this package —
127
+ * `FhirReadClient`/`FhirWriteClient` are extended, `BundleParser` is extended —
128
+ * so the range here decides which transport every generated package actually
129
+ * runs on. `^0.2.0` on a `0.x` version resolves to `>=0.2.0 <0.3.0`, so it did
130
+ * not merely set a floor: it CAPPED every consumer below 0.3.0, and 0.3.0 is
131
+ * where the writer started parsing the `OperationOutcome` of a rejected write.
132
+ * On 0.2.x a failed save reports `PUT Consent/123: 400 Bad Request` and
133
+ * discards the server's account of why — the unresolvable reference, the failed
134
+ * invariant, the scope it would not accept.
135
+ *
136
+ * The cap also multiplied installs: an app depending on both a generated
137
+ * package and `@babelfhir-ts/client-r4@^0.3` gets 0.2.x nested under every
138
+ * generated package alongside its own 0.3.x, so two copies of the client (and
139
+ * of SmartAuth) run in one bundle.
140
+ *
141
+ * Safe to raise by construction: `fhir-client.js` is byte-identical between
142
+ * 0.2.6 and 0.3.2 and `index.js` only ADDS `hasId`, so nothing the emitted
143
+ * layer calls changed shape. Capped below 0.4.0 as usual for a 0.x line.
144
+ */
145
+ client: '^0.3.2',
116
146
  /** Shared base zod schemas (`@babelfhir-ts/zod`). */
117
147
  zodBase: '^0.2.0',
118
148
  /** DICOMweb helpers (`@babelfhir-ts/dicomweb`). */
@@ -22,6 +22,37 @@ export function stripVersionFromCanonicalUrl(url) {
22
22
  * returns a string: the split form is an indexing expression, which is only
23
23
  * `string | undefined` to the compiler even though it can never be empty.
24
24
  */
25
+ /**
26
+ * Whether a name is shaped like a FHIR resource type.
27
+ *
28
+ * `Resource` and `DomainResource` are excluded on purpose: they name every resource
29
+ * rather than one, so a check that accepts them rejects nothing.
30
+ */
31
+ export function looksLikeResourceTypeName(name) {
32
+ if (!name)
33
+ return false;
34
+ if (name === 'Resource' || name === 'DomainResource')
35
+ return false;
36
+ return /^[A-Z][A-Za-z]+$/.test(name);
37
+ }
38
+ /**
39
+ * The resource type a profile URL constrains, or undefined when it cannot be known.
40
+ *
41
+ * The registry comes first, the URL's last segment second. Three emitters resolved
42
+ * this independently and two of them read the segment first, which is wrong in a way
43
+ * that only shows on IG profiles: the last segment of
44
+ * `.../StructureDefinition/MyPatientProfile` is shaped exactly like a resource type,
45
+ * so the heuristic answered `MyPatientProfile` while the registry knew `Patient`. The
46
+ * segment is a fallback for URLs the registry never saw, not a first guess.
47
+ */
48
+ export function resourceTypeForProfileUrl(url, profileUrlToType) {
49
+ const bare = stripVersionFromCanonicalUrl(url);
50
+ const registered = profileUrlToType?.get(bare) ?? profileUrlToType?.get(url);
51
+ if (looksLikeResourceTypeName(registered))
52
+ return registered;
53
+ const lastSegment = bare.split('/').pop() ?? '';
54
+ return looksLikeResourceTypeName(lastSegment) ? lastSegment : undefined;
55
+ }
25
56
  export function stripSliceSuffix(path) {
26
57
  if (!path)
27
58
  return path;
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Which required-strength ValueSet bindings are enforceable, and against what.
3
+ *
4
+ * The validator and the zod schema are compared against each other on every parity
5
+ * run, so they have to agree on which bindings they check. They did not: the
6
+ * validator walked any depth and accepted `code`, `Coding`, `CodeableConcept`,
7
+ * `Quantity`, `string` and `uri`, while the zod builder took only depth-1
8
+ * `CodeableConcept`/`Coding` and inlined the code list. Every binding below the root
9
+ * was reported by one side and not the other — 146 of the 285 disagreements on that
10
+ * board, and the single largest class by a wide margin.
11
+ *
12
+ * The rule is written down here, and the zod builder now follows it: same selection,
13
+ * same ValueSet resolution, same wording. The validator still carries its own copy of
14
+ * the filter, because its builder threads each field through path building, choice
15
+ * suffixing and nested-array handling that would have to move with it — a refactor
16
+ * worth doing on its own, against the parity board, not folded into this change.
17
+ * Until then this module is the statement of the rule and the zod side's single
18
+ * source for it; changing the validator's filter means changing both.
19
+ */
20
+ import { stripVersionFromCanonicalUrl } from '../core/utils.js';
21
+ import { sanitizeValueSetName } from './valueset/valueSetGenerator.js';
22
+ /** Types FHIR permits a binding on. */
23
+ const BINDABLE_TYPES = ['code', 'Coding', 'CodeableConcept', 'Quantity', 'string', 'uri'];
24
+ export function resolveRequiredBindingValueSet(bindingUri, valueSets) {
25
+ if (!bindingUri || !valueSets)
26
+ return undefined;
27
+ let vs = valueSets.get(bindingUri);
28
+ let resolvedUri = bindingUri;
29
+ if (!vs && bindingUri.includes('|')) {
30
+ resolvedUri = stripVersionFromCanonicalUrl(bindingUri);
31
+ vs = valueSets.get(resolvedUri);
32
+ }
33
+ if (!vs)
34
+ return undefined;
35
+ const displayName = resolvedUri.split('/').pop() || vs.name;
36
+ if (vs.concepts.length === 0) {
37
+ if (vs.systems && vs.systems.length > 0) {
38
+ return { systemOnly: true, displayName, systems: vs.systems };
39
+ }
40
+ return undefined;
41
+ }
42
+ const sanitizedName = sanitizeValueSetName(vs.name);
43
+ return {
44
+ sanitizedName,
45
+ displayName,
46
+ validatorFn: `isValid${sanitizedName}Code`,
47
+ importPath: `./valuesets/ValueSet-${sanitizedName}.js`,
48
+ uri: stripVersionFromCanonicalUrl(bindingUri),
49
+ };
50
+ }
51
+ /** The leaf kind for a bindable type, or undefined when the type carries no code. */
52
+ export function bindingLeafKind(type) {
53
+ if (!type)
54
+ return undefined;
55
+ // string and uri hold the code in the element's own value, exactly like `code`.
56
+ if (type === 'code' || type === 'string' || type === 'uri')
57
+ return 'code';
58
+ if (type === 'Quantity')
59
+ return 'code';
60
+ if (type === 'Coding' || type === 'CodeableConcept')
61
+ return 'codings';
62
+ return undefined;
63
+ }
64
+ /**
65
+ * Select the fields whose required binding is worth checking.
66
+ *
67
+ * Mirrors what the validator's binding builder already accepted, so widening one
68
+ * side cannot introduce a finding the other never makes:
69
+ * - required strength with a binding URI
70
+ * - a bindable type
71
+ * - not a slice, unless the slice name encodes a choice variant (`value[x]:valueCode`),
72
+ * which maps to a concrete property
73
+ * - no `[x]` above the leaf: stripping it leaves a property that does not exist
74
+ */
75
+ export function selectRequiredBindingTargets(fields, rootPrefix) {
76
+ const targets = [];
77
+ const seen = new Set();
78
+ for (const field of fields) {
79
+ if (field.binding?.strength !== 'required' || !field.binding.uri)
80
+ continue;
81
+ if (field.max === 0)
82
+ continue;
83
+ if (field.sliceName) {
84
+ const elemId = field.elementId || field.name || '';
85
+ if (!elemId.includes('[x]:'))
86
+ continue;
87
+ }
88
+ const declaredType = field.type || field.baseTypeCode;
89
+ if (!declaredType || !BINDABLE_TYPES.includes(declaredType))
90
+ continue;
91
+ const kind = bindingLeafKind(declaredType);
92
+ if (!kind)
93
+ continue;
94
+ const relRaw = (field.name || '').replace(new RegExp(`^${rootPrefix}\\.`), '');
95
+ if (!relRaw || relRaw === field.name)
96
+ continue;
97
+ const segments = relRaw.split('.');
98
+ if (segments.slice(0, -1).some(segment => segment.includes('[x]')))
99
+ continue;
100
+ const leaf = segments[segments.length - 1];
101
+ if (!leaf)
102
+ continue;
103
+ // A choice leaf becomes its concrete property: value[x] + Coding → valueCoding.
104
+ const path = leaf.includes('[x]')
105
+ ? [...segments.slice(0, -1), leaf.replace('[x]', declaredType.charAt(0).toUpperCase() + declaredType.slice(1))]
106
+ : segments;
107
+ if (path.some(segment => segment.includes('[x]') || segment.includes(':')))
108
+ continue;
109
+ const key = `${path.join('.')}|${field.binding.uri}`;
110
+ if (seen.has(key))
111
+ continue;
112
+ seen.add(key);
113
+ targets.push({ field, path, kind });
114
+ }
115
+ return targets;
116
+ }
117
+ /** The finding both emitters report when no coding in the element is in the ValueSet. */
118
+ export function codingsNotInValueSetMessage(displayName, uri) {
119
+ return `None of the codings provided are in the value set '${displayName}' (${uri}), and a coding from this value set is required)`;
120
+ }
121
+ /** The finding both emitters report when a single code is not in the ValueSet. */
122
+ export function codeNotInValueSetMessage(displayName, uri) {
123
+ return `does not exist in the value set '${displayName}' (${uri}), but the binding is of strength 'required'`;
124
+ }
@@ -21,8 +21,15 @@
21
21
  * conformant resource. Enforcing those needs slice-scoped traversal and is left off
22
22
  * rather than approximated.
23
23
  */
24
- import { stripVersionFromCanonicalUrl } from '../../core/utils.js';
24
+ import { resourceTypeForProfileUrl, stripVersionFromCanonicalUrl } from '../../core/utils.js';
25
25
  const CHOICE_SUFFIX = '[x]';
26
+ /**
27
+ * The resource-type shape, for the *generated* guard.
28
+ *
29
+ * looksLikeResourceTypeName is the emitter-side twin; this is the same rule written
30
+ * for the emitted file, which has no access to our helpers.
31
+ */
32
+ const RESOURCE_TYPE_SHAPE = '/^[A-Z][A-Za-z]+$/';
26
33
  /** The url an instance of this extension slice carries (pinned `url` child, else profile). */
27
34
  function instanceUrl(slice, fields) {
28
35
  const sliceElemId = (slice.elementId || slice.name).replace(/^[^.]+\./, '');
@@ -52,6 +59,38 @@ function choiceProperty(baseProp, slice) {
52
59
  return undefined;
53
60
  return `${baseProp}${code.charAt(0).toUpperCase()}${code.slice(1)}`;
54
61
  }
62
+ /** Whether this element holds a Reference, so its slices are told apart by target. */
63
+ function isReferenceElement(field) {
64
+ if (field.type === 'Reference' || field.baseTypeCode === 'Reference')
65
+ return true;
66
+ return (field.typeOptions ?? []).some(option => option.code === 'Reference');
67
+ }
68
+ /**
69
+ * The resource types a Reference slice accepts, or undefined when any target is
70
+ * unresolvable.
71
+ *
72
+ * A target names a profile, not a type: `.../vitals/StructureDefinition/body-weight`
73
+ * is an Observation, and only the package registry knows that. A core URL carries the
74
+ * type in its last segment. Anything left unresolved returns undefined, which takes
75
+ * the whole slicing out of enforcement rather than judging against a partial set.
76
+ */
77
+ function referenceTargetTypes(slice, profileUrlToType) {
78
+ const targets = (slice.typeOptions ?? [])
79
+ .filter(option => option.code === 'Reference')
80
+ .flatMap(option => option.targetProfileUrls ?? []);
81
+ if (targets.length === 0)
82
+ return undefined;
83
+ const types = [];
84
+ for (const target of targets) {
85
+ // Undefined covers both an unresolvable target and one open to any resource,
86
+ // and either leaves the allowed set incomplete.
87
+ const resolved = resourceTypeForProfileUrl(target, profileUrlToType);
88
+ if (!resolved)
89
+ return undefined;
90
+ types.push(resolved);
91
+ }
92
+ return [...new Set(types)];
93
+ }
55
94
  /** Classify a closed slicing, or undefined when it is not one this module enforces. */
56
95
  function classify(leaf) {
57
96
  if (leaf === 'extension' || leaf === 'modifierExtension')
@@ -74,7 +113,7 @@ function walk(segments, body) {
74
113
  /**
75
114
  * Emit closed-slicing checks for every element that introduces a closed slicing.
76
115
  */
77
- export function generateClosedSlicingValidations(fields, profileUrl, baseResourceType) {
116
+ export function generateClosedSlicingValidations(fields, profileUrl, baseResourceType, profileUrlToType) {
78
117
  const out = [];
79
118
  const seen = new Set();
80
119
  for (const field of fields) {
@@ -91,7 +130,7 @@ export function generateClosedSlicingValidations(fields, profileUrl, baseResourc
91
130
  const leaf = segments.at(-1);
92
131
  if (!leaf)
93
132
  continue;
94
- const kind = classify(leaf);
133
+ const kind = classify(leaf) ?? (isReferenceElement(field) ? 'reference-target' : undefined);
95
134
  if (!kind)
96
135
  continue;
97
136
  // A choice segment above the leaf is not a JSON property name, so the walk
@@ -104,6 +143,45 @@ export function generateClosedSlicingValidations(fields, profileUrl, baseResourc
104
143
  const fhirPath = `${baseResourceType ?? 'Resource'}.${elemId}`;
105
144
  const profileRef = profileUrl ?? (baseResourceType ?? '');
106
145
  const message = `This element does not match any known slice defined in the profile ${profileRef} and slicing is CLOSED: ${fhirPath}`;
146
+ if (kind === 'reference-target') {
147
+ // A `resolve()` discriminator tells slices apart by what the reference points
148
+ // at, which needs the target. The target type does not: a slicing whose slices
149
+ // all accept Observation cannot hold a `Patient/x`, resolvable or not, and HL7
150
+ // reports exactly that — vital-signs-panel.hasMember was the whole `entry`
151
+ // category of the vitals random-parity gap.
152
+ //
153
+ // Only the type is judged, never the identity. Reference.type when present,
154
+ // else the `Type/id` prefix of a relative literal reference; an absolute URL,
155
+ // a `urn:uuid:` or a contained `#id` yields no type and is left alone. That is
156
+ // narrower than the reference-target check in validatorTemplates, which stays
157
+ // silent on any unresolved reference: there the finding IS resolution, here the
158
+ // finding is slice membership, and a closed slicing rules out a wrong type
159
+ // without resolving anything.
160
+ const perSlice = slices.map(s => referenceTargetTypes(s, profileUrlToType));
161
+ // One unresolvable slice and the allowed set is incomplete, so a conformant
162
+ // member could be reported. Same all-or-nothing rule as the other two kinds.
163
+ if (perSlice.some(types => !types))
164
+ continue;
165
+ const allowed = [...new Set(perSlice.flat())];
166
+ if (allowed.length === 0)
167
+ continue;
168
+ const allowedList = `[${allowed.map(t => `"${t}"`).join(', ')}]`;
169
+ out.push(`
170
+ // Closed slicing on ${fhirPath}: only references to ${allowed.join(', ')} can match a slice${walk(segments, `
171
+ const _csRefType = at(_csElem, 'type');
172
+ const _csRefStr = at(_csElem, 'reference');
173
+ const _csPrefix = typeof _csRefStr === 'string' && _csRefStr.indexOf('/') > 0
174
+ ? _csRefStr.slice(0, _csRefStr.indexOf('/'))
175
+ : undefined;
176
+ const _csTarget = typeof _csRefType === 'string' && _csRefType.length > 0
177
+ ? _csRefType
178
+ : (typeof _csPrefix === 'string' && ${RESOURCE_TYPE_SHAPE}.test(_csPrefix) ? _csPrefix : undefined);
179
+ if (_csTarget !== undefined && !${allowedList}.includes(_csTarget)) {
180
+ errors.push("${message} (target = " + _csTarget + ")");
181
+ }`)}`);
182
+ seen.add(elemId);
183
+ continue;
184
+ }
107
185
  if (kind === 'extension-url') {
108
186
  const urls = slices.map(s => instanceUrl(s, fields)).filter((u) => !!u);
109
187
  // Every slice must be identifiable, or a conformant member gets reported.
@@ -115,7 +115,97 @@ export function collectFixedValueSliceChildren(sliceElementId, fields, discrimin
115
115
  }
116
116
  return result;
117
117
  }
118
- export function generateBackboneElementValidation(slice, relPath, errorPath, sliceLabel, varName, min, fields, childFieldsWithPattern, out, profileName) {
118
+ /**
119
+ * Collect fixed-value descendants one level below a slice's direct children.
120
+ *
121
+ * The pinned element of a profiled backbone slice is usually a grandchild, not a
122
+ * child: `component:SystolicBP.value[x]:valueQuantity.code` is fixed to `mm[Hg]`,
123
+ * and `collectFixedValueSliceChildren` skips it because the relative path has a dot.
124
+ * The root-level builder skips it too, deliberately — a pin declared on a slice
125
+ * must apply only to that slice's members, and it has no slice identity to filter
126
+ * on (see the `hasSliceQualifier` guard in validatorFieldBuilders). So nothing
127
+ * checked it, and HL7's `Value is '/min' but is fixed to 'mm[Hg]'` was the whole
128
+ * `code` category of the vitals random-parity gap.
129
+ *
130
+ * Emitted inside the matched-element loop, which is what keeps the pin scoped to
131
+ * the slice that declares it.
132
+ *
133
+ * Two relative shapes reach a grandchild:
134
+ * `valueQuantity.code` a plain child, then its leaf
135
+ * `value[x]:valueQuantity.code` a type slice on a choice, then its leaf
136
+ */
137
+ export function collectNestedFixedValueSliceChildren(sliceElementId, fields, discriminatorProp) {
138
+ const result = [];
139
+ const seen = new Set();
140
+ for (const f of fields) {
141
+ const childId = f.elementId || f.name;
142
+ if (!childId.startsWith(sliceElementId + '.'))
143
+ continue;
144
+ if (f.fixedValue === undefined)
145
+ continue;
146
+ if (typeof f.fixedValue !== 'string' && typeof f.fixedValue !== 'number' && typeof f.fixedValue !== 'boolean')
147
+ continue;
148
+ const relativePath = childId.substring(sliceElementId.length + 1);
149
+ const parts = relativePath.split('.');
150
+ if (parts.length !== 2)
151
+ continue;
152
+ const [parentSegment, leafSegment] = parts;
153
+ if (!parentSegment || !leafSegment)
154
+ continue;
155
+ // A leaf that is itself sliced needs its own scope, and a choice leaf has no
156
+ // single property name to read.
157
+ if (leafSegment.includes(':') || leafSegment.endsWith('[x]'))
158
+ continue;
159
+ // `value[x]:valueQuantity` writes the typed property; a plain child writes itself.
160
+ const parentProp = parentSegment.includes('[x]:')
161
+ ? parentSegment.split(':')[1]
162
+ : parentSegment.includes(':') ? undefined : parentSegment.replace(/\[x\]$/, '');
163
+ if (!parentProp)
164
+ continue;
165
+ if (parentProp === discriminatorProp)
166
+ continue;
167
+ const key = `${parentProp}.${leafSegment}`;
168
+ if (seen.has(key))
169
+ continue;
170
+ seen.add(key);
171
+ const parentField = fields.find(pf => (pf.elementId || pf.name) === `${sliceElementId}.${parentSegment}`);
172
+ result.push({
173
+ parentProp,
174
+ isParentArray: parentField?.isArray ?? false,
175
+ prop: leafSegment,
176
+ fixedValue: f.fixedValue,
177
+ elementPath: childId,
178
+ });
179
+ }
180
+ return result;
181
+ }
182
+ /** Emit the fixed-value checks for a slice's grandchildren, inside the matched-element loop. */
183
+ export function generateNestedFixedValueChecks(nested, profileRef) {
184
+ return nested.map(n => {
185
+ const expected = typeof n.fixedValue === 'string' ? JSON.stringify(n.fixedValue) : String(n.fixedValue);
186
+ const display = String(n.fixedValue).replace(/"/g, '\\"');
187
+ const anchor = `${profileRef}#${n.elementPath}`;
188
+ const report = `errors.push("Value is '" + _fx.${n.prop} + "' but is fixed to '${display}' in the profile ${anchor}");`;
189
+ const guard = `_fx.${n.prop} !== undefined && _fx.${n.prop} !== null && String(_fx.${n.prop}) !== ${expected}`;
190
+ if (n.isParentArray) {
191
+ return `
192
+ for (const _fxItem of elements(at(_m, '${n.parentProp}'))) {
193
+ const _fx = node(_fxItem);
194
+ if (${guard}) {
195
+ ${report}
196
+ }
197
+ }`;
198
+ }
199
+ return `
200
+ if (_m.${n.parentProp} !== undefined && _m.${n.parentProp} !== null) {
201
+ const _fx = node(at(_m, '${n.parentProp}'));
202
+ if (${guard}) {
203
+ ${report}
204
+ }
205
+ }`;
206
+ }).join('');
207
+ }
208
+ export function generateBackboneElementValidation(slice, relPath, errorPath, sliceLabel, varName, min, fields, childFieldsWithPattern, out, profileName, profileUrl) {
119
209
  const sliceElementId = slice.elementId || slice.name;
120
210
  const discriminatorChild = childFieldsWithPattern.find(f => {
121
211
  const fElementId = f.elementId || f.name;
@@ -181,6 +271,7 @@ export function generateBackboneElementValidation(slice, relPath, errorPath, sli
181
271
  const resourceType = profileName || firstSegment(sliceElementId);
182
272
  const requiredChildren = collectRequiredSliceChildren(sliceElementId, fields, discriminatorProp, resourceType, relPath);
183
273
  const nestedChildren = collectNestedRequiredSliceChildren(sliceElementId, fields, discriminatorProp, resourceType, relPath);
274
+ const nestedFixed = collectNestedFixedValueSliceChildren(sliceElementId, fields, discriminatorProp);
184
275
  const directChecksInner = requiredChildren.map(c => c.isChoiceType
185
276
  ? `\n if (!Object.keys(_m).some(k => k.startsWith('${c.prop}'))) {
186
277
  errors.push("${c.errorPath}: minimum required = 1, but only found 0");
@@ -210,11 +301,12 @@ export function generateBackboneElementValidation(slice, relPath, errorPath, sli
210
301
  }
211
302
  }`;
212
303
  }).join('');
304
+ const nestedFixedChecksInner = generateNestedFixedValueChecks(nestedFixed, profileUrl || resourceType);
213
305
  const maxChildren = collectWithinSliceMaxChildren(slice, fields);
214
306
  const maxChecksCode = generateWithinSliceMaxChecks(maxChildren, `${varName}Elements`, resourceType, relPath, sliceLabel);
215
- const childChecksCode = (requiredChildren.length > 0 || nestedChildren.length > 0
307
+ const childChecksCode = (requiredChildren.length > 0 || nestedChildren.length > 0 || nestedFixed.length > 0
216
308
  ? `\n for (const _matched of ${varName}Elements) {
217
- const _m = node(_matched);${directChecksInner}${nestedChecksInner}
309
+ const _m = node(_matched);${directChecksInner}${nestedChecksInner}${nestedFixedChecksInner}
218
310
  }`
219
311
  : '') + maxChecksCode;
220
312
  const pathParts = relPath.split('.');
@@ -266,7 +358,7 @@ export function generateBackboneElementValidation(slice, relPath, errorPath, sli
266
358
  * the child .name has fixedString: "sourceIdentifier".
267
359
  * Generates: resource.parameter.filter(item => item.name === "sourceIdentifier")
268
360
  */
269
- export function generateFixedValueSliceValidation(slice, relPath, errorPath, sliceLabel, varName, min, fields, childFieldsWithFixedValue, out, profileName) {
361
+ export function generateFixedValueSliceValidation(slice, relPath, errorPath, sliceLabel, varName, min, fields, childFieldsWithFixedValue, out, profileName, profileUrl) {
270
362
  const sliceElementId = slice.elementId || slice.name;
271
363
  // The caller only reaches here with a fixed-value child, but an empty list would
272
364
  // otherwise read its discriminator off undefined.
@@ -288,6 +380,7 @@ export function generateFixedValueSliceValidation(slice, relPath, errorPath, sli
288
380
  const resourceType = profileName || firstSegment(sliceElementId);
289
381
  const requiredChildren = collectRequiredSliceChildren(sliceElementId, fields, discriminatorProp, resourceType, relPath);
290
382
  const nestedChildren = collectNestedRequiredSliceChildren(sliceElementId, fields, discriminatorProp, resourceType, relPath);
383
+ const nestedFixed = collectNestedFixedValueSliceChildren(sliceElementId, fields, discriminatorProp);
291
384
  const fixedChildren = collectFixedValueSliceChildren(sliceElementId, fields, discriminatorProp);
292
385
  const directChecksInner = requiredChildren.map(c => c.isChoiceType
293
386
  ? `\n if (!Object.keys(_m).some(k => k.startsWith('${c.prop}'))) {
@@ -325,11 +418,12 @@ export function generateFixedValueSliceValidation(slice, relPath, errorPath, sli
325
418
  errors.push("Value is '" + _m.${c.prop} + "' but is fixed to '${display}' in the profile ${resourceType}#${c.elementPath}");
326
419
  }`;
327
420
  }).join('');
421
+ const nestedFixedChecksInner = generateNestedFixedValueChecks(nestedFixed, profileUrl || resourceType);
328
422
  const maxChildren = collectWithinSliceMaxChildren(slice, fields);
329
423
  const maxChecksCode = generateWithinSliceMaxChecks(maxChildren, `${varName}Elements`, resourceType, relPath, sliceLabel);
330
- const childChecksCode = (requiredChildren.length > 0 || nestedChildren.length > 0 || fixedChildren.length > 0
424
+ const childChecksCode = (requiredChildren.length > 0 || nestedChildren.length > 0 || fixedChildren.length > 0 || nestedFixed.length > 0
331
425
  ? `\n for (const _matched of ${varName}Elements) {
332
- const _m = node(_matched);${directChecksInner}${nestedChecksInner}${fixedChecksInner}
426
+ const _m = node(_matched);${directChecksInner}${nestedChecksInner}${fixedChecksInner}${nestedFixedChecksInner}
333
427
  }`
334
428
  : '') + maxChecksCode;
335
429
  // Detect if this slice is nested inside a parent array (e.g., component.code inside component[])
@@ -6,7 +6,7 @@
6
6
  * validator, which is what keeps findings inside a nested resource from being
7
7
  * lost. Split out of sliceValidatorGenerator, which had grown past the size limit.
8
8
  */
9
- import { stripVersionFromCanonicalUrl, firstSegment } from '../../core/utils.js';
9
+ import { resourceTypeForProfileUrl, stripVersionFromCanonicalUrl, firstSegment } from '../../core/utils.js';
10
10
  import { safeAccessor, safeList, collectWithinSliceMaxChildren, generateWithinSliceMaxChecks } from './sliceValidatorUtils.js';
11
11
  export function generateProfiledChildDelegation(slice, relPath, childFieldsWithFixedValue, out, ctx) {
12
12
  if (!ctx.profileUrlToName || !ctx.validatorImports)
@@ -153,17 +153,8 @@ export function detectReferenceProfileDiscriminator(slice, profileUrlToType) {
153
153
  const targetUrl = refType?.targetProfileUrls?.[0];
154
154
  if (!targetUrl)
155
155
  return undefined;
156
- // 1. Authoritative: resolve from profileUrlToType map
157
- if (profileUrlToType) {
158
- const resolved = profileUrlToType.get(targetUrl);
159
- if (resolved && /^[A-Z][a-zA-Z]+$/.test(resolved))
160
- return resolved;
161
- }
162
- // 2. Heuristic: last URL segment if it looks like a resource type
163
- const lastSeg = targetUrl.split('/').pop() || '';
164
- if (/^[A-Z][a-zA-Z]+$/.test(lastSeg))
165
- return lastSeg;
166
- return undefined;
156
+ // Registry first, URL segment second — see resourceTypeForProfileUrl.
157
+ return resourceTypeForProfileUrl(targetUrl, profileUrlToType);
167
158
  }
168
159
  /**
169
160
  * Generate slice validation for Reference-typed slices discriminated by target profile.
@@ -171,13 +171,13 @@ export function generateSliceValidations(ctx) {
171
171
  generateExtensionUrlSliceValidation(slice, relPath, errorPath, sliceLabel, varName, min, extUrl, fieldPathMap, arrayFieldPaths, sliceValidations, ctx);
172
172
  }
173
173
  else {
174
- generateFixedValueSliceValidation(slice, relPath, errorPath, sliceLabel, varName, min, fields, childFieldsWithFixedValue, sliceValidations, ctx.baseResourceType);
174
+ generateFixedValueSliceValidation(slice, relPath, errorPath, sliceLabel, varName, min, fields, childFieldsWithFixedValue, sliceValidations, ctx.baseResourceType, ctx.profileUrl);
175
175
  // Delegate validation to profiled sub-resource validators (e.g., Parameters.parameter.resource → Bundle profile)
176
176
  generateProfiledChildDelegation(slice, relPath, childFieldsWithFixedValue, sliceValidations, ctx);
177
177
  }
178
178
  }
179
179
  else if (childFieldsWithPattern.length > 0) {
180
- generateBackboneElementValidation(slice, relPath, errorPath, sliceLabel, varName, min, fields, childFieldsWithPattern, sliceValidations, ctx.baseResourceType);
180
+ generateBackboneElementValidation(slice, relPath, errorPath, sliceLabel, varName, min, fields, childFieldsWithPattern, sliceValidations, ctx.baseResourceType, ctx.profileUrl);
181
181
  }
182
182
  else if (hasBindingCodes) {
183
183
  generateBindingCodesValidation(slice, baseName, relPath, errorPath, sliceLabel, varName, min, minText, fields, sliceValidations);
@@ -11,6 +11,32 @@ const FHIRPATH_KEYWORDS = new Set([
11
11
  'mod', 'month', 'months', 'not', 'or', 'second', 'seconds', 'true', 'week',
12
12
  'weeks', 'xor', 'year', 'years',
13
13
  ]);
14
+ /**
15
+ * A constraint declared on a slice applies only to that slice's members.
16
+ *
17
+ * The scope is derived from field.name, which carries no slice identity, so every
18
+ * slice's invariants were being applied to the whole base element. US Core pins
19
+ * Practitioner.identifier:NPI to ten digits and :NCSBN to eight, so a conformant
20
+ * NPI necessarily failed the NCSBN rule and we reported an error HL7 does not.
21
+ *
22
+ * Returns a predicate matching the value the slice pins, so the constraint reaches
23
+ * only its own members. Slices discriminated by something other than a scalar
24
+ * (a CodeableConcept's coding array, say) yield undefined and are left alone
25
+ * rather than guessed at.
26
+ */
27
+ export function buildSliceConstraintFilter(field) {
28
+ const elementId = field.elementId || '';
29
+ if (!elementId.split('.').some(segment => segment.includes(':')))
30
+ return undefined;
31
+ const pattern = field.patternConstraint;
32
+ if (!pattern || typeof pattern !== 'object')
33
+ return undefined;
34
+ for (const [key, value] of Object.entries(pattern)) {
35
+ if (typeof value === 'string')
36
+ return `${key} = '${value.replace(/'/g, "\\'")}'`;
37
+ }
38
+ return undefined;
39
+ }
14
40
  /**
15
41
  * Delimit any keyword segment of a FHIRPath navigation path.
16
42
  *
@@ -9,34 +9,8 @@ import { buildNestedRequiredValidations, buildFixedValueValidations, buildProhib
9
9
  import { buildBindingValidations } from './validatorBindingBuilder.js';
10
10
  import { generateBundleRefValidation, generateContainedRefValidation, generateExtensionStructuralValidation, generateAggregationRefValidation, buildReferenceTargetTypeValidation, buildBundleEntryResolution } from './validatorTemplates.js';
11
11
  import { HELPER_NAMES } from './validatorRuntime.js';
12
- import { isBaseElementInvariant, scopeConstraintExpression } from './validatorExpressions.js';
12
+ import { buildSliceConstraintFilter, isBaseElementInvariant, scopeConstraintExpression } from './validatorExpressions.js';
13
13
  const log = logger.withTag('validator');
14
- /**
15
- * A constraint declared on a slice applies only to that slice's members.
16
- *
17
- * The scope is derived from field.name, which carries no slice identity, so every
18
- * slice's invariants were being applied to the whole base element. US Core pins
19
- * Practitioner.identifier:NPI to ten digits and :NCSBN to eight, so a conformant
20
- * NPI necessarily failed the NCSBN rule and we reported an error HL7 does not.
21
- *
22
- * Returns a predicate matching the value the slice pins, so the constraint reaches
23
- * only its own members. Slices discriminated by something other than a scalar
24
- * (a CodeableConcept's coding array, say) yield undefined and are left alone
25
- * rather than guessed at.
26
- */
27
- function buildSliceConstraintFilter(field) {
28
- const elementId = field.elementId || '';
29
- if (!elementId.split('.').some(segment => segment.includes(':')))
30
- return undefined;
31
- const pattern = field.patternConstraint;
32
- if (!pattern || typeof pattern !== 'object')
33
- return undefined;
34
- for (const [key, value] of Object.entries(pattern)) {
35
- if (typeof value === 'string')
36
- return `${key} = '${value.replace(/'/g, "\\'")}'`;
37
- }
38
- return undefined;
39
- }
40
14
  /** Coded types whose membership a terminology server can decide. */
41
15
  const CODED_BINDING_TYPES = new Set(['code', 'Coding', 'CodeableConcept']);
42
16
  /**
@@ -247,7 +221,7 @@ export function generateValidateProfileFunction(interfaceName, fields, valueSets
247
221
  valueSetImports, profileUrl, profileName: interfaceName, baseResourceType,
248
222
  profileUrlToName, validatorImports, extensionMetadata, profileUrlToType, datatypeProfilePins,
249
223
  });
250
- const closedSlicingValidations = generateClosedSlicingValidations(fields, profileUrl, baseResourceType);
224
+ const closedSlicingValidations = generateClosedSlicingValidations(fields, profileUrl, baseResourceType, profileUrlToType);
251
225
  const primitiveFormatValidations = buildPrimitiveFormatValidations(fields);
252
226
  const structuralTypeValidations = [
253
227
  ...buildStructuralTypeValidations(fields, arrayFieldPaths),
@@ -379,8 +353,9 @@ export function generateValidateProfileFunction(interfaceName, fields, valueSets
379
353
  // Filter $this on choice-type fields — fhirpath scoping differs from HL7.
380
354
  if (c.isChoiceType && /\$this\b/.test(expr))
381
355
  return false;
382
- // txt-1/txt-2 (htmlChecks) are not filtered: htmlChecks is supplied through
383
- // fhirpath's userInvocationTable, and as() over a collection is normalised to
356
+ // txt-1/txt-2 (htmlChecks) are not filtered: fhirpath.js implements htmlChecks
357
+ // as of 5.2.0, which the peer range now requires, and as() over a collection is
358
+ // normalised to
384
359
  // ofType() below.
385
360
  //
386
361
  // dom-3 is implemented natively in generateContainedRefValidation instead.
@@ -522,11 +497,9 @@ ${body}
522
497
  // `at`/`arr` are value imports: they replace the `as any` element probing that
523
498
  // would otherwise appear throughout the emitted body.
524
499
  const optionsImport = 'import type { ValidatorOptions } from \'./ValidatorOptions.js\';\n'
525
- + `import { ${HELPER_NAMES.join(', ')}, fhirpathUserFunctions } from './ValidatorOptions.js';\n\n`;
500
+ + `import { ${HELPER_NAMES.join(', ')} } from './ValidatorOptions.js';\n\n`;
526
501
  const fhirpathOptionsLines = [
527
502
  ' preciseMath: true,',
528
- // Supplies htmlChecks(), which fhirpath.js does not implement.
529
- ' userInvocationTable: fhirpathUserFunctions,',
530
503
  ' traceFn: options?.traceFn ?? (() => {}),',
531
504
  ' ...(options?.signal ? { signal: options.signal } : {}),',
532
505
  ' ...(options?.httpHeaders ? { httpHeaders: options.httpHeaders } : {}),',
@@ -238,53 +238,6 @@ export function asResource<T>(value: unknown): T {
238
238
  return value as T;
239
239
  }
240
240
 
241
- /**
242
- * The XHTML element subset FHIR permits in Narrative.div (R4 Narrative).
243
- * Anything outside this set — script, style, object, embed, iframe, form,
244
- * input — is what txt-1 exists to reject.
245
- */
246
- const NARRATIVE_ELEMENTS = new Set([
247
- 'a', 'abbr', 'acronym', 'b', 'bdo', 'big', 'blockquote', 'br', 'caption',
248
- 'cite', 'code', 'col', 'colgroup', 'dd', 'dfn', 'div', 'dl', 'dt', 'em',
249
- 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p',
250
- 'pre', 'q', 'samp', 'small', 'span', 'strong', 'sub', 'sup', 'table',
251
- 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'tt', 'ul', 'var',
252
- ]);
253
-
254
- /** Void elements that carry meaning without text content. */
255
- const NARRATIVE_VOID_ELEMENTS = /<\\s*(?:img|br|hr)\\b/i;
256
-
257
- /**
258
- * txt-1 / txt-2: the narrative must hold some non-whitespace content and use
259
- * only the permitted XHTML subset, with no event-handler attributes.
260
- *
261
- * fhirpath.js does not implement \`htmlChecks()\`, and an unimplemented function
262
- * throws out of the whole evaluation, so every profile carrying txt-1 lost all of
263
- * its findings. Supplying the function is what keeps the invariant evaluated.
264
- */
265
- export function narrativeHtmlChecks(values: readonly unknown[]): boolean {
266
- for (const value of values) {
267
- if (typeof value !== 'string') continue;
268
241
 
269
- // Only the permitted elements, opening or closing.
270
- for (const match of value.matchAll(/<\\s*\\/?\\s*([a-zA-Z][a-zA-Z0-9]*)/g)) {
271
- if (!NARRATIVE_ELEMENTS.has(match[1].toLowerCase())) return false;
272
- }
273
- // No inline event handlers (onclick, onload, …).
274
- if (/\\son[a-zA-Z]+\\s*=/.test(value)) return false;
275
- // Some non-whitespace content, unless a void element carries it.
276
- const text = value.replace(/<[^>]*>/g, '').replace(/&[a-zA-Z#0-9]+;/g, ' ');
277
- if (text.trim().length === 0 && !NARRATIVE_VOID_ELEMENTS.test(value)) return false;
278
- }
279
- return true;
280
- }
281
-
282
- /**
283
- * FHIRPath functions fhirpath.js lacks, supplied via its userInvocationTable.
284
- * Passed as \`userInvocationTable\` in the evaluation options.
285
- */
286
- export const fhirpathUserFunctions = {
287
- htmlChecks: { fn: narrativeHtmlChecks, arity: { 0: [] } },
288
- };
289
242
  `;
290
243
  }
@@ -1,4 +1,4 @@
1
- import { stripVersionFromCanonicalUrl } from '../../core/utils.js';
1
+ import { resourceTypeForProfileUrl } from '../../core/utils.js';
2
2
  /**
3
3
  * Emit the reference target-type check for one profile.
4
4
  *
@@ -13,13 +13,6 @@ import { stripVersionFromCanonicalUrl } from '../../core/utils.js';
13
13
  * ignores and misses the `urn:uuid:` ones it reports.
14
14
  */
15
15
  export function buildReferenceTargetTypeValidation(fields, profileUrlToType) {
16
- const resolveTargetType = (url) => {
17
- const bare = stripVersionFromCanonicalUrl(url);
18
- const lastSegment = bare.split('/').pop() || '';
19
- if (/^[A-Z][A-Za-z]+$/.test(lastSegment))
20
- return lastSegment;
21
- return profileUrlToType?.get(bare) ?? profileUrlToType?.get(url);
22
- };
23
16
  const rows = [];
24
17
  const seen = new Set();
25
18
  for (const field of fields) {
@@ -33,14 +26,13 @@ export function buildReferenceTargetTypeValidation(fields, profileUrlToType) {
33
26
  .flatMap(option => option.targetProfileUrls ?? []);
34
27
  if (targets.length === 0)
35
28
  continue;
36
- const resolved = targets.map(resolveTargetType);
37
29
  // An unresolved target, or one permitting any resource, leaves the allowed set
38
- // unknown — nothing can be rejected against it.
30
+ // unknown — nothing can be rejected against it. Both come back undefined from
31
+ // the shared resolver.
32
+ const resolved = targets.map(url => resourceTypeForProfileUrl(url, profileUrlToType));
39
33
  if (resolved.some(type => !type))
40
34
  continue;
41
35
  const allowed = [...new Set(resolved)];
42
- if (allowed.includes('Resource') || allowed.includes('DomainResource'))
43
- continue;
44
36
  seen.add(rel);
45
37
  rows.push(` { path: ${JSON.stringify(rel.split('.'))}, allowed: ${JSON.stringify(allowed)} },`);
46
38
  }
@@ -2,9 +2,9 @@
2
2
  * Zod refinement builder for pattern constraints, slice cardinalities, FHIRPath invariants,
3
3
  * and required ValueSet bindings.
4
4
  */
5
- import { sanitizeValueSetName } from '../valueset/valueSetGenerator.js';
6
- import { scopeConstraintExpression } from '../validator/validatorExpressions.js';
7
- import { stripSliceSuffix, stripVersionFromCanonicalUrl } from '../../core/utils.js';
5
+ import { codeNotInValueSetMessage, codingsNotInValueSetMessage, resolveRequiredBindingValueSet, selectRequiredBindingTargets, } from '../requiredBindings.js';
6
+ import { buildSliceConstraintFilter, isBaseElementInvariant, scopeConstraintExpression } from '../validator/validatorExpressions.js';
7
+ import { stripSliceSuffix } from '../../core/utils.js';
8
8
  /** Escape double-quote characters in refinement messages */
9
9
  function escStr(s) {
10
10
  return s.replace(/"/g, '\\"');
@@ -75,6 +75,14 @@ emitInvariants = false) {
75
75
  }
76
76
  }
77
77
  // 2. Required slice cardinality
78
+ //
79
+ // Reaches any depth. This took depth-1 paths only, so a slice on a nested array —
80
+ // ExplanationOfBenefit.item.adjudication:adjudicationamounttype, required by every
81
+ // carin-bb EOB profile — was checked by the validator alone. The path is walked with
82
+ // elementsAt, which flattens every array hop between the root and the sliced
83
+ // element, and the check is guarded on the outermost segment being present: the
84
+ // validator skips a missing parent too, leaving its absence to the required-field
85
+ // checks rather than reporting it twice.
78
86
  const slicesByBase = new Map();
79
87
  for (const field of allFields) {
80
88
  if (!field.sliceName)
@@ -84,47 +92,118 @@ emitInvariants = false) {
84
92
  slicesByBase.set(base, []);
85
93
  slicesByBase.get(base).push(field);
86
94
  }
95
+ /**
96
+ * The child a BackboneElement slice is told apart by.
97
+ *
98
+ * A slice on a backbone pins its discriminator one level down —
99
+ * `adjudication:adjudicationamounttype` is identified by `category` — so the pattern
100
+ * has to be read off the member's child, not the member. Matching the member itself
101
+ * found nothing and reported a required slice missing on a conformant resource.
102
+ */
103
+ const discriminatorChildOf = (slice) => {
104
+ const sliceId = slice.elementId || slice.name;
105
+ const children = [];
106
+ for (const candidate of allFields) {
107
+ const childId = candidate.elementId || candidate.name;
108
+ if (!childId.startsWith(sliceId + '.'))
109
+ continue;
110
+ const rel = childId.slice(sliceId.length + 1);
111
+ if (rel.includes('.') || rel.includes(':') || rel.includes('[x]'))
112
+ continue;
113
+ children.push({ prop: rel, field: candidate });
114
+ }
115
+ // Same order the validator's slice dispatch uses: a pinned value, then a pattern,
116
+ // then binding codes. Taking the first child that had any of them picked
117
+ // `identifier.use`, whose binding is not the discriminator, over
118
+ // `identifier.type`, whose pattern is — and every conformant identifier then
119
+ // failed to match, so a satisfied slice was reported missing.
120
+ return children.find(c => c.field.fixedValue !== undefined)
121
+ ?? children.find(c => !!c.field.patternConstraint)
122
+ ?? children.find(c => !!c.field.binding?.codes?.length);
123
+ };
124
+ /** Codings of the member, or of the child that carries the discriminator. */
125
+ const memberCodings = (disc) => disc ? `codings(at(item, '${disc}'))` : 'codings(item)';
87
126
  for (const [baseName, slices] of slicesByBase) {
88
127
  const relPath = baseName.replace(/^[^.]+\./, '');
89
- if (relPath.includes('.'))
128
+ if (!relPath || relPath === baseName)
90
129
  continue;
91
- if (relPath.includes('[x]'))
130
+ const segments = relPath.split('.');
131
+ if (segments.some(segment => segment.includes('[x]') || segment.includes(':')))
92
132
  continue;
93
- if (!directFieldNames.has(stripSliceSuffix(relPath)))
133
+ const [firstSegment] = segments;
134
+ if (!firstSegment || !directFieldNames.has(stripSliceSuffix(firstSegment)))
94
135
  continue;
136
+ const pathLiteral = `[${segments.map(seg => JSON.stringify(seg)).join(', ')}]`;
137
+ // Present-parent guard, and for depth 1 exactly the Array.isArray check this
138
+ // section already used.
139
+ const guard = `!Array.isArray(at(d, '${firstSegment}'))`;
95
140
  for (const slice of slices) {
96
141
  const min = typeof slice.min === 'number' ? slice.min : (slice.isOptional ? 0 : 1);
97
142
  if (min <= 0)
98
143
  continue;
99
144
  const sliceLabel = slice.sliceName || 'slice';
145
+ const message = `Required slice '${escStr(relPath)}:${escStr(sliceLabel)}' not satisfied`;
146
+ const disc = slice.patternConstraint || slice.binding?.codes?.length
147
+ ? undefined
148
+ : discriminatorChildOf(slice);
149
+ const discProp = disc?.prop;
150
+ const pattern = (slice.patternConstraint ?? disc?.field.patternConstraint);
151
+ const bindingCodes = slice.binding?.codes?.length ? slice.binding.codes : disc?.field.binding?.codes;
152
+ const emit = (predicate) => {
153
+ refinements.push(`.refine(d => ${guard} || elementsAt(d, ${pathLiteral}).filter((item) => ${predicate}).length >= ${min}, `
154
+ + `{ message: "${escStr(message)}", when: () => true })`);
155
+ };
100
156
  // patternCodeableConcept discriminator
101
- if (slice.patternConstraint && typeof slice.patternConstraint === 'object' && 'coding' in slice.patternConstraint) {
102
- const pattern = slice.patternConstraint;
103
- const codings = pattern.coding.filter((c) => c !== null && typeof c === 'object' && 'code' in c);
104
- if (codings.length > 0) {
105
- const checks = codings
106
- .map(c => {
107
- return c.system
108
- ? `codings(item).some((cd) => cd.system === "${c.system}" && cd.code === "${c.code}")`
109
- : `codings(item).some((cd) => cd.code === "${c.code}")`;
110
- })
157
+ if (pattern && typeof pattern === 'object' && 'coding' in pattern) {
158
+ const patternCodings = pattern.coding.filter((c) => c !== null && typeof c === 'object' && 'code' in c);
159
+ if (patternCodings.length > 0) {
160
+ const checks = patternCodings
161
+ .map(c => (c.system
162
+ ? `${memberCodings(discProp)}.some((cd) => cd.system === "${c.system}" && cd.code === "${c.code}")`
163
+ : `${memberCodings(discProp)}.some((cd) => cd.code === "${c.code}")`))
111
164
  .join(' || ');
112
- refinements.push(`.refine(d => !Array.isArray(at(d, '${relPath}')) || arr(at(d, '${relPath}')).filter((item) => ${checks}).length >= ${min}, { message: "Required slice '${escStr(relPath)}:${escStr(sliceLabel)}' not satisfied", when: () => true })`);
165
+ emit(checks);
113
166
  }
114
167
  }
115
168
  // patternCoding discriminator
116
- if (slice.patternConstraint && typeof slice.patternConstraint === 'object' && 'system' in slice.patternConstraint && 'code' in slice.patternConstraint && !('coding' in slice.patternConstraint)) {
117
- const pat = slice.patternConstraint;
118
- refinements.push(`.refine(d => !Array.isArray(at(d, '${relPath}')) || codings(at(d, '${relPath}')).filter((c) => c.system === "${pat.system}" && c.code === "${pat.code}").length >= ${min}, { message: "Required slice '${escStr(relPath)}:${escStr(sliceLabel)}' not satisfied", when: () => true })`);
169
+ if (pattern && typeof pattern === 'object' && 'system' in pattern && 'code' in pattern && !('coding' in pattern)) {
170
+ emit(`${memberCodings(discProp)}.some((cd) => cd.system === "${pattern.system}" && cd.code === "${pattern.code}")`);
171
+ }
172
+ // Fixed-value discriminator child: matched by equality, as the validator's
173
+ // fixed-value slice strategy does.
174
+ const discFixed = disc?.field.fixedValue;
175
+ if (!pattern && discProp && discFixed !== undefined
176
+ && (typeof discFixed === 'string' || typeof discFixed === 'number' || typeof discFixed === 'boolean')) {
177
+ emit(`at(item, '${discProp}') === ${JSON.stringify(discFixed)}`);
178
+ continue;
119
179
  }
120
180
  // Binding codes discriminator
121
- if (!slice.patternConstraint && slice.binding?.codes && slice.binding.codes.length > 0) {
122
- const codes = slice.binding.codes.map(c => JSON.stringify(c.code));
123
- const codesSet = `[${codes.join(', ')}]`;
124
- refinements.push(
125
- // A binding-discriminated slice member is either a coded element or a
126
- // bare code string, so fall back to the member itself.
127
- `.refine(d => !Array.isArray(at(d, '${relPath}')) || arr(at(d, '${relPath}')).filter((item) => { const _code = coding(item).code ?? item; return typeof _code === 'string' && ${codesSet}.includes(_code); }).length >= ${min}, { message: "Required slice '${escStr(relPath)}:${escStr(sliceLabel)}' not satisfied", when: () => true })`);
181
+ if (!pattern && bindingCodes && bindingCodes.length > 0) {
182
+ if (discProp) {
183
+ // The shape the validator emits for a binding-discriminated backbone slice:
184
+ // every coding on the discriminator child is considered, and the system is
185
+ // part of the match. Reading one coding through coding() and comparing the
186
+ // code alone reported total:adjudicationamounttype missing on five carin-bb
187
+ // EOB profiles that satisfy it.
188
+ const pairs = bindingCodes
189
+ .map(c => (c.system
190
+ ? `(cd.system === ${JSON.stringify(c.system)} && cd.code === ${JSON.stringify(c.code)})`
191
+ : `cd.code === ${JSON.stringify(c.code)}`))
192
+ .join(' || ');
193
+ emit(`codings(at(item, '${discProp}')).some((cd) => ${pairs})`);
194
+ }
195
+ else {
196
+ const codesSet = `[${bindingCodes.map(c => JSON.stringify(c.code)).join(', ')}]`;
197
+ emit(
198
+ // Every coding on the member, which is how the validator matches it
199
+ // (`item.coding?.some(...)`). Reading one coding through coding() and then
200
+ // falling back to the member itself matched neither a CodeableConcept nor a
201
+ // multi-coding Coding, so us-core's Condition reported category:us-core
202
+ // missing on a resource that carries problem-list-item. A bare code string
203
+ // is still a legal member, hence the second arm.
204
+ `codings(item).some((cd) => cd.code !== undefined && ${codesSet}.includes(cd.code)) `
205
+ + `|| (typeof item === 'string' && ${codesSet}.includes(item))`);
206
+ }
128
207
  }
129
208
  }
130
209
  }
@@ -134,16 +213,22 @@ emitInvariants = false) {
134
213
  if (!field.constraints || field.constraints.length === 0)
135
214
  continue;
136
215
  const rel = field.name.replace(/^[^.]+\./, '');
216
+ // A constraint declared on a slice applies to that slice's members only. Without
217
+ // the filter, us-core's NCSBN rule — an eight-digit board ID — was evaluated
218
+ // against every Practitioner.identifier and failed the ten-digit NPI, a violation
219
+ // the validator does not report because it scopes the same constraint with
220
+ // `identifier.where(system = '…NCSBNID')`.
221
+ const sliceFilter = buildSliceConstraintFilter(field);
137
222
  for (const c of field.constraints) {
138
223
  if (c.severity !== 'error')
139
224
  continue;
140
- constraintsWithPath.push({ constraint: c, fieldPath: rel });
225
+ constraintsWithPath.push({ constraint: c, fieldPath: rel, sliceFilter });
141
226
  }
142
227
  }
143
228
  // Deduplicate by expression + path
144
229
  const seen = new Set();
145
230
  const uniqueConstraints = constraintsWithPath.filter(c => {
146
- const key = `${c.constraint.expression}|${c.fieldPath}`;
231
+ const key = `${c.constraint.expression}|${c.fieldPath}|${c.sliceFilter ?? ''}`;
147
232
  if (seen.has(key))
148
233
  return false;
149
234
  seen.add(key);
@@ -152,6 +237,7 @@ emitInvariants = false) {
152
237
  // Categorize constraints into supported (potentially translatable to JS)
153
238
  // and unsupported (require a FHIRPath engine). As we add FHIRPath→JS translation,
154
239
  // constraints move from unsupported to supported and get emitted as refinements.
240
+ const sliceFilters = new Map();
155
241
  const unsupportedConstraints = [];
156
242
  const supportedConstraints = [];
157
243
  for (const c of uniqueConstraints) {
@@ -162,9 +248,15 @@ emitInvariants = false) {
162
248
  expression: expr,
163
249
  fieldPath: c.fieldPath,
164
250
  };
251
+ sliceFilters.set(`${expr}|${c.fieldPath}`, c.sliceFilter);
165
252
  const isUnsupported = /(text\.div\b|\.all\(div\b|\$[A-Z]|value\.[A-Z])/.test(expr) ||
166
253
  /\bresolve\b/.test(expr) || /\bmemberOf\b/.test(expr) ||
167
- (/\$this\b/.test(expr) && c.fieldPath.includes('[x]'));
254
+ (/\$this\b/.test(expr) && c.fieldPath.includes('[x]')) ||
255
+ // ele-1 and ext-1 below the root: the validator drops these at depth, because
256
+ // fhirpath.js and HL7 disagree on how they evaluate there, and a schema that keeps
257
+ // them reports a violation the validator never makes — us-core's CarePlan failed on
258
+ // "All FHIR elements must have a @value or children" from one side only.
259
+ (c.fieldPath.split('.').length > 1 && isBaseElementInvariant(expr));
168
260
  if (isUnsupported) {
169
261
  unsupportedConstraints.push(meta);
170
262
  }
@@ -188,7 +280,9 @@ emitInvariants = false) {
188
280
  supportedConstraints.forEach((meta, i) => {
189
281
  if (!emitInvariants)
190
282
  return;
191
- const scoped = scopeConstraintExpression(meta.expression, meta.fieldPath);
283
+ const scoped = scopeConstraintExpression(meta.expression, meta.fieldPath, {
284
+ sliceFilter: sliceFilters.get(`${meta.expression}|${meta.fieldPath}`),
285
+ });
192
286
  if (!scoped)
193
287
  return;
194
288
  const human = escStr(meta.human).replace(/[\r\n]+/g, ' ');
@@ -224,74 +318,40 @@ emitInvariants = false) {
224
318
  refinements.push(`.refine(d => at(d, 'meta', '${subField}') !== undefined, { message: "meta.${subField} must be present", when: () => true })`);
225
319
  }
226
320
  }
227
- // 5. Required ValueSet bindings on CodeableConcept / Coding fields
228
- // When a field has binding.strength === 'required' and we have resolved codes,
229
- // emit a refinement that checks at least one coding.code is in the allowed set.
230
- for (const field of allFields) {
231
- const parts = field.name.split('.');
232
- if (parts.length !== 2 || parts[0] !== rootPrefix)
233
- continue;
234
- if (field.sliceName)
235
- continue;
236
- const type = field.type || field.baseTypeCode || '';
237
- if (type !== 'CodeableConcept' && type !== 'Coding')
238
- continue;
239
- if (field.binding?.strength !== 'required')
240
- continue;
241
- const rel = parts[1];
242
- if (!rel)
243
- continue;
244
- // Skip choice-type placeholders (code[x], value[x], etc.) — the binding applies
245
- // to the resolved variant (e.g. codeCodeableConcept) which we cannot reliably
246
- // identify here. Emitting d.code[x] would produce invalid TypeScript.
247
- if (rel.includes('[x]'))
248
- continue;
249
- if (!directFieldNames.has(rel))
321
+ // 5. Required ValueSet bindings
322
+ //
323
+ // Selection, ValueSet resolution and wording all come from ../requiredBindings, the
324
+ // same module the validator's binding builder uses, so the two sides check the same
325
+ // elements and phrase the finding identically. This used to take only depth-1
326
+ // CodeableConcept/Coding fields with the code list inlined, which left every nested
327
+ // binding — AuditEvent.entity.role, AuditEvent.source.type — reported by the
328
+ // validator alone.
329
+ //
330
+ // The path is walked with elementsAt, which flattens array hops, so a binding under
331
+ // a repeating backbone is reached the same way the validator reaches it. The code
332
+ // list is not copied in: the emitted ValueSet module already exports the predicate
333
+ // both sides call.
334
+ for (const target of selectRequiredBindingTargets(allFields, rootPrefix)) {
335
+ const vs = resolveRequiredBindingValueSet(target.field.binding?.uri, valueSets);
336
+ // A ValueSet with no enumerated concepts is checked against a terminology server
337
+ // by the validator, which a schema cannot do while parsing. Both stay silent.
338
+ if (!vs || 'systemOnly' in vs)
250
339
  continue;
251
- // Resolve codes: prefer field.binding.codes, then fall back to parsed ValueSets
252
- let codes;
253
- let vsDisplayName = '';
254
- if (field.binding.codes && field.binding.codes.length > 0) {
255
- codes = field.binding.codes.map(c => c.code);
256
- vsDisplayName = field.binding.uri?.split('/').pop() || 'ValueSet';
257
- }
258
- else if (field.binding.uri && valueSets) {
259
- const vsUri = stripVersionFromCanonicalUrl(field.binding.uri);
260
- const vs = valueSets.get(vsUri) || valueSets.get(field.binding.uri);
261
- if (vs && vs.concepts.length > 0 && vs.concepts.length <= 200) {
262
- codes = vs.concepts.map(c => c.code);
263
- vsDisplayName = vs.name || vsUri.split('/').pop() || 'ValueSet';
264
- // Import the ValueSet type so the codes are available at runtime
265
- const sanitizedName = sanitizeValueSetName(vs.name);
266
- bindingVsImports.set(sanitizedName, `./valuesets/ValueSet-${sanitizedName}.js`);
267
- }
268
- }
269
- if (!codes || codes.length === 0)
340
+ bindingVsImports.set(vs.validatorFn, vs.importPath);
341
+ const pathLiteral = `[${target.path.map(p => JSON.stringify(p)).join(', ')}]`;
342
+ if (target.kind === 'codings') {
343
+ const message = codingsNotInValueSetMessage(vs.displayName, vs.uri);
344
+ refinements.push(`.refine(d => elementsAt(d, ${pathLiteral}).every((_v) => codings(_v).length === 0 `
345
+ + `|| codings(_v).some((c) => c.code !== undefined && ${vs.validatorFn}(c.code))), `
346
+ + `{ message: "${escStr(message)}", when: () => true })`);
270
347
  continue;
271
- const codesLiteral = `[${codes.map(c => JSON.stringify(c)).join(', ')}]`;
272
- const msgBase = `None of the codings provided are in the value set '${escStr(vsDisplayName)}' (${escStr(field.binding.uri || '')})`;
273
- // `allowed` is a string[], so each candidate code is narrowed to a string
274
- // before the lookup rather than asserted through an `any`.
275
- const isAllowed = (codeExpr) => `${codeExpr} !== undefined && ${codesLiteral}.includes(${codeExpr})`;
276
- if (type === 'CodeableConcept') {
277
- if (field.isArray) {
278
- // Array of CodeableConcept: each entry must have at least one valid coding
279
- refinements.push(`.refine(d => { const _value = at(d, '${rel}'); if (!Array.isArray(_value)) return true; return arr(_value).every((cc) => codings(cc).some((c) => ${isAllowed('c.code')})); }, { message: "${escStr(msgBase)}", when: () => true })`);
280
- }
281
- else {
282
- // Single CodeableConcept: coding must include at least one valid code
283
- refinements.push(`.refine(d => { const _value = at(d, '${rel}'); if (!_value) return true; return codings(_value).some((c) => ${isAllowed('c.code')}); }, { message: "${escStr(msgBase)}", when: () => true })`);
284
- }
285
- }
286
- else {
287
- // Coding field
288
- if (field.isArray) {
289
- refinements.push(`.refine(d => { const _value = at(d, '${rel}'); if (!Array.isArray(_value)) return true; return codings(_value).every((c) => ${isAllowed('c.code')}); }, { message: "${escStr(msgBase)}", when: () => true })`);
290
- }
291
- else {
292
- refinements.push(`.refine(d => { const _value = at(d, '${rel}'); if (!_value) return true; const _c = coding(_value); return ${isAllowed('_c.code')}; }, { message: "${escStr(msgBase)}", when: () => true })`);
293
- }
294
348
  }
349
+ // A `code` leaf holds the code itself; Quantity and Coding carry it on `.code`.
350
+ const message = codeNotInValueSetMessage(vs.displayName, vs.uri);
351
+ refinements.push(`.refine(d => elementsAt(d, ${pathLiteral}).every((_v) => { `
352
+ + `const _c = typeof _v === "string" ? _v : at(_v, 'code'); `
353
+ + `return typeof _c !== "string" || ${vs.validatorFn}(_c); }), `
354
+ + `{ message: "Code ${escStr(message)}", when: () => true })`);
295
355
  }
296
356
  return { refinements, supportedConstraints, unsupportedConstraints, valueSetImports: bindingVsImports, invariantEvaluators };
297
357
  }
@@ -12,7 +12,7 @@ import { mergeFields, capitalize, stripSliceSuffix, firstSegment, stripVersionFr
12
12
  import { buildRefinements } from './zodRefinementBuilder.js';
13
13
  /** Map FHIR primitive types to Zod schema calls */
14
14
  /** Runtime helpers a refinement may reference, imported from ValidatorOptions. */
15
- const ZOD_REFINEMENT_HELPERS = ['at', 'arr', 'coding', 'codings'];
15
+ const ZOD_REFINEMENT_HELPERS = ['at', 'arr', 'coding', 'codings', 'elementsAt'];
16
16
  const ZOD_PRIMITIVE_MAP = {
17
17
  any: 'z.string()',
18
18
  string: 'z.string()',
@@ -262,10 +262,6 @@ emitInvariants = false) {
262
262
  lines.push(`import { ${imp}Schema } from "./${imp}.zod.js";`);
263
263
  }
264
264
  }
265
- // Add ValueSet imports
266
- for (const [vsTypeName, vsPath] of valueSetImports) {
267
- lines.push(`import { ${vsTypeName} } from "${vsPath}";`);
268
- }
269
265
  // Collect refinements (pattern constraints, slice cardinality, FHIRPath invariants, required bindings)
270
266
  // Only emit refinements for Resource profiles (not DataType profiles like Identifier, Coding)
271
267
  // because DataType random() methods don't produce pattern-aware data.
@@ -279,6 +275,13 @@ emitInvariants = false) {
279
275
  valueSetImports.set(name, importPath);
280
276
  }
281
277
  }
278
+ // Emitted after the refinements are built, not before: a binding refinement is what
279
+ // registers the predicate it calls, and writing the import lines first left every
280
+ // one of those calls unresolved — the generated schema referenced
281
+ // isValidHttpVerbCode with no import for it and the package stopped compiling.
282
+ for (const [vsTypeName, vsPath] of valueSetImports) {
283
+ lines.push(`import { ${vsTypeName} } from "${vsPath}";`);
284
+ }
282
285
  // Refinements read dynamic property paths through the same runtime helpers the
283
286
  // generated validators use, rather than casting the parsed object to `any`.
284
287
  // ValidatorOptions.ts is written into every output directory, so the relative
@@ -354,6 +357,17 @@ emitInvariants = false) {
354
357
  function buildZodType(field, valueSets, valueSetImports, complexTypeImports, baseTypeImports) {
355
358
  // Normalize type through the version rules (e.g. http://hl7.org/fhirpath/System.String → string)
356
359
  const rawType = field.type || 'string';
360
+ // An unstated type stays unconstrained. The parser leaves `any` where the element
361
+ // declares no type, chiefly a recursive backbone: FHIR writes those with
362
+ // contentReference and nothing else, so Parameters.parameter.part points back at
363
+ // Parameters.parameter. mapTypeToTS turns `any` into `string` for the interface
364
+ // emitter, where it is harmless, and the schema then rejected every conformant
365
+ // instance — cdex's submit-attachment and mhd's PatchParameters both failed with
366
+ // `expected string, received object`, a finding the validator never makes because it
367
+ // knows the type is unknown. Whatever else applies to the element is emitted
368
+ // separately as a refinement.
369
+ if (rawType === 'any')
370
+ return 'z.unknown()';
357
371
  const fhirType = getRules().mapTypeToTS(rawType);
358
372
  // Only apply ValueSet enum bindings to primitive/code-type fields.
359
373
  // Complex types like CodeableConcept/Coding have bindings that constrain
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "babelfhir-ts",
3
- "version": "1.6.7",
3
+ "version": "1.6.8",
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",
@@ -96,7 +96,7 @@
96
96
  "@vitest/ui": "^4.1.10",
97
97
  "cross-env": "^10.1.0",
98
98
  "eslint": "^10.8.0",
99
- "fhirpath": "^5.1.0",
99
+ "fhirpath": "^5.2.0",
100
100
  "globals": "^17.9.0",
101
101
  "rimraf": "^6.1.3",
102
102
  "semver": "^7.8.5",