babelfhir-ts 1.5.18 → 1.5.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +37 -46
  2. package/out/src/generator/core/constants.js +40 -0
  3. package/out/src/generator/core/generatedPackageDeps.js +86 -11
  4. package/out/src/generator/core/utils.js +40 -1
  5. package/out/src/generator/emitters/class/classGenerator.js +2 -1
  6. package/out/src/generator/emitters/class/classGeneratorHelpers.js +8 -1
  7. package/out/src/generator/emitters/class/classTemplate.js +3 -3
  8. package/out/src/generator/emitters/class/enrichContextTemplate.js +11 -1
  9. package/out/src/generator/emitters/class/enrichCtxTransform.js +1 -0
  10. package/out/src/generator/emitters/class/enrichResourceHandlers.js +21 -2
  11. package/out/src/generator/emitters/class/enrichResourceObservationHandler.js +4 -4
  12. package/out/src/generator/emitters/class/enrichResourceTemplate.js +27 -8
  13. package/out/src/generator/emitters/class/fieldPatternExtractor.js +8 -1
  14. package/out/src/generator/emitters/class/randomSupportGenerator.js +5 -4
  15. package/out/src/generator/emitters/class/randomUtilitiesTemplate.js +79 -0
  16. package/out/src/generator/emitters/class/sliceElementDefaults.js +5 -3
  17. package/out/src/generator/emitters/client/clientGenerator.js +6 -1
  18. package/out/src/generator/emitters/interface/backboneSliceTyping.js +13 -2
  19. package/out/src/generator/emitters/interface/importManager.js +28 -2
  20. package/out/src/generator/emitters/interface/interfaceFieldProcessor.js +21 -16
  21. package/out/src/generator/emitters/interface/interfaceFieldUtils.js +16 -0
  22. package/out/src/generator/emitters/interface/processNestedField.js +12 -34
  23. package/out/src/generator/emitters/prefab/prefabRenderer.js +4 -1
  24. package/out/src/generator/emitters/prefab/prefabRuntimeAssets.js +23 -0
  25. package/out/src/generator/emitters/validator/sliceBackboneValidation.js +28 -24
  26. package/out/src/generator/emitters/validator/sliceDelegation.js +450 -0
  27. package/out/src/generator/emitters/validator/sliceExtensionValidation.js +22 -22
  28. package/out/src/generator/emitters/validator/sliceValidatorGenerator.js +55 -318
  29. package/out/src/generator/emitters/validator/sliceValidatorUtils.js +90 -17
  30. package/out/src/generator/emitters/validator/validatorBindingBuilder.js +113 -237
  31. package/out/src/generator/emitters/validator/validatorBindingLeafEmitters.js +13 -4
  32. package/out/src/generator/emitters/validator/validatorConstraintBuilders.js +91 -62
  33. package/out/src/generator/emitters/validator/validatorExpressions.js +54 -0
  34. package/out/src/generator/emitters/validator/validatorFieldBuilders.js +275 -37
  35. package/out/src/generator/emitters/validator/validatorGenerator.js +168 -87
  36. package/out/src/generator/emitters/validator/validatorRuntime.js +269 -0
  37. package/out/src/generator/emitters/validator/validatorTemplates.js +186 -54
  38. package/out/src/generator/emitters/zod/zodRefinementBuilder.js +29 -19
  39. package/out/src/generator/emitters/zod/zodSchemaGenerator.js +16 -1
  40. package/out/src/generator/parser/packageParser.js +1 -8
  41. package/out/src/generator/sdProcessor.js +97 -12
  42. package/out/src/generator/sdProcessorHelpers.js +1 -1
  43. package/out/src/generator/sdProcessorRequiredFields.js +95 -0
  44. package/out/src/main.js +37 -11
  45. package/package.json +3 -2
  46. package/parity-matrix.json +18 -18
package/README.md CHANGED
@@ -46,59 +46,42 @@
46
46
  - **Type-safe extension handling** with proper slicing and nested extension support
47
47
  - **Random data builders** for testing and development (when class generation is enabled)
48
48
  - **Zero manual mapping**—consume any FHIR package or Implementation Guide directly from registries
