@atomic-ehr/codegen 0.0.18 → 0.0.19-canary.20260917110212.f40edba

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 CHANGED
@@ -113,6 +113,58 @@ yarn add @atomic-ehr/codegen
113
113
  - `bun run generate-types.ts`
114
114
  - `pnpm exec tsx generate-types.ts`
115
115
 
116
+ Alternatively, drive the same pipeline from a JSON config file, with no script at all:
117
+
118
+ ```json
119
+ {
120
+ "version": 1,
121
+ "builders": [
122
+ {
123
+ "name": "core",
124
+ "fromPackages": [{ "name": "hl7.fhir.r4.core", "version": "4.0.1" }],
125
+ "typescript": {},
126
+ "outputTo": "./fhir-types"
127
+ }
128
+ ]
129
+ }
130
+ ```
131
+
132
+ ```bash
133
+ atomic-codegen generate --config ./codegen.json # run every builder
134
+ atomic-codegen generate --config ./codegen.json --dry-run # print the plan without generating
135
+ ```
136
+
137
+ Relative paths resolve against the config file's directory, unknown keys are rejected with the full list of problems, and `outputTo` is removed before generation by default (set `"cleanOutput": false` to keep it). A config may hold several builders: each maps to one `APIBuilder` pipeline (`fromPackages`/`fromPackageRefs`/`localTgzPackages`/`localStructureDefinitions` inputs, `typeSchema` transformations, and `typescript`/`python`/`csharp`/`introspection` generators — see `GenerateConfigBuilder` in `src/api/generate-config.ts`). A failed builder does not stop the others, and the run exits non-zero if any failed.
138
+
139
+ The JSON `typescript` options also accept `moduleSpecifierStyle` (`"extensionless"` or `"node-esm"`) and `terminology`. For Node ESM imports and terminology output restricted to selected packages, replace `"typescript": {}` with:
140
+
141
+ ```json
142
+ "typescript": {
143
+ "moduleSpecifierStyle": "node-esm",
144
+ "terminology": {
145
+ "enabled": true,
146
+ "packages": ["hl7.fhir.r4.core@4.0.1"],
147
+ "packageVerification": {
148
+ "hl7.fhir.r4.core@4.0.1": "registry-integrity"
149
+ }
150
+ }
151
+ }
152
+ ```
153
+
154
+ All terminology fields are optional. When supplied, `enabled` must be a boolean, `packages` must be an array of strings, and `packageVerification` must contain string values. Unknown nested keys and invalid values are reported with their full configuration paths.
155
+
156
+ ### Fixing defective packages
157
+
158
+ Real FHIR packages ship defects — missing dependencies, typo'd names and canonicals, bindings to unavailable ValueSets, incomplete CodeSystems. Fixes are declared, not hand-coded:
159
+
160
+ - **Canonical exclusions** — drop a known-broken canonical at the package index, before codegen sees it: `canonicalManager: { patches: { indexEntry: [excludeCanonical({ package, url, reason })] } }`. Codegen ships `builtinPatches` (`src/api/builtin-patches.ts` — generation-breaking content in HL7's own packages, e.g. `hl7.fhir.r5.core` profiles that break R4-compatible generation); they apply to every loader the builder constructs, on top of your own patches, and `builtinPatches: false` is the explicit opt-out. Index exclusion removes the canonical from resolution too, so exclude whole derivation chains together — and it is applied at scan time, so drop the loader cache after changing the list. A hand-built CanonicalManager owns its wiring: apply `builtinPatches.indexEntry` explicitly (see the ccda example).
161
+ - **Custom patches** — per-phase handlers built from the helpers on the `@atomic-ehr/fhir-canonical-manager/patch` subpath (`ensureDependency`, `renamePackage`, `replaceText`, `ensureCodes`, scoped by `inPackage`/`inResource`), passed as `new APIBuilder({ canonicalManager: { patches: {...} } })`. In the CLI config, `"options": { "forceDependencies": {...} }` pins declared dependency versions across the closure.
162
+ - **Broken package index** — `canonicalManager: { packageIndex: "recover" }` heals a corrupt `.index.json` by falling back to a directory scan (always with a warning); `"regenerate"` ignores the shipped index entirely.
163
+
164
+ Loader settings (`registry`, `packageIndex`, `dropCache`, `patches`) live under the builder's `canonicalManager` option — they configure the CanonicalManager package loader, not the generator. The old flat options are deprecated and warn.
165
+
166
+ Applied fixes surface in the generation report's "Input fixes" section.
167
+
116
168
  ### Usage Examples
117
169
 
118
170
  See the [examples/](examples/) directory for working demonstrations:
@@ -157,6 +209,12 @@ const builder = new APIBuilder()
157
209
  generateProfile?: boolean,
158
210
  withDebugComment?: boolean,
159
211
  openResourceTypeSet?: boolean,
212
+ terminology?: {
213
+ enabled: true,
214
+ packageVerification: {
215
+ "hl7.fhir.r4.core@4.0.1": "registry-integrity",
216
+ },
217
+ },
160
218
  })
