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.
- package/out/src/cli/updateCommand.js +19 -12
- package/out/src/generator/core/constants.js +19 -0
- package/out/src/generator/core/extensionSdReader.js +99 -0
- package/out/src/generator/core/utils.js +97 -3
- package/out/src/generator/emitters/class/classTemplate.js +8 -2
- package/out/src/generator/emitters/class/sliceElementDefaults.js +5 -3
- package/out/src/generator/emitters/client/clientGenerator.js +22 -6
- package/out/src/generator/emitters/client/smartAuthGenerator.js +4 -1
- package/out/src/generator/emitters/interface/backboneSliceTyping.js +15 -4
- package/out/src/generator/emitters/interface/importManager.js +28 -2
- package/out/src/generator/emitters/interface/interfaceFieldProcessor.js +21 -16
- package/out/src/generator/emitters/interface/interfaceFieldUtils.js +16 -0
- package/out/src/generator/emitters/interface/postProcessExtensions.js +1 -0
- package/out/src/generator/emitters/validator/closedSlicingValidation.js +146 -0
- package/out/src/generator/emitters/validator/sliceDelegation.js +450 -0
- package/out/src/generator/emitters/validator/sliceExtensionValidation.js +200 -35
- package/out/src/generator/emitters/validator/sliceValidatorGenerator.js +130 -299
- package/out/src/generator/emitters/validator/sliceValidatorUtils.js +89 -16
- package/out/src/generator/emitters/validator/subExtensionScope.js +100 -0
- package/out/src/generator/emitters/validator/validatorBindingBuilder.js +113 -237
- package/out/src/generator/emitters/validator/validatorBindingLeafEmitters.js +13 -4
- package/out/src/generator/emitters/validator/validatorConstraintBuilders.js +104 -63
- package/out/src/generator/emitters/validator/validatorExpressions.js +54 -0
- package/out/src/generator/emitters/validator/validatorFieldBuilders.js +492 -46
- package/out/src/generator/emitters/validator/validatorGenerator.js +170 -169
- package/out/src/generator/emitters/validator/validatorRuntime.js +279 -0
- package/out/src/generator/emitters/validator/validatorTemplates.js +186 -54
- package/out/src/generator/emitters/zod/zodRefinementBuilder.js +29 -19
- package/out/src/generator/emitters/zod/zodSchemaGenerator.js +16 -1
- package/out/src/generator/index.js +21 -11
- package/out/src/generator/parser/packageParser.js +1 -8
- package/out/src/generator/parser/sdParser.js +1 -0
- package/out/src/generator/sdProcessor.js +303 -30
- package/out/src/generator/sdProcessorHelpers.js +1 -1
- package/out/src/generator/sdProcessorRequiredFields.js +120 -0
- package/out/src/main.js +37 -11
- package/package.json +3 -1
- package/parity-matrix.json +18 -18
|
@@ -10,7 +10,8 @@ import path from 'path';
|
|
|
10
10
|
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
|
-
import { writeInterfaceAndValidatorToFile, mergeFields, alignFields, writeToFile, getBaseResource, sanitizeIdentifier, toPascalCase,
|
|
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';
|
|
@@ -18,14 +19,176 @@ import { logger } from '../logger.js';
|
|
|
18
19
|
import { ctx as versionCtx, fhirModuleSpecifier } from './fhir/versionContext.js';
|
|
19
20
|
import { registerProfile, addDiagnostic } from './core/sdDiagnostics.js';
|
|
20
21
|
import { resetFetchFailureTracking, getFetchFailureCount, getFetchFailureWarning, recordFetchFailure, resolveComplexTypeChildType, ensureRandomSupportFile, ensureValidatorOptionsFile, } from './sdProcessorHelpers.js';
|
|
21
|
-
import { extractAndDeduplicateRequiredFields } from './sdProcessorRequiredFields.js';
|
|
22
|
+
import { extractAndDeduplicateRequiredFields, resolveChoiceSegments } from './sdProcessorRequiredFields.js';
|
|
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
|
-
|
|
212
|
+
const els = extensionElements(extSd);
|
|
213
|
+
if (!els)
|
|
214
|
+
return;
|
|
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
|
+
});
|
|
47
234
|
return;
|
|
48
|
-
|
|
235
|
+
}
|
|
49
236
|
if (!valueEl)
|
|
50
237
|
return;
|
|
51
238
|
const types = (valueEl.type || []).map((t) => t.code).filter(Boolean);
|
|
@@ -54,28 +241,96 @@ 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
|
|
58
|
-
if (!binding
|
|
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 =
|
|
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
|
-
|
|
64
|
-
binding = sliceEl.binding;
|
|
65
|
-
}
|
|
250
|
+
binding = readBinding(sliceEl);
|
|
66
251
|
}
|
|
67
|
-
if (binding
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
252
|
+
if (binding)
|
|
253
|
+
entry.binding = binding;
|
|
254
|
+
map.set(key, entry);
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
// Extension SD not resolvable — skip
|
|
258
|
+
}
|
|
259
|
+
}));
|
|
260
|
+
return map.size > 0 ? map : undefined;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Resolve what a datatype profile pins, for slices discriminated by
|
|
264
|
+
* `{profile, $this}`.
|
|
265
|
+
*
|
|
266
|
+
* Such a slice names a profile instead of a value, so membership can only be decided
|
|
267
|
+
* by reading the profile. sdc slices Questionnaire.useContext against
|
|
268
|
+
* sdc-usagecontext-questionnaire-library, which pins UsageContext.code to
|
|
269
|
+
* usage-context-type|workflow — that pin is the discriminator in practice.
|
|
270
|
+
*
|
|
271
|
+
* Extension slices are excluded: they are already handled by resolveExtensionMetadata
|
|
272
|
+
* and are matched on url, not on a pinned value.
|
|
273
|
+
*/
|
|
274
|
+
async function resolveDatatypeProfilePins(fields) {
|
|
275
|
+
const urls = new Set();
|
|
276
|
+
for (const f of fields) {
|
|
277
|
+
if (!f.sliceName || !f.profileUrls?.length)
|
|
278
|
+
continue;
|
|
279
|
+
const basePath = (f.name || '').replace(/^[^.]+\./, '').split(':')[0];
|
|
280
|
+
if (basePath.endsWith('extension') || basePath.endsWith('modifierExtension'))
|
|
281
|
+
continue;
|
|
282
|
+
urls.add(f.profileUrls[0]);
|
|
283
|
+
}
|
|
284
|
+
if (urls.size === 0)
|
|
285
|
+
return undefined;
|
|
286
|
+
const map = new Map();
|
|
287
|
+
await Promise.all([...urls].map(async (url) => {
|
|
288
|
+
try {
|
|
289
|
+
const sd = await fetchStructureDefinition(url);
|
|
290
|
+
// Only datatype profiles: a resource profile's members are matched by
|
|
291
|
+
// resourceType, which the type-discriminator path already covers.
|
|
292
|
+
if (!sd?.snapshot?.element || sd.kind !== 'complex-type')
|
|
293
|
+
return;
|
|
294
|
+
for (const el of sd.snapshot.element) {
|
|
295
|
+
const id = typeof el.id === 'string' ? el.id : '';
|
|
296
|
+
const relPath = id.includes('.') ? id.substring(id.indexOf('.') + 1) : '';
|
|
297
|
+
// Skip the root and anything below a nested choice, which needs a concrete
|
|
298
|
+
// property name the pin does not carry.
|
|
299
|
+
if (!relPath || relPath.includes('[x]'))
|
|
300
|
+
continue;
|
|
301
|
+
const coding = el.patternCoding ?? el.fixedCoding;
|
|
302
|
+
if (coding && typeof coding === 'object') {
|
|
303
|
+
const c = coding;
|
|
304
|
+
if (typeof c.code === 'string') {
|
|
305
|
+
map.set(url, {
|
|
306
|
+
path: relPath,
|
|
307
|
+
shape: 'coding',
|
|
308
|
+
system: typeof c.system === 'string' ? c.system : undefined,
|
|
309
|
+
code: c.code,
|
|
310
|
+
});
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const concept = el.patternCodeableConcept ?? el.fixedCodeableConcept;
|
|
315
|
+
const nested = concept && typeof concept === 'object'
|
|
316
|
+
? concept.coding
|
|
317
|
+
: undefined;
|
|
318
|
+
if (Array.isArray(nested) && nested.length > 0) {
|
|
319
|
+
const c = nested[0];
|
|
320
|
+
if (typeof c.code === 'string') {
|
|
321
|
+
map.set(url, {
|
|
322
|
+
path: relPath,
|
|
323
|
+
shape: 'codeableConcept',
|
|
324
|
+
system: typeof c.system === 'string' ? c.system : undefined,
|
|
325
|
+
code: c.code,
|
|
326
|
+
});
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
73
329
|
}
|
|
74
330
|
}
|
|
75
|
-
map.set(url, entry);
|
|
76
331
|
}
|
|
77
332
|
catch {
|
|
78
|
-
//
|
|
333
|
+
// Profile not resolvable — leave the slice unmatched rather than guess.
|
|
79
334
|
}
|
|
80
335
|
}));
|
|
81
336
|
return map.size > 0 ? map : undefined;
|
|
@@ -111,7 +366,10 @@ export async function processStructureDefinition(sd, ctx) {
|
|
|
111
366
|
// ── External profile resolution ──────────────────────────────────
|
|
112
367
|
for (const [typeName, profileInfo] of interfaceResult.referencedExternalProfiles) {
|
|
113
368
|
const normalizedTypeName = toPascalCase(sanitizeIdentifier(typeName));
|
|
114
|
-
|
|
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) ||
|
|
115
373
|
(interfaceContent.includes(`from "./${normalizedTypeName}"`) && interfaceContent.includes(`{ ${normalizedTypeName} }`));
|
|
116
374
|
if (!isUsedHere)
|
|
117
375
|
continue;
|
|
@@ -189,9 +447,13 @@ export async function processStructureDefinition(sd, ctx) {
|
|
|
189
447
|
let finalInterfaceContent = interfaceContent;
|
|
190
448
|
for (const [originalName, actualName] of profileIdToName?.entries() || []) {
|
|
191
449
|
if (originalName !== actualName) {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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}"`);
|
|
195
457
|
const typeUsageRegex = new RegExp(`(:\\s+|Array<|\\|\\s+|&\\s+|extends\\s+)${originalName}(?=\\s*[;,>\\|&\\)\\n\\[\\s{])`, 'g');
|
|
196
458
|
finalInterfaceContent = finalInterfaceContent.replace(typeUsageRegex, `$1${actualName}`);
|
|
197
459
|
}
|
|
@@ -211,9 +473,12 @@ export async function processStructureDefinition(sd, ctx) {
|
|
|
211
473
|
finalInterfaceContent = dedupedLines.join('\n');
|
|
212
474
|
// ── Required fields ──────────────────────────────────────────────
|
|
213
475
|
const merged = mergeFields(aligned.alignedNewFields, parsed.oldFields);
|
|
476
|
+
await fillUnstatedFromBaseResource(merged, sd.type);
|
|
477
|
+
await fillSliceProfileUrlsFromBaseProfiles(merged, sd.baseDefinition);
|
|
214
478
|
// ── Pre-resolve Extension SDs for validator emission ─────────────
|
|
215
479
|
const extensionMetadata = await resolveExtensionMetadata(merged);
|
|
216
|
-
const
|
|
480
|
+
const datatypeProfilePins = await resolveDatatypeProfilePins(merged);
|
|
481
|
+
const validateResult = generateValidateProfileFunction(interfaceName, merged, valueSets, sd.url, sd.type, profileUrlToName, extensionMetadata, sd.baseDefinition, profileUrlToType, datatypeProfilePins);
|
|
217
482
|
const validateFn = validateResult.code;
|
|
218
483
|
const candidateRequiredFields = [...aligned.alignedNewFields, ...parsed.oldFields];
|
|
219
484
|
const profileDiffFieldNames = new Set(aligned.alignedNewFields.map(f => f.name));
|
|
@@ -251,12 +516,15 @@ export async function processStructureDefinition(sd, ctx) {
|
|
|
251
516
|
continue;
|
|
252
517
|
const parentFieldName = parts[1].split(':')[0];
|
|
253
518
|
let relativePath = parts.slice(2).map((p) => p.split(':')[0]).join('.');
|
|
254
|
-
if (relativePath.includes('[x]')
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
519
|
+
if (relativePath.includes('[x]')) {
|
|
520
|
+
const resolvedPath = resolveChoiceSegments(f, parts, candidateRequiredFields);
|
|
521
|
+
// An intermediate choice we cannot narrow to one type has no property name.
|
|
522
|
+
// Dropping the requirement is the only honest outcome: naming it after the
|
|
523
|
+
// leaf's type invents a sibling property that no profile permits, and the
|
|
524
|
+
// random generator would then emit data both validators reject.
|
|
525
|
+
if (!resolvedPath)
|
|
526
|
+
continue;
|
|
527
|
+
relativePath = resolvedPath;
|
|
260
528
|
}
|
|
261
529
|
let profileUrl = f.typeOptions?.[0]?.profileUrls?.[0];
|
|
262
530
|
if (!profileUrl && (relativePath === 'identifier' || relativePath.startsWith('identifier.'))) {
|
|
@@ -280,7 +548,12 @@ export async function processStructureDefinition(sd, ctx) {
|
|
|
280
548
|
for (let i = 0; i < relativePathParts.length; i++) {
|
|
281
549
|
const intermediatePath = [root, parentFieldName, ...relativePathParts.slice(0, i + 1)].join('.');
|
|
282
550
|
if (fieldIsArrayMap.get(intermediatePath)) {
|
|
283
|
-
|
|
551
|
+
// The relative prefix, not the bare segment name: a name repeating at two
|
|
552
|
+
// depths would otherwise share one verdict.
|
|
553
|
+
// MedicinalProductDefinition.name.part is 0..*, while the `part` string
|
|
554
|
+
// inside it is 1..1, and matching on the name alone wrapped the string in
|
|
555
|
+
// an array too.
|
|
556
|
+
arraySegments.push(relativePathParts.slice(0, i + 1).join('.'));
|
|
284
557
|
}
|
|
285
558
|
}
|
|
286
559
|
let fieldType = f.type;
|
|
@@ -5,7 +5,7 @@ import path from 'path';
|
|
|
5
5
|
import fs from 'fs';
|
|
6
6
|
import { logger } from '../logger.js';
|
|
7
7
|
import { generateRandomFiles } from './emitters/class/randomSupportGenerator.js';
|
|
8
|
-
import { generateValidatorOptionsContent } from './emitters/validator/
|
|
8
|
+
import { generateValidatorOptionsContent } from './emitters/validator/validatorRuntime.js';
|
|
9
9
|
import { ctx } from './fhir/versionContext.js';
|
|
10
10
|
// ── Fetch failure tracking ─────────────────────────────────────────
|
|
11
11
|
let failedFetchCount = 0;
|
|
@@ -127,3 +127,123 @@ export function extractAndDeduplicateRequiredFields(candidateRequiredFields, sdI
|
|
|
127
127
|
}
|
|
128
128
|
return requiredFields;
|
|
129
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* The type a choice element should be named after.
|
|
132
|
+
*
|
|
133
|
+
* `typeOptions[0].code` is preferred over `field.type` because a choice property
|
|
134
|
+
* is named for the base type, never for a profile on it: R4 declares
|
|
135
|
+
* `Coverage.costToBeneficiary.value[x]` as `Quantity` profiled to SimpleQuantity,
|
|
136
|
+
* and the property is `valueQuantity`. Reading `field.type` yielded
|
|
137
|
+
* `valueSimpleQuantity`, which no FHIR resource has.
|
|
138
|
+
*/
|
|
139
|
+
function firstType(field) {
|
|
140
|
+
const type = field?.typeOptions?.[0]?.code || field?.type;
|
|
141
|
+
if (!type || type === 'any' || type === 'unknown')
|
|
142
|
+
return undefined;
|
|
143
|
+
return type;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* A concrete type for an element that must be named unambiguously.
|
|
147
|
+
*
|
|
148
|
+
* Undefined when more than one type is permitted: a leaf may pick any of them and
|
|
149
|
+
* still be valid data, but an element with children may not, since the children
|
|
150
|
+
* exist under one type only.
|
|
151
|
+
*/
|
|
152
|
+
function singleType(field) {
|
|
153
|
+
if (!field)
|
|
154
|
+
return undefined;
|
|
155
|
+
if (field.typeOptions && field.typeOptions.length > 1)
|
|
156
|
+
return undefined;
|
|
157
|
+
return firstType(field);
|
|
158
|
+
}
|
|
159
|
+
/** `SimpleQuantity` -> `SimpleQuantity`, `code` -> `Code`: the choice-name suffix. */
|
|
160
|
+
function choiceSuffix(type) {
|
|
161
|
+
return type === 'code' ? 'Code' : type.charAt(0).toUpperCase() + type.slice(1);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* The one type an intermediate choice segment has been narrowed to.
|
|
165
|
+
*
|
|
166
|
+
* The parser strips slice qualifiers from field names, so a sliced choice yields
|
|
167
|
+
* several entries under the same id: the open base element carrying every
|
|
168
|
+
* permitted type, plus one narrowed entry per slice. Only the narrowed entries
|
|
169
|
+
* name a property, and they must agree — two slices pinning `value[x]` to
|
|
170
|
+
* different types share no single property name, so the requirement cannot be
|
|
171
|
+
* expressed and the caller drops it.
|
|
172
|
+
*
|
|
173
|
+
* The trailing `[x]` is also tried stripped, because a profile that pins the
|
|
174
|
+
* choice may record it under the bare name.
|
|
175
|
+
*/
|
|
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();
|
|
190
|
+
for (const candidate of candidateFields) {
|
|
191
|
+
const type = singleType(candidate);
|
|
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);
|
|
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
|
+
]);
|
|
211
|
+
return narrowed.size === 1 ? [...narrowed][0] : undefined;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Resolve every `x[x]` segment of a nested requirement path to its concrete
|
|
215
|
+
* property name, using the type of *that* segment.
|
|
216
|
+
*
|
|
217
|
+
* The leaf's type is the field's own; an intermediate `[x]` must be looked up by
|
|
218
|
+
* its element id, because a profile that narrows `value[x]` to one type still
|
|
219
|
+
* carries children beneath it. Reading the leaf's type there produced names like
|
|
220
|
+
* `valueSimpleQuantity.origin` for `Observation.component:ekgLeads.value[x].origin`
|
|
221
|
+
* — four invented sibling properties in place of one `valueSampledData` object,
|
|
222
|
+
* all of which the reference validator rejects as unrecognized.
|
|
223
|
+
*
|
|
224
|
+
* @param parts `field.name` split on `.`, slice qualifiers intact.
|
|
225
|
+
* @returns the dotted path relative to the parent field, or undefined when an
|
|
226
|
+
* intermediate choice cannot be narrowed to a single type.
|
|
227
|
+
*/
|
|
228
|
+
export function resolveChoiceSegments(field, parts, candidateFields) {
|
|
229
|
+
const segments = parts.slice(2);
|
|
230
|
+
const resolved = [];
|
|
231
|
+
for (let i = 0; i < segments.length; i++) {
|
|
232
|
+
const segment = segments[i].split(':')[0];
|
|
233
|
+
const choice = /^(\w+)\[x\]$/.exec(segment);
|
|
234
|
+
if (!choice) {
|
|
235
|
+
resolved.push(segment);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
// A leaf carries no children, so any permitted type yields valid data; an
|
|
239
|
+
// intermediate segment must be the one its children actually belong to.
|
|
240
|
+
const isLeaf = i === segments.length - 1;
|
|
241
|
+
const type = isLeaf
|
|
242
|
+
? firstType(field)
|
|
243
|
+
: narrowedSegmentType(parts.slice(0, 2 + i + 1).join('.'), candidateFields);
|
|
244
|
+
if (!type)
|
|
245
|
+
return undefined;
|
|
246
|
+
resolved.push(choice[1] + choiceSuffix(type));
|
|
247
|
+
}
|
|
248
|
+
return resolved.join('.');
|
|
249
|
+
}
|
package/out/src/main.js
CHANGED
|
@@ -3,6 +3,7 @@ import { generateClient } from "./generator/emitters/client/clientGenerator.js";
|
|
|
3
3
|
import { extractPackage } from "./generator/parser/packageParser.js";
|
|
4
4
|
import { getCacheConfig, getFhirPackagesCacheDir, clearAllCaches, ensureCacheDir, setCacheConfig } from "./generator/core/cacheConfig.js";
|
|
5
5
|
import { fetchArrayBuffer, fetchJson } from "./generator/core/fetchUtils.js";
|
|
6
|
+
import { FHIR_REGISTRIES, PACKAGE_DOWNLOAD_ATTEMPTS } from "./generator/core/constants.js";
|
|
6
7
|
import { handleInstallCommand } from "./cli/installCommand.js";
|
|
7
8
|
import { handleUpdateCommand } from "./cli/updateCommand.js";
|
|
8
9
|
import { buildQualityReport, formatReportSummary, resetDiagnostics } from "./generator/core/sdDiagnostics.js";
|
|
@@ -177,18 +178,43 @@ async function downloadPackage(packageSpec, registry) {
|
|
|
177
178
|
return tgzPath; // Return path to tgz (may not exist, but packageDir does)
|
|
178
179
|
}
|
|
179
180
|
console.log(`Downloading ${packageName}@${version} from ${registry}...`);
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
181
|
+
// Try the requested registry first, then the other known mirrors. A single
|
|
182
|
+
// transient `fetch failed` used to abort the whole generation: a parity run
|
|
183
|
+
// downloads ~30 packages, and four of them failed this way in one run —
|
|
184
|
+
// including one whose version had not changed. The registries serve identical
|
|
185
|
+
// tarballs, so a retry and a mirror are both cheap and safe.
|
|
186
|
+
const candidates = [
|
|
187
|
+
registry.replace(/\/$/, ''),
|
|
188
|
+
...FHIR_REGISTRIES.filter(r => r !== registry.replace(/\/$/, '')),
|
|
189
|
+
];
|
|
190
|
+
const attempts = [];
|
|
191
|
+
for (const base of candidates) {
|
|
192
|
+
// Registry endpoint: GET /{id}/{version}
|
|
193
|
+
const url = `${base}/${packageName}/${version}`;
|
|
194
|
+
for (let attempt = 1; attempt <= PACKAGE_DOWNLOAD_ATTEMPTS; attempt++) {
|
|
195
|
+
try {
|
|
196
|
+
const response = await fetchArrayBuffer(url);
|
|
197
|
+
fs.writeFileSync(tgzPath, Buffer.from(response.data));
|
|
198
|
+
console.log(`✓ Downloaded package`);
|
|
199
|
+
return tgzPath;
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
203
|
+
attempts.push(`${url} (attempt ${attempt}): ${message}`);
|
|
204
|
+
// A 404 means this mirror does not carry the version — no point
|
|
205
|
+
// retrying it, move to the next registry.
|
|
206
|
+
if (/\b404\b|not found/i.test(message))
|
|
207
|
+
break;
|
|
208
|
+
if (attempt < PACKAGE_DOWNLOAD_ATTEMPTS) {
|
|
209
|
+
const backoffMs = 1000 * 2 ** (attempt - 1);
|
|
210
|
+
console.log(` retrying in ${backoffMs}ms (${message})`);
|
|
211
|
+
await new Promise(resolve => setTimeout(resolve, backoffMs));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
191
215
|
}
|
|
216
|
+
throw new Error(`Failed to download package ${packageName}@${version} from ${candidates.length} registry/registries:\n `
|
|
217
|
+
+ attempts.join('\n '));
|
|
192
218
|
}
|
|
193
219
|
export function parseCliArgs(argv) {
|
|
194
220
|
const out = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "babelfhir-ts",
|
|
3
|
-
"version": "1.5.
|
|
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",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"generate:clients:check": "tsx scripts/generate-client-packages.ts --check",
|
|
41
41
|
"generate:parity-matrix": "tsx scripts/generate-parity-matrix.ts",
|
|
42
42
|
"generate:parity-matrix:check": "tsx scripts/generate-parity-matrix.ts --check",
|
|
43
|
+
"check:ig-versions": "tsx scripts/check-ig-versions.ts",
|
|
43
44
|
"generate:check": "npm run generate && npm run typecheck && npm run typecheck:output && npm run lint:output",
|
|
44
45
|
"generate:check:prefab": "npm run generate -- --prefab && npm run typecheck && npm run typecheck:output && npm run lint:output",
|
|
45
46
|
"generate:check:package": "tsx scripts/generate-check-package.ts",
|
|
@@ -85,6 +86,7 @@
|
|
|
85
86
|
},
|
|
86
87
|
"devDependencies": {
|
|
87
88
|
"@eslint/js": "^10.0.1",
|
|
89
|
+
"@max-health/config": "github:max-health-inc/config",
|
|
88
90
|
"@maxhealth.tech/prefab": "^0.3.5",
|
|
89
91
|
"@types/node": "^25.9.5",
|
|
90
92
|
"@types/semver": "^7.7.1",
|