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..dde991c35 --- /dev/null +++ b/test/unit/typeschema/transitive-version-mismatch.test.ts @@ -0,0 +1,308 @@ +/** + * 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(); + }); + }); +});