161
219
  .python({ // Python generator
162
220
  client?: "fhirpy" | "none", // client integration (default: fhirpy)
@@ -204,6 +262,10 @@ Each language generator accepts its own option object. All options are optional;
204
262
  | `sliceGetterDefault` | `"flat" \| "raw"` | `"flat"` | Default return shape for generated slice getters (`flat` strips discriminators, `raw` returns the full FHIR element). |
205
263
  | `lineWidth` | `number` | `120` | Maximum line width before wrapping. |
206
264
  | `withDebugComment` | `boolean` | `false` | Emit comments tracing each generated type back to its source schema. |
265
+ | `terminology.enabled` | `boolean` | `false` | Emit a `terminology.ts` module for every package in the resolved closure. |
266
+ | `terminology.packageVerification` | `Record<string, string>` | `{}` | Map package references such as `hl7.fhir.r4.core@4.0.1` to a closure verification state (`registry-integrity`, `unverifiable`, ...). Absent entries record `not-recorded`. |
267
+
268
+ When terminology generation is enabled, each exported symbol includes its canonical identity, source package and version, declared content mode, and verification state. Only CodeSystems declaring `content: "complete"` emit code unions and display maps. ValueSet expansions are never promoted to constants, and an `unverifiable` package emits identity and provenance without concept content.
207
269
 
208
270
  **Python** — `.python({ ... })`
209
271
 
@@ -441,6 +503,15 @@ Templates enable flexible code generation for any language or format (Go, Rust,
441
503
 
442
504
  When generating TypeScript with `generateProfile: true`, the generator creates profile wrapper classes that provide a fluent API for working with FHIR profiles. These classes handle complex profile constraints like slicing and extensions automatically.
443
505
 
506
+ Resource profile classes expose `static readonly resourceType` alongside `canonicalUrl`, `from()` and `createResource()`. A generic FHIR client can use the class itself as a structural descriptor. The resource type comes from the resolved snapshot base; Extension and datatype profile classes do not expose `resourceType`.
507
+
508
+ ```typescript
509
+ import { observation_bodyweightProfile } from "./profiles/Observation_observation_bodyweight";
510
+
511
+ observation_bodyweightProfile.resourceType; // "Observation"
512
+ observation_bodyweightProfile.canonicalUrl; // "http://hl7.org/fhir/StructureDefinition/bodyweight"
513
+ ```
514
+
444
515
  ```typescript
445
516
  import { observation_bpProfile as bpProfile } from "./profiles/Observation_observation_bp";
446
517
 
@@ -417,6 +417,20 @@ def validate_fixed_value(res: object, profile_name: str, field: str, expected: o
417
417
  )
418
418
 
419
419
 
420
+ def validate_pattern_value(res: object, profile_name: str, field: str, expected: object) -> list[str]:
421
+ """Containment constraint for a field that may be absent: absence is
422
+ ``validate_required``'s concern, so an absent field passes; a present one
423
+ must structurally contain ``expected``."""
424
+ actual = _get_field(res, field)
425
+ if actual is None:
426
+ return []
427
+ return (
428
+ []
429
+ if matches_value(actual, expected)
430
+ else [f"{profile_name}: field '{field}' does not match expected pattern"]
431
+ )
432
+
433
+
420
434
  def validate_slice_cardinality(
421
435
  res: object,
422
436
  profile_name: str,
@@ -451,9 +465,15 @@ def validate_slice_fields(
451
465
  match: Mapping[str, Any],
452
466
  slice_name: str,
453
467
  required_fields: Sequence[str],
468
+ choice_groups: Sequence[Sequence[str]] = (),
454
469
  ) -> list[str]:
455
470
  """Validates required fields within matched slice elements. For each array
456
- item matching the discriminator, checks that the listed fields are present."""
471
+ item matching the discriminator, checks that the listed fields are present.
472
+
473
+ ``choice_groups`` carries the required choice elements of the slice: each
474
+ group lists the typed variants a single ``value[x]``-style element may take,
475
+ and is satisfied by any one of them. E.g. ``[["valueCoding"]]`` for a slice
476
+ whose ``value[x]`` is required and narrowed to Coding."""
457
477
  items = _get_field(res, field) or []
458
478
  if not isinstance(items, Iterable):
459
479
  items = []
@@ -464,6 +484,14 @@ def validate_slice_fields(
464
484
  for rf in required_fields:
465
485
  if _get_field(item, rf) is None:
466
486
  errors.append(f"{profile_name}.{field}[{slice_name}].{rf} is required")
487
+ for group in choice_groups:
488
+ if any(_get_field(item, variant) is not None for variant in group):
489
+ continue
490
+ errors.append(
491
+ f"{profile_name}.{field}[{slice_name}].{group[0]} is required"
492
+ if len(group) == 1
493
+ else f"{profile_name}.{field}[{slice_name}]: at least one of {', '.join(group)} is required"
494
+ )
467
495
  return errors
468
496
 
469
497
 
@@ -369,13 +369,59 @@ export const validateExcluded = (res: object, profileName: string, field: string
369
369
  : [];
370
370
  };
371
371
 
372
- /** Checks that `field` structurally contains the expected fixed value. */
373
- export const validateFixedValue = (res: object, profileName: string, field: string, expected: unknown): string[] => {
374
- return matchesValue((res as Record<string, unknown>)[field], expected)
372
+ /**
373
+ * Shared body for the `fixed[x]`/`pattern[x]` checks, keeping FHIR's two array
374
+ * rules apart:
375
+ *
376
+ * - Across repetitions: a constraint declared on a repeating element applies to
377
+ * all repetitions, so every one of them must match and the element must
378
+ * actually be an array.
379
+ * - Inside one value: arrays nested in the constraint keep `matchesValue`'s
380
+ * "each constraint entry matches at least one instance entry" rule.
381
+ */
382
+ const matchesConstrainedValue = (value: unknown, expected: unknown, repeating: boolean): boolean =>
383
+ repeating
384
+ ? Array.isArray(value) && value.length > 0 && value.every((item) => matchesValue(item, expected))
385
+ : !Array.isArray(value) && matchesValue(value, expected);
386
+
387
+ /**
388
+ * Checks that a present `field` structurally contains the expected fixed value.
389
+ * Absence passes — `fixed[x]` applies "if present", so a missing element is
390
+ * `validateRequired`'s concern. Pass `repeating` for an element with max > 1.
391
+ */
392
+ export const validateFixedValue = (
393
+ res: object,
394
+ profileName: string,
395
+ field: string,
396
+ expected: unknown,
397
+ repeating = false,
398
+ ): string[] => {
399
+ const value = (res as Record<string, unknown>)[field];
400
+ if (value === undefined || value === null) return [];
401
+ return matchesConstrainedValue(value, expected, repeating)
375
402
  ? []
376
403
  : [`${profileName}: field '${field}' does not match expected fixed value`];
377
404
  };
378
405
 
406
+ /**
407
+ * Containment constraint for a field that may be absent: absence is
408
+ * `validateRequired`'s concern, so an absent field passes; a present one must
409
+ * structurally contain `expected` — every repetition, when `repeating`.
410
+ */
411
+ export const validatePatternValue = (
412
+ res: object,
413
+ profileName: string,
414
+ field: string,
415
+ expected: unknown,
416
+ repeating = false,
417
+ ): string[] => {
418
+ const value = (res as Record<string, unknown>)[field];
419
+ if (value === undefined || value === null) return [];
420
+ return matchesConstrainedValue(value, expected, repeating)
421
+ ? []
422
+ : [`${profileName}: field '${field}' does not match expected pattern`];
423
+ };
424
+
379
425
  /**
380
426
  * Checks that the number of array elements matching `match` (a slice
381
427
  * discriminator) falls within [`min`, `max`]. Pass `max = 0` for unbounded.
@@ -404,6 +450,11 @@ export const validateSliceCardinality = (
404
450
  /**
405
451
  * Validate required fields within matched slice elements.
406
452
  * For each array item matching the discriminator, checks that the listed fields are present.
453
+ *
454
+ * `choiceGroups` carries the required choice elements of the slice: each group
455
+ * lists the typed variants a single `value[x]`-style element may take, and is
456
+ * satisfied by any one of them. E.g. `[["valueCodeableConcept"]]` for a slice
457
+ * whose `value[x]` is required and narrowed to CodeableConcept.
407
458
  */
408
459
  export const validateSliceFields = (
409
460
  res: object,
@@ -412,16 +463,27 @@ export const validateSliceFields = (
412
463
  match: Record<string, unknown>,
413
464
  sliceName: string,
414
465
  requiredFields: string[],
466
+ choiceGroups: string[][] = [],
415
467
  ): string[] => {
416
468
  const items = (res as Record<string, unknown>)[field] as unknown[] | undefined;
417
469
  const errors: string[] = [];
470
+ const isPresent = (obj: Record<string, unknown>, key: string): boolean =>
471
+ obj[key] !== undefined && obj[key] !== null;
418
472
  for (const item of (items ?? []).filter((item) => matchesValue(item, match))) {
419
473
  const obj = item as Record<string, unknown>;
420
474
  for (const rf of requiredFields) {
421
- if (obj[rf] === undefined || obj[rf] === null) {
475
+ if (!isPresent(obj, rf)) {
422
476
  errors.push(`${profileName}.${field}[${sliceName}].${rf} is required`);
423
477
  }
424
478
  }
479
+ for (const group of choiceGroups) {
480
+ if (group.some((variant) => isPresent(obj, variant))) continue;
481
+ errors.push(
482
+ group.length === 1
483
+ ? `${profileName}.${field}[${sliceName}].${group[0]} is required`
484
+ : `${profileName}.${field}[${sliceName}]: at least one of ${group.join(", ")} is required`,
485
+ );
486
+ }
425
487
  }
426
488
  return errors;
427
489
  };