babelfhir-ts 1.6.7 → 1.6.9

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
  *
@@ -103,16 +112,43 @@ export const EMITTED_RANGES = {
103
112
  /**
104
113
  * Prefab UI runtime for `--prefab` output.
105
114
  *
106
- * 0.3.0 is the floor: it introduced wire protocol 0.3, where the theme is
107
- * compiled into the top-level `css` array, `stylesheets` carries external
108
- * URLs, and `mode` forces a colour scheme. Emitted renderers pass `theme`
109
- * and spread `PrefabAppOptions` into `PrefabApp`, so they rely on a >= 0.3
115
+ * 0.3.0 introduced wire protocol 0.3, where the theme is compiled into the
116
+ * top-level `css` array, `stylesheets` carries external URLs, and `mode`
117
+ * forces a colour scheme. Emitted renderers pass `theme` and spread
118
+ * `PrefabAppOptions` into `PrefabApp`, so they rely on a >= 0.3
110
119
  * serializer/renderer pair to apply themes. A 0.2.x renderer would silently
111
120
  * drop them.
121
+ *
122
+ * 0.3.10 is the floor because `AutoFormField` gained `options` there, and the
123
+ * emitted form now carries the bound ValueSet's concepts on every coded
124
+ * field. Below it the property is not in the type and is ignored by
125
+ * `autoForm`, so a required-binding field renders as free text: the form
126
+ * still builds, and every constraint the binding states is gone from it.
127
+ */
128
+ prefab: '^0.3.10',
129
+ /**
130
+ * Generated FHIR client base package (`@babelfhir-ts/client-<slug>`).
131
+ *
132
+ * The emitted client is a thin profile-typed layer over this package —
133
+ * `FhirReadClient`/`FhirWriteClient` are extended, `BundleParser` is extended —
134
+ * so the range here decides which transport every generated package actually
135
+ * runs on. `^0.2.0` on a `0.x` version resolves to `>=0.2.0 <0.3.0`, so it did
136
+ * not merely set a floor: it CAPPED every consumer below 0.3.0, and 0.3.0 is
137
+ * where the writer started parsing the `OperationOutcome` of a rejected write.
138
+ * On 0.2.x a failed save reports `PUT Consent/123: 400 Bad Request` and
139
+ * discards the server's account of why — the unresolvable reference, the failed
140
+ * invariant, the scope it would not accept.
141
+ *
142
+ * The cap also multiplied installs: an app depending on both a generated
143
+ * package and `@babelfhir-ts/client-r4@^0.3` gets 0.2.x nested under every
144
+ * generated package alongside its own 0.3.x, so two copies of the client (and
145
+ * of SmartAuth) run in one bundle.
146
+ *
147
+ * Safe to raise by construction: `fhir-client.js` is byte-identical between
148
+ * 0.2.6 and 0.3.2 and `index.js` only ADDS `hasId`, so nothing the emitted
149
+ * layer calls changed shape. Capped below 0.4.0 as usual for a 0.x line.
112
150
  */
113
- prefab: '^0.3.0',
114
- /** Generated FHIR client base package (`@babelfhir-ts/client-<slug>`). */
115
- client: '^0.2.0',
151
+ client: '^0.3.2',
116
152
  /** Shared base zod schemas (`@babelfhir-ts/zod`). */
117
153
  zodBase: '^0.2.0',
118
154
  /** 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;
@@ -28,9 +28,9 @@ function ensurePrefabDir(outputDir) {
28
28
  * Emit `prefab/<InterfaceName>Prefab.ts` for a single profile.
29
29
  * Registers the profile so {@link finalizePrefabOutput} can build the barrel.
30
30
  */
31
- export function writePrefabProfile(outputDir, interfaceName, fields, resourceType) {
31
+ export function writePrefabProfile(outputDir, interfaceName, fields, resourceType, valueSets) {
32
32
  const dir = ensurePrefabDir(outputDir);
33
- const { code, fieldCount } = generatePrefabRenderer(interfaceName, fields, { resourceType });
33
+ const { code, fieldCount } = generatePrefabRenderer(interfaceName, fields, { resourceType, valueSets });
34
34
  const filePath = path.join(dir, `${interfaceName}Prefab.ts`);
35
35
  fs.writeFileSync(filePath, code);
36
36
  let map = emittedProfiles.get(outputDir);
@@ -68,6 +68,17 @@ function emitToRowBody(descriptors) {
68
68
  const lines = descriptors.map(d => ` ${d.name}: ${rowAccessor(d)},`);
69
69
  return lines.join('\n');
70
70
  }
71
+ /**
72
+ * Emit an `AutoFormOption[]` literal for a bound field.
73
+ *
74
+ * The label is omitted when the concept carried no display, because prefab
75
+ * already falls back to the value and restating it would only make the
76
+ * generated file longer.
77
+ */
78
+ function emitOptionsLiteral(options) {
79
+ const entries = options.map(o => o.label ? `{ value: ${JSON.stringify(o.value)}, label: ${JSON.stringify(o.label)} }` : `{ value: ${JSON.stringify(o.value)} }`);
80
+ return `[${entries.join(', ')}]`;
81
+ }
71
82
  /** Emit `autoForm` field descriptors. */
72
83
  function emitFormFields(descriptors) {
73
84
  const lines = [];
@@ -80,6 +91,7 @@ function emitFormFields(descriptors) {
80
91
  continue;
81
92
  if (d.isArray)
82
93
  continue; // v1: scalars only
94
+ const options = d.enumOptions?.length ? d.enumOptions : undefined;
83
95
  let type = 'text';
84
96
  if (d.kind === 'number')
85
97
  type = 'number';
@@ -87,14 +99,13 @@ function emitFormFields(descriptors) {
87
99
  type = 'switch';
88
100
  else if (d.kind === 'date')
89
101
  type = 'date';
90
- else if (d.enumValues && d.enumValues.length > 0)
102
+ else if (options)
91
103
  type = 'select';
92
104
  const extra = [`name: '${d.name}'`, `label: ${JSON.stringify(d.label)}`, `type: '${type}'`];
93
105
  if (d.required)
94
106
  extra.push('required: true');
95
- // NOTE: @maxhealth.tech/prefab AutoFormField does not yet carry an `options`
96
- // property for select inputs; the enum list is elided until the upstream
97
- // type is widened. See prefab-ui#TODO.
107
+ if (options)
108
+ extra.push(`options: ${emitOptionsLiteral(options)}`);
98
109
  lines.push(` { ${extra.join(', ')} },`);
99
110
  seen.add(d.name);
100
111
  }
@@ -206,7 +217,7 @@ function emitBrowserTitleExpr(titleDescriptor, interfaceName) {
206
217
  : `selected.dot('${titleDescriptor.name}')`;
207
218
  }
208
219
  export function generatePrefabRenderer(interfaceName, fields, options) {
209
- const rawDescriptors = fields.flatMap(describeField);
220
+ const rawDescriptors = fields.flatMap(f => describeField(f, options?.valueSets));
210
221
  const descriptors = dedupeDescriptors(rawDescriptors);
211
222
  const resourceType = options?.resourceType;
212
223
  const tableDescriptors = pickTableDescriptors(descriptors);
@@ -8,6 +8,8 @@
8
8
  * This module is deliberately small and data-driven so it can later be
9
9
  * extracted into a shared codegen-spec package (see design notes).
10
10
  */
11
+ import { lookupBoundValueSet } from '../requiredBindings.js';
12
+ import { ctx } from '../../fhir/versionContext.js';
11
13
  /** Lower-cased FHIR primitive type codes. */
12
14
  const PRIMITIVE_TEXT = new Set([
13
15
  'string', 'code', 'uri', 'url', 'canonical', 'id', 'oid', 'uuid', 'markdown', 'xhtml',
@@ -23,6 +25,29 @@ function labelFor(name) {
23
25
  const spaced = name.replace(/([A-Z])/g, ' $1').replace(/[._-]/g, ' ').trim();
24
26
  return spaced.charAt(0).toUpperCase() + spaced.slice(1);
25
27
  }
28
+ /**
29
+ * Build the choice list for a bound field, or undefined when there is none to
30
+ * offer.
31
+ *
32
+ * The parsed ValueSet is preferred over the codes the SD parser sampled onto
33
+ * the binding: it carries the concept displays, and it is the whole set rather
34
+ * than a subset. Both paths are capped at the same `STORE_CODES` threshold the
35
+ * parser and the interface emitter already use to decide a ValueSet is small
36
+ * enough to enumerate — past it a Select stops being a usable control and the
37
+ * field stays a free-text input.
38
+ */
39
+ function selectOptionsFor(field, valueSets) {
40
+ const limit = ctx().valueSetThresholds.STORE_CODES;
41
+ const resolved = lookupBoundValueSet(field.binding?.uri, valueSets);
42
+ const concepts = resolved?.valueSet.concepts;
43
+ if (concepts?.length && concepts.length <= limit) {
44
+ return concepts.map(c => ({ value: c.code, ...(c.display ? { label: c.display } : {}) }));
45
+ }
46
+ const sampled = field.binding?.codes?.filter(c => c.code).map(c => ({ value: c.code }));
47
+ if (sampled?.length && sampled.length <= limit)
48
+ return sampled;
49
+ return undefined;
50
+ }
26
51
  function kindForType(fhirType) {
27
52
  if (PRIMITIVE_TEXT.has(fhirType))
28
53
  return 'text';
@@ -86,7 +111,7 @@ function kindForType(fhirType) {
86
111
  * on the typed interface. Detail views for nested backbone elements are a
87
112
  * follow-up once slicing-aware tree rendering lands.
88
113
  */
89
- export function describeField(field) {
114
+ export function describeField(field, valueSets) {
90
115
  if (field.isForbidden)
91
116
  return [];
92
117
  if (!field.type)
@@ -132,7 +157,7 @@ export function describeField(field) {
132
157
  label: labelFor(variantPropName),
133
158
  valueSetUri: field.binding?.uri,
134
159
  bindingStrength: field.binding?.strength,
135
- enumValues: field.binding?.codes?.map(c => c.code).filter(Boolean),
160
+ enumOptions: selectOptionsFor(field, valueSets),
136
161
  });
137
162
  }
138
163
  return out;
@@ -158,7 +183,7 @@ export function describeField(field) {
158
183
  label: labelFor(bareName),
159
184
  valueSetUri: field.binding?.uri,
160
185
  bindingStrength: field.binding?.strength,
161
- enumValues: field.binding?.codes?.map(c => c.code).filter(Boolean),
186
+ enumOptions: selectOptionsFor(field, valueSets),
162
187
  }];
163
188
  }
164
189
  /**
@@ -0,0 +1,138 @@
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
+ /**
25
+ * Resolve a binding URI to its parsed ValueSet, versioned key first.
26
+ *
27
+ * A binding may cite `…/ValueSet/foo|1.0.0` while the package indexes the
28
+ * ValueSet under its unversioned canonical, so both keys have to be tried.
29
+ * Every emitter that reads concepts off a binding needs the same two lookups,
30
+ * which is why it lives here rather than in each of them.
31
+ */
32
+ export function lookupBoundValueSet(bindingUri, valueSets) {
33
+ if (!bindingUri || !valueSets)
34
+ return undefined;
35
+ const direct = valueSets.get(bindingUri);
36
+ if (direct)
37
+ return { valueSet: direct, resolvedUri: bindingUri };
38
+ if (!bindingUri.includes('|'))
39
+ return undefined;
40
+ const strippedUri = stripVersionFromCanonicalUrl(bindingUri);
41
+ const stripped = valueSets.get(strippedUri);
42
+ return stripped ? { valueSet: stripped, resolvedUri: strippedUri } : undefined;
43
+ }
44
+ export function resolveRequiredBindingValueSet(bindingUri, valueSets) {
45
+ const resolved = lookupBoundValueSet(bindingUri, valueSets);
46
+ if (!resolved || !bindingUri)
47
+ return undefined;
48
+ const { valueSet: vs, resolvedUri } = resolved;
49
+ const displayName = resolvedUri.split('/').pop() || vs.name;
50
+ if (vs.concepts.length === 0) {
51
+ if (vs.systems && vs.systems.length > 0) {
52
+ return { systemOnly: true, displayName, systems: vs.systems };
53
+ }
54
+ return undefined;
55
+ }
56
+ const sanitizedName = sanitizeValueSetName(vs.name);
57
+ return {
58
+ sanitizedName,
59
+ displayName,
60
+ validatorFn: `isValid${sanitizedName}Code`,
61
+ importPath: `./valuesets/ValueSet-${sanitizedName}.js`,
62
+ uri: stripVersionFromCanonicalUrl(bindingUri),
63
+ };
64
+ }
65
+ /** The leaf kind for a bindable type, or undefined when the type carries no code. */
66
+ export function bindingLeafKind(type) {
67
+ if (!type)
68
+ return undefined;
69
+ // string and uri hold the code in the element's own value, exactly like `code`.
70
+ if (type === 'code' || type === 'string' || type === 'uri')
71
+ return 'code';
72
+ if (type === 'Quantity')
73
+ return 'code';
74
+ if (type === 'Coding' || type === 'CodeableConcept')
75
+ return 'codings';
76
+ return undefined;
77
+ }
78
+ /**
79
+ * Select the fields whose required binding is worth checking.
80
+ *
81
+ * Mirrors what the validator's binding builder already accepted, so widening one
82
+ * side cannot introduce a finding the other never makes:
83
+ * - required strength with a binding URI
84
+ * - a bindable type
85
+ * - not a slice, unless the slice name encodes a choice variant (`value[x]:valueCode`),
86
+ * which maps to a concrete property
87
+ * - no `[x]` above the leaf: stripping it leaves a property that does not exist
88
+ */
89
+ export function selectRequiredBindingTargets(fields, rootPrefix) {
90
+ const targets = [];
91
+ const seen = new Set();
92
+ for (const field of fields) {
93
+ if (field.binding?.strength !== 'required' || !field.binding.uri)
94
+ continue;
95
+ if (field.max === 0)
96
+ continue;
97
+ if (field.sliceName) {
98
+ const elemId = field.elementId || field.name || '';
99
+ if (!elemId.includes('[x]:'))
100
+ continue;
101
+ }
102
+ const declaredType = field.type || field.baseTypeCode;
103
+ if (!declaredType || !BINDABLE_TYPES.includes(declaredType))
104
+ continue;
105
+ const kind = bindingLeafKind(declaredType);
106
+ if (!kind)
107
+ continue;
108
+ const relRaw = (field.name || '').replace(new RegExp(`^${rootPrefix}\\.`), '');
109
+ if (!relRaw || relRaw === field.name)
110
+ continue;
111
+ const segments = relRaw.split('.');
112
+ if (segments.slice(0, -1).some(segment => segment.includes('[x]')))
113
+ continue;
114
+ const leaf = segments[segments.length - 1];
115
+ if (!leaf)
116
+ continue;
117
+ // A choice leaf becomes its concrete property: value[x] + Coding → valueCoding.
118
+ const path = leaf.includes('[x]')
119
+ ? [...segments.slice(0, -1), leaf.replace('[x]', declaredType.charAt(0).toUpperCase() + declaredType.slice(1))]
120
+ : segments;
121
+ if (path.some(segment => segment.includes('[x]') || segment.includes(':')))
122
+ continue;
123
+ const key = `${path.join('.')}|${field.binding.uri}`;
124
+ if (seen.has(key))
125
+ continue;
126
+ seen.add(key);
127
+ targets.push({ field, path, kind });
128
+ }
129
+ return targets;
130
+ }
131
+ /** The finding both emitters report when no coding in the element is in the ValueSet. */
132
+ export function codingsNotInValueSetMessage(displayName, uri) {
133
+ return `None of the codings provided are in the value set '${displayName}' (${uri}), and a coding from this value set is required)`;
134
+ }
135
+ /** The finding both emitters report when a single code is not in the ValueSet. */
136
+ export function codeNotInValueSetMessage(displayName, uri) {
137
+ return `does not exist in the value set '${displayName}' (${uri}), but the binding is of strength 'required'`;
138
+ }
@@ -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[])