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 +1 -1
- package/out/src/generator/core/generatedPackageDeps.js +33 -3
- package/out/src/generator/core/utils.js +31 -0
- package/out/src/generator/emitters/requiredBindings.js +124 -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/package.json +2 -2
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
|
*
|
|
@@ -111,8 +120,29 @@ export const EMITTED_RANGES = {
|
|
|
111
120
|
* drop them.
|
|
112
121
|
*/
|
|
113
122
|
prefab: '^0.3.0',
|
|
114
|
-
/**
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
157
|
-
|
|
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:
|
|
383
|
-
//
|
|
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(', ')}
|
|
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 {
|
|
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 {
|
|
6
|
-
import { scopeConstraintExpression } from '../validator/validatorExpressions.js';
|
|
7
|
-
import { stripSliceSuffix
|
|
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
|
|
128
|
+
if (!relPath || relPath === baseName)
|
|
90
129
|
continue;
|
|
91
|
-
|
|
130
|
+
const segments = relPath.split('.');
|
|
131
|
+
if (segments.some(segment => segment.includes('[x]') || segment.includes(':')))
|
|
92
132
|
continue;
|
|
93
|
-
|
|
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 (
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
.
|
|
107
|
-
|
|
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
|
-
|
|
165
|
+
emit(checks);
|
|
113
166
|
}
|
|
114
167
|
}
|
|
115
168
|
// patternCoding discriminator
|
|
116
|
-
if (
|
|
117
|
-
|
|
118
|
-
|
|
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 (!
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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.
|
|
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.
|
|
99
|
+
"fhirpath": "^5.2.0",
|
|
100
100
|
"globals": "^17.9.0",
|
|
101
101
|
"rimraf": "^6.1.3",
|
|
102
102
|
"semver": "^7.8.5",
|