49
- - **Fast and lightweight**—minimal runtime deps; only `fhirpath` is required for validators
50
- - **Type-safe FHIR client** — generated client extends [`@babelfhir-ts/client-r4`](https://www.npmjs.com/package/@babelfhir-ts/client-r4) / [`client-r4b`](https://www.npmjs.com/package/@babelfhir-ts/client-r4b) / [`client-r5`](https://www.npmjs.com/package/@babelfhir-ts/client-r5) with profile-specific methods (e.g., `.usCorePatient()`, `.pASClaim()`) on top of base resource accessors
49
+ - **Fast and lightweight**—the CLI pulls no FHIRPath engine of its own; generated packages declare `fhirpath` as a *peer* dependency (`>=4.9.1 <6`), so the host app owns the version
50
+ - **Type-safe FHIR client** — generated client extends [`@babelfhir-ts/client-r4`](https://www.npmjs.com/package/@babelfhir-ts/client-r4) / [`client-r4b`](https://www.npmjs.com/package/@babelfhir-ts/client-r4b) / [`client-r5`](https://www.npmjs.com/package/@babelfhir-ts/client-r5) with profile-specific methods (e.g., `.uSCorePatientProfile()`, `.pASClaim()`) on top of base resource accessors
51
51
  - **Install any FHIR profile as a node module**—use `babelfhir-ts install` to add Implementation Guides directly to your project
52
52
 
53
53
  <!-- PARITY-BADGES:START - Do not remove or modify this section -->
54
54
  ## Continuous Validation
55
55
 
56
- Every pull request runs two independent CI pipelines that validate generated code against real-world FHIR Implementation Guides. For each IG, the pipeline:
56
+ Every push to `develop` and `main` runs the parity pipeline, which validates generated code against real-world FHIR Implementation Guides. (Pull requests run the faster `CI` workflow: typecheck, unit tests, and generated-artifact drift checks.) For each IG, the pipeline:
57
57
 
58
58
  1. Downloads the FHIR package from a registry
59
59
  2. Generates TypeScript interfaces, validators, and classes
60
60
  3. Compiles the output with `tsc` (zero errors required)
61
61
  4. Generates `empty()` and `random()` test resources for every profile
62
- 5. Validates those resources against two external FHIR validators
63
-
64
- ### Tested Implementation Guides (30 packages)
65
-
66
- | Category | Implementation Guide | Package | FHIR |
67
- |---|---|---|---|
68
- | US | US Core | `hl7.fhir.us.core@8.0.0` | R4 |
69
- | US | QI-Core | `hl7.fhir.us.qicore@6.0.0` | R4 |
70
- | US | mCODE | `hl7.fhir.us.mcode@4.0.0` | R4 |
71
- | US | SDOH Clinical Care | `hl7.fhir.us.sdoh-clinicalcare@2.2.0` | R4 |
72
- | US | NDH (National Directory) | `hl7.fhir.us.ndh@1.0.0` | R4 |
73
- | US | CARIN BB | `hl7.fhir.us.carin-bb@2.1.0` | R4 |
74
- | US | CQF Measures | `hl7.fhir.us.cqfmeasures@4.0.0` | R4 |
75
- | US | Physical Activity | `hl7.fhir.us.physical-activity@1.0.0` | R4 |
76
- | DaVinci | PAS | `hl7.fhir.us.davinci-pas@2.0.1` | R4 |
77
- | DaVinci | CDex | `hl7.fhir.us.davinci-cdex@2.1.0` | R4 |
78
- | DaVinci | PDex | `hl7.fhir.us.davinci-pdex@2.1.0` | R4 |
79
- | DaVinci | DTR | `hl7.fhir.us.davinci-dtr@2.1.0` | R4 |
80
- | DaVinci | Alerts | `hl7.fhir.us.davinci-alerts@1.0.0` | R4 |
81
- | DaVinci | DEQM | `hl7.fhir.us.davinci-deqm@4.0.0` | R4 |
82
- | DaVinci | Drug Formulary | `hl7.fhir.us.davinci-drug-formulary@2.1.0` | R4 |
83
- | Universal | IPS | `hl7.fhir.uv.ips@2.0.0` | R4 |
84
- | Universal | SMART App Launch | `hl7.fhir.uv.smart-app-launch@2.2.0` | R4 |
85
- | Universal | SDC (Structured Data Capture) | `hl7.fhir.uv.sdc@3.0.0` | R4 |
86
- | Universal | Genomics Reporting | `hl7.fhir.uv.genomics-reporting@3.0.0` | R4 |
87
- | Universal | CPG (Clinical Practice Guidelines) | `hl7.fhir.uv.cpg@2.0.0` | R4 |
88
- | DE | ISiK Basis | `de.gematik.isik-basismodul@4.0.3` | R4 |
89
- | DE | KBV eRezept | `kbv.ita.erp@1.1.1` | R4 |
90
- | DE | DE Basisprofil | `de.basisprofil.r4@1.5.0` | R4 |
91
- | DE | ISiK Medikation | `de.gematik.isik-medikation@4.0.1` | R4 |
92
- | CH | CH Core (Switzerland) | `ch.fhir.ig.ch-core@5.0.0` | R4 |
93
- | AU | AU Core (Australia) | `hl7.fhir.au.core@1.0.0` | R4 |
94
- | IHE | PIXm | `ihe.iti.pixm@3.0.4` | R4 |
95
- | IHE | MHD | `ihe.iti.mhd@4.2.2` | R4 |
96
- | R5 | AE Research (R5) | `hl7.fhir.uv.ae-research-ig@1.0.1` | R5 |
97
- | R5 | eMedicinal Product (R5) | `hl7.fhir.uv.emedicinal-product-info@1.0.0` | R5 |
98
-
99
- ### Validation with Firely .NET SDK
100
-
101
- The first pipeline validates generated resources using the [Firely .NET SDK validator](https://docs.fire.ly/projects/Firely-NET-SDK/). Results are published as live badges:
62
+ 5. Validates those resources against two independent external FHIR validators
63
+
64
+ ### Validated Implementation Guides (30)
65
+
66
+ Every parity run validates **30 real-world IGs** (28 R4, 2 R5). The list is not duplicated here — it is generated from the parity suite's own source of truth into [`parity-matrix.json`](./parity-matrix.json).
67
+
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
+
70
+ **[→ Browse the FHIR IG registry](https://github.com/orgs/Max-Health-Inc/packages?ecosystem=npm&q=fhir-)**
71
+
72
+ ```bash
73
+ # GitHub Packages needs the scope mapped, and a token with read:packages
74
+ echo "@max-health-inc:registry=https://npm.pkg.github.com" >> .npmrc
75
+ npm install @max-health-inc/fhir-us-core
76
+ ```
77
+
78
+ Published by [Max-Health-Inc/fhir-igs](https://github.com/Max-Health-Inc/fhir-igs), which pins a babelfhir-ts version and republishes an IG only when the IG version, the pinned generator, or the generation flags actually change.
79
+
80
+ ### Validation with the Firely SDK Validator
81
+
82
+ The `firely` job validates generated resources using the [Firely SDK Validator](https://docs.fire.ly/projects/Firely-NET-SDK/validation.html) (`Firely.Fhir.Validation`, v3.x) running on the Firely .NET SDK (`Hl7.Fhir`, v6.x) — two separately versioned packages. Results are published as live badges:
83
+
84
+ <details><summary>Per-IG results</summary>
102
85
 
103
86
  ![US Core](https://img.shields.io/endpoint?url=https://max-health-inc.github.io/BabelFHIR-TS/badges/badge-us-core.json)
104
87
  ![QI-Core](https://img.shields.io/endpoint?url=https://max-health-inc.github.io/BabelFHIR-TS/badges/badge-qicore.json)
@@ -129,9 +112,13 @@ The first pipeline validates generated resources using the [Firely .NET SDK vali
129
112
  ![PIXm](https://img.shields.io/endpoint?url=https://max-health-inc.github.io/BabelFHIR-TS/badges/badge-pixm.json)
130
113
  ![MHD](https://img.shields.io/endpoint?url=https://max-health-inc.github.io/BabelFHIR-TS/badges/badge-mhd.json)
131
114
 
115
+ </details>
116
+
132
117
  ### Validation with HL7 Java Validator
133
118
 
134
- The second pipeline validates using the [official HL7 FHIR Validator](https://confluence.hl7.org/display/FHIR/Using+the+FHIR+Validator), the reference implementation for FHIR conformance checking:
119
+ The `hl7` job validates the same artifacts using the [official HL7 FHIR Validator](https://confluence.hl7.org/display/FHIR/Using+the+FHIR+Validator), the reference implementation for FHIR conformance checking:
120
+
121
+ <details><summary>Per-IG results</summary>
135
122
 
136
123
  ![US Core](https://img.shields.io/endpoint?url=https://max-health-inc.github.io/BabelFHIR-TS/badges/badge-hl7-us-core.json)
137
124
  ![QI-Core](https://img.shields.io/endpoint?url=https://max-health-inc.github.io/BabelFHIR-TS/badges/badge-hl7-qicore.json)
@@ -162,6 +149,10 @@ The second pipeline validates using the [official HL7 FHIR Validator](https://co
162
149
  ![PIXm](https://img.shields.io/endpoint?url=https://max-health-inc.github.io/BabelFHIR-TS/badges/badge-hl7-pixm.json)
163
150
  ![MHD](https://img.shields.io/endpoint?url=https://max-health-inc.github.io/BabelFHIR-TS/badges/badge-hl7-mhd.json)
164
151
 
152
+ </details>
153
+
154
+ > External-validator badges cover the 28 R4/R4B IGs. R5 packages are generated and typechecked, but neither external validator publishes R5 badges yet.
155
+
165
156
  > 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.
166
157
 
167
158
  📊 **[Full Report](https://max-health-inc.github.io/BabelFHIR-TS/)**
@@ -192,9 +183,9 @@ babelfhir-ts install hl7.fhir.us.core@8.0.0
192
183
  ```
193
184
 
194
185
  ```ts
195
- import { USCorePatientClass } from "./output/USCorePatientClass";
186
+ import { USCorePatientProfileClass } from "./output/USCorePatientProfileClass";
196
187
 
197
- const patient = USCorePatientClass.random();
188
+ const patient = USCorePatientProfileClass.random();
198
189
  const { errors, warnings } = await patient.validate();
199
190
  ```
200
191
 
@@ -29,6 +29,27 @@ const packageJson = JSON.parse(require('fs').readFileSync(_pkgPath, 'utf8'));
29
29
  // ─── Package metadata ────────────────────────────────────────────────────────
30
30
  export const PACKAGE_VERSION = packageJson.version;
31
31
  export const USER_AGENT = `BabelFHIR-TS/${PACKAGE_VERSION}`;
32
+ /**
33
+ * The range this build declares for `name`, from either dependency section.
34
+ *
35
+ * Lets the generated-package manifest derive its ranges from what this repo
36
+ * actually builds and tests against, instead of restating them in a second place
37
+ * that drifts on every dependency bump.
38
+ */
39
+ export function ownDependencyRange(name) {
40
+ return packageJson.dependencies?.[name] ?? packageJson.devDependencies?.[name] ?? '';
41
+ }
42
+ /**
43
+ * The `@types/fhir` range this build depends on.
44
+ *
45
+ * `scripts/generate-fhir-r4-module.js` builds the `fhir-<version>.d.ts` ambient
46
+ * shims from whatever `@types/fhir` is installed here, so the shims can only
47
+ * resolve against a version that actually declares the aliased types. Generated
48
+ * packages therefore derive their own `@types/fhir` range from this one — see
49
+ * EMITTED_RANGES.fhirTypes in generatedPackageDeps.ts — instead of hardcoding a
50
+ * second range that silently drifts on every dependency bump.
51
+ */
52
+ export const FHIR_TYPES_DEPENDENCY_RANGE = ownDependencyRange('@types/fhir');
32
53
  // ─── Network defaults ────────────────────────────────────────────────────────
33
54
  /** Default fetch timeout for FHIR registry / StructureDefinition lookups (ms) */
34
55
  export const FETCH_TIMEOUT_MS = 15_000;
@@ -36,6 +57,25 @@ export const FETCH_TIMEOUT_MS = 15_000;
36
57
  export const TX_TIMEOUT_MS = 30_000;
37
58
  /** Max filename length for terminology expansion cache files */
38
59
  export const MAX_CACHE_FILENAME_LENGTH = 200;
60
+ /**
61
+ * FHIR package registries in priority order.
62
+ *
63
+ * packages.fhir.org is the canonical source; packages.simplifier.net is the
64
+ * fallback and carries some national IGs the canonical registry does not mirror.
65
+ * Both speak the npm metadata shape, so `GET <registry>/<packageId>` returns
66
+ * `dist-tags` and `versions`.
67
+ */
68
+ /**
69
+ * Attempts per registry when downloading a package tarball.
70
+ *
71
+ * Registry blips are the single largest cause of spurious parity failures: a run
72
+ * pulls ~30 packages and a lone `fetch failed` aborted the whole generation.
73
+ */
74
+ export const PACKAGE_DOWNLOAD_ATTEMPTS = 3;
75
+ export const FHIR_REGISTRIES = [
76
+ 'https://packages.fhir.org',
77
+ 'https://packages.simplifier.net',
78
+ ];
39
79
  // ─── FHIR StructureDefinition URL prefixes ───────────────────────────────────
40
80
  export const FHIR_SD_URL_PREFIX = 'http://hl7.org/fhir/StructureDefinition/';
41
81
  export const FHIR_SD_URL_PREFIX_HTTPS = 'https://hl7.org/fhir/StructureDefinition/';
@@ -9,22 +9,97 @@
9
9
  * These ranges describe what the *consumer* of a generated package installs,
10
10
  * so they are deliberately wider than this repo's own devDependencies.
11
11
  */
12
+ import { FHIR_TYPES_DEPENDENCY_RANGE, ownDependencyRange } from './constants.js';
13
+ /**
14
+ * `@types/fhir` range emitted into generated packages.
15
+ *
16
+ * Derived from this build's own `@types/fhir` dependency, never hardcoded. The
17
+ * emitted `fhir-<version>.d.ts` shim aliases types out of the installed
18
+ * `@types/fhir` (e.g. `export type MoneyQuantity = fhir4.MoneyQuantity`), so a
19
+ * consumer resolving an older version gets TS2694 on a type the shim references
20
+ * but their copy does not declare — `MoneyQuantity` does not exist at all before
21
+ * @types/fhir 0.0.42. This repo's own output typecheck cannot catch that,
22
+ * because `resolveGeneratorTypeRoots()` points `typeRoots` at babelfhir's own
23
+ * always-present `@types` — so the floor has to be correct by construction.
24
+ *
25
+ * A floor rather than an exact pin, and this is the part that used to be wrong:
26
+ * `^0.0.41` looks like a floor but npm's caret on `0.0.x` resolves to exactly
27
+ * 0.0.41. Consumers routinely install several generated packages alongside their
28
+ * own `@types/fhir`, so exact pins across them would conflict or install nested
29
+ * duplicates — two `fhir` global namespaces. Capped below 0.1.0 because
30
+ * `@types/fhir` 1.x/3.x restructure the namespaces entirely.
31
+ */
32
+ function emittedFhirTypesRange() {
33
+ const floor = FHIR_TYPES_DEPENDENCY_RANGE.replace(/^[^0-9]*/, '');
34
+ if (!/^0\.0\.\d+$/.test(floor)) {
35
+ throw new Error(`Cannot derive the emitted @types/fhir range from "${FHIR_TYPES_DEPENDENCY_RANGE}" — ` +
36
+ `expected a 0.0.x version. Moving off the 0.0.x line needs a new upper bound here.`);
37
+ }
38
+ return `>=${floor} <0.1.0`;
39
+ }
40
+ /**
41
+ * `zod` range emitted as a peer for `--schema zod` output.
42
+ *
43
+ * Straight pass-through of this build's own range — no widening. Emitted schemas
44
+ * are generated against the installed zod, so advertising a broader range than
45
+ * was tested is the same class of mistake `^0.0.41` was: a manifest claiming
46
+ * support the artifact beside it does not have.
47
+ */
48
+ function emittedZodRange() {
49
+ const range = ownDependencyRange('zod');
50
+ if (!range) {
51
+ throw new Error('Cannot derive the emitted zod range — this package declares no zod dependency.');
52
+ }
53
+ return range;
54
+ }
12
55
  /**
13
56
  * Runtime/peer ranges emitted into generated packages.
14
57
  *
15
- * `fhirpath` is a peer so the host decides the version. All of 3.x/4.x/5.x are
16
- * accepted: generated validators only call `fhirpath.evaluate()` and import
17
- * `fhirpath/fhir-context/<model>/index.js`, both of which are stable across
18
- * those majors. The `/index.js` filename is required — fhirpath >= 5 declares
19
- * an `exports` map, so extensionless deep paths no longer resolve.
58
+ * `fhirpath` and `zod` are peers so the host owns the version; everything else is
59
+ * a direct dependency of the generated package.
20
60
  */
21
61
  export const EMITTED_RANGES = {
22
- /** FHIR type declarations backing the emitted `.d.ts` files. */
23
- fhirTypes: '^0.0.41',
24
- /** FHIRPath engine used by emitted constraint validators. */
25
- fhirpath: '^3.0.0 || ^4.0.0 || ^5.0.0',
26
- /** Schema runtime for `--schema zod` output. */
27
- zod: '^4.0.0',
62
+ /**
63
+ * FHIR type declarations backing the emitted `.d.ts` files.
64
+ *
65
+ * A getter, not a computed value: emittedFhirTypesRange() throws when the
66
+ * derivation no longer holds, and evaluating that at module load would take
67
+ * down every import of this module — `babelfhir-ts --version` included. As a
68
+ * getter the failure is confined to the code that actually emits a manifest,
69
+ * which is the only place a wrong range does damage.
70
+ */
71
+ get fhirTypes() {
72
+ return emittedFhirTypesRange();
73
+ },
74
+ /**
75
+ * FHIRPath engine used by emitted constraint validators.
76
+ *
77
+ * The floor is 4.9.1, not 3.x. Emitted validators evaluate `constraint.expression`
78
+ * verbatim from the IG, and fhirpath 3.x cannot parse the `as` alias form that
79
+ * IGs use freely — `(value as Quantity).value.exists()` throws
80
+ * `Do not know how to alias as by {"is":"isOp"}`. The emitted `evaluate()` call
81
+ * is wrapped in try/catch only for expressions containing `resolve()`, so on 3.x
82
+ * that surfaces as `validate()` rejecting rather than returning errors. 4.9.1 is
83
+ * the floor this repo set when it unlocked `.as()`/`.ofType()`/`is` (24308d806),
84
+ * develop shipped on 4.10.x, and CI runs 5.x.
85
+ *
86
+ * Capped below 6.0.0: majors do break consumers here. 5.x added an `exports`
87
+ * map, which is why the emitted import carries an explicit
88
+ * `/fhir-context/<model>/index.js` filename.
89
+ */
90
+ fhirpath: '>=4.9.1 <6.0.0',
91
+ /**
92
+ * Schema runtime for `--schema zod` output.
93
+ *
94
+ * Passed through from this build's own `zod` range rather than restated, so the
95
+ * emitted peer is exactly what the generated schemas were produced and tested
96
+ * against. That range is currently an exact pin (`4.3.6`), which is deliberate:
97
+ * emitted schemas use `z.looseObject` and `z.infer`, and zod has moved those
98
+ * across minors before. A host must therefore land on the same version.
99
+ */
100
+ get zod() {
101
+ return emittedZodRange();
102
+ },
28
103
  /**
29
104
  * Prefab UI runtime for `--prefab` output.
30
105
  *
@@ -24,6 +24,38 @@ export function capitalize(str) {
24
24
  return str;
25
25
  return str.charAt(0).toUpperCase() + str.slice(1);
26
26
  }
27
+ /**
28
+ * The property name a choice element takes for one of its permitted types.
29
+ *
30
+ * A choice element has no property of its own: FHIR declares one property per
31
+ * permitted type, named for the type code capitalized (`value[x]` as a
32
+ * SampledData is `valueSampledData`, as a `code` primitive it is `valueCode`).
33
+ *
34
+ * Pass the *type code*, never a profile name. R4 declares
35
+ * `Coverage.costToBeneficiary.value[x]` as a Quantity profiled to SimpleQuantity,
36
+ * and the property is `valueQuantity` — `valueSimpleQuantity` does not exist on
37
+ * any resource, so emitting it produces data every validator rejects.
38
+ *
39
+ * @param baseName the choice element's name with `[x]` already removed
40
+ */
41
+ export function choicePropertyName(baseName, typeCode) {
42
+ return baseName + capitalize(typeCode);
43
+ }
44
+ /**
45
+ * The single type a choice element has been narrowed to, or undefined while it
46
+ * still permits more than one.
47
+ *
48
+ * A still-open choice has no one property name, so callers must leave the base
49
+ * definition's expanded properties in force rather than pick a type.
50
+ */
51
+ export function narrowedChoiceType(field) {
52
+ if (field.typeOptions && field.typeOptions.length > 1)
53
+ return undefined;
54
+ const type = field.typeOptions?.[0]?.code || field.type;
55
+ if (!type || type === 'any' || type === 'unknown')
56
+ return undefined;
57
+ return type;
58
+ }
27
59
  /**
28
60
  * Extract base FHIR types from all interface declarations in a file.
29
61
  * Handles:
@@ -220,9 +252,16 @@ function caseAwareWriteFile(filePath, content) {
220
252
  }
221
253
  /**
222
254
  * Extracts the base resource name from a baseDefinition URL.
255
+ *
256
+ * The version suffix has to go first. A canonical may be pinned
257
+ * (`http://hl7.org/fhir/StructureDefinition/Base|4.0.1`, as IPS 2.0.1's logical
258
+ * models write it), and keeping it yielded the base name `Base_4_0_1` after
259
+ * sanitising. That defeated the `baseResource === 'Base'` check that suppresses
260
+ * extending the abstract root, so the emitter produced
261
+ * `export type Document = Base_4_0_1` plus an import of a type nothing generates.
223
262
  */
224
263
  export function getBaseResource(baseDefinition) {
225
- return baseDefinition?.split("/").pop() || "Resource";
264
+ return stripVersionFromCanonicalUrl(baseDefinition ?? '').split('/').pop() || 'Resource';
226
265
  }
227
266
  /**
228
267
  * Sanitizes a string so it can be used as a valid TypeScript identifier.
@@ -4,7 +4,7 @@ import { primitivePlaceholder, generateCodeDefault, makeCodeableConcept, chooseC
4
4
  import { generateSliceElement, buildNestedRequirementsObject } from './sliceElementGenerator.js';
5
5
  import { COMPLEX_SKELETON_MAP, propKey, buildValueExpression, buildCrossReferencedEntryArray, } from './classGeneratorHelpers.js';
6
6
  import { renderClassTemplate } from './classTemplate.js';
7
- export function generateClass(className, interfaceName, baseResource, requiredFields = [], profileUrl, fieldPatterns = [], forbiddenFields = [], valueSetBindings = {}, fieldOrder = []) {
7
+ export function generateClass(className, interfaceName, baseResource, requiredFields = [], profileUrl, fieldPatterns = [], forbiddenFields = [], valueSetBindings = {}, fieldOrder = [], codeableConceptBindings = []) {
8
8
  // Decide whether to emit resourceType. If baseResource points at a known resource or ends with 'Resource'.
9
9
  const CORE_RESOURCE_BASES = ctx().coreResourceBases;
10
10
  const addResourceType = !!baseResource && CORE_RESOURCE_BASES.has(baseResource);
@@ -572,5 +572,6 @@ export function generateClass(className, interfaceName, baseResource, requiredFi
572
572
  profileUrl, constructorMetaProfile, setterMetaProfile,
573
573
  randomMetaProfileInit, resourceTypeLine, baseResource,
574
574
  fieldPatterns, forbiddenFields, valueSetBindings, fieldOrder,
575
+ codeableConceptBindings,
575
576
  });
576
577
  }
@@ -88,7 +88,7 @@ export function buildCrossReferencedEntryArray(sliceElements) {
88
88
  return `(() => { ${decls.join(' ')} return [${rewritten.join(', ')}]; })()`;
89
89
  }
90
90
  /** Generate the static pattern/forbidden/order/binding metadata properties for a class. */
91
- export function generatePatternMetadata(fieldPatterns, forbiddenFields, valueSetBindings, fieldOrder = []) {
91
+ export function generatePatternMetadata(fieldPatterns, forbiddenFields, valueSetBindings, fieldOrder = [], codeableConceptBindings = []) {
92
92
  const patternMap = {};
93
93
  for (const fp of fieldPatterns) {
94
94
  patternMap[fp.fieldName] = fp.pattern;
@@ -107,6 +107,13 @@ export function generatePatternMetadata(fieldPatterns, forbiddenFields, valueSet
107
107
  /** ValueSet bindings for coded fields - maps field name to ValueSet URL */
108
108
  static readonly valueSetBindings: Record<string, string> = ${JSON.stringify(valueSetBindings, null, 2)};
109
109
 
110
+ /**
111
+ * Bound fields declared as CodeableConcept rather than a code primitive.
112
+ * enrichResource emits \`{ coding: [...] }\` for these; a bare code string would
113
+ * be rejected as "must be an Object, not a Primitive property".
114
+ */
115
+ protected static readonly _codeableConceptBindings: string[] = ${JSON.stringify(codeableConceptBindings)};
116
+
110
117
  /** Code resolver for ValueSet bindings - returns a random valid code from a ValueSet by URL */
111
118
  protected static readonly _codeResolver: ((url: string) => { code: string; system: string; display?: string } | undefined) | undefined = ${hasBindings ? `(() => {
112
119
  // Try to load the ValueSetRegistry - it may not exist if no ValueSets were generated
@@ -1,7 +1,7 @@
1
1
  import { COMPLEX_SKELETON_MAP, generatePatternMetadata } from './classGeneratorHelpers.js';
2
2
  /** Render the final class file content from pre-computed template parameters. */
3
3
  export function renderClassTemplate(params) {
4
- const { interfaceName, className, requiredInitBlock, enableMetaProfile, profileUrl, constructorMetaProfile, setterMetaProfile, randomMetaProfileInit, resourceTypeLine, baseResource, fieldPatterns, forbiddenFields, valueSetBindings, fieldOrder, } = params;
4
+ const { interfaceName, className, requiredInitBlock, enableMetaProfile, profileUrl, constructorMetaProfile, setterMetaProfile, randomMetaProfileInit, resourceTypeLine, baseResource, fieldPatterns, forbiddenFields, valueSetBindings, fieldOrder, codeableConceptBindings, } = params;
5
5
  // Determine which skeleton helpers are actually used in requiredInitBlock
6
6
  const usedSkeletons = new Set();
7
7
  for (const helper of Object.values(COMPLEX_SKELETON_MAP)) {
@@ -54,7 +54,7 @@ function safeClone<T>(value: T): T {
54
54
  }
55
55
 
56
56
  export class ${className} {
57
- ${generatePatternMetadata(fieldPatterns, forbiddenFields, valueSetBindings, fieldOrder)}
57
+ ${generatePatternMetadata(fieldPatterns, forbiddenFields, valueSetBindings, fieldOrder, codeableConceptBindings)}
58
58
  constructor(private resource: ${interfaceName}) {
59
59
  ${constructorMetaProfile}
60
60
  }
@@ -108,7 +108,7 @@ ${generatePatternMetadata(fieldPatterns, forbiddenFields, valueSetBindings, fiel
108
108
  // Always include meta.profile with this profile's canonical URL if available & only for resource profiles
109
109
  ${randomMetaProfileInit}
110
110
  // Enrich the base resource with common fields using centralized enrichment logic
111
- enrichResource(base, '${baseResource || ''}', '${profileUrl || ''}', ${className}._fieldPatterns, ${className}._forbiddenFields, ${className}.valueSetBindings, ${className}._codeResolver, ${className}._fieldOrder);
111
+ enrichResource(base, '${baseResource || ''}', '${profileUrl || ''}', ${className}._fieldPatterns, ${className}._forbiddenFields, ${className}.valueSetBindings, ${className}._codeResolver, ${className}._fieldOrder, ${className}._codeableConceptBindings);
112
112
  // Cast through unknown to acknowledge dynamic enrichment
113
113
  return { ...(base as unknown as ${interfaceName}), ...overrides };
114
114
  }
@@ -16,6 +16,13 @@ export interface EnrichContext {
16
16
  codeResolver?: (valueSetUrl: string) => { code: string; system: string; display?: string } | undefined;
17
17
  fieldOrder: string[];
18
18
  isForbidden: (fieldName: string) => boolean;
19
+ /**
20
+ * True when a ValueSet-bound field is declared as CodeableConcept rather than a
21
+ * code primitive. R4 types status as a code, but several R5 product resources
22
+ * type it as CodeableConcept, and writing a bare string there is rejected with
23
+ * "must be an Object, not a Primitive property".
24
+ */
25
+ isCodeableConceptBinding: (fieldName: string) => boolean;
19
26
  resolveBindingCode: (fieldName: string, fallbackSystem?: string, fallbackCode?: string, fallbackDisplay?: string) => { coding: Array<{ system: string; code: string; display?: string }>; text?: string } | undefined;
20
27
  resolveBindingCodePrimitive: (fieldName: string, fallbackCode?: string) => string | undefined;
21
28
  getPatternValue: (fieldName: string) => unknown | undefined;
@@ -37,8 +44,11 @@ export function createEnrichContext(
37
44
  valueSetBindings: Record<string, string>,
38
45
  codeResolver: ((valueSetUrl: string) => { code: string; system: string; display?: string } | undefined) | undefined,
39
46
  fieldOrder: string[],
47
+ codeableConceptBindings: string[] = [],
40
48
  ): EnrichContext {
41
49
  const isForbidden = (fieldName: string): boolean => forbiddenFields.includes(fieldName);
50
+ const isCodeableConceptBinding = (fieldName: string): boolean =>
51
+ codeableConceptBindings.includes(fieldName);
42
52
 
43
53
  const resolveBindingCode = (fieldName: string, fallbackSystem?: string, fallbackCode?: string, fallbackDisplay?: string) => {
44
54
  const bindingUrl = valueSetBindings[fieldName];
@@ -158,7 +168,7 @@ export function createEnrichContext(
158
168
  return {
159
169
  base, baseResource, profileUrl, fieldPatterns, forbiddenFields,
160
170
  valueSetBindings, codeResolver, fieldOrder,
161
- isForbidden, resolveBindingCode, resolveBindingCodePrimitive,
171
+ isForbidden, isCodeableConceptBinding, resolveBindingCode, resolveBindingCodePrimitive,
162
172
  getPatternValue, getRawCodeableConceptPattern, mergeCodingPatterns,
163
173
  getCompleteCodePatternValue, getPrimitivePattern, applyNestedPatterns,
164
174
  getRefRequirements,
@@ -4,6 +4,7 @@
4
4
  */
5
5
  const CTX_FUNCTIONS = [
6
6
  'isForbidden',
7
+ 'isCodeableConceptBinding',
7
8
  'resolveBindingCode',
8
9
  'resolveBindingCodePrimitive',
9
10
  'getPatternValue',
@@ -500,10 +500,29 @@ export function generateEnrichResourceEnd() {
500
500
  }
501
501
  }
502
502
 
503
- // Extension value fallback
503
+ // Extension value fallback.
504
+ //
505
+ // Respect the profile's value[x] type constraint: an Extension SD almost always
506
+ // narrows value[x] to one type, and emitting valueString regardless was the
507
+ // largest single source of random()-resource validation failures ("definition
508
+ // allows for the type CodeableConcept but found type String"). Fall back to
509
+ // valueString only when the profile leaves value[x] open or names a type the
510
+ // skeleton factories don't cover.
504
511
  if (/Extension/.test(baseResource)) {
505
512
  const hasValue = Object.keys(base).some(k => k.startsWith('value'));
506
- if (!hasValue) base['valueString'] = 'value';
513
+ if (!hasValue) {
514
+ const valueTypes = fieldPatterns?.['_choiceType_value[x]'] as string[] | undefined;
515
+ const constrainedType = valueTypes && valueTypes.length === 1 ? valueTypes[0] : undefined;
516
+ const choice = skeletonChoiceValue('value', constrainedType);
517
+ if (!choice) {
518
+ base['valueString'] = 'value';
519
+ } else if (constrainedType === 'CodeableConcept') {
520
+ // A bound value[x] should carry a code from its ValueSet, not a random one.
521
+ base.valueCodeableConcept = resolveBindingCode('value[x]') ?? choice[1];
522
+ } else {
523
+ base[choice[0]] = choice[1];
524
+ }
525
+ }
507
526
  if (!('url' in base)) base.url = profileUrl || 'urn:uuid:extension';
508
527
  }
509
528
 
@@ -175,8 +175,8 @@ export function generateObservationHandler() {
175
175
  base.dataAbsentReason = { coding: [{ system: '${TS.DATA_ABSENT}', code: 'unknown' }] };
176
176
  } else {
177
177
  // Use patterns if available, otherwise derive UCUM unit from observation code
178
- let defaultUnit = '1';
179
- let defaultCode = '1';
178
+ const defaultUnit = '1';
179
+ const defaultCode = '1';
180
180
  if (unitPattern === undefined && codePattern === undefined) {
181
181
  // Map well-known vital sign LOINC codes to correct UCUM units
182
182
  const codeObj = base.code as { coding?: Array<{ code?: string }> } | undefined;
@@ -196,8 +196,8 @@ export function generateObservationHandler() {
196
196
  };
197
197
  if (loincCode && vitalSignUnits[loincCode]) {
198
198
  const vs = vitalSignUnits[loincCode];
199
- defaultUnit = vs.unit;
200
- defaultCode = vs.code;
199
+ // No need to update defaultUnit/defaultCode: this branch uses vs.*
200
+ // directly and only the else branch reads the defaults.
201
201
  base.valueQuantity = { value: vs.value, unit: vs.unit, system: '${TS.UCUM}', code: vs.code };
202
202
  } else {
203
203
  base.valueQuantity = { value: 72, unit: defaultUnit, system: '${TS.UCUM}', code: defaultCode };
@@ -18,6 +18,9 @@ export type CodeResolver = (valueSetUrl: string) => ResolvedCode | undefined;
18
18
  * @param forbiddenFields - Array of field names that are forbidden (max=0) in this profile
19
19
  * @param valueSetBindings - Map of field names to ValueSet URLs for coded bindings
20
20
  * @param codeResolver - Optional function to resolve a ValueSet URL to a random valid code
21
+ * @param fieldOrder - FHIR element ordering for the base resource
22
+ * @param codeableConceptBindings - Bound fields declared as CodeableConcept rather
23
+ * than a code primitive, so a resolved code is emitted as a coding object
21
24
  */
22
25
  export function enrichResource(
23
26
  base: Record<string, unknown>,
@@ -27,11 +30,15 @@ export function enrichResource(
27
30
  forbiddenFields: string[] = [],
28
31
  valueSetBindings: Record<string, string> = {},
29
32
  codeResolver?: CodeResolver,
30
- fieldOrder: string[] = []
33
+ fieldOrder: string[] = [],
34
+ codeableConceptBindings: string[] = []
31
35
  ): void {
32
36
  try {
33
37
  // Helper to check if a field is forbidden
34
38
  const isForbidden = (fieldName: string): boolean => forbiddenFields.includes(fieldName);
39
+
40
+ // Helper to check whether a bound field is a CodeableConcept rather than a code
41
+ const isCodeableConceptBinding = (fieldName: string): boolean => codeableConceptBindings.includes(fieldName);
35
42
 
36
43
  // Helper to resolve a code from ValueSet binding - uses codeResolver if available
37
44
  // Returns a CodeableConcept-compatible structure or undefined if not resolvable
@@ -191,13 +198,25 @@ export function enrichResource(
191
198
  // Also override generic default 'active' when it's not valid for this resource's ValueSet
192
199
  // Skip when status was already set by a fixed pattern above.
193
200
  if (statusFixedPattern === undefined && !isForbidden('status')) {
194
- const resolved = resolveBindingCodePrimitive('status');
195
- if (resolved) {
196
- if (!('status' in base)) {
197
- base.status = resolved;
198
- } else if (base.status === 'active' && resolved !== 'active') {
199
- // Generic 'active' default may not be valid for all resource types (e.g., ChargeItem)
200
- base.status = resolved;
201
+ if (isCodeableConceptBinding('status')) {
202
+ // R5 types status as a CodeableConcept on ClinicalUseDefinition,
203
+ // MedicinalProductDefinition, PackagedProductDefinition,
204
+ // RegulatedAuthorization and SubstanceDefinition. Writing the resolved
205
+ // code as a bare string there fails validation with "The property status
206
+ // must be an Object, not a Primitive property", so emit the coding shape.
207
+ const resolvedConcept = resolveBindingCode('status');
208
+ if (resolvedConcept && (!('status' in base) || typeof base.status !== 'object')) {
209
+ base.status = resolvedConcept;
210
+ }
211
+ } else {
212
+ const resolved = resolveBindingCodePrimitive('status');
213
+ if (resolved) {
214
+ if (!('status' in base)) {
215
+ base.status = resolved;
216
+ } else if (base.status === 'active' && resolved !== 'active') {
217
+ // Generic 'active' default may not be valid for all resource types (e.g., ChargeItem)
218
+ base.status = resolved;
219
+ }
201
220
  }
202
221
  }
203
222
  }
@@ -373,6 +373,9 @@ export async function extractFieldPatternsAndMetadata(patternSources, baseResour
373
373
  }))];
374
374
  // Collect ValueSet bindings for all fields to expose as static metadata
375
375
  const valueSetBindings = {};
376
+ // Record the declared type alongside, so enrichment emits the right JSON shape
377
+ // for a bound field rather than assuming every binding sits on a code primitive.
378
+ const codeableConceptBindingSet = new Set();
376
379
  for (const f of candidateRequiredFields) {
377
380
  if (f.binding?.uri) {
378
381
  // Extract field name from path (e.g., "Observation.status" -> "status")
@@ -383,9 +386,13 @@ export async function extractFieldPatternsAndMetadata(patternSources, baseResour
383
386
  if (!valueSetBindings[fieldName]) {
384
387
  valueSetBindings[fieldName] = f.binding.uri;
385
388
  }
389
+ if ((f.type || f.baseTypeCode) === 'CodeableConcept') {
390
+ codeableConceptBindingSet.add(fieldName);
391
+ }
386
392
  }
387
393
  }
388
394
  }
395
+ const codeableConceptBindings = [...codeableConceptBindingSet];
389
396
  // Extract FHIR element ordering from the StructureDefinition snapshot.
390
397
  // The snapshot lists elements in the canonical FHIR order defined by the spec.
391
398
  // We use this to ensure enrichResource reorders fields correctly, rather than
@@ -412,5 +419,5 @@ export async function extractFieldPatternsAndMetadata(patternSources, baseResour
412
419
  fieldOrder.push(fieldName);
413
420
  }
414
421
  }
415
- return { fieldPatterns, forbiddenFields, valueSetBindings, fieldOrder };
422
+ return { fieldPatterns, forbiddenFields, valueSetBindings, codeableConceptBindings, fieldOrder };
416
423
  }