babelfhir-ts 1.5.19 → 1.5.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/out/src/cli/updateCommand.js +19 -12
  2. package/out/src/generator/core/constants.js +19 -0
  3. package/out/src/generator/core/extensionSdReader.js +99 -0
  4. package/out/src/generator/core/utils.js +97 -3
  5. package/out/src/generator/emitters/class/classTemplate.js +8 -2
  6. package/out/src/generator/emitters/class/sliceElementDefaults.js +5 -3
  7. package/out/src/generator/emitters/client/clientGenerator.js +22 -6
  8. package/out/src/generator/emitters/client/smartAuthGenerator.js +4 -1
  9. package/out/src/generator/emitters/interface/backboneSliceTyping.js +15 -4
  10. package/out/src/generator/emitters/interface/importManager.js +28 -2
  11. package/out/src/generator/emitters/interface/interfaceFieldProcessor.js +21 -16
  12. package/out/src/generator/emitters/interface/interfaceFieldUtils.js +16 -0
  13. package/out/src/generator/emitters/interface/postProcessExtensions.js +1 -0
  14. package/out/src/generator/emitters/validator/closedSlicingValidation.js +146 -0
  15. package/out/src/generator/emitters/validator/sliceDelegation.js +450 -0
  16. package/out/src/generator/emitters/validator/sliceExtensionValidation.js +200 -35
  17. package/out/src/generator/emitters/validator/sliceValidatorGenerator.js +130 -299
  18. package/out/src/generator/emitters/validator/sliceValidatorUtils.js +89 -16
  19. package/out/src/generator/emitters/validator/subExtensionScope.js +100 -0
  20. package/out/src/generator/emitters/validator/validatorBindingBuilder.js +113 -237
  21. package/out/src/generator/emitters/validator/validatorBindingLeafEmitters.js +13 -4
  22. package/out/src/generator/emitters/validator/validatorConstraintBuilders.js +104 -63
  23. package/out/src/generator/emitters/validator/validatorExpressions.js +54 -0
  24. package/out/src/generator/emitters/validator/validatorFieldBuilders.js +492 -46
  25. package/out/src/generator/emitters/validator/validatorGenerator.js +170 -169
  26. package/out/src/generator/emitters/validator/validatorRuntime.js +279 -0
  27. package/out/src/generator/emitters/validator/validatorTemplates.js +186 -54
  28. package/out/src/generator/emitters/zod/zodRefinementBuilder.js +29 -19
  29. package/out/src/generator/emitters/zod/zodSchemaGenerator.js +16 -1
  30. package/out/src/generator/index.js +21 -11
  31. package/out/src/generator/parser/packageParser.js +1 -8
  32. package/out/src/generator/parser/sdParser.js +1 -0
  33. package/out/src/generator/sdProcessor.js +303 -30
  34. package/out/src/generator/sdProcessorHelpers.js +1 -1
  35. package/out/src/generator/sdProcessorRequiredFields.js +120 -0
  36. package/out/src/main.js +37 -11
  37. package/package.json +3 -1
  38. package/parity-matrix.json +18 -18
