babelfhir-ts 1.5.20 → 1.6.0

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 (42) hide show
  1. package/README.md +6 -3
  2. package/out/src/cli/updateCommand.js +19 -12
  3. package/out/src/generator/core/extensionSdReader.js +100 -0
  4. package/out/src/generator/core/utils.js +126 -2
  5. package/out/src/generator/emitters/class/classGeneratorHelpers.js +10 -2
  6. package/out/src/generator/emitters/class/classTemplate.js +8 -2
  7. package/out/src/generator/emitters/class/sliceElementDefaults.js +10 -1
  8. package/out/src/generator/emitters/client/clientGenerator.js +22 -6
  9. package/out/src/generator/emitters/client/smartAuthGenerator.js +4 -1
  10. package/out/src/generator/emitters/interface/backboneSliceTyping.js +2 -2
  11. package/out/src/generator/emitters/interface/interfaceFieldProcessor.js +15 -12
  12. package/out/src/generator/emitters/interface/interfaceFieldUtils.js +6 -3
  13. package/out/src/generator/emitters/interface/postProcessExtensions.js +9 -2
  14. package/out/src/generator/emitters/interface/processNestedField.js +4 -6
  15. package/out/src/generator/emitters/namingsystem/namingSystemGenerator.js +136 -0
  16. package/out/src/generator/emitters/prefab/prefabEmitter.js +2 -15
  17. package/out/src/generator/emitters/prefab/prefabRenderer.js +2 -19
  18. package/out/src/generator/emitters/validator/closedSlicingValidation.js +149 -0
  19. package/out/src/generator/emitters/validator/fhirpathStubInstaller.js +7 -1
  20. package/out/src/generator/emitters/validator/sliceBackboneValidation.js +27 -12
  21. package/out/src/generator/emitters/validator/sliceExtensionValidation.js +200 -35
  22. package/out/src/generator/emitters/validator/sliceValidatorGenerator.js +112 -40
  23. package/out/src/generator/emitters/validator/sliceValidatorUtils.js +5 -1
  24. package/out/src/generator/emitters/validator/subExtensionScope.js +108 -0
  25. package/out/src/generator/emitters/validator/validatorConstraintBuilders.js +13 -1
  26. package/out/src/generator/emitters/validator/validatorExpressions.js +44 -0
  27. package/out/src/generator/emitters/validator/validatorFieldBuilders.js +249 -24
  28. package/out/src/generator/emitters/validator/validatorGenerator.js +34 -41
  29. package/out/src/generator/emitters/validator/validatorRuntime.js +30 -9
  30. package/out/src/generator/emitters/valueset/valueSetGenerator.js +6 -10
  31. package/out/src/generator/emitters/zod/zodRefinementBuilder.js +39 -3
  32. package/out/src/generator/emitters/zod/zodSchemaGenerator.js +36 -8
  33. package/out/src/generator/generationHelpers.js +59 -7
  34. package/out/src/generator/index.js +111 -58
  35. package/out/src/generator/parser/packageParser.js +76 -0
  36. package/out/src/generator/parser/sdParser.js +1 -0
  37. package/out/src/generator/parser/vsParser.js +4 -2
  38. package/out/src/generator/sdProcessor.js +237 -40
  39. package/out/src/generator/sdProcessorRequiredFields.js +32 -7
  40. package/out/src/main.js +5 -0
  41. package/package.json +13 -9
  42. package/parity-matrix.json +6 -0
package/README.md CHANGED
@@ -61,9 +61,9 @@ Every push to `develop` and `main` runs the parity pipeline, which validates gen
61
61
  4. Generates `empty()` and `random()` test resources for every profile
62
62
  5. Validates those resources against two independent external FHIR validators
63
63
 
64
- ### Validated Implementation Guides (30)
64
+ ### Validated Implementation Guides (31)
65
65
 
66
- Every parity run validates **30 real-world IGs** (28 R4, 2 R5). The list is not duplicated here — it is generated from the parity suite's own source of truth into [`parity-matrix.json`](./parity-matrix.json).
66
+ Every parity run validates **31 real-world IGs** (29 R4, 2 R5). The list is not duplicated here — it is generated from the parity suite's own source of truth into [`parity-matrix.json`](./parity-matrix.json).
67
67
 
