babelfhir-ts 1.5.20 → 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.
@@ -114,21 +114,28 @@ async function promptInstallRedirect(packageFilter, confirm) {
114
114
  return { redirectInstall: packageFilter };
115
115
  return {};
116
116
  }
117
- /** Default Y/N prompt reading from stdin. */
118
- function defaultConfirm(question) {
119
- return new Promise((resolve) => {
120
- process.stdout.write(question);
121
- const rl = (async () => {
122
- const { createInterface } = await import('readline');
123
- return createInterface({ input: process.stdin, output: process.stdout });
124
- })();
125
- rl.then(r => {
126
- r.question('', (answer) => {
127
- r.close();
117
+ /**
118
+ * Default Y/N prompt reading from stdin.
119
+ *
120
+ * The readline import is awaited rather than `.then`-ed inside a `new Promise`:
121
+ * that form dropped a rejection on the floor, and since nothing ever called
122
+ * `resolve`, a failed import left the caller awaiting a promise that could not
123
+ * settle. Declining is the safe answer when the prompt cannot be shown.
124
+ */
125
+ async function defaultConfirm(question) {
126
+ process.stdout.write(question);
127
+ const { createInterface } = await import('readline');
128
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
129
+ try {
130
+ return await new Promise((resolve) => {
131
+ rl.question('', (answer) => {
128
132
  resolve(!answer || answer.toLowerCase().startsWith('y'));
129
133
  });
130
134
  });
131
- });
135
+ }
136
+ finally {
137
+ rl.close();
138
+ }
132
139
  }
133
140
  // ── Discovery ───────────────────────────────────────────────────────────────
134
141
  /** Find all lib/ directories starting from startDir. Non-recursive returns only startDir/lib. */
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Reading what an Extension StructureDefinition declares about its own contents.
3
+ *
4
+ * A complex extension carries its requirements in its own SD. A profile that
5
+ * references one re-declares the sub-extension paths as mustSupport markers but
6
+ * copies none of the constraints, so anything read only from the profile's field
7
+ * model misses them entirely — KBV_PR_ERP_PracticeSupply's
8
+ * `extension:Kostentraeger.extension:Kostentraegertyp.value[x]:valueCoding` has no
9
+ * binding, while KBV_EX_ERP_PracticeSupply_Payor binds it required to
10
+ * KBV_VS_FOR_Payor_type.
11
+ */
12
+ /** `Extension.extension:<name>` with no further path below it. */
13
+ const SUB_SLICE_ID = /^Extension\.extension:([^.:]+)$/;
14
+ /**
15
+ * The elements to read constraints from.
16
+ *
17
+ * Prefer the snapshot, but fall back to the differential: several published
18
+ * extension SDs ship without one (KBV_EX_ERP_PracticeSupply_Payor among them), and
19
+ * requiring a snapshot meant they resolved to no metadata at all.
20
+ */
21
+ export function extensionElements(sd) {
22
+ const els = sd?.snapshot?.element ?? sd?.differential?.element;
23
+ return els?.length ? els : undefined;
24
+ }
25
+ /** Read a binding off an element, normalising the ValueSet reference to a bare uri. */
26
+ export function readBinding(el) {
27
+ const binding = el?.binding;
28
+ if (!binding?.strength || !binding.valueSet)
29
+ return undefined;
30
+ const raw = typeof binding.valueSet === 'string'
31
+ ? binding.valueSet
32
+ : binding.valueSet?.reference;
33
+ if (!raw)
34
+ return undefined;
35
+ return { strength: binding.strength, uri: raw.split('|')[0] };
36
+ }
37
+ /** Type codes declared on an element. */
38
+ function typeCodes(el) {
39
+ return (el?.type ?? []).map(t => t.code).filter((c) => !!c);
40
+ }
41
+ /**
42
+ * `min` on the unsliced `Extension.extension` element.
43
+ *
44
+ * This is what HL7 reports as "Extension.extension: minimum required = N", and it is
45
+ * not the sum of the slice minimums — the SD states it directly.
46
+ */
47
+ export function readSubExtensionMin(els) {
48
+ const el = els.find(e => (e.id ?? e.path) === 'Extension.extension' && !e.sliceName);
49
+ return typeof el?.min === 'number' ? el.min : undefined;
50
+ }
51
+ /**
52
+ * Collect the sub-extension slices a complex extension declares.
53
+ *
54
+ * A slice with no resolvable `url` is dropped rather than reported under its slice
55
+ * name: the url is how a member is recognised at runtime, and without it any check
56
+ * would be guessing which member to apply itself to.
57
+ */
58
+ export function collectSubExtensionSlices(els) {
59
+ const byId = new Map();
60
+ for (const el of els) {
61
+ const id = el.id ?? el.path;
62
+ if (id)
63
+ byId.set(id, el);
64
+ }
65
+ const out = [];
66
+ for (const el of els) {
67
+ const id = el.id ?? el.path;
68
+ const match = id ? SUB_SLICE_ID.exec(id) : null;
69
+ if (!match)
70
+ continue;
71
+ const sliceName = el.sliceName ?? match[1];
72
+ const urlEl = byId.get(`Extension.extension:${sliceName}.url`);
73
+ const url = typeof urlEl?.fixedUri === 'string' ? urlEl.fixedUri : undefined;
74
+ if (!url)
75
+ continue;
76
+ const valueEl = byId.get(`Extension.extension:${sliceName}.value[x]`);
77
+ let valueTypes = typeCodes(valueEl);
78
+ let binding = readBinding(valueEl);
79
+ // The binding usually sits on the typed slice (…value[x]:valueCoding) rather than
80
+ // on value[x] itself, which is where the Payor extension puts it.
81
+ for (const code of valueTypes.length ? valueTypes : ['']) {
82
+ const suffix = code ? code.charAt(0).toUpperCase() + code.slice(1) : '';
83
+ const typedEl = suffix ? byId.get(`Extension.extension:${sliceName}.value[x]:value${suffix}`) : undefined;
84
+ if (!typedEl)
85
+ continue;
86
+ binding = binding ?? readBinding(typedEl);
87
+ if (valueTypes.length === 0)
88
+ valueTypes = typeCodes(typedEl);
89
+ }
90
+ out.push({
91
+ sliceName,
92
+ url,
93
+ min: typeof el.min === 'number' ? el.min : 0,
94
+ valueTypes,
95
+ binding,
96
+ });
97
+ }
98
+ return out;
99
+ }
@@ -181,6 +181,48 @@ export function alignFields(newFields, oldFields) {
181
181
  // Return the original oldFields and the aligned newFields
182
182
  return { alignedNewFields, oldFields };
183
183
  }
184
+ /** A type the parser could not pin down from the element itself. */
185
+ function isUnstatedType(type) {
186
+ return !type || type === 'any';
187
+ }
188
+ /**
189
+ * Carry forward what a differential element left unstated.
190
+ *
191
+ * A differential says only what it changes, so an element that constrains
192
+ * cardinality alone carries no `type` and an element that constrains `min` alone
193
+ * carries no `max`. Both are inherited rather than defaulted away.
194
+ */
195
+ function inheritUnstatedFromBase(field, base) {
196
+ if (!base)
197
+ return field;
198
+ const merged = { ...field };
199
+ if (isUnstatedType(merged.type) && !isUnstatedType(base.type)) {
200
+ merged.type = base.type;
201
+ }
202
+ if (!merged.baseTypeCode && base.baseTypeCode) {
203
+ merged.baseTypeCode = base.baseTypeCode;
204
+ }
205
+ // `max` absent means the differential did not narrow the upper bound, so the
206
+ // base's bound — and with it whether the element repeats — still holds.
207
+ if (merged.max === undefined && base.max !== undefined) {
208
+ merged.max = base.max;
209
+ merged.isArray = base.isArray;
210
+ }
211
+ if (!merged.typeOptions?.length && base.typeOptions?.length) {
212
+ merged.typeOptions = base.typeOptions;
213
+ }
214
+ // An extension slice is recognised at runtime by the canonical its profile
215
+ // names. A derived profile that only tightens cardinality does not restate it —
216
+ // KBV_PR_ERP_Medication_PZN redeclares extension:Kategorie and extension:Normgroesse
217
+ // with no type.profile, while KBV_PR_Base_Medication gives them
218
+ // KBV_EX_Base_Medication_Type and .../normgroesse. Dropping the base's left two
219
+ // of four slices unidentifiable, so the closed-slicing check on
220
+ // Medication.extension could not build its allowed set and stayed silent.
221
+ if (!merged.profileUrls?.length && base.profileUrls?.length) {
222
+ merged.profileUrls = base.profileUrls;
223
+ }
224
+ return merged;
225
+ }
184
226
  export function mergeFields(newFields, oldFields) {
185
227
  const fieldMap = new Map();
186
228
  // Create unique key for field - use elementId if available (includes slice names for child elements)
@@ -208,9 +250,22 @@ export function mergeFields(newFields, oldFields) {
208
250
  }
209
251
  fieldMap.set(key, field);
210
252
  }
211
- // Overwrite or add newFields to the map
253
+ // Overwrite or add newFields to the map.
254
+ //
255
+ // A differential element states only what it *changes*. Replacing the base
256
+ // field wholesale therefore threw away everything it stayed silent about:
257
+ // KBV constrains Medication.amount.numerator.value to max=0 and says nothing
258
+ // about its type, so the merged field read `any` and every type-aware check
259
+ // skipped it — HL7 reported "The value 'Example value' is not a valid decimal"
260
+ // where we said nothing. The same silence on `max` left Medication.ingredient
261
+ // with isArray=false despite being 0..* in the base, so paths beneath it were
262
+ // never walked.
263
+ //
264
+ // Only genuinely absent properties are filled in; anything the differential
265
+ // states still wins.
212
266
  for (const field of newFields) {
213
- fieldMap.set(getFieldKey(field), field);
267
+ const key = getFieldKey(field);
268
+ fieldMap.set(key, inheritUnstatedFromBase(field, fieldMap.get(key)));
214
269
  }
215
270
  // Return the merged fields as an array
216
271
  return Array.from(fieldMap.values());
@@ -26,7 +26,7 @@ export function renderClassTemplate(params) {
26
26
  const helperImportList = Array.from(helperImports).join(', ');
27
27
  const hasVsBindings = Object.keys(valueSetBindings).length > 0;
28
28
  return `
29
- import { validate${interfaceName}, ${interfaceName} } from "./${interfaceName}.js";
29
+ import { validate${interfaceName}, type ${interfaceName} } from "./${interfaceName}.js";
30
30
  import type { ValidatorOptions } from "./ValidatorOptions.js";
31
31
  // Only import helpers actually referenced below (dynamic based on required fields)
32
32
  import { ${helperImportList} } from "./random/index.js";
@@ -55,7 +55,13 @@ function safeClone<T>(value: T): T {
55
55
 
56
56
  export class ${className} {
57
57
  ${generatePatternMetadata(fieldPatterns, forbiddenFields, valueSetBindings, fieldOrder, codeableConceptBindings)}
58
- constructor(private resource: ${interfaceName}) {
58
+ private resource: ${interfaceName};
59
+
60
+ // Declared and assigned rather than written as a parameter property, which
61
+ // erasableSyntaxOnly bans: it emits real assignment code from what reads as a
62
+ // type annotation. Generated output is held to the same strictness as src/.
63
+ constructor(resource: ${interfaceName}) {
64
+ this.resource = resource;
59
65
  ${constructorMetaProfile}
60
66
  }
61
67
  /** Validate current resource instance. */
@@ -176,11 +176,16 @@ export type FetchFn = typeof globalThis.fetch;
176
176
  export class FhirResourceReader<T, S extends SearchParams = SearchParams> implements FhirResourceSearcher<T, S> {
177
177
  private readonly fetchFn: FetchFn;
178
178
 
179
+ public readonly baseUrl: string;
180
+ public readonly resourceType: string;
181
+
179
182
  constructor(
180
- public readonly baseUrl: string,
181
- public readonly resourceType: string,
183
+ baseUrl: string,
184
+ resourceType: string,
182
185
  fetchFn?: FetchFn,
183
186
  ) {
187
+ this.baseUrl = baseUrl;
188
+ this.resourceType = resourceType;
184
189
  this.fetchFn = fetchFn ?? globalThis.fetch.bind(globalThis);
185
190
  }
186
191
 
@@ -321,11 +326,16 @@ import type { FetchFn } from "./resource-reader.js";
321
326
  export class FhirResourceWriterImpl<T> implements FhirResourceWriter<T> {
322
327
  private readonly fetchFn: FetchFn;
323
328
 
329
+ public readonly baseUrl: string;
330
+ public readonly resourceType: string;
331
+
324
332
  constructor(
325
- public readonly baseUrl: string,
326
- public readonly resourceType: string,
333
+ baseUrl: string,
334
+ resourceType: string,
327
335
  fetchFn?: FetchFn,
328
336
  ) {
337
+ this.baseUrl = baseUrl;
338
+ this.resourceType = resourceType;
329
339
  this.fetchFn = fetchFn ?? globalThis.fetch.bind(globalThis);
330
340
  }
331
341
 
@@ -451,7 +461,11 @@ export type BundleEntry<T extends FhirResource = FhirResource> = {
451
461
  * FHIR Bundle Parser - provides type-safe parsing and filtering of FHIR Bundles
452
462
  */
453
463
  export class BundleParser {
454
- constructor(private readonly bundle: Bundle<FhirResource>) {}
464
+ private readonly bundle: Bundle<FhirResource>;
465
+
466
+ constructor(bundle: Bundle<FhirResource>) {
467
+ this.bundle = bundle;
468
+ }
455
469
 
456
470
  /**
457
471
  * Get all resources from the bundle
@@ -659,11 +673,13 @@ ${writerMethods}
659
673
  export class FhirClient {
660
674
  private readonly readClient: FhirReadClient;
661
675
  private readonly writeClient: FhirWriteClient;
676
+ public readonly baseUrl: string;
662
677
 
663
678
  constructor(
664
- public readonly baseUrl: string,
679
+ baseUrl: string,
665
680
  fetchFn?: FetchFn,
666
681
  ) {
682
+ this.baseUrl = baseUrl;
667
683
  this.readClient = new FhirReadClient(baseUrl, fetchFn);
668
684
  this.writeClient = new FhirWriteClient(baseUrl, fetchFn);
669
685
  }
@@ -167,7 +167,10 @@ export class SmartAuth {
167
167
  private readonly storage: Storage;
168
168
  private readonly keys: ReturnType<typeof storageKeys>;
169
169
 
170
- constructor(public readonly config: SmartConfig) {
170
+ public readonly config: SmartConfig;
171
+
172
+ constructor(config: SmartConfig) {
173
+ this.config = config;
171
174
  this.storage = config.storage ?? sessionStorage;
172
175
  this.keys = storageKeys(config.storagePrefix ?? "smart_");
173
176
  }
@@ -135,7 +135,7 @@ function collectSliceGroups(fields, baseFields, baseResource) {
135
135
  if (!isEligibleBackbonePath(field.baseTypeCode || field.type || '', path, baseResource))
136
136
  continue;
137
137
  const childFields = gatherChildFields(fields, path);
138
- const slicingRules = field.slicingRules || 'open';
138
+ const slicingRules = field.slicingRules ?? 'open';
139
139
  seenPaths.add(path);
140
140
  groups.push({ parentField: field, sliceFields, childFields, slicingRules });
141
141
  }
@@ -160,7 +160,7 @@ function collectSliceGroups(fields, baseFields, baseResource) {
160
160
  if (sliceFields.length === 0)
161
161
  continue;
162
162
  const childFields = gatherChildFields(fields, path);
163
- const slicingRules = parentField.slicingRules || 'open';
163
+ const slicingRules = parentField.slicingRules ?? 'open';
164
164
  seenPaths.add(path);
165
165
  groups.push({ parentField, sliceFields, childFields, slicingRules });
166
166
  }
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { capitalize, sanitizeIdentifier, toPascalCase, stripVersionFromCanonicalUrl } from '../../core/utils.js';
6
6
  import { logger } from '../../../logger.js';
7
+ import {} from './importManager.js';
7
8
  import { getRules } from '../../fhir/versionContext.js';
8
9
  export function postProcessExtensions(params) {
9
10
  const { interfaces, interfaceName, baseResource, newFields, baseFieldByLastSegment, importManager, existingProfiles, profileUrlToName, generatedAliasTypes, findInterfaceIndex, inferBackboneType, addTypeImport } = params;
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Closed slicing enforcement.
3
+ *
4
+ * `slicing.rules = 'closed'` means an instance element matching none of the declared
5
+ * slices is an error. Nothing enforced it: `slicingRules` was read in the interface
6
+ * emitter through an `as unknown as` cast and never written by any parser, so every
7
+ * slicing looked open. HL7 reports these as
8
+ * "This element does not match any known slice defined in the profile <url>
9
+ * and slicing is CLOSED: <path>"
10
+ * and they were the whole `entry` category of the remaining random-parity failures
11
+ * (davinci-dtr, kbv-erp — issue #138).
12
+ *
13
+ * Two discriminator shapes are enforced:
14
+ * - `{value, url}` on an extension slot — the member's url must be declared
15
+ * - `{type, $this}` on a choice element — the chosen type must be declared
16
+ *
17
+ * Deliberately limited to slicings introduced at an unsliced path. A slicing declared
18
+ * *inside* another slice (`Medication.extension:Kategorie.value[x]`) shares its
19
+ * unsliced name with its siblings, so the field model cannot tell the sets apart:
20
+ * merging them applies one slice's allowed types to every member and reports a
21
+ * conformant resource. Enforcing those needs slice-scoped traversal and is left off
22
+ * rather than approximated.
23
+ */
24
+ import { stripVersionFromCanonicalUrl } from '../../core/utils.js';
25
+ const CHOICE_SUFFIX = '[x]';
26
+ /** The url an instance of this extension slice carries (pinned `url` child, else profile). */
27
+ function instanceUrl(slice, fields) {
28
+ const sliceElemId = (slice.elementId || slice.name).replace(/^[^.]+\./, '');
29
+ const sliceFullId = slice.sliceName && !sliceElemId.includes(':')
30
+ ? `${sliceElemId}:${slice.sliceName}`
31
+ : sliceElemId;
32
+ for (const f of fields) {
33
+ const childId = (f.elementId || f.name).replace(/^[^.]+\./, '');
34
+ if (childId === sliceFullId + '.url' && typeof f.fixedValue === 'string')
35
+ return f.fixedValue;
36
+ }
37
+ return slice.profileUrls?.length ? stripVersionFromCanonicalUrl(slice.profileUrls[0]) : undefined;
38
+ }
39
+ /**
40
+ * The property name a choice slice occupies.
41
+ *
42
+ * A choice slice is conventionally named after the property (`valueCoding`), so the
43
+ * slice name is used when it already starts with the base property; the declared
44
+ * type is the fallback (`value[x]` + type `Coding` → `valueCoding`).
45
+ */
46
+ function choiceProperty(baseProp, slice) {
47
+ if (slice.sliceName?.startsWith(baseProp))
48
+ return slice.sliceName;
49
+ const code = slice.typeOptions?.[0]?.code ?? slice.baseTypeCode ?? slice.type;
50
+ if (!code)
51
+ return undefined;
52
+ return `${baseProp}${code.charAt(0).toUpperCase()}${code.slice(1)}`;
53
+ }
54
+ /** Classify a closed slicing, or undefined when it is not one this module enforces. */
55
+ function classify(leaf) {
56
+ if (leaf === 'extension' || leaf === 'modifierExtension')
57
+ return 'extension-url';
58
+ if (leaf.endsWith(CHOICE_SUFFIX))
59
+ return 'choice-type';
60
+ return undefined;
61
+ }
62
+ /**
63
+ * Bind `_csElem` to every element at `segments`, flattening array hops.
64
+ *
65
+ * Always the flattening walk, never a plain `at()`: the field model's array flags do
66
+ * not always agree with the instance (Medication.ingredient is 0..* yet was read as a
67
+ * single element), and `elementsAt` is correct for both shapes.
68
+ */
69
+ function walk(segments, body) {
70
+ const pathArgs = segments.map(s => `'${s}'`).join(', ');
71
+ return `\n for (const _csElem of elementsAt(resource, [${pathArgs}])) {${body}\n }`;
72
+ }
73
+ /**
74
+ * Emit closed-slicing checks for every element that introduces a closed slicing.
75
+ */
76
+ export function generateClosedSlicingValidations(fields, profileUrl, baseResourceType) {
77
+ const out = [];
78
+ const seen = new Set();
79
+ for (const field of fields) {
80
+ if (field.slicingRules !== 'closed' || field.sliceName)
81
+ continue;
82
+ // Address the slicing by its element id so slice qualifiers stay visible.
83
+ const elemId = (field.elementId || field.name).replace(/^[^.]+\./, '');
84
+ if (!elemId || seen.has(elemId))
85
+ continue;
86
+ // A slicing nested inside another slice cannot be addressed from the root.
87
+ if (elemId.includes(':'))
88
+ continue;
89
+ const segments = elemId.split('.');
90
+ const leaf = segments[segments.length - 1];
91
+ const kind = classify(leaf);
92
+ if (!kind)
93
+ continue;
94
+ // A choice segment above the leaf is not a JSON property name, so the walk
95
+ // cannot reach past it.
96
+ if (segments.slice(0, -1).some(s => s.endsWith(CHOICE_SUFFIX)))
97
+ continue;
98
+ const slices = fields.filter(f => f.sliceName && f.name === field.name);
99
+ if (slices.length === 0)
100
+ continue;
101
+ const fhirPath = `${baseResourceType ?? 'Resource'}.${elemId}`;
102
+ const profileRef = profileUrl ?? (baseResourceType ?? '');
103
+ const message = `This element does not match any known slice defined in the profile ${profileRef} and slicing is CLOSED: ${fhirPath}`;
104
+ if (kind === 'extension-url') {
105
+ const urls = slices.map(s => instanceUrl(s, fields)).filter((u) => !!u);
106
+ // Every slice must be identifiable, or a conformant member gets reported.
107
+ if (urls.length !== slices.length)
108
+ continue;
109
+ const allowed = `[${[...new Set(urls)].map(u => `"${u}"`).join(', ')}]`;
110
+ // A member with no url matches no slice either, and HL7 reports it: every
111
+ // discriminator here is `<canonical> in url`, so an absent url satisfies none
112
+ // of them. Firing only on a *present* url meant the member KBV's random
113
+ // Medication carries — `{extension:[…]}` with no url at all — went unreported
114
+ // while HL7 named all four slices it failed to match. The missing url itself
115
+ // is a separate finding (Extension.url: minimum required = 1) that both
116
+ // validators already agree on; this is the slicing violation on top of it.
117
+ out.push(`
118
+ // Closed slicing on ${fhirPath}: only the declared extension urls are allowed${walk(segments, `
119
+ const _csUrl = at(_csElem, 'url');
120
+ if (typeof _csUrl !== 'string' || !${allowed}.includes(_csUrl)) {
121
+ errors.push("${message}" + (typeof _csUrl === 'string' ? " (url = " + _csUrl + ")" : " (no url)"));
122
+ }`)}`);
123
+ seen.add(elemId);
124
+ continue;
125
+ }
126
+ // choice-type: the slot is `foo[x]`, so an instance writes `fooCoding` etc.
127
+ const baseProp = leaf.slice(0, -CHOICE_SUFFIX.length);
128
+ const props = slices.map(s => choiceProperty(baseProp, s)).filter((p) => !!p);
129
+ if (props.length !== slices.length)
130
+ continue;
131
+ const allowedProps = `[${[...new Set(props)].map(p => `"${p}"`).join(', ')}]`;
132
+ // The choice lives on the parent element — `foo[x]` is not a property name.
133
+ const body = `
134
+ const _csKey = Object.keys(node(_csElem)).find((k) => k.startsWith("${baseProp}") && k !== "${baseProp}");
135
+ if (_csKey !== undefined && !${allowedProps}.includes(_csKey)) {
136
+ errors.push("${message}.ofType(" + _csKey.slice(${baseProp.length}).toLowerCase() + ")");
137
+ }`;
138
+ const parentSegments = segments.slice(0, -1);
139
+ out.push(`
140
+ // Closed slicing on ${fhirPath}: only the declared types are allowed${parentSegments.length === 0
141
+ ? `\n {\n const _csElem = resource;${body}\n }`
142
+ : walk(parentSegments, body)}`);
143
+ seen.add(elemId);
144
+ }
145
+ return out;
146
+ }