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 +1 -1
- package/out/src/generator/core/generatedPackageDeps.js +44 -8
- package/out/src/generator/core/utils.js +31 -0
- package/out/src/generator/emitters/prefab/prefabEmitter.js +2 -2
- package/out/src/generator/emitters/prefab/prefabRenderer.js +16 -5
- package/out/src/generator/emitters/prefab/prefabTypeMapper.js +28 -3
- package/out/src/generator/emitters/requiredBindings.js +138 -0
- package/out/src/generator/emitters/validator/closedSlicingValidation.js +81 -3
- package/out/src/generator/emitters/validator/sliceBackboneValidation.js +100 -6
- package/out/src/generator/emitters/validator/sliceDelegation.js +3 -12
- package/out/src/generator/emitters/validator/sliceValidatorGenerator.js +2 -2
- package/out/src/generator/emitters/validator/validatorExpressions.js +26 -0
- package/out/src/generator/emitters/validator/validatorGenerator.js +6 -33
- package/out/src/generator/emitters/validator/validatorRuntime.js +0 -47
- package/out/src/generator/emitters/validator/validatorTemplates.js +4 -12
- package/out/src/generator/emitters/zod/zodRefinementBuilder.js +156 -96
- package/out/src/generator/emitters/zod/zodSchemaGenerator.js +19 -5
- package/out/src/generator/sdProcessor.js +1 -1
- package/package.json +3 -3
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 (`>=
|
|
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: '>=
|
|
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
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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[])
|