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.
@@ -11,6 +11,7 @@ import { generateClass } from './emitters/class/classGenerator.js';
11
11
  import { generateInterfaces } from './emitters/interface/interfaceGenerator.js';
12
12
  import { fetchStructureDefinition, parseStructureDefinition } from './parser/sdParser.js';
13
13
  import { writeInterfaceAndValidatorToFile, mergeFields, alignFields, writeToFile, getBaseResource, sanitizeIdentifier, toPascalCase, stripVersionFromCanonicalUrl } from './core/utils.js';
14
+ import { extensionElements, collectSubExtensionSlices, readSubExtensionMin, readBinding } from './core/extensionSdReader.js';
14
15
  import { generateValidateProfileFunction } from './emitters/validator/validatorGenerator.js';
15
16
  import { generateZodSchema } from './emitters/zod/zodSchemaGenerator.js';
16
17
  import { extractFieldPatternsAndMetadata } from './emitters/class/fieldPatternExtractor.js';
@@ -22,10 +23,172 @@ import { extractAndDeduplicateRequiredFields, resolveChoiceSegments } from './sd
22
23
  // Re-export public API consumed by index.ts / generationHelpers.ts
23
24
  export { resetFetchFailureTracking, getFetchFailureCount, getFetchFailureWarning };
24
25
  const log = logger.withTag('generator');
