From 9a41f0e35982d1bc84ee9872110a10662baa6544 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Fri, 5 Jun 2026 14:36:33 +0200 Subject: [PATCH 01/14] API: thread CM `patches` through APIBuilder (P1.0) Adds an optional `patches?: Partial` constructor option, passed to the CanonicalManager config; warns if set alongside an injected manager/register (which owns its own patch wiring). No example changes yet. --- src/api/builder.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/api/builder.ts b/src/api/builder.ts index e44fd18e..c1706afd 100644 --- a/src/api/builder.ts +++ b/src/api/builder.ts @@ -11,6 +11,7 @@ import * as Path from "node:path"; import { CanonicalManager, type LocalPackageConfig, + type Patches, type PreprocessContext, type TgzPackageConfig, } from "@atomic-ehr/fhir-canonical-manager"; @@ -171,6 +172,8 @@ export class APIBuilder { userOpts: Partial & { manager?: ReturnType; register?: Register; + /** Per-phase patch handlers passed to the CanonicalManager (package-defect fixes). */ + patches?: Partial; preprocessPackage?: (context: PreprocessContext) => PreprocessContext; ignorePackageIndex?: boolean; logger?: CodegenLogManager; @@ -213,10 +216,16 @@ export class APIBuilder { workingDir: ".codegen-cache/canonical-manager-cache", registry: userOpts.registry, dropCache: userOpts.dropCanonicalManagerCache, + patches: userOpts.patches, preprocessPackage: userOpts.preprocessPackage, ignorePackageIndex: userOpts.ignorePackageIndex, }); this.logger = userOpts.logger ?? mkLogger({ prefix: "api" }); + // `patches` only apply to a CM that this builder constructs; an injected + // manager/register owns its own patch wiring. + if (userOpts.patches && (userOpts.manager || userOpts.register)) { + this.logger.warn("`patches` is ignored when a prebuilt `manager`/`register` is provided."); + } this.options = opts; } From 45d445e2eece04b7241e2165aa14a106b74f591f Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Fri, 5 Jun 2026 14:42:19 +0200 Subject: [PATCH 02/14] API: add injectDependency patch helper, migrate kbv/norge dep injection (P1.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New src/api/patches/ helper module with injectDependency(match, deps) — a PackagePatch that adds missing FHIR package dependencies to a matching manifest. Migrates the dependency-injection blocks of the kbv-r4 and norge-r4 examples off preprocessPackage onto CM's patches config (kbv's preprocessPackage is fully removed; norge keeps its rename + resource fixes for P1.2/P1.3). Generated code typechecks and example tests pass. --- examples/on-the-fly/kbv-r4/generate.ts | 28 +++--------------------- examples/on-the-fly/norge-r4/generate.ts | 17 +++++--------- src/api/patches/index.ts | 25 +++++++++++++++++++++ 3 files changed, 33 insertions(+), 37 deletions(-) create mode 100644 src/api/patches/index.ts diff --git a/examples/on-the-fly/kbv-r4/generate.ts b/examples/on-the-fly/kbv-r4/generate.ts index 286df767..c694f50f 100644 --- a/examples/on-the-fly/kbv-r4/generate.ts +++ b/examples/on-the-fly/kbv-r4/generate.ts @@ -1,34 +1,12 @@ -import type { PreprocessContext } from "@atomic-ehr/fhir-canonical-manager"; import { APIBuilder, prettyReport } from "../../../src/api/builder"; - -const preprocessPackage = (ctx: PreprocessContext): PreprocessContext => { - if (ctx.kind !== "package") return ctx; - const json = ctx.packageJson; - const name = json.name as string; - - // de.basisprofil.r4 doesn't declare hl7.fhir.r4.core as a dependency - if (name === "de.basisprofil.r4") { - const deps = (json.dependencies as Record) || {}; - if (!deps["hl7.fhir.r4.core"]) { - return { - ...ctx, - kind: "package", - packageJson: { - ...json, - dependencies: { ...deps, "hl7.fhir.r4.core": "4.0.1" }, - }, - }; - } - } - - return ctx; -}; +import { injectDependency } from "../../../src/api/patches"; if (require.main === module) { console.log("Generating KBV R4 types..."); const builder = new APIBuilder({ - preprocessPackage, + // de.basisprofil.r4 references core types without declaring hl7.fhir.r4.core. + patches: { package: [injectDependency("de.basisprofil.r4", { "hl7.fhir.r4.core": "4.0.1" })] }, registry: "https://packages.simplifier.net", ignorePackageIndex: true, }) diff --git a/examples/on-the-fly/norge-r4/generate.ts b/examples/on-the-fly/norge-r4/generate.ts index e805a21a..dab2a217 100644 --- a/examples/on-the-fly/norge-r4/generate.ts +++ b/examples/on-the-fly/norge-r4/generate.ts @@ -1,5 +1,6 @@ import type { PreprocessContext } from "@atomic-ehr/fhir-canonical-manager"; import { APIBuilder, prettyReport } from "../../../src/api/builder"; +import { injectDependency } from "../../../src/api/patches"; // Fix known package name typos (in-memory transformation) const packageNameFixes: Record = { @@ -51,18 +52,6 @@ const preprocessPackage = (ctx: PreprocessContext): PreprocessContext => { json = { ...json, name: fixedName }; } - // Add missing core dependency to packages that don't properly declare it - if (needsCoreDependency(name)) { - const deps = (json.dependencies as Record) || {}; - if (!deps["hl7.fhir.r4.core"]) { - console.log(`Injecting hl7.fhir.r4.core dependency into ${name}`); - json = { - ...json, - dependencies: { ...deps, "hl7.fhir.r4.core": "4.0.1" }, - }; - } - } - return { ...ctx, kind: "package", packageJson: json }; }; @@ -70,6 +59,10 @@ if (require.main === module) { console.log("Generating Norge R4 types..."); const builder = new APIBuilder({ + // Norwegian/simplifier core packages reference core types without declaring the dep. + patches: { + package: [injectDependency((pkg) => needsCoreDependency(pkg.name), { "hl7.fhir.r4.core": "4.0.1" })], + }, preprocessPackage, registry: "https://packages.simplifier.net", }) diff --git a/src/api/patches/index.ts b/src/api/patches/index.ts new file mode 100644 index 00000000..e4dea0d9 --- /dev/null +++ b/src/api/patches/index.ts @@ -0,0 +1,25 @@ +/** + * Codegen patch-helper factories built on CanonicalManager's `patches` runtime. + * + * Each factory returns a CM phase handler (`PackagePatch` / `EntryPatch` / `ResourcePatch`) + * that works around a package defect. Pass them via `APIBuilder`'s `patches` option, e.g. + * `patches: { package: [injectDependency("de.basisprofil.r4", { "hl7.fhir.r4.core": "4.0.1" })] }`. + */ + +import type { PackagePatch } from "@atomic-ehr/fhir-canonical-manager"; +import { matchPackage, type PackageMatch } from "@atomic-ehr/fhir-canonical-manager/patch"; + +/** + * Inject FHIR package dependencies into a matching package's manifest when they aren't + * already declared (a common defect: a package references core types without depending on + * the core package). No-op if the package doesn't match or already declares every dep. + */ +export const injectDependency = + (match: PackageMatch, deps: Record): PackagePatch => + (pkg, packageJson) => { + if (!matchPackage(match, pkg)) return undefined; + const existing = (packageJson.dependencies as Record | undefined) ?? {}; + const missing = Object.entries(deps).filter(([name]) => !(name in existing)); + if (missing.length === 0) return undefined; + return { ...packageJson, dependencies: { ...existing, ...Object.fromEntries(missing) } }; + }; From 8a63cc4377ae665020f6588251ea0886cc26e7d2 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Fri, 5 Jun 2026 15:10:47 +0200 Subject: [PATCH 03/14] chore: pin CM to 0.0.24-canary (patches API) for Phase 1 Switches @atomic-ehr/fhir-canonical-manager from 0.0.24 to the canary build that ships the composable patches runtime, so the Phase-1 patch helpers resolve against a published version (not a local link). To be repinned to the proper release once CM #14 ships. --- bun.lock | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index d3ebce7e..160edc12 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@atomic-ehr/codegen", "dependencies": { - "@atomic-ehr/fhir-canonical-manager": "0.0.24", + "@atomic-ehr/fhir-canonical-manager": "0.0.24-canary.20260605130552.a469e45", "@atomic-ehr/fhirschema": "0.0.11", "mustache": "^4.2.0", "picocolors": "^1.1.1", @@ -32,7 +32,7 @@ "smol-toml": ">=1.6.1", }, "packages": { - "@atomic-ehr/fhir-canonical-manager": ["@atomic-ehr/fhir-canonical-manager@0.0.24", "", { "peerDependencies": { "typescript": "^5" }, "bin": { "fcm": "dist/cli/index.js" } }, "sha512-3h+uGf3qqxNX2oClVx+Fz1NZ2Jm2h2dXKrbhA77gURmX5TUYYQKxdTh58a7N/tteLLsig5fW8q41wfeUqy7GdQ=="], + "@atomic-ehr/fhir-canonical-manager": ["@atomic-ehr/fhir-canonical-manager@0.0.24-canary.20260605130552.a469e45", "", { "peerDependencies": { "typescript": "^5" }, "bin": { "fcm": "dist/cli/index.js" } }, "sha512-ThKIkfbRr3a9LSoPv+STQ0272a5jRwN+qf76qZo7M+OdaNA0GJ2lGuozjORJVlWY4JmWG8MbfJfORuR8ZJxBHQ=="], "@atomic-ehr/fhirschema": ["@atomic-ehr/fhirschema@0.0.11", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-oMNxhncEGspGI+QlK/FPjc7akLbfwMYw/hDfW6SbO8xF1KvSSH7NWqc3CJg/k5/309ZuJ6lKsHkgmgVDxo80sQ=="], diff --git a/package.json b/package.json index df77f381..8b5c78d3 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ }, "homepage": "https://github.com/atomic-ehr/codegen#readme", "dependencies": { - "@atomic-ehr/fhir-canonical-manager": "0.0.24", + "@atomic-ehr/fhir-canonical-manager": "0.0.24-canary.20260605130552.a469e45", "@atomic-ehr/fhirschema": "0.0.11", "mustache": "^4.2.0", "picocolors": "^1.1.1", From 7c05233b88ab9d1801ee792bab692a4b69a20e88 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Fri, 5 Jun 2026 15:12:34 +0200 Subject: [PATCH 04/14] API: add renamePackage patch helper, migrate norge name-typo fix (P1.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renamePackage(renames) — a PackagePatch that fixes manifest-name typos via an old→new map. Migrates norge-r4's packageNameFixes off preprocessPackage onto patches; norge's preprocessPackage now only handles the gd-RelatedPerson resource fix (P1.3 next). Generated code typechecks. --- examples/on-the-fly/norge-r4/generate.ts | 22 +++++++--------------- src/api/patches/index.ts | 11 +++++++++++ 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/examples/on-the-fly/norge-r4/generate.ts b/examples/on-the-fly/norge-r4/generate.ts index dab2a217..d69b242d 100644 --- a/examples/on-the-fly/norge-r4/generate.ts +++ b/examples/on-the-fly/norge-r4/generate.ts @@ -1,6 +1,6 @@ import type { PreprocessContext } from "@atomic-ehr/fhir-canonical-manager"; import { APIBuilder, prettyReport } from "../../../src/api/builder"; -import { injectDependency } from "../../../src/api/patches"; +import { injectDependency, renamePackage } from "../../../src/api/patches"; // Fix known package name typos (in-memory transformation) const packageNameFixes: Record = { @@ -40,28 +40,20 @@ const preprocessPackage = (ctx: PreprocessContext): PreprocessContext => { ); return { ...ctx, resource: JSON.parse(str) }; } - return ctx; } - let json = ctx.packageJson; - const name = json.name as string; - - // Fix package name typos - const fixedName = packageNameFixes[name]; - if (fixedName) { - console.log(`Fixed package name: ${name} -> ${fixedName}`); - json = { ...json, name: fixedName }; - } - - return { ...ctx, kind: "package", packageJson: json }; + return ctx; }; if (require.main === module) { console.log("Generating Norge R4 types..."); const builder = new APIBuilder({ - // Norwegian/simplifier core packages reference core types without declaring the dep. patches: { - package: [injectDependency((pkg) => needsCoreDependency(pkg.name), { "hl7.fhir.r4.core": "4.0.1" })], + package: [ + // Core packages reference core types without declaring the dep; fix the name typo. + injectDependency((pkg) => needsCoreDependency(pkg.name), { "hl7.fhir.r4.core": "4.0.1" }), + renamePackage(packageNameFixes), + ], }, preprocessPackage, registry: "https://packages.simplifier.net", diff --git a/src/api/patches/index.ts b/src/api/patches/index.ts index e4dea0d9..5205c8c4 100644 --- a/src/api/patches/index.ts +++ b/src/api/patches/index.ts @@ -23,3 +23,14 @@ export const injectDependency = if (missing.length === 0) return undefined; return { ...packageJson, dependencies: { ...existing, ...Object.fromEntries(missing) } }; }; + +/** + * Rename a package whose manifest name is a typo, via an old-name → new-name map. No-op for + * packages not in the map. + */ +export const renamePackage = + (renames: Record): PackagePatch => + (pkg, packageJson) => { + const renamed = renames[pkg.name]; + return renamed === undefined ? undefined : { ...packageJson, name: renamed }; + }; From 7fde5710a991637e9b09ac81f720b9e999cb5f1e Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Fri, 5 Jun 2026 15:29:39 +0200 Subject: [PATCH 05/14] API: add renameCanonical/renameReferenceTarget helpers (P1.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds scoped resource-phase URL-rewrite helpers (shared blanket replace + a ResourceScope of package/url). Migrates norge's gd-RelatedPerson Person->Patient fix (renameReferenceTarget) — norge's preprocessPackage is now fully gone — and ccda's IVL_TS->IVL-TS canonical typo (renameCanonical) onto its manual CM's patches config. Both examples regenerate successfully; norge generated code typechecks. ccda keeps preprocessPackage for CarePlanAct + bundle-type (P1.4/P1.5). --- examples/on-the-fly/norge-r4/generate.ts | 44 +++++++++--------------- examples/typescript-ccda/generate.ts | 21 ++++++----- src/api/patches/index.ts | 36 ++++++++++++++++++- 3 files changed, 64 insertions(+), 37 deletions(-) diff --git a/examples/on-the-fly/norge-r4/generate.ts b/examples/on-the-fly/norge-r4/generate.ts index d69b242d..95286ef5 100644 --- a/examples/on-the-fly/norge-r4/generate.ts +++ b/examples/on-the-fly/norge-r4/generate.ts @@ -1,6 +1,5 @@ -import type { PreprocessContext } from "@atomic-ehr/fhir-canonical-manager"; import { APIBuilder, prettyReport } from "../../../src/api/builder"; -import { injectDependency, renamePackage } from "../../../src/api/patches"; +import { injectDependency, renamePackage, renameReferenceTarget } from "../../../src/api/patches"; // Fix known package name typos (in-memory transformation) const packageNameFixes: Record = { @@ -19,31 +18,6 @@ const needsCoreDependency = (name: string): boolean => { ); }; -const preprocessPackage = (ctx: PreprocessContext): PreprocessContext => { - // GdRelatedPerson widens patient reference to include Person, but the - // base R4 RelatedPerson.patient only allows Patient. Drop the Person targets. - if (ctx.kind === "resource") { - const res = ctx.resource as { url?: string }; - if (res.url === "http://ehelse.no/fhir/StructureDefinition/gd-RelatedPerson") { - let str = JSON.stringify(ctx.resource); - str = str.replaceAll( - "http://hl7.org/fhir/StructureDefinition/Person", - "http://hl7.org/fhir/StructureDefinition/Patient", - ); - str = str.replaceAll( - "http://hl7.no/fhir/StructureDefinition/no-basis-Person", - "http://hl7.org/fhir/StructureDefinition/Patient", - ); - str = str.replaceAll( - "http://ehelse.no/fhir/StructureDefinition/gd-Person", - "http://hl7.org/fhir/StructureDefinition/Patient", - ); - return { ...ctx, resource: JSON.parse(str) }; - } - } - return ctx; -}; - if (require.main === module) { console.log("Generating Norge R4 types..."); @@ -54,8 +28,22 @@ if (require.main === module) { injectDependency((pkg) => needsCoreDependency(pkg.name), { "hl7.fhir.r4.core": "4.0.1" }), renamePackage(packageNameFixes), ], + // gd-RelatedPerson widens patient to include Person, but base R4 RelatedPerson.patient + // only allows Patient — narrow the Person targets back to Patient. + resource: [ + renameReferenceTarget( + { + "http://hl7.org/fhir/StructureDefinition/Person": + "http://hl7.org/fhir/StructureDefinition/Patient", + "http://hl7.no/fhir/StructureDefinition/no-basis-Person": + "http://hl7.org/fhir/StructureDefinition/Patient", + "http://ehelse.no/fhir/StructureDefinition/gd-Person": + "http://hl7.org/fhir/StructureDefinition/Patient", + }, + { url: "http://ehelse.no/fhir/StructureDefinition/gd-RelatedPerson" }, + ), + ], }, - preprocessPackage, registry: "https://packages.simplifier.net", }) .fromPackage("hl7.fhir.r4.core", "4.0.1") diff --git a/examples/typescript-ccda/generate.ts b/examples/typescript-ccda/generate.ts index 4a4f0b8e..cddfab21 100644 --- a/examples/typescript-ccda/generate.ts +++ b/examples/typescript-ccda/generate.ts @@ -4,17 +4,10 @@ import { CanonicalManager, type PreprocessContext } from "@atomic-ehr/fhir-canonical-manager"; import { registerFromManager } from "@root/typeschema/register"; import { APIBuilder, prettyReport } from "../../src/api/builder"; +import { renameCanonical } from "../../src/api/patches"; const preprocessPackage = (ctx: PreprocessContext): PreprocessContext => { if (ctx.kind !== "resource") return ctx; - if (ctx.package.name === "hl7.cda.uv.core") { - let str = JSON.stringify(ctx.resource); - str = str.replaceAll( - "http://hl7.org/cda/stds/core/StructureDefinition/IVL_TS", - "http://hl7.org/cda/stds/core/StructureDefinition/IVL-TS", - ); - return { ...ctx, resource: JSON.parse(str) }; - } // CarePlanAct profile binds moodCode to an external NLM ValueSet that // isn't available in any loaded package. Reuse the base Act binding. if (ctx.package.name === "hl7.cda.us.ccda") { @@ -55,6 +48,18 @@ if (require.main === module) { const manager = CanonicalManager({ packages: [], workingDir: ".codegen-cache/canonical-manager-cache", + // IVL_TS is a typo'd canonical in hl7.cda.uv.core (should be IVL-TS). + patches: { + resource: [ + renameCanonical( + { + "http://hl7.org/cda/stds/core/StructureDefinition/IVL_TS": + "http://hl7.org/cda/stds/core/StructureDefinition/IVL-TS", + }, + { package: "hl7.cda.uv.core" }, + ), + ], + }, preprocessPackage, }); diff --git a/src/api/patches/index.ts b/src/api/patches/index.ts index 5205c8c4..ef00b081 100644 --- a/src/api/patches/index.ts +++ b/src/api/patches/index.ts @@ -6,9 +6,43 @@ * `patches: { package: [injectDependency("de.basisprofil.r4", { "hl7.fhir.r4.core": "4.0.1" })] }`. */ -import type { PackagePatch } from "@atomic-ehr/fhir-canonical-manager"; +import type { PackageId, PackagePatch, Resource, ResourcePatch } from "@atomic-ehr/fhir-canonical-manager"; import { matchPackage, type PackageMatch } from "@atomic-ehr/fhir-canonical-manager/patch"; +/** Scope a resource-phase patch to a package and/or a specific canonical url. */ +export type ResourceScope = { package?: PackageMatch; url?: string }; + +const inScope = (scope: ResourceScope | undefined, pkg: PackageId, resource: Resource): boolean => { + if (!scope) return true; + if (scope.package && !matchPackage(scope.package, pkg)) return false; + if (scope.url !== undefined && resource.url !== scope.url) return false; + return true; +}; + +/** Replace every occurrence of each `from` URL with its `to` throughout a (scoped) resource body. */ +const replaceUrls = + (renames: Record, scope: ResourceScope | undefined): ResourcePatch => + (pkg, resource) => { + if (!inScope(scope, pkg, resource)) return undefined; + let str = JSON.stringify(resource); + let changed = false; + for (const [from, to] of Object.entries(renames)) { + if (str.includes(from)) { + str = str.replaceAll(from, to); + changed = true; + } + } + return changed ? JSON.parse(str) : undefined; + }; + +/** Fix a typo'd canonical URL — the resource's own identity and every reference to it. */ +export const renameCanonical = (renames: Record, scope?: ResourceScope): ResourcePatch => + replaceUrls(renames, scope); + +/** Rewrite reference targets (e.g. a profile that points at the wrong/unavailable type). */ +export const renameReferenceTarget = (renames: Record, scope?: ResourceScope): ResourcePatch => + replaceUrls(renames, scope); + /** * Inject FHIR package dependencies into a matching package's manifest when they aren't * already declared (a common defect: a package references core types without depending on From 8530c76ea54e96d5d0654afb33963840d15d9221 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Fri, 5 Jun 2026 15:43:06 +0200 Subject: [PATCH 06/14] API: add swapBinding + patchCodeSystem, finish ccda migration (P1.4+P1.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit swapBinding(swaps, scope?) — swap a binding's ValueSet URL for an available one (scoped URL replace). patchCodeSystem(url, codes) — add missing codes to a CodeSystem. Migrates ccda's CarePlanAct ValueSet swap and bundle-type missing codes; ccda's preprocessPackage is now fully removed. All three examples (kbv/norge/ccda) regenerate successfully and their generated code typechecks. --- examples/typescript-ccda/generate.ts | 55 ++++++++-------------------- src/api/patches/index.ts | 20 ++++++++++ 2 files changed, 35 insertions(+), 40 deletions(-) diff --git a/examples/typescript-ccda/generate.ts b/examples/typescript-ccda/generate.ts index cddfab21..751b6827 100644 --- a/examples/typescript-ccda/generate.ts +++ b/examples/typescript-ccda/generate.ts @@ -1,46 +1,10 @@ // Run this script using Bun CLI with: // bun run scripts/generate-fhir-types.ts -import { CanonicalManager, type PreprocessContext } from "@atomic-ehr/fhir-canonical-manager"; +import { CanonicalManager } from "@atomic-ehr/fhir-canonical-manager"; import { registerFromManager } from "@root/typeschema/register"; import { APIBuilder, prettyReport } from "../../src/api/builder"; -import { renameCanonical } from "../../src/api/patches"; - -const preprocessPackage = (ctx: PreprocessContext): PreprocessContext => { - if (ctx.kind !== "resource") return ctx; - // CarePlanAct profile binds moodCode to an external NLM ValueSet that - // isn't available in any loaded package. Reuse the base Act binding. - if (ctx.package.name === "hl7.cda.us.ccda") { - const res = ctx.resource as { url?: string }; - if (res.url === "http://hl7.org/cda/us/ccda/StructureDefinition/CarePlanAct") { - let str = JSON.stringify(ctx.resource); - str = str.replaceAll( - "http://cts.nlm.nih.gov/fhir/ValueSet/2.16.840.1.113762.1.4.1267.37", - "http://terminology.hl7.org/ValueSet/v3-xDocumentActMood", - ); - return { ...ctx, resource: JSON.parse(str) }; - } - } - // The bundle-type CodeSystem is missing codes "bundle" and - // "subscription-notification" used by BatchBundle and - // SubscriptionNotificationBundle profiles. Patch all instances since - // resolveAny may pick any package's copy of the CodeSystem. - const res = ctx.resource as { url?: string; concept?: { code: string }[] }; - if (res.url === "http://hl7.org/fhir/bundle-type" && res.concept) { - const existing = new Set(res.concept.map((c) => c.code)); - const missing = ["bundle", "subscription-notification"].filter((c) => !existing.has(c)); - if (missing.length > 0) { - return { - ...ctx, - resource: { - ...ctx.resource, - concept: [...res.concept, ...missing.map((code) => ({ code }))], - }, - }; - } - } - return ctx; -}; +import { patchCodeSystem, renameCanonical, swapBinding } from "../../src/api/patches"; if (require.main === module) { console.log("📦 Generating CCDA Types..."); @@ -48,9 +12,9 @@ if (require.main === module) { const manager = CanonicalManager({ packages: [], workingDir: ".codegen-cache/canonical-manager-cache", - // IVL_TS is a typo'd canonical in hl7.cda.uv.core (should be IVL-TS). patches: { resource: [ + // IVL_TS is a typo'd canonical in hl7.cda.uv.core (should be IVL-TS). renameCanonical( { "http://hl7.org/cda/stds/core/StructureDefinition/IVL_TS": @@ -58,9 +22,20 @@ if (require.main === module) { }, { package: "hl7.cda.uv.core" }, ), + // CarePlanAct binds moodCode to an external NLM ValueSet absent from every loaded + // package; reuse the base Act binding. + swapBinding( + { + "http://cts.nlm.nih.gov/fhir/ValueSet/2.16.840.1.113762.1.4.1267.37": + "http://terminology.hl7.org/ValueSet/v3-xDocumentActMood", + }, + { package: "hl7.cda.us.ccda", url: "http://hl7.org/cda/us/ccda/StructureDefinition/CarePlanAct" }, + ), + // The bundle-type CodeSystem omits codes used by BatchBundle / + // SubscriptionNotificationBundle; patch every copy (resolveAny may pick any). + patchCodeSystem("http://hl7.org/fhir/bundle-type", ["bundle", "subscription-notification"]), ], }, - preprocessPackage, }); // Initialize manager with packages to discover CDA resources diff --git a/src/api/patches/index.ts b/src/api/patches/index.ts index ef00b081..61edbce8 100644 --- a/src/api/patches/index.ts +++ b/src/api/patches/index.ts @@ -43,6 +43,26 @@ export const renameCanonical = (renames: Record, scope?: Resourc export const renameReferenceTarget = (renames: Record, scope?: ResourceScope): ResourcePatch => replaceUrls(renames, scope); +/** Swap a binding's ValueSet URL for an available one (e.g. an external set not in any package). */ +export const swapBinding = (swaps: Record, scope?: ResourceScope): ResourcePatch => + replaceUrls(swaps, scope); + +/** + * Add missing codes to a CodeSystem (matched by `url`) — for systems that omit codes used by + * profiles. No-op if the resource isn't that CodeSystem or already declares every code. + */ +export const patchCodeSystem = + (url: string, codes: string[]): ResourcePatch => + (_pkg, resource) => { + if (resource.url !== url) return undefined; + const concept = (resource as { concept?: { code: string }[] }).concept; + if (!concept) return undefined; + const existing = new Set(concept.map((c) => c.code)); + const missing = codes.filter((code) => !existing.has(code)); + if (missing.length === 0) return undefined; + return { ...resource, concept: [...concept, ...missing.map((code) => ({ code }))] }; + }; + /** * Inject FHIR package dependencies into a matching package's manifest when they aren't * already declared (a common defect: a package references core types without depending on From c1c3171eb7b262da875230a54697d4918c1c86a0 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Fri, 5 Jun 2026 16:49:29 +0200 Subject: [PATCH 07/14] API: scoping combinators forPackage/forResource, scope-free helpers (P1.6) Adds nesting scope combinators forPackage(match, handlers) and forResource(url, handlers); the transform helpers (injectDependency/renameCanonical/ renameReferenceTarget/swapBinding) drop their scope args and are scoped by the combinators instead. APIBuilder.patches now accepts a single handler or a list per phase (PatchesInput), normalized to CM's array form. Restyles all three examples to the grouped/nested form, e.g. patches: { package: forPackage("de.basisprofil.r4", [injectDependency({ "hl7.fhir.r4.core": "4.0.1" })]) } All three examples regenerate (Status: Success) and tests pass (kbv 5/0, norge 3/0, ccda 24/0). --- examples/on-the-fly/kbv-r4/generate.ts | 4 +- examples/on-the-fly/norge-r4/generate.ts | 32 +++++----- examples/typescript-ccda/generate.ts | 26 ++++---- src/api/builder.ts | 30 +++++++++- src/api/patches/index.ts | 75 +++++++++++++++--------- 5 files changed, 107 insertions(+), 60 deletions(-) diff --git a/examples/on-the-fly/kbv-r4/generate.ts b/examples/on-the-fly/kbv-r4/generate.ts index c694f50f..bf0daf7d 100644 --- a/examples/on-the-fly/kbv-r4/generate.ts +++ b/examples/on-the-fly/kbv-r4/generate.ts @@ -1,12 +1,12 @@ import { APIBuilder, prettyReport } from "../../../src/api/builder"; -import { injectDependency } from "../../../src/api/patches"; +import { forPackage, injectDependency } from "../../../src/api/patches"; if (require.main === module) { console.log("Generating KBV R4 types..."); const builder = new APIBuilder({ // de.basisprofil.r4 references core types without declaring hl7.fhir.r4.core. - patches: { package: [injectDependency("de.basisprofil.r4", { "hl7.fhir.r4.core": "4.0.1" })] }, + patches: { package: forPackage("de.basisprofil.r4", [injectDependency({ "hl7.fhir.r4.core": "4.0.1" })]) }, registry: "https://packages.simplifier.net", ignorePackageIndex: true, }) diff --git a/examples/on-the-fly/norge-r4/generate.ts b/examples/on-the-fly/norge-r4/generate.ts index 95286ef5..4d09f849 100644 --- a/examples/on-the-fly/norge-r4/generate.ts +++ b/examples/on-the-fly/norge-r4/generate.ts @@ -1,5 +1,11 @@ import { APIBuilder, prettyReport } from "../../../src/api/builder"; -import { injectDependency, renamePackage, renameReferenceTarget } from "../../../src/api/patches"; +import { + forPackage, + forResource, + injectDependency, + renamePackage, + renameReferenceTarget, +} from "../../../src/api/patches"; // Fix known package name typos (in-memory transformation) const packageNameFixes: Record = { @@ -25,24 +31,20 @@ if (require.main === module) { patches: { package: [ // Core packages reference core types without declaring the dep; fix the name typo. - injectDependency((pkg) => needsCoreDependency(pkg.name), { "hl7.fhir.r4.core": "4.0.1" }), + forPackage((pkg) => needsCoreDependency(pkg.name), [injectDependency({ "hl7.fhir.r4.core": "4.0.1" })]), renamePackage(packageNameFixes), ], // gd-RelatedPerson widens patient to include Person, but base R4 RelatedPerson.patient // only allows Patient — narrow the Person targets back to Patient. - resource: [ - renameReferenceTarget( - { - "http://hl7.org/fhir/StructureDefinition/Person": - "http://hl7.org/fhir/StructureDefinition/Patient", - "http://hl7.no/fhir/StructureDefinition/no-basis-Person": - "http://hl7.org/fhir/StructureDefinition/Patient", - "http://ehelse.no/fhir/StructureDefinition/gd-Person": - "http://hl7.org/fhir/StructureDefinition/Patient", - }, - { url: "http://ehelse.no/fhir/StructureDefinition/gd-RelatedPerson" }, - ), - ], + resource: forResource("http://ehelse.no/fhir/StructureDefinition/gd-RelatedPerson", [ + renameReferenceTarget({ + "http://hl7.org/fhir/StructureDefinition/Person": "http://hl7.org/fhir/StructureDefinition/Patient", + "http://hl7.no/fhir/StructureDefinition/no-basis-Person": + "http://hl7.org/fhir/StructureDefinition/Patient", + "http://ehelse.no/fhir/StructureDefinition/gd-Person": + "http://hl7.org/fhir/StructureDefinition/Patient", + }), + ]), }, registry: "https://packages.simplifier.net", }) diff --git a/examples/typescript-ccda/generate.ts b/examples/typescript-ccda/generate.ts index 751b6827..f4b2829f 100644 --- a/examples/typescript-ccda/generate.ts +++ b/examples/typescript-ccda/generate.ts @@ -4,7 +4,7 @@ import { CanonicalManager } from "@atomic-ehr/fhir-canonical-manager"; import { registerFromManager } from "@root/typeschema/register"; import { APIBuilder, prettyReport } from "../../src/api/builder"; -import { patchCodeSystem, renameCanonical, swapBinding } from "../../src/api/patches"; +import { forPackage, forResource, patchCodeSystem, renameCanonical, swapBinding } from "../../src/api/patches"; if (require.main === module) { console.log("📦 Generating CCDA Types..."); @@ -15,22 +15,22 @@ if (require.main === module) { patches: { resource: [ // IVL_TS is a typo'd canonical in hl7.cda.uv.core (should be IVL-TS). - renameCanonical( - { + forPackage("hl7.cda.uv.core", [ + renameCanonical({ "http://hl7.org/cda/stds/core/StructureDefinition/IVL_TS": "http://hl7.org/cda/stds/core/StructureDefinition/IVL-TS", - }, - { package: "hl7.cda.uv.core" }, - ), + }), + ]), // CarePlanAct binds moodCode to an external NLM ValueSet absent from every loaded // package; reuse the base Act binding. - swapBinding( - { - "http://cts.nlm.nih.gov/fhir/ValueSet/2.16.840.1.113762.1.4.1267.37": - "http://terminology.hl7.org/ValueSet/v3-xDocumentActMood", - }, - { package: "hl7.cda.us.ccda", url: "http://hl7.org/cda/us/ccda/StructureDefinition/CarePlanAct" }, - ), + forPackage("hl7.cda.us.ccda", [ + forResource("http://hl7.org/cda/us/ccda/StructureDefinition/CarePlanAct", [ + swapBinding({ + "http://cts.nlm.nih.gov/fhir/ValueSet/2.16.840.1.113762.1.4.1267.37": + "http://terminology.hl7.org/ValueSet/v3-xDocumentActMood", + }), + ]), + ]), // The bundle-type CodeSystem omits codes used by BatchBundle / // SubscriptionNotificationBundle; patch every copy (resolveAny may pick any). patchCodeSystem("http://hl7.org/fhir/bundle-type", ["bundle", "subscription-notification"]), diff --git a/src/api/builder.ts b/src/api/builder.ts index c1706afd..d288cb49 100644 --- a/src/api/builder.ts +++ b/src/api/builder.ts @@ -10,9 +10,12 @@ import * as fs from "node:fs"; import * as Path from "node:path"; import { CanonicalManager, + type EntryPatch, type LocalPackageConfig, + type PackagePatch, type Patches, type PreprocessContext, + type ResourcePatch, type TgzPackageConfig, } from "@atomic-ehr/fhir-canonical-manager"; import { CSharp, type CSharpGeneratorOptions } from "@root/api/writer-generator/csharp/csharp"; @@ -33,6 +36,29 @@ import * as Mustache from "./writer-generator/mustache"; import { TypeScript, type TypeScriptOptions } from "./writer-generator/typescript/writer"; import type { FileBuffer, FileSystemWriter, FileSystemWriterOptions, WriterOptions } from "./writer-generator/writer"; +/** + * Per-phase patch handlers for the `APIBuilder` `patches` option. Each phase accepts a single + * handler or a list (handlers are typically `forPackage`/`forResource` combinators); normalized + * to the CanonicalManager `Patches` (array) form before being passed to the manager. + */ +export type PatchesInput = { + package?: PackagePatch | PackagePatch[]; + entry?: EntryPatch | EntryPatch[]; + resource?: ResourcePatch | ResourcePatch[]; +}; + +const toArray = (value: T | T[] | undefined): T[] | undefined => { + if (value === undefined) return undefined; + return Array.isArray(value) ? value : [value]; +}; + +const normalizePatches = (patches: PatchesInput | undefined): Partial | undefined => + patches && { + package: toArray(patches.package), + entry: toArray(patches.entry), + resource: toArray(patches.resource), + }; + /** * Configuration options for the API builder */ @@ -173,7 +199,7 @@ export class APIBuilder { manager?: ReturnType; register?: Register; /** Per-phase patch handlers passed to the CanonicalManager (package-defect fixes). */ - patches?: Partial; + patches?: PatchesInput; preprocessPackage?: (context: PreprocessContext) => PreprocessContext; ignorePackageIndex?: boolean; logger?: CodegenLogManager; @@ -216,7 +242,7 @@ export class APIBuilder { workingDir: ".codegen-cache/canonical-manager-cache", registry: userOpts.registry, dropCache: userOpts.dropCanonicalManagerCache, - patches: userOpts.patches, + patches: normalizePatches(userOpts.patches), preprocessPackage: userOpts.preprocessPackage, ignorePackageIndex: userOpts.ignorePackageIndex, }); diff --git a/src/api/patches/index.ts b/src/api/patches/index.ts index 61edbce8..31b1379e 100644 --- a/src/api/patches/index.ts +++ b/src/api/patches/index.ts @@ -1,29 +1,52 @@ /** - * Codegen patch-helper factories built on CanonicalManager's `patches` runtime. + * Codegen patch-helper factories + scoping combinators built on CanonicalManager's `patches` + * runtime. * - * Each factory returns a CM phase handler (`PackagePatch` / `EntryPatch` / `ResourcePatch`) - * that works around a package defect. Pass them via `APIBuilder`'s `patches` option, e.g. - * `patches: { package: [injectDependency("de.basisprofil.r4", { "hl7.fhir.r4.core": "4.0.1" })] }`. + * Helpers are unscoped transforms (`injectDependency`, `renameCanonical`, …); scope them with + * the `forPackage` / `forResource` combinators, which nest. Pass the result via `APIBuilder`'s + * `patches` option (a phase accepts a single handler or a list), e.g. + * `patches: { package: forPackage("de.basisprofil.r4", [injectDependency({ "hl7.fhir.r4.core": "4.0.1" })]) }`. */ -import type { PackageId, PackagePatch, Resource, ResourcePatch } from "@atomic-ehr/fhir-canonical-manager"; +import type { PackageId, PackagePatch, PatchReportSink, ResourcePatch } from "@atomic-ehr/fhir-canonical-manager"; import { matchPackage, type PackageMatch } from "@atomic-ehr/fhir-canonical-manager/patch"; -/** Scope a resource-phase patch to a package and/or a specific canonical url. */ -export type ResourceScope = { package?: PackageMatch; url?: string }; +/** A non-dropping phase handler — `(pkg, value, report) => value | undefined`. */ +type Handler = (pkg: PackageId, value: V, report: PatchReportSink) => V | undefined; -const inScope = (scope: ResourceScope | undefined, pkg: PackageId, resource: Resource): boolean => { - if (!scope) return true; - if (scope.package && !matchPackage(scope.package, pkg)) return false; - if (scope.url !== undefined && resource.url !== scope.url) return false; - return true; +/** Run `handlers` left-to-right over `value`; return the result only if something changed. */ +const run = (handlers: Handler[], pkg: PackageId, value: V, report: PatchReportSink): V | undefined => { + let acc = value; + let changed = false; + for (const handler of handlers) { + const result = handler(pkg, acc, report); + if (result !== undefined) { + acc = result; + changed = true; + } + } + return changed ? acc : undefined; }; -/** Replace every occurrence of each `from` URL with its `to` throughout a (scoped) resource body. */ +// ── Scoping combinators ────────────────────────────────────────────────────── + +/** Apply `handlers` only to packages matching `match`. Works for package- and resource-phase + * handlers, and nests with `forResource`. */ +export const forPackage = (match: PackageMatch, handlers: Handler[]): Handler => { + return (pkg, value, report) => (matchPackage(match, pkg) ? run(handlers, pkg, value, report) : undefined); +}; + +/** Apply resource `handlers` only to the resource with the given canonical `url`. */ +export const forResource = (url: string, handlers: ResourcePatch[]): ResourcePatch => { + return (pkg, resource, report) => (resource.url === url ? run(handlers, pkg, resource, report) : undefined); +}; + +// ── Unscoped transform helpers ─────────────────────────────────────────────── + +/** Replace every occurrence of each `from` URL with its `to` throughout the resource body. */ const replaceUrls = - (renames: Record, scope: ResourceScope | undefined): ResourcePatch => - (pkg, resource) => { - if (!inScope(scope, pkg, resource)) return undefined; + (renames: Record): ResourcePatch => + (_pkg, resource) => { let str = JSON.stringify(resource); let changed = false; for (const [from, to] of Object.entries(renames)) { @@ -36,16 +59,13 @@ const replaceUrls = }; /** Fix a typo'd canonical URL — the resource's own identity and every reference to it. */ -export const renameCanonical = (renames: Record, scope?: ResourceScope): ResourcePatch => - replaceUrls(renames, scope); +export const renameCanonical = (renames: Record): ResourcePatch => replaceUrls(renames); /** Rewrite reference targets (e.g. a profile that points at the wrong/unavailable type). */ -export const renameReferenceTarget = (renames: Record, scope?: ResourceScope): ResourcePatch => - replaceUrls(renames, scope); +export const renameReferenceTarget = (renames: Record): ResourcePatch => replaceUrls(renames); /** Swap a binding's ValueSet URL for an available one (e.g. an external set not in any package). */ -export const swapBinding = (swaps: Record, scope?: ResourceScope): ResourcePatch => - replaceUrls(swaps, scope); +export const swapBinding = (swaps: Record): ResourcePatch => replaceUrls(swaps); /** * Add missing codes to a CodeSystem (matched by `url`) — for systems that omit codes used by @@ -64,14 +84,13 @@ export const patchCodeSystem = }; /** - * Inject FHIR package dependencies into a matching package's manifest when they aren't - * already declared (a common defect: a package references core types without depending on - * the core package). No-op if the package doesn't match or already declares every dep. + * Inject FHIR package dependencies into the manifest when they aren't already declared (a common + * defect: a package references core types without depending on the core package). Scope it with + * `forPackage`. No-op if every dep is already declared. */ export const injectDependency = - (match: PackageMatch, deps: Record): PackagePatch => - (pkg, packageJson) => { - if (!matchPackage(match, pkg)) return undefined; + (deps: Record): PackagePatch => + (_pkg, packageJson) => { const existing = (packageJson.dependencies as Record | undefined) ?? {}; const missing = Object.entries(deps).filter(([name]) => !(name in existing)); if (missing.length === 0) return undefined; From 2b5338f846318847b9389eb6fd2d49f46faeaf4b Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Fri, 5 Jun 2026 17:44:34 +0200 Subject: [PATCH 08/14] chore: repin CM to 0.0.24-canary.20260605153206.1343644 (renamed Patches fields) --- bun.lock | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index 160edc12..75f3741c 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@atomic-ehr/codegen", "dependencies": { - "@atomic-ehr/fhir-canonical-manager": "0.0.24-canary.20260605130552.a469e45", + "@atomic-ehr/fhir-canonical-manager": "0.0.24-canary.20260605153206.1343644", "@atomic-ehr/fhirschema": "0.0.11", "mustache": "^4.2.0", "picocolors": "^1.1.1", @@ -32,7 +32,7 @@ "smol-toml": ">=1.6.1", }, "packages": { - "@atomic-ehr/fhir-canonical-manager": ["@atomic-ehr/fhir-canonical-manager@0.0.24-canary.20260605130552.a469e45", "", { "peerDependencies": { "typescript": "^5" }, "bin": { "fcm": "dist/cli/index.js" } }, "sha512-ThKIkfbRr3a9LSoPv+STQ0272a5jRwN+qf76qZo7M+OdaNA0GJ2lGuozjORJVlWY4JmWG8MbfJfORuR8ZJxBHQ=="], + "@atomic-ehr/fhir-canonical-manager": ["@atomic-ehr/fhir-canonical-manager@0.0.24-canary.20260605153206.1343644", "", { "peerDependencies": { "typescript": "^5" }, "bin": { "fcm": "dist/cli/index.js" } }, "sha512-V8QCLV7fIqmWtix2njrNaE3keqwFVhfTEqDAlc08EB4R8bp0DUNux+L8JNtXQFBeZbpIIZz/aNbQf+nQnm5HQw=="], "@atomic-ehr/fhirschema": ["@atomic-ehr/fhirschema@0.0.11", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-oMNxhncEGspGI+QlK/FPjc7akLbfwMYw/hDfW6SbO8xF1KvSSH7NWqc3CJg/k5/309ZuJ6lKsHkgmgVDxo80sQ=="], diff --git a/package.json b/package.json index 8b5c78d3..cdf72d8f 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ }, "homepage": "https://github.com/atomic-ehr/codegen#readme", "dependencies": { - "@atomic-ehr/fhir-canonical-manager": "0.0.24-canary.20260605130552.a469e45", + "@atomic-ehr/fhir-canonical-manager": "0.0.24-canary.20260605153206.1343644", "@atomic-ehr/fhirschema": "0.0.11", "mustache": "^4.2.0", "picocolors": "^1.1.1", From 609b2e2e42b340ccb6db951b7e03f9a23d08c98f Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Fri, 5 Jun 2026 17:44:34 +0200 Subject: [PATCH 09/14] ref: rename patch combinators to inPackage/inResource, Patches fields to packageJson/indexEntry/fhirResource --- examples/on-the-fly/kbv-r4/generate.ts | 4 ++-- examples/on-the-fly/norge-r4/generate.ts | 10 +++++----- examples/typescript-ccda/generate.ts | 10 +++++----- src/api/builder.ts | 14 +++++++------- src/api/patches/index.ts | 12 ++++++------ 5 files changed, 25 insertions(+), 25 deletions(-) diff --git a/examples/on-the-fly/kbv-r4/generate.ts b/examples/on-the-fly/kbv-r4/generate.ts index bf0daf7d..d180e26a 100644 --- a/examples/on-the-fly/kbv-r4/generate.ts +++ b/examples/on-the-fly/kbv-r4/generate.ts @@ -1,12 +1,12 @@ import { APIBuilder, prettyReport } from "../../../src/api/builder"; -import { forPackage, injectDependency } from "../../../src/api/patches"; +import { injectDependency, inPackage } from "../../../src/api/patches"; if (require.main === module) { console.log("Generating KBV R4 types..."); const builder = new APIBuilder({ // de.basisprofil.r4 references core types without declaring hl7.fhir.r4.core. - patches: { package: forPackage("de.basisprofil.r4", [injectDependency({ "hl7.fhir.r4.core": "4.0.1" })]) }, + patches: { packageJson: inPackage("de.basisprofil.r4", [injectDependency({ "hl7.fhir.r4.core": "4.0.1" })]) }, registry: "https://packages.simplifier.net", ignorePackageIndex: true, }) diff --git a/examples/on-the-fly/norge-r4/generate.ts b/examples/on-the-fly/norge-r4/generate.ts index 4d09f849..3c59ff48 100644 --- a/examples/on-the-fly/norge-r4/generate.ts +++ b/examples/on-the-fly/norge-r4/generate.ts @@ -1,8 +1,8 @@ import { APIBuilder, prettyReport } from "../../../src/api/builder"; import { - forPackage, - forResource, injectDependency, + inPackage, + inResource, renamePackage, renameReferenceTarget, } from "../../../src/api/patches"; @@ -29,14 +29,14 @@ if (require.main === module) { const builder = new APIBuilder({ patches: { - package: [ + packageJson: [ // Core packages reference core types without declaring the dep; fix the name typo. - forPackage((pkg) => needsCoreDependency(pkg.name), [injectDependency({ "hl7.fhir.r4.core": "4.0.1" })]), + inPackage((pkg) => needsCoreDependency(pkg.name), [injectDependency({ "hl7.fhir.r4.core": "4.0.1" })]), renamePackage(packageNameFixes), ], // gd-RelatedPerson widens patient to include Person, but base R4 RelatedPerson.patient // only allows Patient — narrow the Person targets back to Patient. - resource: forResource("http://ehelse.no/fhir/StructureDefinition/gd-RelatedPerson", [ + fhirResource: inResource("http://ehelse.no/fhir/StructureDefinition/gd-RelatedPerson", [ renameReferenceTarget({ "http://hl7.org/fhir/StructureDefinition/Person": "http://hl7.org/fhir/StructureDefinition/Patient", "http://hl7.no/fhir/StructureDefinition/no-basis-Person": diff --git a/examples/typescript-ccda/generate.ts b/examples/typescript-ccda/generate.ts index f4b2829f..5e78788c 100644 --- a/examples/typescript-ccda/generate.ts +++ b/examples/typescript-ccda/generate.ts @@ -4,7 +4,7 @@ import { CanonicalManager } from "@atomic-ehr/fhir-canonical-manager"; import { registerFromManager } from "@root/typeschema/register"; import { APIBuilder, prettyReport } from "../../src/api/builder"; -import { forPackage, forResource, patchCodeSystem, renameCanonical, swapBinding } from "../../src/api/patches"; +import { inPackage, inResource, patchCodeSystem, renameCanonical, swapBinding } from "../../src/api/patches"; if (require.main === module) { console.log("📦 Generating CCDA Types..."); @@ -13,9 +13,9 @@ if (require.main === module) { packages: [], workingDir: ".codegen-cache/canonical-manager-cache", patches: { - resource: [ + fhirResource: [ // IVL_TS is a typo'd canonical in hl7.cda.uv.core (should be IVL-TS). - forPackage("hl7.cda.uv.core", [ + inPackage("hl7.cda.uv.core", [ renameCanonical({ "http://hl7.org/cda/stds/core/StructureDefinition/IVL_TS": "http://hl7.org/cda/stds/core/StructureDefinition/IVL-TS", @@ -23,8 +23,8 @@ if (require.main === module) { ]), // CarePlanAct binds moodCode to an external NLM ValueSet absent from every loaded // package; reuse the base Act binding. - forPackage("hl7.cda.us.ccda", [ - forResource("http://hl7.org/cda/us/ccda/StructureDefinition/CarePlanAct", [ + inPackage("hl7.cda.us.ccda", [ + inResource("http://hl7.org/cda/us/ccda/StructureDefinition/CarePlanAct", [ swapBinding({ "http://cts.nlm.nih.gov/fhir/ValueSet/2.16.840.1.113762.1.4.1267.37": "http://terminology.hl7.org/ValueSet/v3-xDocumentActMood", diff --git a/src/api/builder.ts b/src/api/builder.ts index d288cb49..927f13c9 100644 --- a/src/api/builder.ts +++ b/src/api/builder.ts @@ -38,13 +38,13 @@ import type { FileBuffer, FileSystemWriter, FileSystemWriterOptions, WriterOptio /** * Per-phase patch handlers for the `APIBuilder` `patches` option. Each phase accepts a single - * handler or a list (handlers are typically `forPackage`/`forResource` combinators); normalized + * handler or a list (handlers are typically `inPackage`/`inResource` combinators); normalized * to the CanonicalManager `Patches` (array) form before being passed to the manager. */ export type PatchesInput = { - package?: PackagePatch | PackagePatch[]; - entry?: EntryPatch | EntryPatch[]; - resource?: ResourcePatch | ResourcePatch[]; + packageJson?: PackagePatch | PackagePatch[]; + indexEntry?: EntryPatch | EntryPatch[]; + fhirResource?: ResourcePatch | ResourcePatch[]; }; const toArray = (value: T | T[] | undefined): T[] | undefined => { @@ -54,9 +54,9 @@ const toArray = (value: T | T[] | undefined): T[] | undefined => { const normalizePatches = (patches: PatchesInput | undefined): Partial | undefined => patches && { - package: toArray(patches.package), - entry: toArray(patches.entry), - resource: toArray(patches.resource), + packageJson: toArray(patches.packageJson), + indexEntry: toArray(patches.indexEntry), + fhirResource: toArray(patches.fhirResource), }; /** diff --git a/src/api/patches/index.ts b/src/api/patches/index.ts index 31b1379e..071e324a 100644 --- a/src/api/patches/index.ts +++ b/src/api/patches/index.ts @@ -3,9 +3,9 @@ * runtime. * * Helpers are unscoped transforms (`injectDependency`, `renameCanonical`, …); scope them with - * the `forPackage` / `forResource` combinators, which nest. Pass the result via `APIBuilder`'s + * the `inPackage` / `inResource` combinators, which nest. Pass the result via `APIBuilder`'s * `patches` option (a phase accepts a single handler or a list), e.g. - * `patches: { package: forPackage("de.basisprofil.r4", [injectDependency({ "hl7.fhir.r4.core": "4.0.1" })]) }`. + * `patches: { packageJson: inPackage("de.basisprofil.r4", [injectDependency({ "hl7.fhir.r4.core": "4.0.1" })]) }`. */ import type { PackageId, PackagePatch, PatchReportSink, ResourcePatch } from "@atomic-ehr/fhir-canonical-manager"; @@ -31,13 +31,13 @@ const run = (handlers: Handler[], pkg: PackageId, value: V, report: PatchR // ── Scoping combinators ────────────────────────────────────────────────────── /** Apply `handlers` only to packages matching `match`. Works for package- and resource-phase - * handlers, and nests with `forResource`. */ -export const forPackage = (match: PackageMatch, handlers: Handler[]): Handler => { + * handlers, and nests with `inResource`. */ +export const inPackage = (match: PackageMatch, handlers: Handler[]): Handler => { return (pkg, value, report) => (matchPackage(match, pkg) ? run(handlers, pkg, value, report) : undefined); }; /** Apply resource `handlers` only to the resource with the given canonical `url`. */ -export const forResource = (url: string, handlers: ResourcePatch[]): ResourcePatch => { +export const inResource = (url: string, handlers: ResourcePatch[]): ResourcePatch => { return (pkg, resource, report) => (resource.url === url ? run(handlers, pkg, resource, report) : undefined); }; @@ -86,7 +86,7 @@ export const patchCodeSystem = /** * Inject FHIR package dependencies into the manifest when they aren't already declared (a common * defect: a package references core types without depending on the core package). Scope it with - * `forPackage`. No-op if every dep is already declared. + * `inPackage`. No-op if every dep is already declared. */ export const injectDependency = (deps: Record): PackagePatch => From fa52025927f95044701f08c548123af3b77e534e Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Mon, 8 Jun 2026 11:25:30 +0200 Subject: [PATCH 10/14] chore: repin CM to 0.0.24-canary.20260608091810.e54bf2f (PackageId type + Package{Name,Version} aliases) --- bun.lock | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index 75f3741c..c1953ce3 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@atomic-ehr/codegen", "dependencies": { - "@atomic-ehr/fhir-canonical-manager": "0.0.24-canary.20260605153206.1343644", + "@atomic-ehr/fhir-canonical-manager": "0.0.24-canary.20260608091810.e54bf2f", "@atomic-ehr/fhirschema": "0.0.11", "mustache": "^4.2.0", "picocolors": "^1.1.1", @@ -32,7 +32,7 @@ "smol-toml": ">=1.6.1", }, "packages": { - "@atomic-ehr/fhir-canonical-manager": ["@atomic-ehr/fhir-canonical-manager@0.0.24-canary.20260605153206.1343644", "", { "peerDependencies": { "typescript": "^5" }, "bin": { "fcm": "dist/cli/index.js" } }, "sha512-V8QCLV7fIqmWtix2njrNaE3keqwFVhfTEqDAlc08EB4R8bp0DUNux+L8JNtXQFBeZbpIIZz/aNbQf+nQnm5HQw=="], + "@atomic-ehr/fhir-canonical-manager": ["@atomic-ehr/fhir-canonical-manager@0.0.24-canary.20260608091810.e54bf2f", "", { "peerDependencies": { "typescript": "^5" }, "bin": { "fcm": "dist/cli/index.js" } }, "sha512-FzIsA3tMav0yZEchHsEq77CnwqU9/7o/YWCQuEdaYOiIYDOE2+hqBtDNXfvjzhhZwpP9lixSdvDKbTBTQouRig=="], "@atomic-ehr/fhirschema": ["@atomic-ehr/fhirschema@0.0.11", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-oMNxhncEGspGI+QlK/FPjc7akLbfwMYw/hDfW6SbO8xF1KvSSH7NWqc3CJg/k5/309ZuJ6lKsHkgmgVDxo80sQ=="], diff --git a/package.json b/package.json index cdf72d8f..948419a5 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ }, "homepage": "https://github.com/atomic-ehr/codegen#readme", "dependencies": { - "@atomic-ehr/fhir-canonical-manager": "0.0.24-canary.20260605153206.1343644", + "@atomic-ehr/fhir-canonical-manager": "0.0.24-canary.20260608091810.e54bf2f", "@atomic-ehr/fhirschema": "0.0.11", "mustache": "^4.2.0", "picocolors": "^1.1.1", From 60fd8c293ebc614bd4284a3dcdd5bdcd69259b6f Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Mon, 8 Jun 2026 11:31:43 +0200 Subject: [PATCH 11/14] ref: inline packageNameFixes into renamePackage (norge example) --- examples/on-the-fly/norge-r4/generate.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/examples/on-the-fly/norge-r4/generate.ts b/examples/on-the-fly/norge-r4/generate.ts index 3c59ff48..347f3d6c 100644 --- a/examples/on-the-fly/norge-r4/generate.ts +++ b/examples/on-the-fly/norge-r4/generate.ts @@ -7,11 +7,6 @@ import { renameReferenceTarget, } from "../../../src/api/patches"; -// Fix known package name typos (in-memory transformation) -const packageNameFixes: Record = { - "simplifier.core.r4.rResources": "simplifier.core.r4.resources", -}; - // Packages that need hl7.fhir.r4.core dependency injected const needsCoreDependency = (name: string): boolean => { return ( @@ -32,7 +27,8 @@ if (require.main === module) { packageJson: [ // Core packages reference core types without declaring the dep; fix the name typo. inPackage((pkg) => needsCoreDependency(pkg.name), [injectDependency({ "hl7.fhir.r4.core": "4.0.1" })]), - renamePackage(packageNameFixes), + // Fix known package name typo. + renamePackage({ "simplifier.core.r4.rResources": "simplifier.core.r4.resources" }), ], // gd-RelatedPerson widens patient to include Person, but base R4 RelatedPerson.patient // only allows Patient — narrow the Person targets back to Patient. From 0d7e6ceb7fd086d9f93d7ee970be6592fc8066b8 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Mon, 8 Jun 2026 11:52:41 +0200 Subject: [PATCH 12/14] ref: simplify norge core-dependency predicate to exclude only core itself --- examples/on-the-fly/norge-r4/generate.ts | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/examples/on-the-fly/norge-r4/generate.ts b/examples/on-the-fly/norge-r4/generate.ts index 347f3d6c..f263c1f1 100644 --- a/examples/on-the-fly/norge-r4/generate.ts +++ b/examples/on-the-fly/norge-r4/generate.ts @@ -7,17 +7,11 @@ import { renameReferenceTarget, } from "../../../src/api/patches"; -// Packages that need hl7.fhir.r4.core dependency injected -const needsCoreDependency = (name: string): boolean => { - return ( - name.startsWith("simplifier.core.r4.") || - name === "simplifier.core.r4" || - name.startsWith("hl7.fhir.no.") || - name.startsWith("ehelse.fhir.no.") || - name.startsWith("nhn.fhir.no.") || - name.startsWith("sfm.") - ); -}; +const CORE_PACKAGE = "hl7.fhir.r4.core"; + +// True for every package except core itself — injectDependency is a no-op when the +// dependency is already declared, so this only needs to keep core from depending on itself. +const needsCoreDependency = (name: string): boolean => name !== CORE_PACKAGE; if (require.main === module) { console.log("Generating Norge R4 types..."); @@ -25,8 +19,9 @@ if (require.main === module) { const builder = new APIBuilder({ patches: { packageJson: [ - // Core packages reference core types without declaring the dep; fix the name typo. - inPackage((pkg) => needsCoreDependency(pkg.name), [injectDependency({ "hl7.fhir.r4.core": "4.0.1" })]), + // Many Norge packages reference core types without declaring the dependency; + // inject it wherever it's missing. + inPackage((pkg) => needsCoreDependency(pkg.name), [injectDependency({ [CORE_PACKAGE]: "4.0.1" })]), // Fix known package name typo. renamePackage({ "simplifier.core.r4.rResources": "simplifier.core.r4.resources" }), ], From 134b400d32de4c9aea0ae119440b0491cc6be54a Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Mon, 8 Jun 2026 11:52:53 +0200 Subject: [PATCH 13/14] ref: use array form for patches in kbv/norge examples --- examples/on-the-fly/kbv-r4/generate.ts | 2 +- examples/on-the-fly/norge-r4/generate.ts | 21 ++++++++++++--------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/examples/on-the-fly/kbv-r4/generate.ts b/examples/on-the-fly/kbv-r4/generate.ts index d180e26a..3cdf1afa 100644 --- a/examples/on-the-fly/kbv-r4/generate.ts +++ b/examples/on-the-fly/kbv-r4/generate.ts @@ -6,7 +6,7 @@ if (require.main === module) { const builder = new APIBuilder({ // de.basisprofil.r4 references core types without declaring hl7.fhir.r4.core. - patches: { packageJson: inPackage("de.basisprofil.r4", [injectDependency({ "hl7.fhir.r4.core": "4.0.1" })]) }, + patches: { packageJson: [inPackage("de.basisprofil.r4", [injectDependency({ "hl7.fhir.r4.core": "4.0.1" })])] }, registry: "https://packages.simplifier.net", ignorePackageIndex: true, }) diff --git a/examples/on-the-fly/norge-r4/generate.ts b/examples/on-the-fly/norge-r4/generate.ts index f263c1f1..a4ffd01f 100644 --- a/examples/on-the-fly/norge-r4/generate.ts +++ b/examples/on-the-fly/norge-r4/generate.ts @@ -27,15 +27,18 @@ if (require.main === module) { ], // gd-RelatedPerson widens patient to include Person, but base R4 RelatedPerson.patient // only allows Patient — narrow the Person targets back to Patient. - fhirResource: inResource("http://ehelse.no/fhir/StructureDefinition/gd-RelatedPerson", [ - renameReferenceTarget({ - "http://hl7.org/fhir/StructureDefinition/Person": "http://hl7.org/fhir/StructureDefinition/Patient", - "http://hl7.no/fhir/StructureDefinition/no-basis-Person": - "http://hl7.org/fhir/StructureDefinition/Patient", - "http://ehelse.no/fhir/StructureDefinition/gd-Person": - "http://hl7.org/fhir/StructureDefinition/Patient", - }), - ]), + fhirResource: [ + inResource("http://ehelse.no/fhir/StructureDefinition/gd-RelatedPerson", [ + renameReferenceTarget({ + "http://hl7.org/fhir/StructureDefinition/Person": + "http://hl7.org/fhir/StructureDefinition/Patient", + "http://hl7.no/fhir/StructureDefinition/no-basis-Person": + "http://hl7.org/fhir/StructureDefinition/Patient", + "http://ehelse.no/fhir/StructureDefinition/gd-Person": + "http://hl7.org/fhir/StructureDefinition/Patient", + }), + ]), + ], }, registry: "https://packages.simplifier.net", }) From f5aa6f5a0454d927897529f04135719ea75f101f Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Mon, 8 Jun 2026 11:52:53 +0200 Subject: [PATCH 14/14] API: require array form for patches phases (drop single-handler shorthand) --- src/api/builder.ts | 29 +++++------------------------ 1 file changed, 5 insertions(+), 24 deletions(-) diff --git a/src/api/builder.ts b/src/api/builder.ts index 927f13c9..3f7d7b55 100644 --- a/src/api/builder.ts +++ b/src/api/builder.ts @@ -10,12 +10,9 @@ import * as fs from "node:fs"; import * as Path from "node:path"; import { CanonicalManager, - type EntryPatch, type LocalPackageConfig, - type PackagePatch, type Patches, type PreprocessContext, - type ResourcePatch, type TgzPackageConfig, } from "@atomic-ehr/fhir-canonical-manager"; import { CSharp, type CSharpGeneratorOptions } from "@root/api/writer-generator/csharp/csharp"; @@ -37,27 +34,11 @@ import { TypeScript, type TypeScriptOptions } from "./writer-generator/typescrip import type { FileBuffer, FileSystemWriter, FileSystemWriterOptions, WriterOptions } from "./writer-generator/writer"; /** - * Per-phase patch handlers for the `APIBuilder` `patches` option. Each phase accepts a single - * handler or a list (handlers are typically `inPackage`/`inResource` combinators); normalized - * to the CanonicalManager `Patches` (array) form before being passed to the manager. + * Per-phase patch handlers for the `APIBuilder` `patches` option. Each phase is a list of + * handlers (typically `inPackage`/`inResource` combinators), passed straight to the + * CanonicalManager `Patches` config. */ -export type PatchesInput = { - packageJson?: PackagePatch | PackagePatch[]; - indexEntry?: EntryPatch | EntryPatch[]; - fhirResource?: ResourcePatch | ResourcePatch[]; -}; - -const toArray = (value: T | T[] | undefined): T[] | undefined => { - if (value === undefined) return undefined; - return Array.isArray(value) ? value : [value]; -}; - -const normalizePatches = (patches: PatchesInput | undefined): Partial | undefined => - patches && { - packageJson: toArray(patches.packageJson), - indexEntry: toArray(patches.indexEntry), - fhirResource: toArray(patches.fhirResource), - }; +export type PatchesInput = Partial; /** * Configuration options for the API builder @@ -242,7 +223,7 @@ export class APIBuilder { workingDir: ".codegen-cache/canonical-manager-cache", registry: userOpts.registry, dropCache: userOpts.dropCanonicalManagerCache, - patches: normalizePatches(userOpts.patches), + patches: userOpts.patches, preprocessPackage: userOpts.preprocessPackage, ignorePackageIndex: userOpts.ignorePackageIndex, });