From a60c67f47667313deb302906dd4740804d959938 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Fri, 5 Jun 2026 14:36:33 +0200 Subject: [PATCH 01/18] 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 7ac05e49..2a42b607 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 eb4294432b5eb5bbc6c5ebe0385f80451eebfcdb Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Fri, 5 Jun 2026 14:42:19 +0200 Subject: [PATCH 02/18] 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 0a9eb441374bffa9a98eb48ee6372500bccac1e4 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Fri, 5 Jun 2026 15:10:47 +0200 Subject: [PATCH 03/18] chore: pin CM to 0.0.25 (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 b75b43d0..006c39c6 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.25", "@atomic-ehr/fhirschema": "0.0.14", "brace-expansion": "^5.0.8", "mustache": "^4.2.0", @@ -34,7 +34,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.25", "", { "peerDependencies": { "typescript": "^5" }, "bin": { "fcm": "dist/cli/index.js" } }, "sha512-0GRWX/9IyHqkAhEKapNYIBYUPL5/rqGNkEeaArb9JJdWek2hi0bMQEy6uxfnp7/wbrpTqKL+9gj6HWM2MGpTfg=="], "@atomic-ehr/fhirschema": ["@atomic-ehr/fhirschema@0.0.14", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-RqSQI0y82d+2+Uti8OyurIA+DxyUT9P6GZ9b4HXbCsgurpBvf752ZckuISVOuhabqXWZpBjZfiRbrYNPZYuxKw=="], diff --git a/package.json b/package.json index 73459a3b..0430b5a8 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.25", "@atomic-ehr/fhirschema": "0.0.14", "brace-expansion": "^5.0.8", "mustache": "^4.2.0", From 1663fd8bf4b8ccbd9fb64ad473c1f3507c200969 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Fri, 5 Jun 2026 15:12:34 +0200 Subject: [PATCH 04/18] 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 cb0f42939d498544610d6341c72784f7ec857159 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Fri, 5 Jun 2026 15:29:39 +0200 Subject: [PATCH 05/18] 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/ccda/generate.ts | 21 ++++++----- examples/on-the-fly/norge-r4/generate.ts | 44 +++++++++--------------- src/api/patches/index.ts | 36 ++++++++++++++++++- 3 files changed, 64 insertions(+), 37 deletions(-) diff --git a/examples/on-the-fly/ccda/generate.ts b/examples/on-the-fly/ccda/generate.ts index a7f15829..8ce145f6 100644 --- a/examples/on-the-fly/ccda/generate.ts +++ b/examples/on-the-fly/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/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/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 8b2fc70416c9d94d1ced477db5ee0548ede52c4d Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Fri, 5 Jun 2026 15:43:06 +0200 Subject: [PATCH 06/18] 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/on-the-fly/ccda/generate.ts | 55 ++++++++-------------------- src/api/patches/index.ts | 20 ++++++++++ 2 files changed, 35 insertions(+), 40 deletions(-) diff --git a/examples/on-the-fly/ccda/generate.ts b/examples/on-the-fly/ccda/generate.ts index 8ce145f6..a9591d3d 100644 --- a/examples/on-the-fly/ccda/generate.ts +++ b/examples/on-the-fly/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 15d13480a02160db5d9105cc38418a788ca3caa0 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Fri, 5 Jun 2026 16:49:29 +0200 Subject: [PATCH 07/18] 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/ccda/generate.ts | 26 ++++---- examples/on-the-fly/kbv-r4/generate.ts | 4 +- examples/on-the-fly/norge-r4/generate.ts | 32 +++++----- 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/ccda/generate.ts b/examples/on-the-fly/ccda/generate.ts index a9591d3d..890fb6dc 100644 --- a/examples/on-the-fly/ccda/generate.ts +++ b/examples/on-the-fly/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/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/src/api/builder.ts b/src/api/builder.ts index 2a42b607..8dc7a0c6 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 a5565bcebc6516d87530c7bc26494ec4295d9e5b Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Fri, 5 Jun 2026 17:44:34 +0200 Subject: [PATCH 08/18] ref: rename patch combinators to inPackage/inResource, Patches fields to packageJson/indexEntry/fhirResource --- examples/on-the-fly/ccda/generate.ts | 10 +++++----- examples/on-the-fly/kbv-r4/generate.ts | 4 ++-- examples/on-the-fly/norge-r4/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/ccda/generate.ts b/examples/on-the-fly/ccda/generate.ts index 890fb6dc..eafec612 100644 --- a/examples/on-the-fly/ccda/generate.ts +++ b/examples/on-the-fly/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/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/src/api/builder.ts b/src/api/builder.ts index 8dc7a0c6..b428ee81 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 22a58ec26de9a9be524ae23f439605205afe912f Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Mon, 8 Jun 2026 11:31:43 +0200 Subject: [PATCH 09/18] 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 e9502409a6a0384446cdd95f16de2bde3625295e Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Mon, 8 Jun 2026 11:52:41 +0200 Subject: [PATCH 10/18] 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 da6844ce62419492d09bc750783bfa863c481649 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Mon, 8 Jun 2026 11:52:53 +0200 Subject: [PATCH 11/18] 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 1bb935eb010670ba7635b674024049fe9e3fb210 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Mon, 8 Jun 2026 11:52:53 +0200 Subject: [PATCH 12/18] 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 b428ee81..851706aa 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, }); From ab2c668d857d286f51760193b6a020c8dfb152a8 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Wed, 29 Jul 2026 16:45:58 +0400 Subject: [PATCH 13/18] ref: flatten src/api/patches/index.ts to src/api/patches.ts --- src/api/{patches/index.ts => patches.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/api/{patches/index.ts => patches.ts} (100%) diff --git a/src/api/patches/index.ts b/src/api/patches.ts similarity index 100% rename from src/api/patches/index.ts rename to src/api/patches.ts From 650b3ab6774c5d3aa71951731d049f7f1a254dc9 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Wed, 29 Jul 2026 17:04:37 +0400 Subject: [PATCH 14/18] API: thread packageIndex mode through APIBuilder, migrate kbv off ignorePackageIndex --- examples/on-the-fly/kbv-r4/generate.ts | 2 +- src/api/builder.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/examples/on-the-fly/kbv-r4/generate.ts b/examples/on-the-fly/kbv-r4/generate.ts index 3cdf1afa..065b081d 100644 --- a/examples/on-the-fly/kbv-r4/generate.ts +++ b/examples/on-the-fly/kbv-r4/generate.ts @@ -8,7 +8,7 @@ if (require.main === module) { // 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" })])] }, registry: "https://packages.simplifier.net", - ignorePackageIndex: true, + packageIndex: "recover", }) .fromPackage("kbv.ita.for", "1.3.1") .fromPackage("de.basisprofil.r4", "1.6.0-ballot2") diff --git a/src/api/builder.ts b/src/api/builder.ts index 851706aa..fb8e54b8 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 PackageIndexMode, type Patches, type PreprocessContext, type TgzPackageConfig, @@ -182,6 +183,10 @@ export class APIBuilder { /** Per-phase patch handlers passed to the CanonicalManager (package-defect fixes). */ patches?: PatchesInput; preprocessPackage?: (context: PreprocessContext) => PreprocessContext; + /** How the CanonicalManager treats a package's `.index.json`: trust it (`"use"`, default), + * heal a broken one (`"recover"`), or rebuild it unconditionally (`"regenerate"`). */ + packageIndex?: PackageIndexMode; + /** @deprecated Use `packageIndex: "regenerate"` instead. */ ignorePackageIndex?: boolean; logger?: CodegenLogManager; } = {}, @@ -225,6 +230,7 @@ export class APIBuilder { dropCache: userOpts.dropCanonicalManagerCache, patches: userOpts.patches, preprocessPackage: userOpts.preprocessPackage, + packageIndex: userOpts.packageIndex, ignorePackageIndex: userOpts.ignorePackageIndex, }); this.logger = userOpts.logger ?? mkLogger({ prefix: "api" }); From f5f2e101817da5eb2c300f08ff59c7229fd894ba Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Wed, 29 Jul 2026 18:00:35 +0400 Subject: [PATCH 15/18] API: surface CM input-fix report in prettyReport (P1.7) --- src/api/builder.ts | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/api/builder.ts b/src/api/builder.ts index fb8e54b8..8a7a7e63 100644 --- a/src/api/builder.ts +++ b/src/api/builder.ts @@ -11,9 +11,11 @@ import * as Path from "node:path"; import { CanonicalManager, type LocalPackageConfig, + type PackageId, type PackageIndexMode, type Patches, type PreprocessContext, + type ReportEntry, type TgzPackageConfig, } from "@atomic-ehr/fhir-canonical-manager"; import { CSharp, type CSharpGeneratorOptions } from "@root/api/writer-generator/csharp/csharp"; @@ -64,6 +66,9 @@ export type GenerationReport = { errors: string[]; warnings: string[]; duration: number; + /** CanonicalManager input-fix diagnostics (exclusions, index recoveries, deprecations). + * Absent when a prebuilt `register` is used; empty for packages served from cache. */ + inputReport?: ReportEntry[]; }; function countLinesByMatches(text: string): number { @@ -83,11 +88,31 @@ export interface PrettyReportOptions { fileLimit?: number; } +const formatReportEntry = (entry: ReportEntry): string => { + const pkg = (p: PackageId): string => `${p.name}@${p.version}`; + switch (entry.kind) { + case "exclusion": + return `excluded ${entry.url} (${pkg(entry.package)}): ${entry.reason}`; + case "index-recovery": + return `recovered index for ${pkg(entry.package)}: ${entry.reason}, ${entry.recovered} resources`; + case "deprecation": + return `deprecation: ${entry.message}`; + default: + return JSON.stringify(entry); + } +}; + export const prettyReport = (report: GenerationReport, options: PrettyReportOptions = {}): string => { - const { success, filesGenerated, errors, warnings, duration } = report; + const { success, filesGenerated, errors, warnings, duration, inputReport } = report; const fileLimit = options.fileLimit ?? 20; const errorsStr = errors.length > 0 ? `Errors: ${errors.join(", ")}` : undefined; const warningsStr = warnings.length > 0 ? `Warnings: ${warnings.join(", ")}` : undefined; + const inputFixesStr = + inputReport && inputReport.length > 0 + ? [`Input fixes (${inputReport.length}):`, ...inputReport.map((e) => ` - ${formatReportEntry(e)}`)].join( + "\n", + ) + : undefined; let totalFiles = 0; let totalLoc = 0; @@ -133,6 +158,7 @@ export const prettyReport = (report: GenerationReport, options: PrettyReportOpti return [ `Generated files (${totalFiles} files, ${formatLoc(totalLoc)}):`, ...groupStrs, + inputFixesStr, errorsStr, warningsStr, `Duration: ${Math.round(duration)}ms`, @@ -490,6 +516,7 @@ export class APIBuilder { logger: this.logger.fork("reg"), focusedPackages: packageMetas, }); + result.inputReport = this.manager.report(); } const tsLogger = this.logger.fork("ts"); From 0519ae06dc62fe792db976bc83798620c90e9f4c Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Wed, 29 Jul 2026 18:00:35 +0400 Subject: [PATCH 16/18] test(api): cover prettyReport Input fixes section --- test/unit/api/pretty-report.test.ts | 47 +++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 test/unit/api/pretty-report.test.ts diff --git a/test/unit/api/pretty-report.test.ts b/test/unit/api/pretty-report.test.ts new file mode 100644 index 00000000..f332be80 --- /dev/null +++ b/test/unit/api/pretty-report.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "bun:test"; +import { type GenerationReport, prettyReport } from "@root/api/builder"; + +const mkReport = (overrides: Partial = {}): GenerationReport => ({ + success: true, + outputDir: "./generated", + filesGenerated: {}, + errors: [], + warnings: [], + duration: 42, + ...overrides, +}); + +describe("prettyReport input fixes", () => { + it("renders an Input fixes section for each report entry kind", () => { + const report = mkReport({ + inputReport: [ + { + kind: "index-recovery", + package: { name: "de.basisprofil.r4", version: "1.6.0-ballot2" }, + reason: "unparseable", + recovered: 120, + }, + { + kind: "exclusion", + package: { name: "hl7.fhir.r4.core", version: "4.0.1" }, + url: "http://example.org/StructureDefinition/broken", + reason: "references R5-only type", + }, + { kind: "deprecation", message: "`ignorePackageIndex` is deprecated; use `packageIndex`." }, + ], + }); + + const out = prettyReport(report); + expect(out).toContain("Input fixes (3):"); + expect(out).toContain(" - recovered index for de.basisprofil.r4@1.6.0-ballot2: unparseable, 120 resources"); + expect(out).toContain( + " - excluded http://example.org/StructureDefinition/broken (hl7.fhir.r4.core@4.0.1): references R5-only type", + ); + expect(out).toContain(" - deprecation: `ignorePackageIndex` is deprecated; use `packageIndex`."); + }); + + it("omits the section when the input report is empty or absent", () => { + expect(prettyReport(mkReport({ inputReport: [] }))).not.toContain("Input fixes"); + expect(prettyReport(mkReport())).not.toContain("Input fixes"); + }); +}); From 33c0cb74ee1c6f0c40ebbba64d62ed6b9d4df993 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Wed, 29 Jul 2026 18:13:28 +0400 Subject: [PATCH 17/18] API: add ensureDependency patch helper, migrate norge off inPackage+injectDependency; rename packageJson params to pkgJson --- examples/on-the-fly/norge-r4/generate.ts | 18 +++------------- src/api/patches.ts | 26 +++++++++++++++++++----- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/examples/on-the-fly/norge-r4/generate.ts b/examples/on-the-fly/norge-r4/generate.ts index a4ffd01f..f81a686e 100644 --- a/examples/on-the-fly/norge-r4/generate.ts +++ b/examples/on-the-fly/norge-r4/generate.ts @@ -1,17 +1,5 @@ import { APIBuilder, prettyReport } from "../../../src/api/builder"; -import { - injectDependency, - inPackage, - inResource, - renamePackage, - renameReferenceTarget, -} from "../../../src/api/patches"; - -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; +import { ensureDependency, inResource, renamePackage, renameReferenceTarget } from "../../../src/api/patches"; if (require.main === module) { console.log("Generating Norge R4 types..."); @@ -20,8 +8,8 @@ if (require.main === module) { patches: { packageJson: [ // 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" })]), + // make every package (except core itself) depend on core. + ensureDependency({ "hl7.fhir.r4.core": "4.0.1" }), // Fix known package name typo. renamePackage({ "simplifier.core.r4.rResources": "simplifier.core.r4.resources" }), ], diff --git a/src/api/patches.ts b/src/api/patches.ts index 071e324a..a5c5882d 100644 --- a/src/api/patches.ts +++ b/src/api/patches.ts @@ -90,11 +90,27 @@ export const patchCodeSystem = */ export const injectDependency = (deps: Record): PackagePatch => - (_pkg, packageJson) => { - const existing = (packageJson.dependencies as Record | undefined) ?? {}; + (_pkg, pkgJson) => { + const existing = (pkgJson.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) } }; + return { ...pkgJson, dependencies: { ...existing, ...Object.fromEntries(missing) } }; + }; + +/** + * Ensure the manifest declares each dependency at the given version — adds missing entries and + * adjusts mismatched versions (unlike `injectDependency`, which never touches declared deps). + * A package is never given itself as a dependency, so it needs no `inPackage` scoping. + */ +export const ensureDependency = + (deps: Record): PackagePatch => + (pkg, pkgJson) => { + const existing = (pkgJson.dependencies as Record | undefined) ?? {}; + const updates = Object.entries(deps).filter( + ([name, version]) => name !== pkg.name && existing[name] !== version, + ); + if (updates.length === 0) return undefined; + return { ...pkgJson, dependencies: { ...existing, ...Object.fromEntries(updates) } }; }; /** @@ -103,7 +119,7 @@ export const injectDependency = */ export const renamePackage = (renames: Record): PackagePatch => - (pkg, packageJson) => { + (pkg, pkgJson) => { const renamed = renames[pkg.name]; - return renamed === undefined ? undefined : { ...packageJson, name: renamed }; + return renamed === undefined ? undefined : { ...pkgJson, name: renamed }; }; From cd22dd5e50f0bbfff187f9249c188b8c9be9d912 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Wed, 29 Jul 2026 18:13:28 +0400 Subject: [PATCH 18/18] test(api): cover ensureDependency --- test/unit/api/patches.test.ts | 46 +++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 test/unit/api/patches.test.ts diff --git a/test/unit/api/patches.test.ts b/test/unit/api/patches.test.ts new file mode 100644 index 00000000..01f1f32f --- /dev/null +++ b/test/unit/api/patches.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "bun:test"; +import { ensureDependency } from "@root/api/patches"; + +const noReport = () => undefined; + +describe("ensureDependency", () => { + const patch = ensureDependency({ "hl7.fhir.r4.core": "4.0.1" }); + + it("adds the dependency when missing", () => { + const result = patch( + { name: "ehelse.fhir.no.grunndata", version: "2.3.5" }, + { name: "ehelse.fhir.no.grunndata" }, + noReport, + ); + expect(result).toEqual({ + name: "ehelse.fhir.no.grunndata", + dependencies: { "hl7.fhir.r4.core": "4.0.1" }, + }); + }); + + it("adjusts a mismatched version", () => { + const result = patch( + { name: "ehelse.fhir.no.grunndata", version: "2.3.5" }, + { name: "ehelse.fhir.no.grunndata", dependencies: { "hl7.fhir.r4.core": "4.0.0", other: "1.0.0" } }, + noReport, + ); + expect(result).toEqual({ + name: "ehelse.fhir.no.grunndata", + dependencies: { "hl7.fhir.r4.core": "4.0.1", other: "1.0.0" }, + }); + }); + + it("never injects a package into itself", () => { + const result = patch({ name: "hl7.fhir.r4.core", version: "4.0.1" }, { name: "hl7.fhir.r4.core" }, noReport); + expect(result).toBeUndefined(); + }); + + it("is a no-op when the dependency is already at the wanted version", () => { + const result = patch( + { name: "ehelse.fhir.no.grunndata", version: "2.3.5" }, + { name: "ehelse.fhir.no.grunndata", dependencies: { "hl7.fhir.r4.core": "4.0.1" } }, + noReport, + ); + expect(result).toBeUndefined(); + }); +});