68
68
  Each validated IG is also published as a ready-to-install package — `@max-health-inc/fhir-<name>` — so you do not have to generate it yourself:
69
69
 
@@ -111,6 +111,7 @@ The `firely` job validates generated resources using the [Firely SDK Validator](
111
111
  ![AU Core (Australia)](https://img.shields.io/endpoint?url=https://max-health-inc.github.io/BabelFHIR-TS/badges/badge-au-core.json)
112
112
  ![PIXm](https://img.shields.io/endpoint?url=https://max-health-inc.github.io/BabelFHIR-TS/badges/badge-pixm.json)
113
113
  ![MHD](https://img.shields.io/endpoint?url=https://max-health-inc.github.io/BabelFHIR-TS/badges/badge-mhd.json)
114
+ ![vitals](https://img.shields.io/endpoint?url=https://max-health-inc.github.io/BabelFHIR-TS/badges/badge-vitals.json)
114
115
 
115
116
  </details>
116
117
 
@@ -148,10 +149,11 @@ The `hl7` job validates the same artifacts using the [official HL7 FHIR Validato
148
149
  ![AU Core (Australia)](https://img.shields.io/endpoint?url=https://max-health-inc.github.io/BabelFHIR-TS/badges/badge-hl7-au-core.json)
149
150
  ![PIXm](https://img.shields.io/endpoint?url=https://max-health-inc.github.io/BabelFHIR-TS/badges/badge-hl7-pixm.json)
150
151
  ![MHD](https://img.shields.io/endpoint?url=https://max-health-inc.github.io/BabelFHIR-TS/badges/badge-hl7-mhd.json)
152
+ ![vitals](https://img.shields.io/endpoint?url=https://max-health-inc.github.io/BabelFHIR-TS/badges/badge-hl7-vitals.json)
151
153
 
152
154
  </details>
153
155
 
154
- > External-validator badges cover the 28 R4/R4B IGs. R5 packages are generated and typechecked, but neither external validator publishes R5 badges yet.
156
+ > External-validator badges cover the 29 R4/R4B IGs. R5 packages are generated and typechecked, but neither external validator publishes R5 badges yet.
155
157
 
156
158
  > Terminology validation requires a tx server. The pipeline uses `--tx-server https://tx.fhir.org/{r4|r5}` (matching the package's FHIR version) during generation to expand ValueSets and produce valid codes.
157
159
 
@@ -223,6 +225,7 @@ Options:
223
225
  --no-classes Only generate interfaces and types (skip class generation)
224
226
  --no-client Skip FHIR client generation (client generated by default)
225
227
  --schema <format> Generate schema files alongside outputs (supported: zod)
228
+ --zod-invariants Emit FHIRPath invariants into Zod schemas (fuller checking, slower parse)
226
229
  --dicomweb Generate DICOMweb helpers typed to ImagingStudy profiles in the IG
227
230
  --prefab Generate Prefab UI render functions per profile (@maxhealth.tech/prefab)
228
231
  --prefab-styles <path> Path to styles module (.ts/.js) copied into the generated prefab/ folder
@@ -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,100 @@
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
+ import { stripVersionFromCanonicalUrl } from './utils.js';
13
+ /** `Extension.extension:<name>` with no further path below it. */
14
+ const SUB_SLICE_ID = /^Extension\.extension:([^.:]+)$/;
15
+ /**
16
+ * The elements to read constraints from.
17
+ *
18
+ * Prefer the snapshot, but fall back to the differential: several published
19
+ * extension SDs ship without one (KBV_EX_ERP_PracticeSupply_Payor among them), and
20
+ * requiring a snapshot meant they resolved to no metadata at all.
21
+ */
22
+ export function extensionElements(sd) {
23
+ const els = sd?.snapshot?.element ?? sd?.differential?.element;
24
+ return els?.length ? els : undefined;
25
+ }
26
+ /** Read a binding off an element, normalising the ValueSet reference to a bare uri. */
27
+ export function readBinding(el) {
28
+ const binding = el?.binding;
29
+ if (!binding?.strength || !binding.valueSet)
30
+ return undefined;
31
+ const raw = typeof binding.valueSet === 'string'
32
+ ? binding.valueSet
33
+ : binding.valueSet?.reference;
34
+ if (!raw)
35
+ return undefined;
36
+ return { strength: binding.strength, uri: stripVersionFromCanonicalUrl(raw) };
37
+ }
38
+ /** Type codes declared on an element. */
39
+ function typeCodes(el) {
40
+ return (el?.type ?? []).map(t => t.code).filter((c) => !!c);
41
+ }
42
+ /**
43
+ * `min` on the unsliced `Extension.extension` element.
44
+ *
45
+ * This is what HL7 reports as "Extension.extension: minimum required = N", and it is
46
+ * not the sum of the slice minimums — the SD states it directly.
47
+ */
48
+ export function readSubExtensionMin(els) {
49
+ const el = els.find(e => (e.id ?? e.path) === 'Extension.extension' && !e.sliceName);
50
+ return typeof el?.min === 'number' ? el.min : undefined;
51
+ }
52
+ /**
53
+ * Collect the sub-extension slices a complex extension declares.
54
+ *
55
+ * A slice with no resolvable `url` is dropped rather than reported under its slice
56
+ * name: the url is how a member is recognised at runtime, and without it any check
57
+ * would be guessing which member to apply itself to.
58
+ */
59
+ export function collectSubExtensionSlices(els) {
60
+ const byId = new Map();
61
+ for (const el of els) {
62
+ const id = el.id ?? el.path;
63
+ if (id)
64
+ byId.set(id, el);
65
+ }
66
+ const out = [];
67
+ for (const el of els) {
68
+ const id = el.id ?? el.path;
69
+ const match = id ? SUB_SLICE_ID.exec(id) : null;
70
+ if (!match)
71
+ continue;
72
+ const sliceName = el.sliceName ?? match[1];
73
+ const urlEl = byId.get(`Extension.extension:${sliceName}.url`);
74
+ const url = typeof urlEl?.fixedUri === 'string' ? urlEl.fixedUri : undefined;
75
+ if (!url)
76
+ continue;
77
+ const valueEl = byId.get(`Extension.extension:${sliceName}.value[x]`);
78
+ let valueTypes = typeCodes(valueEl);
79
+ let binding = readBinding(valueEl);
80
+ // The binding usually sits on the typed slice (…value[x]:valueCoding) rather than
81
+ // on value[x] itself, which is where the Payor extension puts it.
82
+ for (const code of valueTypes.length ? valueTypes : ['']) {
83
+ const suffix = code ? code.charAt(0).toUpperCase() + code.slice(1) : '';
84
+ const typedEl = suffix ? byId.get(`Extension.extension:${sliceName}.value[x]:value${suffix}`) : undefined;
85
+ if (!typedEl)
86
+ continue;
87
+ binding = binding ?? readBinding(typedEl);
88
+ if (valueTypes.length === 0)
89
+ valueTypes = typeCodes(typedEl);
90
+ }
91
+ out.push({
92
+ sliceName,
93
+ url,
94
+ min: typeof el.min === 'number' ? el.min : 0,
95
+ valueTypes,
96
+ binding,
97
+ });
98
+ }
99
+ return out;
100
+ }
@@ -14,6 +14,33 @@ export function stripVersionFromCanonicalUrl(url) {
14
14
  const pipeIndex = url.indexOf('|');
15
15
  return pipeIndex >= 0 ? url.substring(0, pipeIndex) : url;
16
16
  }
17
+ /**
18
+ * An element path with any slice suffix removed: `identifier:NPI` -> `identifier`.
19
+ *
20
+ * FHIR element ids name the slice after a colon, and most path comparisons want
21
+ * the unsliced form. Written with indexOf rather than `split(':')[0]` so it
22
+ * returns a string: the split form is an indexing expression, which is only
23
+ * `string | undefined` to the compiler even though it can never be empty.
24
+ */
25
+ export function stripSliceSuffix(path) {
26
+ if (!path)
27
+ return path;
28
+ const colonIndex = path.indexOf(':');
29
+ return colonIndex >= 0 ? path.substring(0, colonIndex) : path;
30
+ }
31
+ /**
32
+ * The first dot-separated segment of a path: `code.coding.system` -> `code`.
33
+ *
34
+ * indexOf rather than `split('.')[0]`, for the same reason as
35
+ * {@link stripSliceSuffix}: the split form is an indexing expression and reads as
36
+ * possibly-undefined even when it cannot be.
37
+ */
38
+ export function firstSegment(path) {
39
+ if (!path)
40
+ return path;
41
+ const dotIndex = path.indexOf('.');
42
+ return dotIndex >= 0 ? path.substring(0, dotIndex) : path;
43
+ }
17
44
  export function writeInterfaceAndValidatorToFile(outputDir, interfaceName, interfaceContent, validateFunctionContent) {
18
45
  const filePath = path.join(outputDir, `${interfaceName}.ts`);
19
46
  const content = `${interfaceContent}\n\n${validateFunctionContent}`;
@@ -181,6 +208,48 @@ export function alignFields(newFields, oldFields) {
181
208
  // Return the original oldFields and the aligned newFields
182
209
  return { alignedNewFields, oldFields };
183
210
  }
211
+ /** A type the parser could not pin down from the element itself. */
212
+ function isUnstatedType(type) {
213
+ return !type || type === 'any';
214
+ }
215
+ /**
216
+ * Carry forward what a differential element left unstated.
217
+ *
218
+ * A differential says only what it changes, so an element that constrains
219
+ * cardinality alone carries no `type` and an element that constrains `min` alone
220
+ * carries no `max`. Both are inherited rather than defaulted away.
221
+ */
222
+ function inheritUnstatedFromBase(field, base) {
223
+ if (!base)
224
+ return field;
225
+ const merged = { ...field };
226
+ if (isUnstatedType(merged.type) && !isUnstatedType(base.type)) {
227
+ merged.type = base.type;
228
+ }
229
+ if (!merged.baseTypeCode && base.baseTypeCode) {
230
+ merged.baseTypeCode = base.baseTypeCode;
231
+ }
232
+ // `max` absent means the differential did not narrow the upper bound, so the
233
+ // base's bound — and with it whether the element repeats — still holds.
234
+ if (merged.max === undefined && base.max !== undefined) {
235
+ merged.max = base.max;
236
+ merged.isArray = base.isArray;
237
+ }
238
+ if (!merged.typeOptions?.length && base.typeOptions?.length) {
239
+ merged.typeOptions = base.typeOptions;
240
+ }
241
+ // An extension slice is recognised at runtime by the canonical its profile
242
+ // names. A derived profile that only tightens cardinality does not restate it —
243
+ // KBV_PR_ERP_Medication_PZN redeclares extension:Kategorie and extension:Normgroesse
244
+ // with no type.profile, while KBV_PR_Base_Medication gives them
245
+ // KBV_EX_Base_Medication_Type and .../normgroesse. Dropping the base's left two
246
+ // of four slices unidentifiable, so the closed-slicing check on
247
+ // Medication.extension could not build its allowed set and stayed silent.
248
+ if (!merged.profileUrls?.length && base.profileUrls?.length) {
249
+ merged.profileUrls = base.profileUrls;
250
+ }
251
+ return merged;
252
+ }
184
253
  export function mergeFields(newFields, oldFields) {
185
254
  const fieldMap = new Map();
186
255
  // Create unique key for field - use elementId if available (includes slice names for child elements)
@@ -208,9 +277,22 @@ export function mergeFields(newFields, oldFields) {
208
277
  }
209
278
  fieldMap.set(key, field);
210
279
  }
211
- // Overwrite or add newFields to the map
280
+ // Overwrite or add newFields to the map.
281
+ //
282
+ // A differential element states only what it *changes*. Replacing the base
283
+ // field wholesale therefore threw away everything it stayed silent about:
284
+ // KBV constrains Medication.amount.numerator.value to max=0 and says nothing
285
+ // about its type, so the merged field read `any` and every type-aware check
286
+ // skipped it — HL7 reported "The value 'Example value' is not a valid decimal"
287
+ // where we said nothing. The same silence on `max` left Medication.ingredient
288
+ // with isArray=false despite being 0..* in the base, so paths beneath it were
289
+ // never walked.
290
+ //
291
+ // Only genuinely absent properties are filled in; anything the differential
292
+ // states still wins.
212
293
  for (const field of newFields) {
213
- fieldMap.set(getFieldKey(field), field);
294
+ const key = getFieldKey(field);
295
+ fieldMap.set(key, inheritUnstatedFromBase(field, fieldMap.get(key)));
214
296
  }
215
297
  // Return the merged fields as an array
216
298
  return Array.from(fieldMap.values());
@@ -276,6 +358,48 @@ export function sanitizeIdentifier(name) {
276
358
  cleaned = `_${cleaned}`;
277
359
  return cleaned;
278
360
  }
361
+ /**
362
+ * Turn a FHIR resource name into a PascalCase TypeScript identifier.
363
+ *
364
+ * Shared by the ValueSet and NamingSystem emitters, which previously carried
365
+ * the same split-and-capitalize loop with only the digit prefix differing.
366
+ * {@link toPascalCase} alone is not enough: it drops the underscore that
367
+ * {@link sanitizeIdentifier} adds, so a name starting with a digit comes back
368
+ * as an invalid identifier.
369
+ */
370
+ export function pascalCaseIdentifier(name, opts) {
371
+ const stripped = opts.stripPrefix ? name.replace(opts.stripPrefix, '') : name;
372
+ const pascal = toPascalCase(stripped);
373
+ if (!pascal)
374
+ return opts.fallback;
375
+ return /^[0-9]/.test(pascal) ? `${opts.digitPrefix}${pascal}` : pascal;
376
+ }
377
+ /**
378
+ * Lower-case a name's first character, keeping a leading acronym run intact.
379
+ *
380
+ * "CHCorePatient" → "chCorePatient" (the acronym lowers wholesale, but the
381
+ * last capital starts the next word)
382
+ * "Patient" → "patient"
383
+ * "HumanName" → "humanName"
384
+ *
385
+ * Shared by the prefab renderer and the MCP view registry, which each carried a
386
+ * byte-identical copy — the second one commented "Mirrors the same logic in
387
+ * prefabRenderer.ts", which is the drift warning rather than a defence.
388
+ *
389
+ * charAt() is deliberate over indexing: it yields '' past the end, so the
390
+ * acronym run terminates at the boundary without an index assertion.
391
+ */
392
+ export function lowerFirstPreservingAcronym(name) {
393
+ if (!name)
394
+ return name;
395
+ let i = 0;
396
+ while (i < name.length && name.charAt(i) >= 'A' && name.charAt(i) <= 'Z')
397
+ i++;
398
+ if (i <= 1)
399
+ return name.charAt(0).toLowerCase() + name.slice(1);
400
+ const acronymEnd = i === name.length ? i : i - 1;
401
+ return name.slice(0, acronymEnd).toLowerCase() + name.slice(acronymEnd);
402
+ }
279
403
  export function toPascalCase(name) {
280
404
  if (!name)
281
405
  return name;
@@ -95,8 +95,16 @@ export function generatePatternMetadata(fieldPatterns, forbiddenFields, valueSet
95
95
  }
96
96
  const hasBindings = Object.keys(valueSetBindings).length > 0;
97
97
  return `
98
- /** Pattern constraints for profile fields (used by random() generator) */
99
- protected static readonly _fieldPatterns = ${JSON.stringify(patternMap, null, 2)};
98
+ /**
99
+ * Pattern constraints this profile fixes on its fields, keyed by field path
100
+ * (e.g. \`"shlSession.system": "https://example.org/fhir/shl-session"\`).
101
+ *
102
+ * Public alongside \`valueSetBindings\`: a consumer that needs a fixed value —
103
+ * an identifier system, a fixed code — would otherwise hand-write the literal
104
+ * the profile already declares, which is the duplication this package exists
105
+ * to remove. Read by the random() generator too.
106
+ */
107
+ static readonly _fieldPatterns = ${JSON.stringify(patternMap, null, 2)};
100
108
 
101
109
  /** Fields that are forbidden (max=0) in this profile - must not be generated */
102
110
  protected static readonly _forbiddenFields: string[] = ${JSON.stringify(forbiddenFields)};
@@ -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. */
@@ -252,6 +252,13 @@ export function buildNestedRequirementsObject(nestedRequirements) {
252
252
  // Navigate/create nested objects for all but the last part
253
253
  for (let i = 0; i < parts.length - 1; i++) {
254
254
  const part = parts[i];
255
+ // The loop bound guarantees a segment here; if one is ever missing the
256
+ // requirement is abandoned rather than navigated halfway, which is the same
257
+ // course the non-object branch below takes.
258
+ if (part === undefined) {
259
+ skipThis = true;
260
+ break;
261
+ }
255
262
  const isArraySegment = allArraySegments.has(parts.slice(0, i + 1).join('.'));
256
263
  if (!(part in current)) {
257
264
  // Create either an array with one object or a plain object
@@ -290,7 +297,9 @@ export function buildNestedRequirementsObject(nestedRequirements) {
290
297
  continue;
291
298
  }
292
299
  // Set the final value
293
- const lastPart = parts[parts.length - 1];
300
+ const lastPart = parts.at(-1);
301
+ if (lastPart === undefined)
302
+ continue;
294
303
  // If this property already exists and is an object with properties, merge instead of overwrite
295
304
  const existingValue = current[lastPart];
296
305
  const hasExplicitConstraint = req.patternConstraint !== undefined || req.fixedValue !== undefined;
@@ -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
  }
@@ -1,4 +1,4 @@
1
- import { capitalize, choicePropertyName, narrowedChoiceType, sanitizeIdentifier, toPascalCase, stripVersionFromCanonicalUrl } from '../../core/utils.js';
1
+ import { capitalize, choicePropertyName, narrowedChoiceType, sanitizeIdentifier, toPascalCase, stripVersionFromCanonicalUrl, stripSliceSuffix } from '../../core/utils.js';
2
2
  import { sanitizeValueSetName } from '../valueset/valueSetGenerator.js';
3
3
  import { logger } from '../../../logger.js';
4
4
  import { getRules } from '../../fhir/versionContext.js';
@@ -45,8 +45,8 @@ export function processFields(ctx, fields, parentInterfaceName, parentFieldType,
45
45
  }
46
46
  // Register external profiles for FHIR datatype profiles (like SimpleQuantity) early
47
47
  // This must happen before any returns to ensure the profile is registered
48
- if (field.isProfiled && field.profileUrls && field.profileUrls.length > 0) {
49
- const profileUrl = field.profileUrls[0];
48
+ const profileUrl = field.isProfiled ? field.profileUrls?.[0] : undefined;
49
+ if (profileUrl) {
50
50
  // Use profileUrlToName to get the actual interface name, fallback to URL-derived name
51
51
  // Strip version suffix from canonical URL (e.g., |5.3.0-ballot-tc1) before extracting name
52
52
  const strippedUrl = stripVersionFromCanonicalUrl(profileUrl);
@@ -61,13 +61,9 @@ export function processFields(ctx, fields, parentInterfaceName, parentFieldType,
61
61
  }
62
62
  }
63
63
  const fieldParts = field.name.split(".");
64
- let fieldName = fieldParts[fieldParts.length - 1];
65
- // Remove slice notation (e.g., "extension:Stadtteil" -> "extension") for property names
66
- // The slice name is preserved in field.sliceName for semantic purposes
67
- const colonIndex = fieldName.indexOf(':');
68
- if (colonIndex !== -1) {
69
- fieldName = fieldName.substring(0, colonIndex);
70
- }
64
+ // Slice notation is dropped for the property name ("extension:Stadtteil"
65
+ // -> "extension"); field.sliceName keeps it for semantic purposes.
66
+ let fieldName = stripSliceSuffix(fieldParts.at(-1) ?? field.name);
71
67
  const isRootInterface = parentInterfaceName === interfaceName;
72
68
  const isNested = isRootInterface ? fieldParts.length > 1 : fieldParts.length >= 1;
73
69
  // When generating a slice-specific interface, only include direct children
@@ -126,7 +122,7 @@ export function processFields(ctx, fields, parentInterfaceName, parentFieldType,
126
122
  // (2 segments: Identifier.system) and skip nested paths (4+ segments: Identifier.assigner.identifier.system)
127
123
  if (isNested && fieldParts.length >= 2) {
128
124
  const baseTypeName = fieldParts[0]; // e.g., "Identifier" from "Identifier.assigner.identifier.system"
129
- const isExtendingBaseType = baseResource && baseTypeName.toLowerCase() === baseResource.toLowerCase();
125
+ const isExtendingBaseType = !!baseResource && baseTypeName?.toLowerCase() === baseResource.toLowerCase();
130
126
  // Check if this field will create a nested interface (has children in fields)
131
127
  const hasChildren = fields.some((f) => f.name.startsWith(field.name + '.'));
132
128
  const shouldKeepForNested = field.mustSupport && hasChildren;
@@ -173,7 +169,8 @@ export function processFields(ctx, fields, parentInterfaceName, parentFieldType,
173
169
  }
174
170
  else if (!isLogicalModel) {
175
171
  // For other profiles, use the original logic
176
- if (!parentInterfaceName.toLowerCase().includes(fieldParts[fieldParts.length - 2].toLowerCase())) {
172
+ const parentSegment = fieldParts.at(-2);
173
+ if (!parentSegment || !parentInterfaceName.toLowerCase().includes(parentSegment.toLowerCase())) {
177
174
  return; // Skip nested fields that don't match the parent interface name
178
175
  }
179
176
  }
@@ -529,7 +526,13 @@ export function processFields(ctx, fields, parentInterfaceName, parentFieldType,
529
526
  deduped.push(line); // JSDoc or other non-field lines
530
527
  continue;
531
528
  }
529
+ // Group 1 is present whenever the regex matched, but the type does not
530
+ // say so; a line without it is passed through rather than deduped.
532
531
  const name = fieldMatch[1];
532
+ if (!name) {
533
+ deduped.push(line);
534
+ continue;
535
+ }
533
536
  if (seenFieldNames.has(name)) {
534
537
  continue; // Skip duplicate — keep the first (more specific) version
535
538
  }
@@ -26,11 +26,14 @@ export function canonicalToTypeName(name, profileUrlToName) {
26
26
  matches.add(generatedName);
27
27
  }
28
28
  }
29
- if (matches.size === 1)
30
- return [...matches][0];
29
+ if (matches.size === 1) {
30
+ const [only] = [...matches];
31
+ if (only)
32
+ return only;
33
+ }
31
34
  }
32
35
  // if already a valid identifier with capitalization, return as-is
33
- if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(t) && /[A-Z]/.test(t[0]))
36
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(t) && /^[A-Z]/.test(t))
34
37
  return t;
35
38
  const parts = t.split(/[^A-Za-z0-9]+/).filter(Boolean);
36
39
  let pascal = parts.map(p => p.charAt(0).toUpperCase() + p.slice(1)).join('');
@@ -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;
@@ -299,9 +300,15 @@ export function postProcessExtensions(params) {
299
300
  });
300
301
  const baseFld2 = baseFieldByLastSegment.get(seg2);
301
302
  let extendT2 = actualField2?.type || baseFld2?.type;
302
- // Fallback to specific mappings if needed
303
+ // A generic BackboneElement carries none of the base backbone's own properties, so
304
+ // resolve it against the level-1 backbone already resolved above
305
+ // (QuestionnaireResponseItem + 'answer' -> QuestionnaireResponseItemAnswer).
303
306
  if (!extendT2 || extendT2 === 'any' || extendT2 === 'BackboneElement') {
304
- if (baseResource === 'Encounter' && seg1 === 'hospitalization' && seg2 === 'dischargeDisposition') {
307
+ const inferred2 = extendT1 ? inferBackboneType(extendT1, seg2) : undefined;
308
+ if (inferred2) {
309
+ extendT2 = inferred2;
310
+ }
311
+ else if (baseResource === 'Encounter' && seg1 === 'hospitalization' && seg2 === 'dischargeDisposition') {
305
312
  extendT2 = 'CodeableConcept';
306
313
  }
307
314
  }
@@ -175,12 +175,10 @@ export function handleNestedField(args) {
175
175
  }
176
176
  else {
177
177
  // No nested interface existed; create a minimal one extending the base/inferred type to host the extension union
178
- let parentT = baseFieldByLastSegment.get(fieldName)?.type || field.type;
179
- if (!parentT || parentT === 'any') {
180
- const inferred = inferBackboneType(baseResource, fieldName);
181
- if (inferred)
182
- parentT = inferred;
183
- }
178
+ // Reuse the type resolved above rather than recomputing it: the local
179
+ // recomputation used to stop at a generic BackboneElement, which drops the
180
+ // base backbone's own properties (e.g. QuestionnaireResponseItem.linkId).
181
+ let parentT = baseFieldType;
184
182
  if (parentT && !isPrimitiveType(parentT)) {
185
183
  parentT = canonicalToTypeName(parentT);
186
184
  const sanitized = sanitizeIdentifier(parentT);