@@ -0,0 +1,279 @@
1
+ /**
2
+ * The runtime module every generated package emits as ValidatorOptions.ts.
3
+ *
4
+ * Kept apart from the emitter that orchestrates validate() so the emitted source
5
+ * can be read as source, rather than as a template buried in control flow.
6
+ */
7
+ /**
8
+ * Runtime helpers every generated validator may import from ValidatorOptions.
9
+ * Single source of truth for the emitted import list.
10
+ */
11
+ export const HELPER_NAMES = ['at', 'arr', 'node', 'elements', 'elementsAt', 'coding', 'codings', 'asResource', 'refsAt'];
12
+ /**
13
+ * Returns the content of ValidatorOptions.ts — the shared runtime options type
14
+ * plus the element-probing helpers that every generated validateX() imports.
15
+ *
16
+ * The helpers exist so validators can read arbitrary child elements off an
17
+ * open-world FHIR resource *without* widening to `any`. `any` would disable
18
+ * checking for every downstream access, which contradicts the type safety this
19
+ * generator promises; `at()`/`arr()` return `unknown`, so the emitted code must
20
+ * still narrow before it can do anything with a value.
21
+ */
22
+ export function generateValidatorOptionsContent() {
23
+ return `/** Runtime options for generated FHIR validators. */
24
+ export interface ValidatorOptions {
25
+ /** Terminology server URL for memberOf() evaluation (e.g. "https://tx.fhir.org/r4"). */
26
+ terminologyUrl?: string;
27
+ /** FHIR server URL for resolve() evaluation (e.g. "https://hapi.fhir.org/baseR4"). */
28
+ fhirServerUrl?: string;
29
+ /** HTTP headers keyed by server URL — used for auth with terminology or FHIR servers. */
30
+ httpHeaders?: Record<string, string>;
31
+ /** AbortSignal for cancelling long-running async evaluations. */
32
+ signal?: AbortSignal;
33
+ /** Debug tracing callback — invoked for every FHIRPath trace() call. */
34
+ traceFn?: (value: unknown, label: string) => void;
35
+ /**
36
+ * Resolution map for references, keyed by both fullUrl and \`Type/id\`, valued by the
37
+ * resourceType each key resolves to.
38
+ *
39
+ * A Bundle validator populates this for the resources it contains and passes it to
40
+ * the validators it delegates to, which is what lets a profile check its own
41
+ * reference target types. Absent it, target types are not checked at all — matching
42
+ * the reference validator, which reports a wrong target only when it can resolve
43
+ * the reference, and stays silent on a dangling one even when the type is spelled
44
+ * out in the reference string.
45
+ */
46
+ bundleEntries?: Readonly<Record<string, string>>;
47
+ }
48
+
49
+ /** Property bag view of a FHIR element. Reads yield \`unknown\`, never \`any\`. */
50
+ export type FhirNode = { readonly [key: string]: unknown };
51
+
52
+ /**
53
+ * View an arbitrary value as a property bag, for key enumeration
54
+ * (\`Object.entries(node(x))\`) and repeated child reads.
55
+ *
56
+ * Non-objects become \`{}\` so callers never need a null check.
57
+ */
58
+ export function node(value: unknown): FhirNode {
59
+ return typeof value === 'object' && value !== null ? (value as FhirNode) : {};
60
+ }
61
+
62
+ /**
63
+ * Read a (possibly nested) child element off an arbitrary FHIR value.
64
+ *
65
+ * Returns \`undefined\` as soon as the path leaves an object, so it is safe on
66
+ * absent, null, primitive and array-valued intermediates alike. This is the one
67
+ * place the untyped boundary is crossed, replacing a cast at every call site.
68
+ *
69
+ * @example at(resource, 'channel', 'payload', 'extension')
70
+ */
71
+ export function at(value: unknown, ...path: readonly string[]): unknown {
72
+ let current = value;
73
+ for (const key of path) {
74
+ if (typeof current !== 'object' || current === null) return undefined;
75
+ current = (current as FhirNode)[key];
76
+ }
77
+ return current;
78
+ }
79
+
80
+ /**
81
+ * View an element as an iterable list of children.
82
+ *
83
+ * Exactly equivalent to the \`if (Array.isArray(x)) for (const c of x)\` guard it
84
+ * replaces: non-array values (absent, null, or a scalar) yield no iterations, so
85
+ * substituting this helper does not change which elements get validated.
86
+ */
87
+ export function arr(value: unknown): readonly unknown[] {
88
+ return Array.isArray(value) ? value : [];
89
+ }
90
+
91
+ /** A coding as read by the emitted slice, pattern and binding checks. */
92
+ export interface CodingShape {
93
+ code?: string;
94
+ system?: string;
95
+ display?: string;
96
+ version?: string;
97
+ }
98
+
99
+ /**
100
+ * The properties emitted element checks actually read off a FHIR element.
101
+ *
102
+ * Deliberately structural and all-optional: slice discriminators match on
103
+ * \`url\`/\`code\`/\`system\`, pattern checks walk \`coding\`, and choice-type checks
104
+ * look for \`value*\` keys. Typing the element this way keeps every access checked,
105
+ * where a \`(item: any)\` callback parameter would switch checking off entirely.
106
+ */
107
+ export interface ElementShape {
108
+ /** Codings when the element is itself a CodeableConcept. */
109
+ coding?: CodingShape[];
110
+ /**
111
+ * Everything else reads as \`unknown\`.
112
+ *
113
+ * Discriminator properties are deliberately *not* declared with concrete types:
114
+ * a FHIR element's \`code\` may be a \`code\` primitive on one profile and a
115
+ * \`CodeableConcept\` on another, so committing to \`string\` would break the
116
+ * \`.coding\` reads (and vice versa). \`unknown\` still supports \`===\` comparisons
117
+ * and \`typeof\` narrowing; use \`at()\` / \`codings()\` to go deeper.
118
+ */
119
+ readonly [key: string]: unknown;
120
+ }
121
+
122
+ /**
123
+ * Like {@link arr} but typed for element inspection, so emitted callbacks need no
124
+ * parameter annotation and stay type-checked.
125
+ *
126
+ * Non-arrays yield no iterations, matching the \`Array.isArray\` guard this
127
+ * replaces.
128
+ */
129
+ export function elements(value: unknown): readonly ElementShape[] {
130
+ return Array.isArray(value) ? (value as ElementShape[]) : [];
131
+ }
132
+
133
+ /**
134
+ * Collect every element sitting at a property path, flattening arrays at each hop.
135
+ *
136
+ * One path therefore covers both single and repeating elements
137
+ * (Composition.author is 1..*, Composition.subject 0..1) and any mix of the two,
138
+ * which is what \`at()\` cannot do — it stops the moment the path meets an array.
139
+ * Absent and null intermediates simply contribute nothing.
140
+ */
141
+ export function elementsAt(value: unknown, path: readonly string[]): unknown[] {
142
+ let current: unknown[] = [value];
143
+ for (const key of path) {
144
+ const next: unknown[] = [];
145
+ for (const item of current) {
146
+ const child = at(item, key);
147
+ if (Array.isArray(child)) next.push(...child);
148
+ else if (child !== undefined && child !== null) next.push(child);
149
+ }
150
+ current = next;
151
+ }
152
+ return current;
153
+ }
154
+
155
+ /**
156
+ * Collect the literal references found at a property path.
157
+ *
158
+ * Only string \`reference\` values are returned; a Reference carrying just an
159
+ * identifier names no target.
160
+ */
161
+ export function refsAt(value: unknown, path: readonly string[]): string[] {
162
+ const found: string[] = [];
163
+ for (const item of elementsAt(value, path)) {
164
+ const reference = at(item, 'reference');
165
+ if (typeof reference === 'string') found.push(reference);
166
+ }
167
+ return found;
168
+ }
169
+
170
+ /**
171
+ * Read the coded properties an element carries *itself*, as opposed to
172
+ * {@link codings} which walks into a CodeableConcept's \`.coding\`.
173
+ *
174
+ * This is the shape binding checks need for elements typed \`Coding\` or
175
+ * \`Quantity\`: both expose \`code\`/\`system\` directly. Values that are not strings
176
+ * are dropped rather than asserted through, so the result is genuinely
177
+ * \`string | undefined\` and callers can concatenate and compare it without a cast.
178
+ * A malformed non-string code is reported by the structural type checks instead.
179
+ */
180
+ export function coding(value: unknown): CodingShape {
181
+ const source = node(value);
182
+ const result: CodingShape = {};
183
+ if (typeof source.code === 'string') result.code = source.code;
184
+ if (typeof source.system === 'string') result.system = source.system;
185
+ if (typeof source.display === 'string') result.display = source.display;
186
+ if (typeof source.version === 'string') result.version = source.version;
187
+ return result;
188
+ }
189
+
190
+ /**
191
+ * View a value as a list of codings.
192
+ *
193
+ * Accepts either a CodeableConcept (reads \`.coding\`) or an already-bare array of
194
+ * Codings, which is what slice discriminators need since the same discriminator
195
+ * path can be typed either way across profiles. Anything else yields \`[]\`.
196
+ */
197
+ export function codings(value: unknown): readonly CodingShape[] {
198
+ if (Array.isArray(value)) return value as CodingShape[];
199
+ const nested = at(value, 'coding');
200
+ if (Array.isArray(nested)) return nested as CodingShape[];
201
+ // A lone Coding. Elements typed Coding 0..1 are neither an array nor a
202
+ // CodeableConcept — AuditEvent.entity.type in R4 is the common case — and used
203
+ // to fall through to [], so every slice discriminated on one matched nothing.
204
+ // That silently disabled the whole within-slice block: the IHE audit profiles
205
+ // reported "No elements matched required slice" while their max/min child
206
+ // constraints were never evaluated at all.
207
+ if (value !== null && typeof value === 'object') {
208
+ const lone = value as CodingShape;
209
+ if (lone.system !== undefined || lone.code !== undefined) return [lone];
210
+ }
211
+ return [];
212
+ }
213
+
214
+ /**
215
+ * Hand an untyped element to a validator expecting a concrete profile type.
216
+ *
217
+ * Cross-profile delegation reaches its target through \`at()\`, which yields
218
+ * \`unknown\`; the callee wants e.g. \`USCorePatient\`. \`T\` is inferred from the
219
+ * parameter position, so the conversion needs no type import and no
220
+ * \`Parameters<typeof validateX>[0]\` — the latter breaks outright in packages that
221
+ * define a FHIR resource named \`Parameters\`, which shadows the utility type, and
222
+ * it rejects array-valued fields whose element type is the delegate's.
223
+ *
224
+ * Widening happens here once rather than at each call site.
225
+ */
226
+ export function asResource<T>(value: unknown): T {
227
+ return value as T;
228
+ }
229
+
230
+ /**
231
+ * The XHTML element subset FHIR permits in Narrative.div (R4 Narrative).
232
+ * Anything outside this set — script, style, object, embed, iframe, form,
233
+ * input — is what txt-1 exists to reject.
234
+ */
235
+ const NARRATIVE_ELEMENTS = new Set([
236
+ 'a', 'abbr', 'acronym', 'b', 'bdo', 'big', 'blockquote', 'br', 'caption',
237
+ 'cite', 'code', 'col', 'colgroup', 'dd', 'dfn', 'div', 'dl', 'dt', 'em',
238
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p',
239
+ 'pre', 'q', 'samp', 'small', 'span', 'strong', 'sub', 'sup', 'table',
240
+ 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'tt', 'ul', 'var',
241
+ ]);
242
+
243
+ /** Void elements that carry meaning without text content. */
244
+ const NARRATIVE_VOID_ELEMENTS = /<\\s*(?:img|br|hr)\\b/i;
245
+
246
+ /**
247
+ * txt-1 / txt-2: the narrative must hold some non-whitespace content and use
248
+ * only the permitted XHTML subset, with no event-handler attributes.
249
+ *
250
+ * fhirpath.js does not implement \`htmlChecks()\`, and an unimplemented function
251
+ * throws out of the whole evaluation, so every profile carrying txt-1 lost all of
252
+ * its findings. Supplying the function is what keeps the invariant evaluated.
253
+ */
254
+ export function narrativeHtmlChecks(values: readonly unknown[]): boolean {
255
+ for (const value of values) {
256
+ if (typeof value !== 'string') continue;
257
+
258
+ // Only the permitted elements, opening or closing.
259
+ for (const match of value.matchAll(/<\\s*\\/?\\s*([a-zA-Z][a-zA-Z0-9]*)/g)) {
260
+ if (!NARRATIVE_ELEMENTS.has(match[1].toLowerCase())) return false;
261
+ }
262
+ // No inline event handlers (onclick, onload, …).
263
+ if (/\\son[a-zA-Z]+\\s*=/.test(value)) return false;
264
+ // Some non-whitespace content, unless a void element carries it.
265
+ const text = value.replace(/<[^>]*>/g, '').replace(/&[a-zA-Z#0-9]+;/g, ' ');
266
+ if (text.trim().length === 0 && !NARRATIVE_VOID_ELEMENTS.test(value)) return false;
267
+ }
268
+ return true;
269
+ }
270
+
271
+ /**
272
+ * FHIRPath functions fhirpath.js lacks, supplied via its userInvocationTable.
273
+ * Passed as \`userInvocationTable\` in the evaluation options.
274
+ */
275
+ export const fhirpathUserFunctions = {
276
+ htmlChecks: { fn: narrativeHtmlChecks, arity: { 0: [] } },
277
+ };
278
+ `;
279
+ }
@@ -1,3 +1,95 @@
1
+ /**
2
+ * Emit the reference target-type check for one profile.
3
+ *
4
+ * Driven by a table rather than a block per element: us-core alone declares ~480
5
+ * reference elements with restricted targets, and inlining a check each would bloat
6
+ * the emitted file for no gain.
7
+ *
8
+ * The check consults options.bundleEntries and does nothing without it, which is what
9
+ * matches the reference validator: it reports a wrong target only when the reference
10
+ * resolves, and stays silent on a dangling `Patient/id-x` even though the type is
11
+ * spelled out there. Parsing the type out of the string instead reports the cases it
12
+ * ignores and misses the `urn:uuid:` ones it reports.
13
+ */
14
+ export function buildReferenceTargetTypeValidation(fields, profileUrlToType) {
15
+ const resolveTargetType = (url) => {
16
+ const bare = url.split('|')[0];
17
+ const lastSegment = bare.split('/').pop() || '';
18
+ if (/^[A-Z][A-Za-z]+$/.test(lastSegment))
19
+ return lastSegment;
20
+ return profileUrlToType?.get(bare) ?? profileUrlToType?.get(url);
21
+ };
22
+ const rows = [];
23
+ const seen = new Set();
24
+ for (const field of fields) {
25
+ if (field.sliceName)
26
+ continue;
27
+ const rel = (field.name || '').replace(/^[^.]+\./, '');
28
+ if (!rel || rel.includes('[x]') || seen.has(rel))
29
+ continue;
30
+ const targets = (field.typeOptions ?? [])
31
+ .filter(option => option.code === 'Reference')
32
+ .flatMap(option => option.targetProfileUrls ?? []);
33
+ if (targets.length === 0)
34
+ continue;
35
+ const resolved = targets.map(resolveTargetType);
36
+ // An unresolved target, or one permitting any resource, leaves the allowed set
37
+ // unknown — nothing can be rejected against it.
38
+ if (resolved.some(type => !type))
39
+ continue;
40
+ const allowed = [...new Set(resolved)];
41
+ if (allowed.includes('Resource') || allowed.includes('DomainResource'))
42
+ continue;
43
+ seen.add(rel);
44
+ rows.push(` { path: ${JSON.stringify(rel.split('.'))}, allowed: ${JSON.stringify(allowed)} },`);
45
+ }
46
+ if (rows.length === 0)
47
+ return '';
48
+ return `
49
+ // Reference target types, checked only where a reference resolves
50
+ if (options?.bundleEntries) {
51
+ const _rtTargets: ReadonlyArray<{ path: string[]; allowed: string[] }> = [
52
+ ${rows.join('\n')}
53
+ ];
54
+ for (const _rt of _rtTargets) {
55
+ for (const _rtRef of refsAt(resource, _rt.path)) {
56
+ const _rtFound = options.bundleEntries[_rtRef];
57
+ if (_rtFound !== undefined && !_rt.allowed.includes(_rtFound)) {
58
+ errors.push("Invalid Resource target type. Found " + _rtFound + ", but expected one of ([" + _rt.allowed.join(', ') + "])");
59
+ }
60
+ }
61
+ }
62
+ }
63
+ `;
64
+ }
65
+ /**
66
+ * Emit the resolution map a Bundle passes to the validators it delegates to.
67
+ *
68
+ * Keyed by fullUrl and by \`Type/id\`, since a reference may use either form. Only
69
+ * Bundles can populate it, so only Bundles emit it.
70
+ */
71
+ export function buildBundleEntryResolution(baseResourceType) {
72
+ if (baseResourceType !== 'Bundle') {
73
+ // Outside a Bundle there is nothing to resolve against, so delegates inherit the
74
+ // caller's options unchanged — including any map the caller was itself given.
75
+ return `
76
+ const _delegateOptions = options;
77
+ `;
78
+ }
79
+ return `
80
+ // Resolution map for delegated validators: fullUrl and Type/id → resourceType
81
+ const _bundleEntries: Record<string, string> = {};
82
+ for (const _be of arr(at(resource, 'entry'))) {
83
+ const _beType = at(_be, 'resource', 'resourceType');
84
+ if (typeof _beType !== 'string') continue;
85
+ const _beFullUrl = at(_be, 'fullUrl');
86
+ if (typeof _beFullUrl === 'string') _bundleEntries[_beFullUrl] = _beType;
87
+ const _beId = at(_be, 'resource', 'id');
88
+ if (typeof _beId === 'string') _bundleEntries[_beType + '/' + _beId] = _beType;
89
+ }
90
+ const _delegateOptions = { ...options, bundleEntries: _bundleEntries };
91
+ `;
92
+ }
1
93
  /**
2
94
  * Generate Bundle reference resolution validation code.
3
95
  * Only applies to Bundle profiles — verifies all references within entries are resolvable.
@@ -93,6 +185,45 @@ export function generateContainedRefValidation(baseResourceType) {
93
185
  }
94
186
  };
95
187
  _checkContainedRef(_res);
188
+
189
+ // dom-3: every contained resource must be referenced from the containing
190
+ // resource. Evaluated here rather than through its FHIRPath form: that
191
+ // expression builds the fragment as '#'+id, so an absent id makes the whole
192
+ // predicate empty and the invariant silently passes, where HL7 reports the
193
+ // resource as unreferenced (its message names it 'null').
194
+ if (Array.isArray(_res.contained) && _res.contained.length > 0) {
195
+ const _fragments = new Set<string>();
196
+ const _collectFragments = (obj: unknown): void => {
197
+ if (!obj || typeof obj !== 'object') return;
198
+ if (Array.isArray(obj)) { obj.forEach(_collectFragments); return; }
199
+ for (const [_k, _v] of Object.entries(obj as Record<string, unknown>)) {
200
+ if (_k === 'reference' && typeof _v === 'string' && _v.startsWith('#')) {
201
+ _fragments.add(_v.substring(1));
202
+ } else {
203
+ _collectFragments(_v);
204
+ }
205
+ }
206
+ };
207
+ _collectFragments(_res);
208
+ // The invariant is satisfied either way round, so a contained resource that
209
+ // points back at its container with the bare '#' reference also passes.
210
+ const _refersToContainer = (obj: unknown): boolean => {
211
+ if (!obj || typeof obj !== 'object') return false;
212
+ if (Array.isArray(obj)) return obj.some(_refersToContainer);
213
+ for (const [_k, _v] of Object.entries(obj as Record<string, unknown>)) {
214
+ if (_k === 'reference' && _v === '#') return true;
215
+ if (_refersToContainer(_v)) return true;
216
+ }
217
+ return false;
218
+ };
219
+ for (const _c of _res.contained as Array<Record<string, unknown>>) {
220
+ const _cid = _c && typeof _c.id === 'string' ? _c.id : undefined;
221
+ if (_refersToContainer(_c)) continue;
222
+ if (_cid === undefined || !_fragments.has(_cid)) {
223
+ errors.push("The contained resource '" + (_cid ?? 'null') + "' is not referenced to from elsewhere in the containing resource nor does it refer to the containing resource (dom-3)");
224
+ }
225
+ }
226
+ }
96
227
  }
97
228
  `;
98
229
  }
@@ -125,6 +256,10 @@ export function generateExtensionStructuralValidation() {
125
256
  const e = ext as Record<string, unknown>;
126
257
  if (typeof e.url !== 'string' || !e.url) {
127
258
  errors.push('Extension.url is required in order to identify, use and validate the extension');
259
+ } else if (e.url.includes('|')) {
260
+ // Extension.url identifies the definition and is not version-pinned;
261
+ // a '|version' suffix belongs on a canonical reference, not here.
262
+ errors.push('The extension URL must not contain a version');
128
263
  }
129
264
  // ext-1: Must have either extensions or value[x], not both
130
265
  const hasNestedExt = Array.isArray(e.extension) && e.extension.length > 0;
@@ -147,69 +282,66 @@ export function generateExtensionStructuralValidation() {
147
282
  }
148
283
  `;
149
284
  }
285
+ /** Per-aggregation error text and the predicate that marks a reference unresolvable. */
286
+ const AGGREGATION_RULES = {
287
+ // A bundled reference resolves only inside a Bundle, so a relative
288
+ // ResourceType/id reference is unresolvable in standalone validation.
289
+ bundled: {
290
+ message: 'Bundle reference not found: ',
291
+ violates: (ref) => `/^[A-Za-z]+\\//.test(${ref})`,
292
+ },
293
+ // A contained reference must be a local #fragment. Fragments that do not
294
+ // resolve are already reported by generateContainedRefValidation, so this
295
+ // only catches references that are not contained at all.
296
+ contained: {
297
+ message: 'Contained reference not found: ',
298
+ violates: (ref) => `!${ref}.startsWith('#')`,
299
+ },
300
+ };
150
301
  /**
151
- * Generate validation for reference fields declared with aggregation=["bundled"].
302
+ * Generate validation for reference fields whose profile restricts aggregation.
152
303
  *
153
- * The HL7 validator enforces that bundled-aggregation references resolve within
154
- * a Bundle. When validating a standalone resource (not inside a Bundle) any
155
- * relative reference (ResourceType/id) on such a field is unresolvable and must
156
- * be reported as an error to match HL7 parity.
304
+ * The HL7 validator enforces that such references resolve within the bundle or
305
+ * resource; when validating a standalone resource, a reference of the wrong form
306
+ * is unresolvable and must be reported to match HL7 parity.
157
307
  *
158
- * Bundle profiles handle this via {@link generateBundleRefValidation} instead.
308
+ * Bundle profiles handle the bundled case via {@link generateBundleRefValidation}.
159
309
  */
