babelfhir-ts 1.5.21 → 1.6.1
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/README.md +6 -3
- package/out/src/generator/core/constants.js +2 -1
- package/out/src/generator/core/extensionSdReader.js +4 -1
- package/out/src/generator/core/utils.js +91 -9
- package/out/src/generator/emitters/class/classGenerator.js +14 -8
- package/out/src/generator/emitters/class/classGeneratorHelpers.js +22 -9
- package/out/src/generator/emitters/class/classHeuristics.js +4 -2
- package/out/src/generator/emitters/class/fieldPatternExtractor.js +17 -13
- package/out/src/generator/emitters/class/sliceElementDefaults.js +21 -6
- package/out/src/generator/emitters/class/sliceElementGenerator.js +3 -3
- package/out/src/generator/emitters/interface/backboneSliceTyping.js +21 -13
- package/out/src/generator/emitters/interface/importManager.js +1 -1
- package/out/src/generator/emitters/interface/interfaceFieldProcessor.js +15 -12
- package/out/src/generator/emitters/interface/interfaceFieldUtils.js +6 -3
- package/out/src/generator/emitters/interface/interfaceGenerator.js +14 -9
- package/out/src/generator/emitters/interface/postProcessExtensions.js +32 -16
- package/out/src/generator/emitters/interface/processFlatField.js +9 -7
- package/out/src/generator/emitters/interface/processNestedField.js +9 -10
- package/out/src/generator/emitters/namingsystem/namingSystemGenerator.js +136 -0
- package/out/src/generator/emitters/prefab/prefabEmitter.js +2 -15
- package/out/src/generator/emitters/prefab/prefabRenderer.js +2 -19
- package/out/src/generator/emitters/prefab/prefabTypeMapper.js +2 -0
- package/out/src/generator/emitters/validator/closedSlicingValidation.js +5 -2
- package/out/src/generator/emitters/validator/fhirpathStubInstaller.js +7 -1
- package/out/src/generator/emitters/validator/sliceBackboneValidation.js +27 -12
- package/out/src/generator/emitters/validator/sliceDelegation.js +8 -6
- package/out/src/generator/emitters/validator/sliceExtensionValidation.js +3 -3
- package/out/src/generator/emitters/validator/sliceValidatorGenerator.js +41 -41
- package/out/src/generator/emitters/validator/sliceValidatorUtils.js +8 -3
- package/out/src/generator/emitters/validator/subExtensionScope.js +10 -2
- package/out/src/generator/emitters/validator/validatorBindingBuilder.js +4 -3
- package/out/src/generator/emitters/validator/validatorBindingLeafEmitters.js +5 -5
- package/out/src/generator/emitters/validator/validatorConstraintBuilders.js +8 -6
- package/out/src/generator/emitters/validator/validatorExpressions.js +44 -0
- package/out/src/generator/emitters/validator/validatorFieldBuilders.js +34 -17
- package/out/src/generator/emitters/validator/validatorGenerator.js +43 -47
- package/out/src/generator/emitters/validator/validatorRuntime.js +13 -2
- package/out/src/generator/emitters/validator/validatorTemplates.js +4 -1
- package/out/src/generator/emitters/valueset/valueSetGenerator.js +6 -10
- package/out/src/generator/emitters/zod/zodRefinementBuilder.js +48 -8
- package/out/src/generator/emitters/zod/zodSchemaGenerator.js +51 -24
- package/out/src/generator/fhir/corePackageResolver.js +2 -3
- package/out/src/generator/fhir/corePackageResolver.ts +2 -3
- package/out/src/generator/generationHelpers.js +59 -7
- package/out/src/generator/index.js +94 -49
- package/out/src/generator/parser/packageManager.js +4 -4
- package/out/src/generator/parser/packageParser.js +116 -64
- package/out/src/generator/parser/sdFetcher.js +5 -3
- package/out/src/generator/parser/sdParser.js +1 -1
- package/out/src/generator/parser/sliceAggregator.js +13 -10
- package/out/src/generator/parser/sliceAggregatorComposition.js +11 -2
- package/out/src/generator/parser/vsParser.js +6 -3
- package/out/src/generator/sdProcessor.js +30 -21
- package/out/src/generator/sdProcessorRequiredFields.js +3 -2
- package/out/src/main.js +5 -0
- package/package.json +13 -10
- 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 (
|
|
64
|
+
### Validated Implementation Guides (31)
|
|
65
65
|
|
|
66
|
-
Every parity run validates **
|
|
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
|

|
|
112
112
|

|
|
113
113
|

|
|
114
|
+

|
|
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
|

|
|
149
150
|

|
|
150
151
|

|
|
152
|
+

|
|
151
153
|
|
|
152
154
|
</details>
|
|
153
155
|
|
|
154
|
-
> External-validator badges cover the
|
|
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
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* maintenance trivial.
|
|
7
7
|
*/
|
|
8
8
|
import { createRequire } from 'module';
|
|
9
|
+
import { stripVersionFromCanonicalUrl } from './utils.js';
|
|
9
10
|
import { fileURLToPath } from 'url';
|
|
10
11
|
import path from 'path';
|
|
11
12
|
const require = createRequire(import.meta.url);
|
|
@@ -244,7 +245,7 @@ export const VALUESET_DEFAULTS = {
|
|
|
244
245
|
*/
|
|
245
246
|
export function resolveValueSetDefault(bindingUri) {
|
|
246
247
|
// Strip version suffix: "http://...ValueSet/foo|4.0.1" → "http://...ValueSet/foo"
|
|
247
|
-
const bare = bindingUri
|
|
248
|
+
const bare = stripVersionFromCanonicalUrl(bindingUri);
|
|
248
249
|
return VALUESET_DEFAULTS[bare];
|
|
249
250
|
}
|
|
250
251
|
export const FALLBACK_CODES = {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* binding, while KBV_EX_ERP_PracticeSupply_Payor binds it required to
|
|
10
10
|
* KBV_VS_FOR_Payor_type.
|
|
11
11
|
*/
|
|
12
|
+
import { stripVersionFromCanonicalUrl } from './utils.js';
|
|
12
13
|
/** `Extension.extension:<name>` with no further path below it. */
|
|
13
14
|
const SUB_SLICE_ID = /^Extension\.extension:([^.:]+)$/;
|
|
14
15
|
/**
|
|
@@ -32,7 +33,7 @@ export function readBinding(el) {
|
|
|
32
33
|
: binding.valueSet?.reference;
|
|
33
34
|
if (!raw)
|
|
34
35
|
return undefined;
|
|
35
|
-
return { strength: binding.strength, uri: raw
|
|
36
|
+
return { strength: binding.strength, uri: stripVersionFromCanonicalUrl(raw) };
|
|
36
37
|
}
|
|
37
38
|
/** Type codes declared on an element. */
|
|
38
39
|
function typeCodes(el) {
|
|
@@ -69,6 +70,8 @@ export function collectSubExtensionSlices(els) {
|
|
|
69
70
|
if (!match)
|
|
70
71
|
continue;
|
|
71
72
|
const sliceName = el.sliceName ?? match[1];
|
|
73
|
+
if (!sliceName)
|
|
74
|
+
continue;
|
|
72
75
|
const urlEl = byId.get(`Extension.extension:${sliceName}.url`);
|
|
73
76
|
const url = typeof urlEl?.fixedUri === 'string' ? urlEl.fixedUri : undefined;
|
|
74
77
|
if (!url)
|
|
@@ -14,6 +14,43 @@ 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 slice name after the colon: `extension:Stadtteil` -> `Stadtteil`.
|
|
33
|
+
*
|
|
34
|
+
* Undefined when the segment carries no slice, which is the distinction callers
|
|
35
|
+
* act on — the counterpart to {@link stripSliceSuffix}, which returns the base.
|
|
36
|
+
*/
|
|
37
|
+
export function sliceSuffix(segment) {
|
|
38
|
+
const colonIndex = segment.indexOf(':');
|
|
39
|
+
return colonIndex >= 0 ? segment.substring(colonIndex + 1) : undefined;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The first dot-separated segment of a path: `code.coding.system` -> `code`.
|
|
43
|
+
*
|
|
44
|
+
* indexOf rather than `split('.')[0]`, for the same reason as
|
|
45
|
+
* {@link stripSliceSuffix}: the split form is an indexing expression and reads as
|
|
46
|
+
* possibly-undefined even when it cannot be.
|
|
47
|
+
*/
|
|
48
|
+
export function firstSegment(path) {
|
|
49
|
+
if (!path)
|
|
50
|
+
return path;
|
|
51
|
+
const dotIndex = path.indexOf('.');
|
|
52
|
+
return dotIndex >= 0 ? path.substring(0, dotIndex) : path;
|
|
53
|
+
}
|
|
17
54
|
export function writeInterfaceAndValidatorToFile(outputDir, interfaceName, interfaceContent, validateFunctionContent) {
|
|
18
55
|
const filePath = path.join(outputDir, `${interfaceName}.ts`);
|
|
19
56
|
const content = `${interfaceContent}\n\n${validateFunctionContent}`;
|
|
@@ -96,7 +133,9 @@ export function getResourceTypes(options) {
|
|
|
96
133
|
const exportRegex = /export\s+(?:\*|{[^}]+})\s+from\s+['"]\.\/([\w-]+)\.js['"]/g;
|
|
97
134
|
let match;
|
|
98
135
|
while ((match = exportRegex.exec(indexContent)) !== null) {
|
|
99
|
-
|
|
136
|
+
const moduleName = match[1];
|
|
137
|
+
if (moduleName)
|
|
138
|
+
exportedTypes.add(moduleName);
|
|
100
139
|
}
|
|
101
140
|
}
|
|
102
141
|
for (const file of files) {
|
|
@@ -109,9 +148,9 @@ export function getResourceTypes(options) {
|
|
|
109
148
|
if (actualFile) {
|
|
110
149
|
const content = fs.readFileSync(actualFile, "utf-8");
|
|
111
150
|
// Check if it has resourceType property (base resource)
|
|
112
|
-
const
|
|
113
|
-
if (
|
|
114
|
-
resourceTypes.set(profileName,
|
|
151
|
+
const declaredResourceType = content.match(/resourceType:\s*["'](\w+)["']/)?.[1];
|
|
152
|
+
if (declaredResourceType) {
|
|
153
|
+
resourceTypes.set(profileName, declaredResourceType);
|
|
115
154
|
continue;
|
|
116
155
|
}
|
|
117
156
|
// Check if it extends a FHIR resource (handles both `extends Foo` and `extends Omit<Foo, ...>`)
|
|
@@ -136,15 +175,16 @@ export function getBaseResourceType(profileName, outputDir) {
|
|
|
136
175
|
}
|
|
137
176
|
const content = fs.readFileSync(actualFile, "utf-8");
|
|
138
177
|
// Check for explicit resourceType property
|
|
139
|
-
const
|
|
140
|
-
if (
|
|
141
|
-
return
|
|
178
|
+
const declaredResourceType = content.match(/resourceType:\s*["'](\w+)["']/)?.[1];
|
|
179
|
+
if (declaredResourceType) {
|
|
180
|
+
return declaredResourceType;
|
|
142
181
|
}
|
|
143
182
|
// Check for extends clause (handles both `extends Foo` and `extends Omit<Foo, ...>`)
|
|
144
183
|
const baseTypes = extractBaseTypes(content);
|
|
145
|
-
|
|
184
|
+
const [firstBase] = baseTypes;
|
|
185
|
+
if (firstBase) {
|
|
146
186
|
// Prefer a known resource type, fall back to the first candidate
|
|
147
|
-
return baseTypes.find(bt => ctx().resourceNames.includes(bt)) ??
|
|
187
|
+
return baseTypes.find(bt => ctx().resourceNames.includes(bt)) ?? firstBase;
|
|
148
188
|
}
|
|
149
189
|
return profileName; // fallback
|
|
150
190
|
}
|
|
@@ -331,6 +371,48 @@ export function sanitizeIdentifier(name) {
|
|
|
331
371
|
cleaned = `_${cleaned}`;
|
|
332
372
|
return cleaned;
|
|
333
373
|
}
|
|
374
|
+
/**
|
|
375
|
+
* Turn a FHIR resource name into a PascalCase TypeScript identifier.
|
|
376
|
+
*
|
|
377
|
+
* Shared by the ValueSet and NamingSystem emitters, which previously carried
|
|
378
|
+
* the same split-and-capitalize loop with only the digit prefix differing.
|
|
379
|
+
* {@link toPascalCase} alone is not enough: it drops the underscore that
|
|
380
|
+
* {@link sanitizeIdentifier} adds, so a name starting with a digit comes back
|
|
381
|
+
* as an invalid identifier.
|
|
382
|
+
*/
|
|
383
|
+
export function pascalCaseIdentifier(name, opts) {
|
|
384
|
+
const stripped = opts.stripPrefix ? name.replace(opts.stripPrefix, '') : name;
|
|
385
|
+
const pascal = toPascalCase(stripped);
|
|
386
|
+
if (!pascal)
|
|
387
|
+
return opts.fallback;
|
|
388
|
+
return /^[0-9]/.test(pascal) ? `${opts.digitPrefix}${pascal}` : pascal;
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Lower-case a name's first character, keeping a leading acronym run intact.
|
|
392
|
+
*
|
|
393
|
+
* "CHCorePatient" → "chCorePatient" (the acronym lowers wholesale, but the
|
|
394
|
+
* last capital starts the next word)
|
|
395
|
+
* "Patient" → "patient"
|
|
396
|
+
* "HumanName" → "humanName"
|
|
397
|
+
*
|
|
398
|
+
* Shared by the prefab renderer and the MCP view registry, which each carried a
|
|
399
|
+
* byte-identical copy — the second one commented "Mirrors the same logic in
|
|
400
|
+
* prefabRenderer.ts", which is the drift warning rather than a defence.
|
|
401
|
+
*
|
|
402
|
+
* charAt() is deliberate over indexing: it yields '' past the end, so the
|
|
403
|
+
* acronym run terminates at the boundary without an index assertion.
|
|
404
|
+
*/
|
|
405
|
+
export function lowerFirstPreservingAcronym(name) {
|
|
406
|
+
if (!name)
|
|
407
|
+
return name;
|
|
408
|
+
let i = 0;
|
|
409
|
+
while (i < name.length && name.charAt(i) >= 'A' && name.charAt(i) <= 'Z')
|
|
410
|
+
i++;
|
|
411
|
+
if (i <= 1)
|
|
412
|
+
return name.charAt(0).toLowerCase() + name.slice(1);
|
|
413
|
+
const acronymEnd = i === name.length ? i : i - 1;
|
|
414
|
+
return name.slice(0, acronymEnd).toLowerCase() + name.slice(acronymEnd);
|
|
415
|
+
}
|
|
334
416
|
export function toPascalCase(name) {
|
|
335
417
|
if (!name)
|
|
336
418
|
return name;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ctx } from '../../fhir/versionContext.js';
|
|
2
|
+
import { stripVersionFromCanonicalUrl } from '../../core/utils.js';
|
|
2
3
|
import { TS, } from '../../core/constants.js';
|
|
3
4
|
import { primitivePlaceholder, generateCodeDefault, makeCodeableConcept, chooseChoiceVariant, applyRequiredFieldHeuristic, deriveIdentifierConfig, } from './classHeuristics.js';
|
|
4
5
|
import { generateSliceElement, buildNestedRequirementsObject } from './sliceElementGenerator.js';
|
|
@@ -187,7 +188,11 @@ export function generateClass(className, interfaceName, baseResource, requiredFi
|
|
|
187
188
|
.filter((s) => typeof s === 'string' && s.length > 0);
|
|
188
189
|
const uniqueSystems = Array.from(new Set(systems));
|
|
189
190
|
const singleSystem = uniqueSystems.length === 1 ? JSON.stringify(uniqueSystems[0]) : undefined;
|
|
191
|
+
// bindingCodes is non-empty per the guard above, but indexing does not say
|
|
192
|
+
// so; without a code there is no skeleton value to emit.
|
|
190
193
|
const primary = rf.bindingCodes[0];
|
|
194
|
+
if (!primary)
|
|
195
|
+
continue;
|
|
191
196
|
if (rf.type === 'CodeableConcept') {
|
|
192
197
|
const expr = singleSystem
|
|
193
198
|
? `skeletonCodeableConcept(${singleSystem}, ${codesArray})`
|
|
@@ -349,8 +354,8 @@ export function generateClass(className, interfaceName, baseResource, requiredFi
|
|
|
349
354
|
}
|
|
350
355
|
}
|
|
351
356
|
// Check profile URLs for known coding profiles
|
|
352
|
-
|
|
353
|
-
|
|
357
|
+
const profileUrl = slice.profileUrls?.[0];
|
|
358
|
+
if (profileUrl) {
|
|
354
359
|
const profileUrlLower = profileUrl.toLowerCase();
|
|
355
360
|
// ISiK SNOMED-CT coding profile
|
|
356
361
|
if (profileUrlLower.includes('snomedctcoding') || profileUrlLower.includes('snomed')) {
|
|
@@ -376,7 +381,7 @@ export function generateClass(className, interfaceName, baseResource, requiredFi
|
|
|
376
381
|
// code from a different context and should be deferred to runtime resolution.
|
|
377
382
|
const sliceHasOwnBinding = !!(slice.binding?.uri && slice.binding.uri.length > 0);
|
|
378
383
|
const sliceBindingMatchesParent = !rf.bindingUri ||
|
|
379
|
-
(sliceHasOwnBinding && slice.binding.uri
|
|
384
|
+
(sliceHasOwnBinding && stripVersionFromCanonicalUrl(slice.binding.uri) === stripVersionFromCanonicalUrl(rf.bindingUri));
|
|
380
385
|
if (sliceBindingMatchesParent && slice.binding?.sampleCode) {
|
|
381
386
|
if (slice.binding.sampleCode.system) {
|
|
382
387
|
system = slice.binding.sampleCode.system;
|
|
@@ -384,7 +389,7 @@ export function generateClass(className, interfaceName, baseResource, requiredFi
|
|
|
384
389
|
code = JSON.stringify(slice.binding.sampleCode.code);
|
|
385
390
|
codeResolved = true;
|
|
386
391
|
}
|
|
387
|
-
else if (sliceBindingMatchesParent && slice.binding?.codes
|
|
392
|
+
else if (sliceBindingMatchesParent && slice.binding?.codes?.[0]) {
|
|
388
393
|
// Fallback to first code from the binding's codes array
|
|
389
394
|
const firstCode = slice.binding.codes[0];
|
|
390
395
|
if (firstCode.system) {
|
|
@@ -458,8 +463,8 @@ export function generateClass(className, interfaceName, baseResource, requiredFi
|
|
|
458
463
|
// Handle CodeableConcept with pattern constraint - use the pattern instead of skeleton
|
|
459
464
|
if (rf.type === 'CodeableConcept' && rf.patternConstraint && typeof rf.patternConstraint === 'object') {
|
|
460
465
|
const pc = rf.patternConstraint;
|
|
461
|
-
|
|
462
|
-
|
|
466
|
+
const firstCoding = Array.isArray(pc.coding) ? pc.coding[0] : undefined;
|
|
467
|
+
if (firstCoding) {
|
|
463
468
|
const systemStr = JSON.stringify(firstCoding.system || TS.NULL_FLAVOR);
|
|
464
469
|
const codeStr = JSON.stringify(firstCoding.code || 'UNK');
|
|
465
470
|
const displayStr = firstCoding.display ? `, ${JSON.stringify(firstCoding.display)}` : '';
|
|
@@ -522,8 +527,9 @@ export function generateClass(className, interfaceName, baseResource, requiredFi
|
|
|
522
527
|
}
|
|
523
528
|
// Complex type skeletons (also check baseType for profiled types like CodeableConcept-uv-ips)
|
|
524
529
|
const skeletonType = COMPLEX_SKELETON_MAP[rf.type] ? rf.type : (rf.baseType && COMPLEX_SKELETON_MAP[rf.baseType] ? rf.baseType : undefined);
|
|
525
|
-
|
|
526
|
-
|
|
530
|
+
const skeletonExpr = skeletonType ? COMPLEX_SKELETON_MAP[skeletonType] : undefined;
|
|
531
|
+
if (skeletonType && skeletonExpr) {
|
|
532
|
+
const expr = skeletonExpr;
|
|
527
533
|
const initExpr = buildValueExpression(rf, expr);
|
|
528
534
|
requiredInits.push(`${propKey(rf.name)}: ${initExpr}`);
|
|
529
535
|
continue;
|
|
@@ -61,15 +61,14 @@ export function buildValueExpression(rf, expr, opts) {
|
|
|
61
61
|
export function buildCrossReferencedEntryArray(sliceElements) {
|
|
62
62
|
const compositionIdx = [];
|
|
63
63
|
const resourceEntries = [];
|
|
64
|
-
for (
|
|
65
|
-
const expr = sliceElements[i];
|
|
64
|
+
for (const [i, expr] of sliceElements.entries()) {
|
|
66
65
|
if (/resourceType:\s*'Composition'/.test(expr)) {
|
|
67
66
|
compositionIdx.push(i);
|
|
68
67
|
}
|
|
69
68
|
else {
|
|
70
|
-
const
|
|
71
|
-
if (
|
|
72
|
-
resourceEntries.push({ idx: i, type
|
|
69
|
+
const type = expr.match(/resourceType:\s*'(\w+)'/)?.[1];
|
|
70
|
+
if (type)
|
|
71
|
+
resourceEntries.push({ idx: i, type });
|
|
73
72
|
}
|
|
74
73
|
}
|
|
75
74
|
if (compositionIdx.length === 0 || resourceEntries.length === 0)
|
|
@@ -79,10 +78,16 @@ export function buildCrossReferencedEntryArray(sliceElements) {
|
|
|
79
78
|
for (const { idx, type } of resourceEntries) {
|
|
80
79
|
const varName = `_${type.charAt(0).toLowerCase() + type.slice(1)}Url`;
|
|
81
80
|
decls.push(`const ${varName} = 'urn:uuid:' + crypto.randomUUID();`);
|
|
82
|
-
|
|
81
|
+
const entry = rewritten[idx];
|
|
82
|
+
if (entry !== undefined) {
|
|
83
|
+
rewritten[idx] = entry.replace(/fullUrl:\s*'urn:uuid:'\s*\+\s*crypto\.randomUUID\(\)/, `fullUrl: ${varName}`);
|
|
84
|
+
}
|
|
83
85
|
for (const ci of compositionIdx) {
|
|
84
86
|
const refPattern = new RegExp(`reference:\\s*'${type}/\\s*'\\s*\\+\\s*randomId\\(\\)`);
|
|
85
|
-
|
|
87
|
+
const composition = rewritten[ci];
|
|
88
|
+
if (composition !== undefined) {
|
|
89
|
+
rewritten[ci] = composition.replace(refPattern, `reference: ${varName}`);
|
|
90
|
+
}
|
|
86
91
|
}
|
|
87
92
|
}
|
|
88
93
|
return `(() => { ${decls.join(' ')} return [${rewritten.join(', ')}]; })()`;
|
|
@@ -95,8 +100,16 @@ export function generatePatternMetadata(fieldPatterns, forbiddenFields, valueSet
|
|
|
95
100
|
}
|
|
96
101
|
const hasBindings = Object.keys(valueSetBindings).length > 0;
|
|
97
102
|
return `
|
|
98
|
-
/**
|
|
99
|
-
|
|
103
|
+
/**
|
|
104
|
+
* Pattern constraints this profile fixes on its fields, keyed by field path
|
|
105
|
+
* (e.g. \`"shlSession.system": "https://example.org/fhir/shl-session"\`).
|
|
106
|
+
*
|
|
107
|
+
* Public alongside \`valueSetBindings\`: a consumer that needs a fixed value —
|
|
108
|
+
* an identifier system, a fixed code — would otherwise hand-write the literal
|
|
109
|
+
* the profile already declares, which is the duplication this package exists
|
|
110
|
+
* to remove. Read by the random() generator too.
|
|
111
|
+
*/
|
|
112
|
+
static readonly _fieldPatterns = ${JSON.stringify(patternMap, null, 2)};
|
|
100
113
|
|
|
101
114
|
/** Fields that are forbidden (max=0) in this profile - must not be generated */
|
|
102
115
|
protected static readonly _forbiddenFields: string[] = ${JSON.stringify(forbiddenFields)};
|
|
@@ -91,6 +91,8 @@ export function chooseChoiceVariant(rf) {
|
|
|
91
91
|
return null;
|
|
92
92
|
}
|
|
93
93
|
let chosen = options[0];
|
|
94
|
+
if (chosen === undefined)
|
|
95
|
+
return null;
|
|
94
96
|
if (lowerBase === 'value' && preferCodeableConcept) {
|
|
95
97
|
chosen = 'CodeableConcept';
|
|
96
98
|
}
|
|
@@ -210,8 +212,8 @@ export function applyRequiredFieldHeuristic(rf, ctx) {
|
|
|
210
212
|
return { expr: makeCoding(system, picked.code), isObjectLiteral: true };
|
|
211
213
|
}
|
|
212
214
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
+
const [first] = rf.bindingCodes;
|
|
216
|
+
if (isCodeableConcept && first) {
|
|
215
217
|
const system = first.system || TS.SNOMED;
|
|
216
218
|
return { expr: `skeletonCodeableConcept('${system}', ['${first.code}'])` };
|
|
217
219
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { fetchStructureDefinition } from '../../parser/sdParser.js';
|
|
2
|
+
import { stripSliceSuffix, sliceSuffix } from '../../core/utils.js';
|
|
2
3
|
export async function extractFieldPatternsAndMetadata(patternSources, baseResource, sd, requiredFields, candidateRequiredFields, nestedRequirementsMap, resolvedProfilePatterns) {
|
|
3
4
|
const patternMap = new Map();
|
|
4
5
|
for (const src of patternSources) {
|
|
@@ -7,7 +8,7 @@ export async function extractFieldPatternsAndMetadata(patternSources, baseResour
|
|
|
7
8
|
continue;
|
|
8
9
|
// Extract the field path parts
|
|
9
10
|
const parts = f.name.split('.');
|
|
10
|
-
const lastPart = (parts.pop()
|
|
11
|
+
const lastPart = stripSliceSuffix(parts.pop() ?? f.name); // base field name without slice
|
|
11
12
|
// For patterns on '.coding' elements, associate with the parent CodeableConcept field
|
|
12
13
|
// e.g., for "Coverage.type.coding:VersicherungsArtDeBasis", use "type" as key
|
|
13
14
|
let key = lastPart;
|
|
@@ -15,7 +16,7 @@ export async function extractFieldPatternsAndMetadata(patternSources, baseResour
|
|
|
15
16
|
// This is a coding inside a CodeableConcept - use the parent field name
|
|
16
17
|
const parentPart = parts.pop();
|
|
17
18
|
if (parentPart) {
|
|
18
|
-
key = parentPart
|
|
19
|
+
key = stripSliceSuffix(parentPart);
|
|
19
20
|
}
|
|
20
21
|
}
|
|
21
22
|
// For nested fields (children of children, e.g., identifier.type vs top-level type),
|
|
@@ -28,8 +29,9 @@ export async function extractFieldPatternsAndMetadata(patternSources, baseResour
|
|
|
28
29
|
idParts.shift(); // remove resource type prefix
|
|
29
30
|
const nestedParts = [];
|
|
30
31
|
for (const part of idParts) {
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
const partSliceName = sliceSuffix(part);
|
|
33
|
+
if (partSliceName !== undefined) {
|
|
34
|
+
nestedParts.push(partSliceName); // use slice name
|
|
33
35
|
}
|
|
34
36
|
else if (!part.includes('[x]')) {
|
|
35
37
|
nestedParts.push(part);
|
|
@@ -204,9 +206,10 @@ export async function extractFieldPatternsAndMetadata(patternSources, baseResour
|
|
|
204
206
|
// e.g., "value[x]:valueQuantity.unit" -> "valueQuantity.unit"
|
|
205
207
|
const nestedParts = [];
|
|
206
208
|
for (const part of idParts) {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
209
|
+
// Slice name, e.g. "value[x]:valueQuantity" -> "valueQuantity"
|
|
210
|
+
const partSliceName = sliceSuffix(part);
|
|
211
|
+
if (partSliceName !== undefined) {
|
|
212
|
+
nestedParts.push(partSliceName);
|
|
210
213
|
}
|
|
211
214
|
else if (!part.includes('[x]')) {
|
|
212
215
|
nestedParts.push(part);
|
|
@@ -235,8 +238,9 @@ export async function extractFieldPatternsAndMetadata(patternSources, baseResour
|
|
|
235
238
|
// Build nested key, extracting slice names where present
|
|
236
239
|
const nestedParts = [];
|
|
237
240
|
for (const part of idParts) {
|
|
238
|
-
|
|
239
|
-
|
|
241
|
+
const partSliceName = sliceSuffix(part);
|
|
242
|
+
if (partSliceName !== undefined) {
|
|
243
|
+
nestedParts.push(partSliceName);
|
|
240
244
|
}
|
|
241
245
|
else if (!part.includes('[x]')) {
|
|
242
246
|
nestedParts.push(part);
|
|
@@ -252,7 +256,7 @@ export async function extractFieldPatternsAndMetadata(patternSources, baseResour
|
|
|
252
256
|
}
|
|
253
257
|
}
|
|
254
258
|
// Otherwise, if this is a slice with binding codes, synthesize a pattern
|
|
255
|
-
else if (!patternMap.has(key) && f.sliceName && f.binding
|
|
259
|
+
else if (!patternMap.has(key) && f.sliceName && f.binding?.codes?.[0]) {
|
|
256
260
|
// Create a CodeableConcept pattern from the first binding code
|
|
257
261
|
const firstCode = f.binding.codes[0];
|
|
258
262
|
const synthesizedPattern = {
|
|
@@ -363,9 +367,9 @@ export async function extractFieldPatternsAndMetadata(patternSources, baseResour
|
|
|
363
367
|
const [basePart, sliceName] = p.split(':', 2);
|
|
364
368
|
// For choice types (e.g., value[x]:valueQuantity), use the slice name
|
|
365
369
|
// as the actual JS property name
|
|
366
|
-
if (basePart
|
|
370
|
+
if (basePart?.includes('[x]') && sliceName)
|
|
367
371
|
return sliceName;
|
|
368
|
-
return basePart; // strip slice qualifier for non-choice types
|
|
372
|
+
return basePart ?? p; // strip slice qualifier for non-choice types
|
|
369
373
|
}
|
|
370
374
|
return p;
|
|
371
375
|
});
|
|
@@ -413,7 +417,7 @@ export async function extractFieldPatternsAndMetadata(patternSources, baseResour
|
|
|
413
417
|
const parts = elemId.split('.');
|
|
414
418
|
if (parts.length !== 2)
|
|
415
419
|
continue;
|
|
416
|
-
const fieldName = (parts[1] || '')
|
|
420
|
+
const fieldName = stripSliceSuffix(parts[1] || '');
|
|
417
421
|
if (fieldName && !seenFieldNames.has(fieldName)) {
|
|
418
422
|
seenFieldNames.add(fieldName);
|
|
419
423
|
fieldOrder.push(fieldName);
|
|
@@ -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
|
|
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;
|
|
@@ -390,11 +399,17 @@ export function getTypePlaceholder(fhirType) {
|
|
|
390
399
|
return '"https://babelfhir.dev"';
|
|
391
400
|
case 'code':
|
|
392
401
|
return '"code"';
|
|
402
|
+
// Dates are emitted as a call, not a value. This function returns code that
|
|
403
|
+
// lands in a generated class, so interpolating new Date() here froze the
|
|
404
|
+
// generation-time wall clock into the source: every regeneration produced a
|
|
405
|
+
// different file, and every consumer got the same instant forever. The
|
|
406
|
+
// sibling getDefaultValueForType already defers to randomDate() through its
|
|
407
|
+
// __DATE_EXPR__ placeholder; this now agrees with it.
|
|
393
408
|
case 'dateTime':
|
|
394
409
|
case 'instant':
|
|
395
|
-
return '
|
|
410
|
+
return 'randomDate()';
|
|
396
411
|
case 'date':
|
|
397
|
-
return '
|
|
412
|
+
return 'randomDate().slice(0, 10)';
|
|
398
413
|
case 'time':
|
|
399
414
|
return '"12:00:00"';
|
|
400
415
|
case 'Reference':
|
|
@@ -414,7 +429,7 @@ export function getTypePlaceholder(fhirType) {
|
|
|
414
429
|
case 'ContactPoint':
|
|
415
430
|
return '{ system: "phone", value: "555-1234" }';
|
|
416
431
|
case 'Period':
|
|
417
|
-
return '{ start:
|
|
432
|
+
return '{ start: randomDate() }';
|
|
418
433
|
case 'Attachment':
|
|
419
434
|
return '{ contentType: "text/plain" }';
|
|
420
435
|
case 'Narrative':
|
|
@@ -438,9 +453,9 @@ export function getTypePlaceholder(fhirType) {
|
|
|
438
453
|
case 'Annotation':
|
|
439
454
|
return '{ text: "Note" }';
|
|
440
455
|
case 'Signature':
|
|
441
|
-
return `{ type: [{ system: "urn:iso-astm:E1762-95:2013", code: "1.2.840.10065.1.12.1.1" }], when:
|
|
456
|
+
return `{ type: [{ system: "urn:iso-astm:E1762-95:2013", code: "1.2.840.10065.1.12.1.1" }], when: randomDate(), who: { reference: "Practitioner/example" } }`;
|
|
442
457
|
case 'Meta':
|
|
443
|
-
return '{ lastUpdated:
|
|
458
|
+
return '{ lastUpdated: randomDate() }';
|
|
444
459
|
case 'ContactDetail':
|
|
445
460
|
return '{ name: "Contact" }';
|
|
446
461
|
case 'UsageContext':
|
|
@@ -102,8 +102,8 @@ export function generateSliceElement(slice, elementType) {
|
|
|
102
102
|
// Detect already-resolved choice type variants
|
|
103
103
|
if (!wasResolvedFromChoiceNotation) {
|
|
104
104
|
const choiceMatch = childPath.match(/^([a-z]+)(Boolean|Integer|Decimal|DateTime|Date|Time|Instant|String|Uri|Url|Canonical|Base64Binary|Code|Oid|Id|Uuid|Markdown|UnsignedInt|PositiveInt|Coding|CodeableConcept|Quantity|Range|Period|Ratio|SampledData|Attachment|Duration|Distance|Count|Money|Age|Annotation|Signature|HumanName|Address|ContactPoint|Timing|Reference|Identifier|Meta|Dosage|Narrative|Expression|TriggerDefinition|DataRequirement|ParameterDefinition|RelatedArtifact|ContactDetail|UsageContext)$/);
|
|
105
|
-
|
|
106
|
-
|
|
105
|
+
const baseName = choiceMatch?.[1];
|
|
106
|
+
if (baseName) {
|
|
107
107
|
if (resolvedChoiceBases.has(baseName)) {
|
|
108
108
|
continue;
|
|
109
109
|
}
|
|
@@ -169,7 +169,7 @@ export function generateSliceElement(slice, elementType) {
|
|
|
169
169
|
else if (profileUrl) {
|
|
170
170
|
const profileName = profileUrl.split('/').pop() || '';
|
|
171
171
|
if (profileName.includes('-')) {
|
|
172
|
-
resourceType = capitalise(profileName.split('-')[0]);
|
|
172
|
+
resourceType = capitalise(profileName.split('-')[0] ?? profileName);
|
|
173
173
|
}
|
|
174
174
|
else {
|
|
175
175
|
resourceType = capitalise(profileName);
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* }
|
|
19
19
|
* ```
|
|
20
20
|
*/
|
|
21
|
-
import { capitalize, choicePropertyName, narrowedChoiceType, sanitizeIdentifier } from '../../core/utils.js';
|
|
21
|
+
import { capitalize, choicePropertyName, narrowedChoiceType, sanitizeIdentifier, firstSegment } from '../../core/utils.js';
|
|
22
22
|
import { getRules } from '../../fhir/versionContext.js';
|
|
23
23
|
import { logger } from '../../../logger.js';
|
|
24
24
|
const log = logger.withTag('backbone-slices');
|
|
@@ -35,7 +35,11 @@ export function generateBackboneSliceTypes(ctx) {
|
|
|
35
35
|
return;
|
|
36
36
|
for (const group of sliceGroups) {
|
|
37
37
|
const { parentField, sliceFields, childFields, slicingRules } = group;
|
|
38
|
-
const fieldName = parentField.name.split('.').
|
|
38
|
+
const fieldName = parentField.name.split('.').at(-1);
|
|
39
|
+
// A grouped parent always has a dotted path, so this is unreachable; skipping
|
|
40
|
+
// is right if it ever is not, and it replaces a non-null assertion.
|
|
41
|
+
if (!fieldName)
|
|
42
|
+
continue;
|
|
39
43
|
// Determine the base backbone type for this field
|
|
40
44
|
let backboneType = inferBackboneType(baseResource, fieldName);
|
|
41
45
|
if (!backboneType) {
|
|
@@ -100,8 +104,9 @@ export function generateBackboneSliceTypes(ctx) {
|
|
|
100
104
|
if (isOpen) {
|
|
101
105
|
unionMembers.push(backboneType);
|
|
102
106
|
}
|
|
103
|
-
const
|
|
104
|
-
|
|
107
|
+
const [soleMember] = unionMembers;
|
|
108
|
+
const unionType = unionMembers.length === 1 && soleMember
|
|
109
|
+
? `${soleMember}[]`
|
|
105
110
|
: `(${unionMembers.join(' | ')})[]`;
|
|
106
111
|
// Find and update the field in the root interface
|
|
107
112
|
updateRootInterfaceField(interfaces, interfaceName, fieldName, parentField.isOptional, unionType, baseResource);
|
|
@@ -181,7 +186,7 @@ function isEligibleBackbonePath(baseType, path, baseResource) {
|
|
|
181
186
|
const parts = path.split('.');
|
|
182
187
|
if (parts.length !== 2)
|
|
183
188
|
return false;
|
|
184
|
-
if (baseResource &&
|
|
189
|
+
if (baseResource && firstSegment(path) !== baseResource)
|
|
185
190
|
return false;
|
|
186
191
|
return true;
|
|
187
192
|
}
|
|
@@ -224,9 +229,10 @@ function buildSliceInterfaceBody(sliceField, sliceChildren, sliceElementId, _bac
|
|
|
224
229
|
const childId = child.elementId || child.name;
|
|
225
230
|
const relativePath = childId.substring(sliceElementId.length + 1);
|
|
226
231
|
const match = choiceSlicePattern.exec(relativePath);
|
|
227
|
-
|
|
232
|
+
const choiceParent = match?.[1]; // e.g. "value[x]"
|
|
233
|
+
if (choiceParent) {
|
|
228
234
|
choiceTypeSlices.push(child);
|
|
229
|
-
choiceTypeParents.add(
|
|
235
|
+
choiceTypeParents.add(choiceParent);
|
|
230
236
|
}
|
|
231
237
|
}
|
|
232
238
|
// Collect deeply nested fixed values for object literal synthesis.
|
|
@@ -240,7 +246,9 @@ function buildSliceInterfaceBody(sliceField, sliceChildren, sliceElementId, _bac
|
|
|
240
246
|
if (relativePath.includes('.')) {
|
|
241
247
|
if (child.fixedValue !== undefined) {
|
|
242
248
|
const segments = relativePath.split('.');
|
|
243
|
-
|
|
249
|
+
// firstSegment is segments[0] without the possibly-undefined read; segments
|
|
250
|
+
// is still needed for the tail below.
|
|
251
|
+
const rawPropName = firstSegment(relativePath);
|
|
244
252
|
const propName = rawPropName.replace(/\[x\]$/, '');
|
|
245
253
|
// Skip nested values under:
|
|
246
254
|
// - choice-type elements ([x]) — handled by direct child or sub-slices
|
|
@@ -425,8 +433,8 @@ function updateRootInterfaceField(interfaces, interfaceName, fieldName, isOption
|
|
|
425
433
|
const newFieldLine = ` ${fieldName}${optMark}: ${unionType};`;
|
|
426
434
|
// Try to find an existing interface declaration
|
|
427
435
|
const rootIdx = interfaces.findIndex(i => i.startsWith(`export interface ${interfaceName} `) || i.startsWith(`export interface ${interfaceName}<`));
|
|
428
|
-
|
|
429
|
-
|
|
436
|
+
const iface = rootIdx >= 0 ? interfaces[rootIdx] : undefined;
|
|
437
|
+
if (iface !== undefined) {
|
|
430
438
|
const fieldPattern = new RegExp(`^(\\s+)${fieldName}[?]?:\\s*.+;`, 'm');
|
|
431
439
|
if (fieldPattern.test(iface)) {
|
|
432
440
|
// Replace existing field line
|
|
@@ -443,10 +451,10 @@ function updateRootInterfaceField(interfaces, interfaceName, fieldName, isOption
|
|
|
443
451
|
}
|
|
444
452
|
// Try to find a type alias (e.g., "export type BloodPressure = Observation;")
|
|
445
453
|
const aliasIdx = interfaces.findIndex(i => i.startsWith(`export type ${interfaceName} =`));
|
|
446
|
-
|
|
454
|
+
const alias = aliasIdx >= 0 ? interfaces[aliasIdx] : undefined;
|
|
455
|
+
if (alias !== undefined) {
|
|
447
456
|
// Convert type alias to interface with the field
|
|
448
|
-
const
|
|
449
|
-
const baseType = aliasMatch ? aliasMatch[1] : baseResource || '';
|
|
457
|
+
const baseType = alias.match(/export type \S+ = (\S+);/)?.[1] ?? baseResource ?? '';
|
|
450
458
|
interfaces[aliasIdx] = `export interface ${interfaceName} extends ${baseType} {\n${newFieldLine}\n}`;
|
|
451
459
|
}
|
|
452
460
|
}
|