26
+ /**
27
+ * Fill in what a profile's differential never states, from the base resource.
28
+ *
29
+ * A differential element says only what it *changes*. KBV constrains
30
+ * `Medication.amount.numerator.value` to max=0 and says nothing about its type, so
31
+ * the merged field read `any` and every type-aware check skipped it — HL7 reported
32
+ * "The value 'Example value' is not a valid decimal" where we were silent. The same
33
+ * silence on `max` left `Medication.ingredient` non-repeating despite being 0..* in
34
+ * the base, so nothing beneath it was ever walked.
35
+ *
36
+ * The base resource snapshot supplies the top of each path (`Medication.amount` is a
37
+ * Ratio); below that it stops, because a resource never expands its datatypes'
38
+ * children. Those come from the datatype tables — `Ratio.numerator` is a Quantity,
39
+ * `Quantity.value` a decimal — walked down from the deepest ancestor whose type is
40
+ * already known. An unresolvable hop leaves the field alone rather than guessing.
41
+ *
42
+ * Only unstated properties are filled; anything the differential declares still wins.
43
+ */
44
+ async function fillUnstatedFromBaseResource(fields, resourceType) {
45
+ if (!resourceType)
46
+ return;
47
+ let baseSd;
48
+ try {
49
+ baseSd = await fetchStructureDefinition(`http://hl7.org/fhir/StructureDefinition/${resourceType}`);
50
+ }
51
+ catch {
52
+ return;
53
+ }
54
+ const baseElements = baseSd?.snapshot?.element;
55
+ if (!baseElements?.length)
56
+ return;
57
+ const baseByPath = new Map();
58
+ for (const el of baseElements) {
59
+ const id = el.id ?? el.path;
60
+ if (!id)
61
+ continue;
62
+ const rel = id.replace(/^[^.]+\./, '');
63
+ if (!rel || rel === id || rel.includes(':'))
64
+ continue;
65
+ baseByPath.set(rel, { type: el.type?.[0]?.code, max: el.max });
66
+ }
67
+ // Container types name no datatype to walk into, so they seed nothing.
68
+ const knownType = new Map();
69
+ for (const [rel, meta] of baseByPath) {
70
+ if (meta.type && meta.type !== 'BackboneElement' && meta.type !== 'Element') {
71
+ knownType.set(rel, meta.type);
72
+ }
73
+ }
74
+ const unstated = (type) => !type || type === 'any';
75
+ // Shallow paths first, so a deeper one can walk down from an ancestor this pass
76
+ // has just resolved.
77
+ const ordered = [...fields].sort((a, b) => (a.name || '').split('.').length - (b.name || '').split('.').length);
78
+ for (const field of ordered) {
79
+ const rel = (field.name || '').replace(/^[^.]+\./, '');
80
+ if (!rel)
81
+ continue;
82
+ const base = baseByPath.get(rel);
83
+ if (base) {
84
+ if (unstated(field.type) && base.type)
85
+ field.type = base.type;
86
+ if (field.max === undefined && base.max !== undefined) {
87
+ const unbounded = base.max === '*';
88
+ const parsed = unbounded ? Infinity : Number.parseInt(base.max, 10);
89
+ if (unbounded || Number.isFinite(parsed)) {
90
+ field.max = parsed;
91
+ field.isArray = unbounded || parsed > 1;
92
+ }
93
+ }
94
+ if (!unstated(field.type))
95
+ knownType.set(rel, field.type);
96
+ continue;
97
+ }
98
+ if (!unstated(field.type)) {
99
+ knownType.set(rel, field.type);
100
+ continue;
101
+ }
102
+ const segments = rel.split('.');
103
+ let current;
104
+ let next = 0;
105
+ for (let depth = segments.length - 1; depth >= 1; depth--) {
106
+ const ancestorType = knownType.get(segments.slice(0, depth).join('.'));
107
+ if (ancestorType) {
108
+ current = ancestorType;
109
+ next = depth;
110
+ break;
111
+ }
112
+ }
113
+ if (!current)
114
+ continue;
115
+ for (let i = next; i < segments.length && current; i++) {
116
+ current = resolveComplexTypeChildType(current, segments[i]);
117
+ }
118
+ if (current) {
119
+ field.type = current;
120
+ knownType.set(rel, current);
121
+ }
122
+ }
123
+ }
124
+ /**
125
+ * Fill in extension-slice canonicals a derived profile does not restate.
126
+ *
127
+ * An extension slice is recognised at runtime by the canonical its `type.profile`
128
+ * names. A derived profile that only tightens cardinality omits it:
129
+ * KBV_PR_ERP_Medication_PZN redeclares `extension:Kategorie` and
130
+ * `extension:Normgroesse` with no profile, while its base KBV_PR_Base_Medication
131
+ * gives them KBV_EX_Base_Medication_Type and .../normgroesse. Two of four slices
132
+ * were therefore unidentifiable, and the closed-slicing check on
133
+ * `Medication.extension` could not build an allowed set — so it stayed silent where
134
+ * HL7 reports "does not match any known slice … and slicing is CLOSED".
135
+ *
136
+ * Walks the baseDefinition chain, nearest ancestor first, and stops at the first
137
+ * that names a canonical. Depth-bounded: a malformed chain must not loop.
138
+ */
139
+ async function fillSliceProfileUrlsFromBaseProfiles(fields, baseDefinition) {
140
+ const needing = fields.filter(f => f.sliceName && !f.profileUrls?.length && f.elementId);
141
+ if (needing.length === 0)
142
+ return;
143
+ const seen = new Set();
144
+ let canonical = baseDefinition;
145
+ for (let depth = 0; depth < 5 && canonical; depth++) {
146
+ const key = stripVersionFromCanonicalUrl(canonical);
147
+ if (seen.has(key))
148
+ break;
149
+ seen.add(key);
150
+ let baseSd;
151
+ try {
152
+ baseSd = await fetchStructureDefinition(canonical);
153
+ }
154
+ catch {
155
+ return;
156
+ }
157
+ if (!baseSd)
158
+ return;
159
+ const byElementId = new Map();
160
+ for (const el of (baseSd.snapshot?.element ?? baseSd.differential?.element ?? [])) {
161
+ const id = el.id;
162
+ if (!id)
163
+ continue;
164
+ const profiles = (el.type ?? []).flatMap(t => t.profile ?? []);
165
+ if (profiles.length)
166
+ byElementId.set(id, profiles);
167
+ }
168
+ for (const field of needing) {
169
+ if (field.profileUrls?.length)
170
+ continue;
171
+ const inherited = byElementId.get(field.elementId);
172
+ if (inherited?.length)
173
+ field.profileUrls = inherited;
174
+ }
175
+ if (needing.every(f => f.profileUrls?.length))
176
+ return;
177
+ canonical = baseSd.baseDefinition;
178
+ }
179
+ }
25
180
  // ── Extension metadata resolution for validator emission ──────────────────