160
- export function generateBundledAggregationValidation(paths) {
310
+ export function generateAggregationRefValidation(paths) {
161
311
  if (paths.length === 0)
162
312
  return '';
163
- const checks = [];
164
- for (const { segments, isArray } of paths) {
165
- const _checkRef = (accessor) => `typeof ${accessor}.reference === 'string' && /^[A-Za-z]+\\//.test(${accessor}.reference)`;
166
- if (segments.length === 1) {
167
- const f = segments[0];
168
- if (isArray[0]) {
169
- checks.push(`
170
- if (Array.isArray(_res.${f})) {
171
- for (const _item of _res.${f}) {
172
- if (_item && ${_checkRef('_item')}) {
173
- errors.push('Bundle reference not found: ' + _item.reference);
174
- }
175
- }
176
- }`);
177
- }
178
- else {
179
- checks.push(`
180
- if (_res.${f} && ${_checkRef('_res.' + f)}) {
181
- errors.push('Bundle reference not found: ' + _res.${f}.reference);
182
- }`);
183
- }
313
+ /**
314
+ * Emit the descent into `segments`, iterating any array level, then check the
315
+ * leaf reference. Uses at()/arr() so every hop stays typed as unknown.
316
+ * Consecutive single-valued segments collapse into one variadic at() call.
317
+ */
318
+ const walk = (path, index, expr, pending, indent, id) => {
319
+ const { segments, isArray, kind } = path;
320
+ const asArgs = (parts) => parts.map(p => `, '${p}'`).join('');
321
+ if (index === segments.length) {
322
+ const { message, violates } = AGGREGATION_RULES[kind];
323
+ const refVar = `_aggRef${id}`;
324
+ return `
325
+ ${indent}const ${refVar} = at(${expr}${asArgs([...pending, 'reference'])});
326
+ ${indent}if (typeof ${refVar} === 'string' && ${violates(refVar)}) {
327
+ ${indent} errors.push('${message}' + ${refVar});
328
+ ${indent}}`;
184
329
  }
185
- else if (segments.length === 2) {
186
- const parent = segments[0];
187
- const child = segments[1];
188
- if (isArray[0]) {
189
- checks.push(`
190
- if (Array.isArray(_res.${parent})) {
191
- for (const _item of _res.${parent}) {
192
- if (_item?.${child} && ${_checkRef('_item.' + child)}) {
193
- errors.push('Bundle reference not found: ' + _item.${child}.reference);
194
- }
195
- }
196
- }`);
197
- }
198
- else {
199
- checks.push(`
200
- if (_res.${parent}?.${child} && ${_checkRef('_res.' + parent + '.' + child)}) {
201
- errors.push('Bundle reference not found: ' + _res.${parent}.${child}.reference);
202
- }`);
203
- }
330
+ const segment = segments[index];
331
+ if (isArray[index]) {
332
+ const itemVar = `_aggItem${id}${index}`;
333
+ return `
334
+ ${indent}for (const ${itemVar} of arr(at(${expr}${asArgs([...pending, segment])}))) {${walk(path, index + 1, itemVar, [], indent + ' ', `${id}${index}`)}
335
+ ${indent}}`;
204
336
  }
205
- // Paths deeper than 2 levels are extremely rare in FHIR profiles
206
- }
337
+ return walk(path, index + 1, expr, [...pending, segment], indent, `${id}${index}`);
338
+ };
339
+ const checks = paths.map((path, i) => walk(path, 0, 'resource', [], ' ', String(i)));
207
340
  return `
208
- // Bundled aggregation: references on fields with aggregation=["bundled"] must be
209
- // resolvable within a Bundle. For standalone validation, relative references are errors.
210
- {
211
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
212
- const _res = resource as unknown as Record<string, any>;${checks.join('')}
341
+ // Restrictive reference aggregation: a reference on a field limited to
342
+ // bundled/contained must resolve within the bundle/resource. For standalone
343
+ // validation, a reference of the wrong form is an error.
344
+ {${checks.join('')}
213
345
  }
214
346
  `;
215
347
  }