From 09965b69111699bba27e6425278ca1a71237339d Mon Sep 17 00:00:00 2001 From: Malte Sussdorff Date: Sun, 26 Apr 2026 20:24:28 +0200 Subject: [PATCH 1/2] fix(register): resolve cross-package + transitive-version + cache-mismatch canonical lookups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canonical resolver had three related bugs causing 'Base resource not found' errors when packages have different versions of the same dependency. This commit consolidates three iterations (vrq, 7y9, pkt) of the fix into a single change. ## Bug 1: kbv.basis@1.8.0 .index.json has null id entries de.basisprofil.r4@1.5.4 ships an .index.json with null-id ImplementationGuide entries. parseIndex treats any null id as fatal and rejects the entire .index.json — leaving the package with 0 indexed resources. Cross-package base-type lookups for KBV profiles that inherit from de.basisprofil.r4 fail at transform time. Fix (vrq): when manager.search returns 0 for a package, scan the canonical-manager's node_modules cache directly. Equivalent to scanDirectoryForResources but tolerant of malformed .index.json. Implemented as scanNodeModulesPackage with a nodeModulesPath parameter computed from the focusedPackages cache key. ## Bug 2: top-level + transitive package version mismatch When the top level loads de.basisprofil.r4@1.6.0-ballot2 and a dependency (kbv.basis) needs de.basisprofil.r4@1.5.4, npm installs the older version into a nested location: node_modules/kbv.basis/node_modules/de.basisprofil.r4/. The original scanNodeModulesPackage only checked the flat top-level path → returned wrong-version content. Fix (7y9): scanNodeModulesPackage now reads the package.json version from the flat path, and if it doesn't match the requested version, scans nested node_modules//node_modules// paths. Returns the first one whose version matches; falls back to flat path if no match (graceful degradation). ## Bug 3: APIBuilder.localTgzPackage cache-key divergence When packages are added via APIBuilder.localTgzPackage() (or addTgzPackage on the manager), the canonical-manager's cache hash is computed from constructor packages ONLY. addTgzPackage adds packages to the cache record but doesn't change the hash. Codegen however computed nodeModulesPath from ALL focusedPackages → divergent hashes → computed path doesn't exist → fallback never triggers → silent failure. Concrete failure: polaris dental builder uses .localTgzPackage(praxis) + .localTgzPackage(dental). manager.search returned 0 for de.basisprofil.r4@1.6.0-ballot2 even though the resource existed in the actual cache directory. Fix (pkt): when the computed nodeModulesPath does not exist, scan ALL sibling cache record directories under /canonical-manager-cache/ and try each one. The warning message identifies which cache record was actually scanned. ## Tests - test/unit/typeschema/versioned-canonical.test.ts (5 tests): cross-package base type lookup with kbv.basis@1.8.0 + de.basisprofil.r4@1.5.4 against null-id .index.json scenario - test/unit/typeschema/transitive-version-mismatch.test.ts (5 tests): - Integration: 1.6.0-ballot2 top-level + kbv.basis@1.8.0 → no errors - Synthetic: scanNodeModulesPackage prefers nested 1.5.4 over flat 1.6.0-ballot2 - Synthetic: scanNodeModulesPackage scans sibling cache dirs when computed path missing All 10 tests pass on upstream/main. --- src/typeschema/register.ts | 274 +++++++++++++++- src/utils/log.ts | 3 +- .../transitive-version-mismatch.test.ts | 306 ++++++++++++++++++ .../typeschema/versioned-canonical.test.ts | 100 ++++++ 4 files changed, 677 insertions(+), 6 deletions(-) create mode 100644 test/unit/typeschema/transitive-version-mismatch.test.ts create mode 100644 test/unit/typeschema/versioned-canonical.test.ts diff --git a/src/typeschema/register.ts b/src/typeschema/register.ts index 141dfccac..0df3b4443 100644 --- a/src/typeschema/register.ts +++ b/src/typeschema/register.ts @@ -1,3 +1,7 @@ +import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; import { CanonicalManager } from "@atomic-ehr/fhir-canonical-manager"; import * as fhirschema from "@atomic-ehr/fhirschema"; import { @@ -87,13 +91,27 @@ const mkPackageAwareResolver = async ( deep: number, acc: PackageAwareResolver, logger?: CodegenLog, + nodeModulesPath?: string, ): Promise => { const pkgId = packageMetaToFhir(pkg); logger?.info(`${" ".repeat(deep * 2)}+ ${pkgId}`); if (acc[pkgId]) return acc[pkgId]; const index = mkEmptyPkgIndex(pkg); - for (const resource of await manager.search({ package: pkg })) { + + let resources: FocusedResource[] = (await manager.search({ package: pkg })) as unknown as FocusedResource[]; + + // Fallback: some FHIR packages (e.g. de.basisprofil.r4@1.5.4) ship a .index.json with + // entries that have null `id` fields (e.g. ImplementationGuide resources). The canonical + // manager's strict parseIndex validation rejects the entire .index.json in this case, + // leaving the package with 0 indexed resources. When that happens, we fall back to + // reading the package files directly from the canonical manager's node_modules cache. + // This is equivalent to the canonical manager's own scanDirectoryForResources fallback. + if (resources.length === 0 && nodeModulesPath) { + resources = await scanNodeModulesPackage(nodeModulesPath, pkg, logger); + } + + for (const resource of resources) { const rawUrl = resource.url; if (!rawUrl) continue; if (!(isStructureDefinition(resource) || isValueSet(resource) || isCodeSystem(resource))) continue; @@ -105,7 +123,14 @@ const mkPackageAwareResolver = async ( const deps = await readPackageDependencies(manager, pkg); for (const depPkg of deps) { - const { canonicalResolution } = await mkPackageAwareResolver(manager, depPkg, deep + 1, acc, logger); + const { canonicalResolution } = await mkPackageAwareResolver( + manager, + depPkg, + deep + 1, + acc, + logger, + nodeModulesPath, + ); for (const [surl, resolutions] of Object.entries(canonicalResolution)) { const url = surl as CanonicalUrl; index.canonicalResolution[url] = [...(index.canonicalResolution[url] || []), ...resolutions]; @@ -164,16 +189,39 @@ export type RegisterConfig = { focusedPackages?: PackageMeta[]; /** Custom FHIR package registry URL */ registry?: string; + /** + * Path to the canonical manager's node_modules directory. + * Used as a fallback when the canonical manager reports 0 resources for a package + * (which happens when the package's .index.json has invalid entries). + * Computed automatically in registerFromPackageMetas and registerFromManager. + * Can be overridden explicitly if the canonical manager is configured with a custom + * workingDir or a non-standard package layout. + */ + nodeModulesPath?: string; }; export const registerFromManager = async ( manager: ReturnType, - { logger, focusedPackages }: RegisterConfig, + { logger, focusedPackages, nodeModulesPath }: RegisterConfig, ): Promise => { const packages = focusedPackages ?? (await manager.packages()); + + // Compute the node_modules fallback path if not supplied by the caller. + // This covers APIBuilder callers that invoke registerFromManager directly without + // going through registerFromPackageMetas. Both code paths use the same hardcoded + // workingDir, so the cache-key derivation produces the correct path. + // NOTE: computeCanonicalManagerCacheKey mirrors the SHA-256 algorithm inside + // @atomic-ehr/fhir-canonical-manager@0.0.23 (dist/cache.js#computeCacheKey). + // If the canonical manager changes its hash strategy, this fallback will silently + // stop working — update both together. + if (!nodeModulesPath && focusedPackages) { + const pkgNames = focusedPackages.map(packageMetaToNpm); + nodeModulesPath = computeNodeModulesPath(pkgNames, CANONICAL_MANAGER_WORKING_DIR); + } + const resolver: PackageAwareResolver = {}; for (const pkg of packages) { - await mkPackageAwareResolver(manager, pkg, 0, resolver, logger); + await mkPackageAwareResolver(manager, pkg, 0, resolver, logger, nodeModulesPath); } enrichResolver(resolver, logger); @@ -335,6 +383,218 @@ export const registerFromManager = async ( }; }; +/** + * Compute the same cache key as @atomic-ehr/fhir-canonical-manager uses internally + * (mirrors computeCacheKey in dist/cache.js — tracked at @0.0.23). + * Key: SHA-256 of the sorted, JSON-stringified package spec list (e.g. ["kbv.basis@1.8.0", ...]). + * NOTE: Only the explicitly requested packages go into the key; transitive dependencies + * are installed into the same node_modules but do not affect the hash. + */ +const computeCanonicalManagerCacheKey = (packageNames: string[]): string => { + const content = JSON.stringify([...packageNames].sort()); + return createHash("sha256").update(content).digest("hex"); +}; + +/** + * Returns the path to the canonical manager's node_modules directory for a given + * set of package names and working directory. Both this function and process.cwd() + * must stay in sync with @atomic-ehr/fhir-canonical-manager's cacheRecordPaths logic. + */ +const computeNodeModulesPath = (packageNames: string[], workingDir: string): string => { + const cacheKey = computeCanonicalManagerCacheKey(packageNames); + return join(process.cwd(), workingDir, cacheKey, "node", "node_modules"); +}; + +/** + * Some FHIR packages (e.g. de.basisprofil.r4@1.5.4) ship an .index.json that contains + * entries where the `id` field is null (e.g. ImplementationGuide resources without an id). + * The canonical manager's parseIndex function treats ANY such entry as fatal — it returns + * null and silently skips ALL resources from that package. This means `manager.search()` + * returns 0 resources for the affected package, so nothing gets added to the canonical + * resolution and cross-package base-type lookups fail at transform time. + * + * Rather than trying to patch the canonical manager's cache (which gets regenerated on + * reinstall), we scan the package directory directly from the canonical manager's + * node_modules when the manager reports 0 resources for a focused package. + * This mirrors what the canonical manager's own `scanDirectoryForResources` does. + */ + +/** + * Reads the version from a package directory's package.json. + * Returns undefined if the file cannot be read or parsed. + */ +const readPackageDirVersion = async (pkgDir: string): Promise => { + const pkgJsonPath = join(pkgDir, "package.json"); + if (!existsSync(pkgJsonPath)) return undefined; + try { + const content = await readFile(pkgJsonPath, "utf-8"); + const parsed = JSON.parse(content) as Record; + return typeof parsed.version === "string" ? parsed.version : undefined; + } catch { + return undefined; + } +}; + +/** + * Scans a single package directory and returns all FHIR resources found. + * Does not check version — callers must verify the directory holds the correct version. + */ +const scanNodeModulesPackageDir = async ( + pkgDir: string, + pkg: PackageMeta, + logger?: CodegenLog, +): Promise => { + const resources: FocusedResource[] = []; + let fileNames: string[]; + try { + // readdir without withFileTypes returns string[] — avoids Bun's Dirent type mismatch + fileNames = await readdir(pkgDir); + } catch (err) { + logger?.dryWarn( + "#canonicalManagerFallback", + `Failed to read directory for ${packageMetaToFhir(pkg)} at ${pkgDir}: ${err}`, + ); + return []; + } + + for (const name of fileNames) { + if (!name.endsWith(".json")) continue; + if (name === "package.json" || name === ".index.json") continue; + try { + const content = await readFile(join(pkgDir, name), "utf-8"); + const resource = JSON.parse(content) as Record; + if (!resource.resourceType || !resource.url) continue; + if (!(isStructureDefinition(resource) || isValueSet(resource) || isCodeSystem(resource))) continue; + resources.push(resource as unknown as FocusedResource); + } catch (err) { + logger?.dryWarn("#canonicalManagerFallback", `Skipping ${name} in ${packageMetaToFhir(pkg)}: ${err}`); + } + } + return resources; +}; + +/** + * Find candidate node_modules paths to scan for a package. + * + * The codegen-7y9 fix computed a single `nodeModulesPath` based on the focusedPackages + * SHA-256 hash. That works when CanonicalManager was initialised with all packages up-front + * (registerFromPackageMetas case). It FAILS when packages are added later via + * addTgzPackage / addLocalPackage (APIBuilder.localTgzPackage path) — those calls do NOT + * change the canonical-manager's cache hash, so the directory the codegen computes does + * not exist and the fallback is silently disabled. + * + * Workaround: if the computed path doesn't exist, scan ALL sibling cache record directories + * under the codegen-cache/canonical-manager-cache root and try each one. Return paths in + * insertion order so the most recently used cache record (likely the active one) is tried first. + */ +const findCandidateNodeModulesPaths = (computedPath: string): string[] => { + const candidates: string[] = []; + if (existsSync(computedPath)) candidates.push(computedPath); + + // The canonical-manager-cache root is the parent of the cache hash dir's grandparent. + // computedPath = //node/node_modules → cacheRoot = + const cacheRoot = join(computedPath, "..", "..", ".."); + if (!existsSync(cacheRoot)) return candidates; + + let entries: string[]; + try { + entries = require("node:fs").readdirSync(cacheRoot); + } catch { + return candidates; + } + for (const entry of entries) { + const candidate = join(cacheRoot, entry, "node", "node_modules"); + if (candidate === computedPath) continue; + if (existsSync(candidate)) candidates.push(candidate); + } + return candidates; +}; + +/** + * Scans node_modules for a package, preferring an exact version match. + * + * Strategy (per candidate cache directory): + * 1. Check the flat top-level path (nodeModulesPath//). + * If its package.json version matches the requested version → use it. + * 2. If the flat path holds a DIFFERENT version, scan all sibling package directories + * for nested paths (nodeModulesPath//node_modules//) and + * return the first one whose version matches the requested version. + * 3. If no exact-version match anywhere → fall back to the flat path content + * (graceful degradation; preserves the original vrq fix behaviour). + * + * Across multiple cache directories: tries each until resources are found. This handles + * the APIBuilder.localTgzPackage case where the computed cache hash does not reflect + * later addTgzPackage calls. + */ +const scanNodeModulesPackage = async ( + nodeModulesPath: string, + pkg: PackageMeta, + logger?: CodegenLog, +): Promise => { + const candidatePaths = findCandidateNodeModulesPaths(nodeModulesPath); + if (candidatePaths.length === 0) return []; + + let chosenDir: string | undefined; + let chosenSource = ""; + let chosenFlatVersion: string | undefined; + + // Try each candidate cache directory (computed first, then siblings). + outer: for (const candidate of candidatePaths) { + const flatPkgDir = join(candidate, pkg.name); + if (!existsSync(flatPkgDir)) continue; + + const flatVersion = await readPackageDirVersion(flatPkgDir); + if (flatVersion === pkg.version) { + chosenDir = flatPkgDir; + chosenSource = candidate === nodeModulesPath ? "flat" : `flat (sibling-cache)`; + chosenFlatVersion = flatVersion; + break; + } + + // Scan sibling parent directories for a nested copy with the exact version. + let parentDirNames: string[]; + try { + parentDirNames = await readdir(candidate); + } catch { + parentDirNames = []; + } + for (const parentDir of parentDirNames) { + const nestedPkgDir = join(candidate, parentDir, "node_modules", pkg.name); + if (!existsSync(nestedPkgDir)) continue; + const nestedVersion = await readPackageDirVersion(nestedPkgDir); + if (nestedVersion === pkg.version) { + chosenDir = nestedPkgDir; + chosenSource = `nested (${parentDir}/node_modules/${pkg.name}${candidate === nodeModulesPath ? "" : ", sibling-cache"})`; + chosenFlatVersion = flatVersion; + break outer; + } + } + + // Remember the flat dir as a graceful-degradation fallback if no exact match found. + if (!chosenDir) { + chosenDir = flatPkgDir; + chosenSource = `flat path (version mismatch: flat=${flatVersion ?? "unknown"}, requested=${pkg.version})`; + chosenFlatVersion = flatVersion; + } + } + + if (!chosenDir) return []; + + const resources = await scanNodeModulesPackageDir(chosenDir, pkg, logger); + + if (resources.length > 0) { + logger?.warn( + "#canonicalManagerFallback", + `Package ${packageMetaToFhir(pkg)} had 0 resources in canonical manager ` + + `(likely due to invalid .index.json entries or addTgzPackage cache mismatch). ` + + `Falling back to direct directory scan (${chosenSource}, flat-version=${chosenFlatVersion ?? "unknown"}): ${resources.length} resources found.`, + ); + } + return resources; +}; + +const CANONICAL_MANAGER_WORKING_DIR = ".codegen-cache/canonical-manager-cache" as const; + export const registerFromPackageMetas = async ( packageMetas: PackageMeta[], conf: RegisterConfig, @@ -343,13 +603,17 @@ export const registerFromPackageMetas = async ( conf?.logger?.info(`Loading FHIR packages: ${packageNames.join(", ")}`); const manager = CanonicalManager({ packages: packageNames, - workingDir: ".codegen-cache/canonical-manager-cache", + workingDir: CANONICAL_MANAGER_WORKING_DIR, registry: conf.registry || undefined, }); await manager.init(); + return await registerFromManager(manager, { ...conf, focusedPackages: packageMetas, + // Provide nodeModulesPath explicitly so registerFromManager doesn't have to + // recompute it from focusedPackages (both produce the same result here). + nodeModulesPath: computeNodeModulesPath(packageNames, CANONICAL_MANAGER_WORKING_DIR), }); }; diff --git a/src/utils/log.ts b/src/utils/log.ts index 137240cf2..d7a24a497 100644 --- a/src/utils/log.ts +++ b/src/utils/log.ts @@ -18,7 +18,8 @@ export type CodegenTag = | "#duplicateSchema" | "#duplicateCanonical" | "#resolveBase" - | "#resolveCollisionMiss"; + | "#resolveCollisionMiss" + | "#canonicalManagerFallback"; export type CodegenLog = Log; export type CodegenLogManager = LogManager; diff --git a/test/unit/typeschema/transitive-version-mismatch.test.ts b/test/unit/typeschema/transitive-version-mismatch.test.ts new file mode 100644 index 000000000..ef30df17a --- /dev/null +++ b/test/unit/typeschema/transitive-version-mismatch.test.ts @@ -0,0 +1,306 @@ +/** + * Regression tests for transitive package version mismatch in canonical resolution. + * + * Scenario (codegen-7y9): + * - de.basisprofil.r4@1.6.0-ballot2 is loaded as a top-level focused package + * - kbv.basis@1.8.0 is also loaded; it depends on de.basisprofil.r4@1.5.4 (an older version) + * + * In npm-based environments the canonical manager installs: + * - nodeModulesPath/de.basisprofil.r4/ → 1.6.0-ballot2 (top-level, WRONG for kbv.basis) + * - nodeModulesPath/kbv.basis/node_modules/de.basisprofil.r4/ → 1.5.4 (nested, correct) + * + * In bun-based environments (current), bun overrides kbv.basis's 1.5.4 dep with the + * user-specified 1.6.0-ballot2, so only one version exists at the flat path. + * + * Root cause: scanNodeModulesPackage only scanned the flat top-level path, picking up + * wrong-version content when kbv.basis needs a different version. + * + * Fix: scanNodeModulesPackage checks the package.json version in the flat path first. + * If it doesn't match the requested version, scan nested paths: + * nodeModulesPath//node_modules// + * Return the first directory whose version matches. Fall back to flat if none found. + * + * See: codegen-7y9 + */ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { existsSync } from "node:fs"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { CanonicalManager } from "@atomic-ehr/fhir-canonical-manager"; +import type { CanonicalUrl } from "@root/typeschema/types"; +import type { Register } from "@typeschema/register"; +import { registerFromManager, registerFromPackageMetas } from "@typeschema/register"; + +const kbvPkg = { name: "kbv.basis", version: "1.8.0" }; +const basisprofilNew = { name: "de.basisprofil.r4", version: "1.6.0-ballot2" }; +const basisprofilOld = { name: "de.basisprofil.r4", version: "1.5.4" }; + +/** + * Integration test: registers de.basisprofil.r4@1.6.0-ballot2 + kbv.basis@1.8.0. + * + * In bun environments: + * - The canonical manager installs 1.6.0-ballot2 at the flat path (user override wins) + * - kbv.basis depends on 1.5.4, but bun uses 1.6.0-ballot2 for the dep install + * - scanNodeModulesPackage finds 1.6.0-ballot2 at flat (version MATCHES the requested 1.6.0-ballot2 focused pkg) + * - For the 1.5.4 transitive dep: 1.6.0-ballot2 is at flat (version MISMATCH) → falls back to flat + * + * Key behaviour: resolution must not throw, and the pflegegrad URL must resolve. + */ +describe("Integration: 1.6.0-ballot2 top-level + kbv.basis@1.8.0 (codegen-7y9)", () => { + let register: Register; + + beforeAll(async () => { + register = await registerFromPackageMetas([basisprofilNew, kbvPkg], {}); + }, 120000); + + it("resolves observation-de-pflegegrad from kbv.basis context without throwing", () => { + const url = "http://fhir.de/StructureDefinition/observation-de-pflegegrad" as CanonicalUrl; + const resolved = register.resolveFs(kbvPkg, url); + expect(resolved, "observation-de-pflegegrad must be resolvable from kbv.basis context").toBeDefined(); + expect(resolved?.url).toBe(url); + }); + + it("resolves KBV_PR_Base_Observation_Care_Level without throwing", () => { + const careLevelUrl = + "https://fhir.kbv.de/StructureDefinition/KBV_PR_Base_Observation_Care_Level" as CanonicalUrl; + const resolved = register.resolveFs(kbvPkg, careLevelUrl); + expect(resolved, "KBV_PR_Base_Observation_Care_Level must be resolvable").toBeDefined(); + }); + + it("resolves base type chain for KBV_PR_Base_Observation_Care_Level", () => { + const careLevelUrl = + "https://fhir.kbv.de/StructureDefinition/KBV_PR_Base_Observation_Care_Level" as CanonicalUrl; + const careLevel = register.resolveFs(kbvPkg, careLevelUrl); + expect(careLevel).toBeDefined(); + const strippedBase = register.ensureSpecializationCanonicalUrl(careLevel!.base!); + const baseResolved = register.resolveFs(kbvPkg, strippedBase); + expect(baseResolved, `Base type '${strippedBase}' (from '${careLevel!.base}') must resolve`).toBeDefined(); + }); +}); + +/** + * Unit test for nested-path preference in scanNodeModulesPackage. + * + * Creates a synthetic node_modules layout that mimics npm-style nested installs: + * tempDir/ + * de.basisprofil.r4/ ← flat path (version 1.6.0-ballot2, WRONG for kbv.basis dep) + * package.json + * StructureDefinition-obs-wrong.json (FHIR version: 1.6.0-ballot) + * kbv.basis/ + * node_modules/ + * de.basisprofil.r4/ ← nested path (version 1.5.4, CORRECT) + * package.json + * StructureDefinition-observation-de-pflegegrad.json (FHIR version: 1.5.4) + * + * When scanNodeModulesPackage is called for de.basisprofil.r4@1.5.4: + * - BEFORE fix: uses flat path → returns 1.6.0-ballot content tagged as 1.5.4 + * - AFTER fix: detects version mismatch at flat path → scans nested paths → finds 1.5.4 → uses it + */ +describe("scanNodeModulesPackage: nested path preferred over flat wrong-version", () => { + let tempDir: string; + + const pflegegradUrl = "http://fhir.de/StructureDefinition/observation-de-pflegegrad" as CanonicalUrl; + + beforeAll(async () => { + tempDir = join(process.cwd(), `.test-tmp-transitive-version-mismatch-${Date.now()}`); + await mkdir(tempDir, { recursive: true }); + + // Set up flat path: de.basisprofil.r4 at version 1.6.0-ballot2 (wrong version) + const flatDir = join(tempDir, "de.basisprofil.r4"); + await mkdir(flatDir, { recursive: true }); + await writeFile( + join(flatDir, "package.json"), + JSON.stringify({ name: "de.basisprofil.r4", version: "1.6.0-ballot2" }), + ); + // Add a fake FHIR resource with wrong version (would be returned if flat path used) + await writeFile( + join(flatDir, "StructureDefinition-wrong-version.json"), + JSON.stringify({ + resourceType: "StructureDefinition", + url: pflegegradUrl, + version: "1.6.0-ballot", + name: "ObservationDePflegegrad", + kind: "resource", + abstract: false, + status: "active", + type: "Observation", + baseDefinition: "http://hl7.org/fhir/StructureDefinition/Observation", + derivation: "constraint", + }), + ); + + // Set up nested path: kbv.basis/node_modules/de.basisprofil.r4 at version 1.5.4 (correct) + const nestedDir = join(tempDir, "kbv.basis", "node_modules", "de.basisprofil.r4"); + await mkdir(nestedDir, { recursive: true }); + await writeFile( + join(nestedDir, "package.json"), + JSON.stringify({ name: "de.basisprofil.r4", version: "1.5.4" }), + ); + // Stub with 1.5.4 version field + await writeFile( + join(nestedDir, "StructureDefinition-observation-de-pflegegrad.json"), + JSON.stringify({ + resourceType: "StructureDefinition", + url: pflegegradUrl, + version: "1.5.4", + name: "ObservationDePflegegrad", + kind: "resource", + abstract: false, + status: "active", + type: "Observation", + baseDefinition: "http://hl7.org/fhir/StructureDefinition/Observation", + derivation: "constraint", + }), + ); + + // Also set up a minimal kbv.basis dir in the flat path so manager can find it + const kbvDir = join(tempDir, "kbv.basis"); + await writeFile( + join(kbvDir, "package.json"), + JSON.stringify({ + name: "kbv.basis", + version: "1.8.0", + dependencies: { "de.basisprofil.r4": "1.5.4" }, + }), + ); + }, 30000); + + afterAll(async () => { + if (tempDir && existsSync(tempDir)) { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("uses nested path content (version 1.5.4) when flat path has wrong version (1.6.0-ballot2)", async () => { + // Create a minimal CanonicalManager that will report 0 resources for both packages + // (simulating the null-id .index.json scenario). + // We use the real packages + nodeModulesPath override to exercise the fallback path. + // + // The manager must be initialized (it downloads packages), but we override + // nodeModulesPath to point at our synthetic temp dir. + // The manager will index 0 resources → fallback kicks in → reads from tempDir. + const manager = CanonicalManager({ + packages: ["kbv.basis@1.8.0", "de.basisprofil.r4@1.5.4"], + workingDir: ".codegen-cache/canonical-manager-cache", + registry: "https://packages.simplifier.net", + }); + await manager.init(); + + // Override nodeModulesPath with our synthetic structure. + // This forces scanNodeModulesPackage to use tempDir instead of the real cache. + // For de.basisprofil.r4@1.5.4: + // - flat path (tempDir/de.basisprofil.r4) has version 1.6.0-ballot2 → MISMATCH + // - nested path (tempDir/kbv.basis/node_modules/de.basisprofil.r4) has version 1.5.4 → MATCH + // After fix: nested path should be preferred, returning 1.5.4 content. + const register = await registerFromManager(manager, { + focusedPackages: [basisprofilOld, kbvPkg], + nodeModulesPath: tempDir, + }); + + // The pflegegrad resource loaded from the nested 1.5.4 path should have version "1.5.4" + const resolved = register.resolveFs(basisprofilOld, pflegegradUrl); + expect(resolved, "pflegegrad must resolve from nested 1.5.4 path").toBeDefined(); + expect( + resolved!.version, + "resolved pflegegrad should have FHIR version 1.5.4 (from nested path), " + + "not 1.6.0-ballot (from flat wrong-version path)", + ).toBe("1.5.4"); + }, 60000); +}); + +/** + * Regression test for codegen-pkt: APIBuilder.localTgzPackage cache-key mismatch. + * + * Scenario: + * - User calls `new APIBuilder().fromPackage("hl7.fhir.r4.core", "4.0.1") + * .fromPackage("de.basisprofil.r4", "1.6.0-ballot2") + * .localTgzPackage(some.tgz)`. + * - Internally, the manager is constructed with only the fromPackage entries. + * - Then addTgzPackage(some.tgz) is called → adds packages to the cache record but + * does NOT change the canonical-manager's cache hash. + * - Codegen however computes nodeModulesPath from ALL focusedPackages (including those + * from the tgz). The hash diverges from the canonical-manager's actual cache directory. + * - The computed nodeModulesPath does not exist → fallback never triggers → resources + * for transitive deps remain 0 → resolveFs returns undefined → "Base resource not found". + * + * Fix: scanNodeModulesPackage now scans sibling cache directories under + * /canonical-manager-cache/ when the computed path does not exist. This recovers + * the actual cache regardless of how the canonical-manager hash was derived. + */ +describe("scanNodeModulesPackage: cache-key mismatch fallback (codegen-pkt)", () => { + let workdirRoot: string; + let cacheRoot: string; + let actualCacheDir: string; + let computedNodeModulesPath: string; + + const pflegegradUrl = "http://fhir.de/StructureDefinition/observation-de-pflegegrad" as CanonicalUrl; + + beforeAll(async () => { + // Set up a synthetic codegen-cache layout that mimics the bug: + // workdirRoot/ + // ACTUAL-HASH-FROM-CANONICAL-MANAGER/ + // node/node_modules/de.basisprofil.r4/ ← real package data is here + // COMPUTED-HASH-FROM-CODEGEN/ ← (does not exist; codegen would compute this) + workdirRoot = join(process.cwd(), `.test-tmp-pkt-${Date.now()}`); + cacheRoot = join(workdirRoot, "canonical-manager-cache"); + actualCacheDir = join(cacheRoot, "actual-cache-hash-abcdef123456"); + computedNodeModulesPath = join(cacheRoot, "computed-cache-hash-fedcba654321", "node", "node_modules"); + + const realPkgDir = join(actualCacheDir, "node", "node_modules", "de.basisprofil.r4"); + await mkdir(realPkgDir, { recursive: true }); + await writeFile( + join(realPkgDir, "package.json"), + JSON.stringify({ name: "de.basisprofil.r4", version: "1.6.0-ballot2" }), + ); + await writeFile( + join(realPkgDir, "StructureDefinition-actual-cache.json"), + JSON.stringify({ + resourceType: "StructureDefinition", + url: pflegegradUrl, + version: "1.6.0-ballot", + name: "ObservationDePflegegrad", + kind: "resource", + abstract: false, + status: "active", + type: "Observation", + baseDefinition: "http://hl7.org/fhir/StructureDefinition/Observation", + derivation: "constraint", + }), + ); + }, 30000); + + afterAll(async () => { + if (workdirRoot && existsSync(workdirRoot)) { + await rm(workdirRoot, { recursive: true, force: true }); + } + }); + + it("scans sibling cache directories when computed nodeModulesPath does not exist", async () => { + // Sanity check: computed path does NOT exist (mirroring the bug). + expect(existsSync(computedNodeModulesPath)).toBe(false); + // Sanity check: real package data EXISTS at the actual cache hash directory. + expect(existsSync(join(actualCacheDir, "node", "node_modules", "de.basisprofil.r4"))).toBe(true); + + // Use a real CanonicalManager — but with a focusedPackages list whose hash does not + // match the actual cache directory. We pass nodeModulesPath as the (non-existent) + // computed path. The fix must scan sibling cache hashes and find the real one. + const manager = CanonicalManager({ + packages: ["de.basisprofil.r4@1.6.0-ballot2"], + workingDir: ".codegen-cache/canonical-manager-cache", + registry: "https://packages.simplifier.net", + }); + await manager.init(); + + const register = await registerFromManager(manager, { + focusedPackages: [basisprofilNew], + nodeModulesPath: computedNodeModulesPath, + }); + + const resolved = register.resolveFs(basisprofilNew, pflegegradUrl); + expect( + resolved, + "pflegegrad must resolve from sibling cache directory when computed path does not exist", + ).toBeDefined(); + expect(resolved!.version, "should have loaded from the actual cache, version 1.6.0-ballot").toBe("1.6.0-ballot"); + }, 60000); +}); diff --git a/test/unit/typeschema/versioned-canonical.test.ts b/test/unit/typeschema/versioned-canonical.test.ts new file mode 100644 index 000000000..9ce0136ef --- /dev/null +++ b/test/unit/typeschema/versioned-canonical.test.ts @@ -0,0 +1,100 @@ +/** + * Regression tests for versioned canonical resolution across packages. + * + * Root cause: kbv.basis@1.8.0 profiles reference de.basisprofil.r4@1.5.4 profiles + * using versioned canonical URLs (e.g. |1.5.4 suffix). de.basisprofil.r4@1.5.4 ships + * an .index.json that contains an ImplementationGuide entry with id=null. The canonical + * manager's parseIndex rejects the entire .index.json when any entry has a null id, + * silently leaving de.basisprofil.r4 with 0 indexed resources. This caused "Base resource + * not found" errors when transforming KBV profiles that inherit from de.basisprofil.r4 + * profiles via versioned canonical references. + * + * Fix: registerFromPackageMetas and registerFromManager compute the canonical manager's + * node_modules path and pass it as nodeModulesPath. mkPackageAwareResolver uses it as a + * fallback when the canonical manager returns 0 resources for a focused package — scanning + * the directory directly, which has no id-null restriction. + * + * See: codegen-vrq + */ +import { beforeAll, describe, expect, it } from "bun:test"; +import type { CanonicalUrl } from "@root/typeschema/types"; +import type { Register } from "@typeschema/register"; +import { registerFromPackageMetas } from "@typeschema/register"; + +const kbvPkg = { name: "kbv.basis", version: "1.8.0" }; +const basisprofil = { name: "de.basisprofil.r4", version: "1.5.4" }; + +describe("Versioned canonical resolution (codegen-vrq)", () => { + let register: Register; + + beforeAll(async () => { + register = await registerFromPackageMetas([kbvPkg, basisprofil], {}); + }); + + describe("resolveFs — cross-package base type lookup", () => { + it("finds de.basisprofil.r4 profile from kbv.basis context using clean URL", () => { + const url = "http://fhir.de/StructureDefinition/observation-de-pflegegrad" as CanonicalUrl; + const resolved = register.resolveFs(kbvPkg, url); + expect(resolved).toBeDefined(); + expect(resolved?.url).toBe(url); + }); + + it("strips |version suffix before lookup — versioned canonical resolves to the same schema", () => { + const versioned = "http://fhir.de/StructureDefinition/observation-de-pflegegrad|1.5.4" as CanonicalUrl; + const clean = "http://fhir.de/StructureDefinition/observation-de-pflegegrad" as CanonicalUrl; + + // ensureSpecializationCanonicalUrl must strip the |version suffix + const stripped = register.ensureSpecializationCanonicalUrl(versioned); + expect(stripped).toBe(clean); + + // resolveFs with the stripped URL must find the schema + const resolved = register.resolveFs(kbvPkg, stripped); + expect(resolved).toBeDefined(); + expect(resolved?.url).toBe(clean); + }); + + it("resolves all vitalsign profiles that kbv.basis pins to de.basisprofil.r4@1.5.4", () => { + // These are the profiles that kbv.basis@1.8.0 uses with |1.5.4 suffix in baseDefinition + const vitalsignUrls: CanonicalUrl[] = [ + "http://fhir.de/StructureDefinition/observation-de-vitalsign-blutdruck", + "http://fhir.de/StructureDefinition/observation-de-vitalsign-koerpergroesse", + "http://fhir.de/StructureDefinition/observation-de-vitalsign-koerpergewicht", + "http://fhir.de/StructureDefinition/observation-de-vitalsign-koerpertemperatur", + ].map((u) => u as CanonicalUrl); + + for (const url of vitalsignUrls) { + const resolved = register.resolveFs(kbvPkg, url); + expect(resolved, `Expected ${url} to resolve`).toBeDefined(); + } + }); + }); + + describe("transformFhirSchema — base type resolution for KBV profiles", () => { + it("resolves base type for KBV_PR_Base_Observation_Care_Level (versioned pflegegrad reference)", () => { + // KBV_PR_Base_Observation_Care_Level has baseDefinition pointing to pflegegrad|1.5.4. + // Before the fix, transformFhirSchema would throw "Base resource not found '...pflegegrad|1.5.4'" + // because de.basisprofil.r4 had 0 indexed resources in the canonical manager. + const careLevelUrl = "https://fhir.kbv.de/StructureDefinition/KBV_PR_Base_Observation_Care_Level"; + const careLevel = register.resolveFs(kbvPkg, careLevelUrl as CanonicalUrl); + expect(careLevel, "KBV_PR_Base_Observation_Care_Level must be resolvable").toBeDefined(); + + // The base type of care level is observation-de-pflegegrad|1.5.4. + // After stripping the version suffix, it must be resolvable. + expect(careLevel!.base, "care level must have a base definition").toBeDefined(); + const strippedBase = register.ensureSpecializationCanonicalUrl(careLevel!.base!); + const baseResolved = register.resolveFs(kbvPkg, strippedBase); + expect(baseResolved, `Base type '${strippedBase}' (from '${careLevel!.base}') must resolve`).toBeDefined(); + }); + + it("resolves base type chain for KBV vitalsign profiles with versioned de.basisprofil.r4 references", () => { + // KBV blood pressure profile: baseDefinition = ...observation-de-vitalsign-blutdruck|1.5.4 + const bpUrl = "https://fhir.kbv.de/StructureDefinition/KBV_PR_Base_Observation_Blood_Pressure"; + const bp = register.resolveFs(kbvPkg, bpUrl as CanonicalUrl); + expect(bp, "KBV_PR_Base_Observation_Blood_Pressure must be resolvable").toBeDefined(); + + const strippedBase = register.ensureSpecializationCanonicalUrl(bp!.base!); + const baseResolved = register.resolveFs(kbvPkg, strippedBase); + expect(baseResolved, `Base type '${strippedBase}' must resolve`).toBeDefined(); + }); + }); +}); From 7007ab0ddd1c54ca90c8e35d8c347b5aec752ce1 Mon Sep 17 00:00:00 2001 From: Malte Sussdorff Date: Sun, 26 Apr 2026 20:47:37 +0200 Subject: [PATCH 2/2] style: fix biome formatting in transitive-version-mismatch test --- test/unit/typeschema/transitive-version-mismatch.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/unit/typeschema/transitive-version-mismatch.test.ts b/test/unit/typeschema/transitive-version-mismatch.test.ts index ef30df17a..dde991c35 100644 --- a/test/unit/typeschema/transitive-version-mismatch.test.ts +++ b/test/unit/typeschema/transitive-version-mismatch.test.ts @@ -301,6 +301,8 @@ describe("scanNodeModulesPackage: cache-key mismatch fallback (codegen-pkt)", () resolved, "pflegegrad must resolve from sibling cache directory when computed path does not exist", ).toBeDefined(); - expect(resolved!.version, "should have loaded from the actual cache, version 1.6.0-ballot").toBe("1.6.0-ballot"); + expect(resolved!.version, "should have loaded from the actual cache, version 1.6.0-ballot").toBe( + "1.6.0-ballot", + ); }, 60000); });