26
181
  /**
27
182
  * Scan fields for extension slices with profile URLs, resolve their
28
183
  * StructureDefinitions, and extract value[x] type + binding metadata.
184
+ *
185
+ * Keyed by the *version-stripped* canonical. A slice names its extension twice and
186
+ * the two forms disagree: `type.profile` carries a version suffix
187
+ * (`…/sdc-questionnaire-contextExpression|4.0.0`) while the pinned `url` child
188
+ * carries the bare canonical. Keying on whichever arrived first left the other
189
+ * lookup empty, and an absent entry means "SD unreadable, conclude nothing" — so
190
+ * DTRStdQuestionnaire's contextExpression emitted no validation at all.
191
+ * Callers must strip before reading; use `lookupExtensionMetadata`.
29
192
  */
30
193
  async function resolveExtensionMetadata(fields) {
31
194
  const extUrls = new Set();
@@ -41,11 +204,35 @@ async function resolveExtensionMetadata(fields) {
41
204
  return undefined;
42
205
  const map = new Map();
43
206
  await Promise.all([...extUrls].map(async (url) => {
207
+ // Fetch by the url as written — the version suffix selects the right SD — but
208
+ // key the result by the stripped form so either spelling finds it.
209
+ const key = stripVersionFromCanonicalUrl(url);
44
210
  try {
45
211
  const extSd = await fetchStructureDefinition(url);
46
- if (!extSd?.snapshot?.element)
212
+ const els = extensionElements(extSd);
213
+ if (!els)
47
214
  return;
48
- const valueEl = extSd.snapshot.element.find((el) => (el.path === 'Extension.value[x]' || el.id === 'Extension.value[x]'));
215
+ const valueEl = els.find(el => el.path === 'Extension.value[x]' || el.id === 'Extension.value[x]');
216
+ const subExtensions = collectSubExtensionSlices(els);
217
+ const subExtensionMin = readSubExtensionMin(els);
218
+ // A complex extension carries sub-extensions instead of a value, and forbids a
219
+ // value outright (ext-1: extension.exists() != value.exists()). Recorded as an
220
+ // empty type list — different from no entry at all, which would mean the SD
221
+ // could not be read and nothing can be concluded.
222
+ //
223
+ // Recognised two ways because published SDs state it two ways: value[x] pinned
224
+ // to max=0 (sdc-questionnaire-contextExpression), or a required
225
+ // Extension.extension with no value[x] element at all
226
+ // (KBV_EX_ERP_PracticeSupply_Payor, whose differential omits value[x] entirely —
227
+ // the earlier `if (!valueEl) return` dropped it before anything was read).
228
+ if (valueEl?.max === '0' || (subExtensionMin ?? 0) >= 1) {
229
+ map.set(key, {
230
+ valueTypes: [],
231
+ subExtensionMin,
232
+ subExtensions: subExtensions.length ? subExtensions : undefined,
233
+ });
234
+ return;
235
+ }
49
236
  if (!valueEl)
50
237
  return;
51
238
  const types = (valueEl.type || []).map((t) => t.code).filter(Boolean);
@@ -54,25 +241,17 @@ async function resolveExtensionMetadata(fields) {
54
241
  const entry = { valueTypes: types };
55
242
  // Check binding on value[x] first, then fall back to typed slices
56
243
  // (e.g., Extension.valueCode may carry the binding instead of Extension.value[x])
57
- let binding = valueEl.binding;
58
- if (!binding?.valueSet && types.length === 1) {
244
+ let binding = readBinding(valueEl);
245
+ if (!binding && types.length === 1) {
59
246
  const suffix = types[0].charAt(0).toUpperCase() + types[0].slice(1);
60
- const sliceEl = extSd.snapshot.element.find((el) => el.id === `Extension.value[x]:value${suffix}` ||
247
+ const sliceEl = els.find(el => el.id === `Extension.value[x]:value${suffix}` ||
61
248
  el.path === `Extension.value${suffix}` ||
62
249
  el.id === `Extension.value${suffix}`);
63
- if (sliceEl?.binding?.valueSet) {
64
- binding = sliceEl.binding;
65
- }
66
- }
67
- if (binding?.strength && binding?.valueSet) {
68
- const vsUri = typeof binding.valueSet === 'string'
69
- ? binding.valueSet
70
- : binding.valueSet?.reference;
71
- if (vsUri) {
72
- entry.binding = { strength: binding.strength, uri: vsUri.split('|')[0] };
73
- }
250
+ binding = readBinding(sliceEl);
74
251
  }
75
- map.set(url, entry);
252
+ if (binding)
253
+ entry.binding = binding;
254
+ map.set(key, entry);
76
255
  }
77
256
  catch {
78
257
  // Extension SD not resolvable — skip
@@ -187,7 +366,10 @@ export async function processStructureDefinition(sd, ctx) {
187
366
  // ── External profile resolution ──────────────────────────────────
188
367
  for (const [typeName, profileInfo] of interfaceResult.referencedExternalProfiles) {
189
368
  const normalizedTypeName = toPascalCase(sanitizeIdentifier(typeName));
190
- const isUsedHere = interfaceContent.includes(`import { ${normalizedTypeName} } from "./${normalizedTypeName}"`) ||
369
+ // Tolerates the `type` modifier the interface emitter writes; the second
370
+ // clause already did, but leaving the first matching only the bare form
371
+ // makes the two disagree for no reason.
372
+ const isUsedHere = new RegExp(`import(?:\\s+type)?\\s+\\{\\s*${normalizedTypeName}\\s*\\}\\s+from\\s+"\\./${normalizedTypeName}"`).test(interfaceContent) ||
191
373
  (interfaceContent.includes(`from "./${normalizedTypeName}"`) && interfaceContent.includes(`{ ${normalizedTypeName} }`));
192
374
  if (!isUsedHere)
193
375
  continue;
@@ -265,9 +447,13 @@ export async function processStructureDefinition(sd, ctx) {
265
447
  let finalInterfaceContent = interfaceContent;
266
448
  for (const [originalName, actualName] of profileIdToName?.entries() || []) {
267
449
  if (originalName !== actualName) {
268
- const oldImport = `import { ${originalName} } from "./${originalName}"`;
269
- const newImport = `import { ${actualName} } from "./${actualName}"`;
270
- finalInterfaceContent = finalInterfaceContent.replace(oldImport, newImport);
450
+ // Matched by pattern, not by a literal string: the emitted form carries a
451
+ // `type` modifier, and a literal that omitted it renamed the *usages*
452
+ // while leaving the import pointing at the old module — which generated
453
+ // `Cannot find module './BodySite'` alongside `Cannot find name
454
+ // 'BodyStructureReference'` in the same file.
455
+ const importRegex = new RegExp(`import(\\s+type)?\\s+\\{\\s*${originalName}\\s*\\}\\s+from\\s+"\\./${originalName}"`, 'g');
456
+ finalInterfaceContent = finalInterfaceContent.replace(importRegex, (_m, typeModifier) => `import${typeModifier ?? ''} { ${actualName} } from "./${actualName}"`);
271
457
  const typeUsageRegex = new RegExp(`(:\\s+|Array<|\\|\\s+|&\\s+|extends\\s+)${originalName}(?=\\s*[;,>\\|&\\)\\n\\[\\s{])`, 'g');
272
458
  finalInterfaceContent = finalInterfaceContent.replace(typeUsageRegex, `$1${actualName}`);
273
459
  }
@@ -287,6 +473,8 @@ export async function processStructureDefinition(sd, ctx) {
287
473
  finalInterfaceContent = dedupedLines.join('\n');
288
474
  // ── Required fields ──────────────────────────────────────────────
289
475
  const merged = mergeFields(aligned.alignedNewFields, parsed.oldFields);
476
+ await fillUnstatedFromBaseResource(merged, sd.type);
477
+ await fillSliceProfileUrlsFromBaseProfiles(merged, sd.baseDefinition);
290
478
  // ── Pre-resolve Extension SDs for validator emission ─────────────
291
479
  const extensionMetadata = await resolveExtensionMetadata(merged);
292
480
  const datatypeProfilePins = await resolveDatatypeProfilePins(merged);
@@ -173,16 +173,41 @@ function choiceSuffix(type) {
173
173
  * The trailing `[x]` is also tried stripped, because a profile that pins the
174
174
  * choice may record it under the bare name.
175
175
  */
176
- function narrowedSegmentType(id, candidateFields) {
177
- const bare = id.replace(/\[x\]$/, '');
178
- const narrowed = new Set();
176
+ /**
177
+ * Narrowed types by field name, built once per field list.
178
+ *
179
+ * The scan used to run per `[x]` segment, which is O(fields × segments): us-core
180
+ * has thousands of both, and generation crossed the 300s subprocess timeout in CI
181
+ * (`spawnSync /bin/sh ETIMEDOUT`) while every package small enough to finish still
182
+ * passed. Keyed on the array itself so callers need not thread an index through.
183
+ */
184
+ const narrowedTypeIndexes = new WeakMap();
185
+ function narrowedTypeIndex(candidateFields) {
186
+ const cached = narrowedTypeIndexes.get(candidateFields);
187
+ if (cached)
188
+ return cached;
189
+ const index = new Map();
179
190
  for (const candidate of candidateFields) {
180
- if (candidate.name !== id && candidate.name !== bare)
181
- continue;
182
191
  const type = singleType(candidate);
183
- if (type)
184
- narrowed.add(type);
192
+ if (!type || !candidate.name)
193
+ continue;
194
+ let types = index.get(candidate.name);
195
+ if (!types)
196
+ index.set(candidate.name, (types = new Set()));
197
+ types.add(type);
185
198
  }
199
+ narrowedTypeIndexes.set(candidateFields, index);
200
+ return index;
201
+ }
202
+ function narrowedSegmentType(id, candidateFields) {
203
+ const index = narrowedTypeIndex(candidateFields);
204
+ const bare = id.replace(/\[x\]$/, '');
205
+ // Both spellings, unioned: the same element appears under `value[x]` and, once a
206
+ // profile pins it, under `value`. Disagreement still names nothing.
207
+ const narrowed = new Set([
208
+ ...(index.get(id) ?? []),
209
+ ...(bare === id ? [] : index.get(bare) ?? []),
210
+ ]);
186
211
  return narrowed.size === 1 ? [...narrowed][0] : undefined;
187
212
  }
188
213
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "babelfhir-ts",
3
- "version": "1.5.20",
3
+ "version": "1.5.21",
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",
@@ -86,6 +86,7 @@
86
86
  },
87
87
  "devDependencies": {
88
88
  "@eslint/js": "^10.0.1",
89
+ "@max-health/config": "github:max-health-inc/config",
89
90
  "@maxhealth.tech/prefab": "^0.3.5",
90
91
  "@types/node": "^25.9.5",
91
92
  "@types/semver": "^7.7.1",