From 21f4ae110791fe3ba9286879ead21f44908b40de Mon Sep 17 00:00:00 2001 From: raleigh-g-thompson Date: Tue, 30 Jun 2026 07:40:46 -0600 Subject: [PATCH 1/7] fix file path to look at containing folder --- .../cqf/cql/ls/server/command/CqlEvaluator.kt | 4 +- .../ContentServiceModelInfoProviderTest.kt | 110 ++++++++++++++++++ .../FederatedLibrarySourceProviderTest.kt | 34 ++++++ 3 files changed, 146 insertions(+), 2 deletions(-) diff --git a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/CqlEvaluator.kt b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/CqlEvaluator.kt index e2de7741..61f78f00 100644 --- a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/CqlEvaluator.kt +++ b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/CqlEvaluator.kt @@ -416,7 +416,7 @@ object CqlEvaluator { evaluationSettings.librarySourceProviders.clear() if (libraryUri != null) { evaluationSettings.librarySourceProviders.add( - FederatedLibrarySourceProvider(libraryUri, contentService, npmProcessor), + FederatedLibrarySourceProvider(Uris.getHead(libraryUri), contentService, npmProcessor), ) } else if (libraryKotlinPath != null) { evaluationSettings.librarySourceProviders.add( @@ -452,7 +452,7 @@ object CqlEvaluator { // Model info providers have no ordering concern; register after engine creation. if (libraryUri != null) { engine.environment.libraryManager!!.modelManager.modelInfoLoader.registerModelInfoProvider( - ContentServiceModelInfoProvider(libraryUri, contentService), + ContentServiceModelInfoProvider(Uris.getHead(libraryUri), contentService), ) } else if (libraryKotlinPath != null) { engine.environment.libraryManager!!.modelManager.modelInfoLoader.registerModelInfoProvider( diff --git a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/provider/ContentServiceModelInfoProviderTest.kt b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/provider/ContentServiceModelInfoProviderTest.kt index ff857d7f..b029fd54 100644 --- a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/provider/ContentServiceModelInfoProviderTest.kt +++ b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/provider/ContentServiceModelInfoProviderTest.kt @@ -2,7 +2,9 @@ package org.opencds.cqf.cql.ls.server.provider import org.hl7.cql.model.ModelIdentifier import org.hl7.elm.r1.VersionedIdentifier +import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import org.opencds.cqf.cql.ls.core.ContentService @@ -64,4 +66,112 @@ class ContentServiceModelInfoProviderTest { ) assertThrows { provider.load(ModelIdentifier(id = "Bad")) } } + + // ----------------------------------------------------------------------- + // File URI root — constructs wrong path (treats .cql file as a directory) + // ----------------------------------------------------------------------- + // CqlEvaluator passes the .cql file URI as root (libraryUri). The provider + // appends modelinfo paths to it, producing URIs like: + // file:///.../MyLibrary.cql/c4bb-modelinfo-2.1.1.xml + // which fails with "Not a directory" at read time. + + @Test + fun load_fileUriRoot_appendsModelinfoToCqlFile() { + var capturedUri: URI? = null + val capturingService = + object : ContentService { + override fun locate( + root: URI, + identifier: VersionedIdentifier, + ): Set = emptySet() + + override fun read(uri: URI): InputStream? { + capturedUri = uri + return null + } + } + val libraryFile = URI.create("file:///workspace/input/cql/MyLibrary.cql") + + val provider = ContentServiceModelInfoProvider(libraryFile, capturingService) + provider.load(ModelIdentifier(id = "C4BB", version = "2.1.1")) + + assertNotNull(capturedUri) + assertTrue( + capturedUri!!.toString().contains("MyLibrary.cql/c4bb-modelinfo-2.1.1.xml"), + "File URI root causes the .cql file to be treated as a directory: ${capturedUri}", + ) + } + + // ----------------------------------------------------------------------- + // Directory URI root — constructs correct path + // ----------------------------------------------------------------------- + // CqlCompilationManager uses Uris.getHead(uri) which strips the filename, + // giving a directory root. The modelinfo path resolves correctly: + // file:///.../input/cql/c4bb-modelinfo-2.1.1.xml + + @Test + fun load_directoryUriRoot_appendsModelinfoFlat() { + var capturedUri: URI? = null + val capturingService = + object : ContentService { + override fun locate( + root: URI, + identifier: VersionedIdentifier, + ): Set = emptySet() + + override fun read(uri: URI): InputStream? { + capturedUri = uri + return null + } + } + val cqlDir = URI.create("file:///workspace/input/cql/") + + val provider = ContentServiceModelInfoProvider(cqlDir, capturingService) + provider.load(ModelIdentifier(id = "C4BB", version = "2.1.1")) + + assertNotNull(capturedUri) + assertTrue( + capturedUri!!.toString().endsWith("c4bb-modelinfo-2.1.1.xml"), + "Directory URI root produces correct flat path: ${capturedUri}", + ) + } + + // ----------------------------------------------------------------------- + // End-to-end: file URI root cannot resolve valid modelinfo + // ----------------------------------------------------------------------- + // Even when a content service can serve modelinfo from the correct path, + // a file URI root constructs the wrong path and never finds it. + + @Test + fun load_fileUriRoot_cannotResolveModelinfo_whenDirectoryRootCan() { + val cqlDir = URI.create("file:///workspace/input/cql/") + val libraryFile = URI.create("file:///workspace/input/cql/MyLibrary.cql") + val validModelinfo = """""" + + val correctModelinfoUri = URI.create("file:///workspace/input/cql/c4bb-modelinfo-2.1.1.xml") + + val servingService = + object : ContentService { + override fun locate( + root: URI, + identifier: VersionedIdentifier, + ): Set = emptySet() + + override fun read(uri: URI): InputStream? { + // Only serve from the exact correct path — file-root constructs + // "MyLibrary.cql/c4bb-modelinfo-2.1.1.xml" which won't match + return if (uri == correctModelinfoUri) + validModelinfo.byteInputStream() + else null + } + } + + // Directory root succeeds — correct flat path + val dirProvider = ContentServiceModelInfoProvider(cqlDir, servingService) + assertNotNull(dirProvider.load(ModelIdentifier(id = "C4BB", version = "2.1.1"))) + + // File root fails — wrong path (file treated as directory) + val fileProvider = ContentServiceModelInfoProvider(libraryFile, servingService) + assertNull(fileProvider.load(ModelIdentifier(id = "C4BB", version = "2.1.1"))) + } } diff --git a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/provider/FederatedLibrarySourceProviderTest.kt b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/provider/FederatedLibrarySourceProviderTest.kt index 8bc540b2..9b233caf 100644 --- a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/provider/FederatedLibrarySourceProviderTest.kt +++ b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/provider/FederatedLibrarySourceProviderTest.kt @@ -2,6 +2,7 @@ package org.opencds.cqf.cql.ls.server.provider import org.cqframework.fhir.npm.NpmProcessor import org.hl7.elm.r1.VersionedIdentifier +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Test @@ -120,6 +121,39 @@ class FederatedLibrarySourceProviderTest { assertNull(provider.getLibrarySource(VersionedIdentifier().withId("LibA"))) } + // ----------------------------------------------------------------------- + // Root URI passthrough — FederatedLibrarySourceProvider does NOT normalize + // file URIs to directory URIs (unlike CqlCompilationManager which calls + // Uris.getHead first). When CqlEvaluator passes libraryUri (a .cql file), + // it arrives at locate() as-is — no directory normalization. + // ----------------------------------------------------------------------- + + @Test + fun getLibrarySource_passesRootUriThroughWithoutNormalization() { + var capturedRoot: URI? = null + val capturingCs = + object : ContentService { + override fun locate( + root: URI, + identifier: VersionedIdentifier, + ): Set { + capturedRoot = root + return emptySet() + } + + override fun read(uri: URI): InputStream? = null + } + + val fileUri = URI.create("file:///workspace/input/cql/MyLibrary.cql") + val provider = FederatedLibrarySourceProvider(fileUri, capturingCs, null) + provider.getLibrarySource(VersionedIdentifier().withId("Test")) + + // Root is passed through unchanged — no getHead normalization. + // This means locate() receives a file URI, forcing tier1 to fail + // (BFS requires a directory) and relying entirely on tier2 fallback. + assertEquals(fileUri, capturedRoot, "Root must be passed through without normalization") + } + // ----------------------------------------------------------------------- // Helpers // ----------------------------------------------------------------------- From bdc5af55929f27a6b4806b21f4d1870b66359572 Mon Sep 17 00:00:00 2001 From: raleigh-g-thompson Date: Thu, 2 Jul 2026 12:47:17 -0600 Subject: [PATCH 2/7] fix model info file resolution --- .../cqf/cql/ls/server/command/CqlEvaluator.kt | 62 +++++++++- .../server/manager/CqlCompilationManager.kt | 1 + .../ContentServiceModelInfoProvider.kt | 96 ++++++++++++++- .../cql/ls/server/command/CqlEvaluatorTest.kt | 111 ++++++++++++++++++ .../ContentServiceModelInfoProviderTest.kt | 72 ++++++++++++ .../cqf/cql/ls/server/UsesCustomModel.cql | 8 ++ 6 files changed, 344 insertions(+), 6 deletions(-) create mode 100644 ls/server/src/test/resources/org/opencds/cqf/cql/ls/server/UsesCustomModel.cql diff --git a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/CqlEvaluator.kt b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/CqlEvaluator.kt index 61f78f00..1fb7dca4 100644 --- a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/CqlEvaluator.kt +++ b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/CqlEvaluator.kt @@ -415,8 +415,12 @@ object CqlEvaluator { // (local → npm → bundled FHIRHelpers) is guaranteed even if that ever changes. evaluationSettings.librarySourceProviders.clear() if (libraryUri != null) { + // The client sends libraryUri as the CQL *directory* (input/cql), not a file, so + // it is used directly as the provider root. Do NOT apply Uris.getHead here: that + // would strip a segment (input/cql → input) and mis-root resolution. This matches + // CqlCompilationManager, which passes the already-derived directory as the root. evaluationSettings.librarySourceProviders.add( - FederatedLibrarySourceProvider(Uris.getHead(libraryUri), contentService, npmProcessor), + FederatedLibrarySourceProvider(libraryUri, contentService, npmProcessor), ) } else if (libraryKotlinPath != null) { evaluationSettings.librarySourceProviders.add( @@ -451,8 +455,13 @@ object CqlEvaluator { // Model info providers have no ordering concern; register after engine creation. if (libraryUri != null) { + // libraryUri is the CQL directory (input/cql); pass it directly. Applying + // Uris.getHead here was the cause of ModelInfo lookups landing in input/ instead + // of input/cql/ during execution (they succeed at edit time because + // CqlCompilationManager already roots the provider at input/cql). + log.info("Registered ContentServiceModelInfoProvider (execute) root={}", libraryUri) engine.environment.libraryManager!!.modelManager.modelInfoLoader.registerModelInfoProvider( - ContentServiceModelInfoProvider(Uris.getHead(libraryUri), contentService), + ContentServiceModelInfoProvider(libraryUri, contentService), ) } else if (libraryKotlinPath != null) { engine.environment.libraryManager!!.modelManager.modelInfoLoader.registerModelInfoProvider( @@ -532,10 +541,26 @@ object CqlEvaluator { libraryResults.add(LibraryResult(libraryRequest.libraryName, expressions, defaultParams)) } catch (e: Exception) { log.error("Error evaluating library ${libraryRequest.libraryName} for context ${libraryRequest.context?.contextValue}", e) + // Surface the nested cause chain on one output-channel-visible line, in addition + // to the stack trace above. NOTE: for engine compile failures the deepest reachable + // cause is the flattened CqlException message — the engine does not chain + // CqlCompilerException causes (LoadAndValidateLibrariesResult.wrapExceptions joins + // only messages). The requiredModelInfo logging in ContentServiceModelInfoProvider + // is what exposes model version-conflict failures. + val causeChain = describeCauseChain(e) + log.error(" cause chain: {}", causeChain) + val topMessage = e.message ?: e.javaClass.simpleName + val rootMessage = deepestCause(e).message + val errorText = + if (rootMessage != null && rootMessage != topMessage) { + "$topMessage (cause: $rootMessage)" + } else { + topMessage + } libraryResults.add( LibraryResult( libraryRequest.libraryName, - listOf(ExpressionResult("Error", e.message ?: e.javaClass.simpleName)), + listOf(ExpressionResult("Error", errorText)), ), ) } @@ -544,6 +569,37 @@ object CqlEvaluator { return libraryResults to detailedResults } + /** Returns the deepest (root) cause of [t], following the `cause` chain (cycle-safe). */ + internal fun deepestCause(t: Throwable): Throwable { + val seen = HashSet() + var current = t + while (true) { + val next = current.cause ?: return current + if (!seen.add(current) || next === current) return current + current = next + } + } + + /** + * Renders [t]'s cause chain as `SimpleName: message` per level (deepest last), skipping + * consecutive levels whose message is identical to the previous one. Cycle-safe. + */ + internal fun describeCauseChain(t: Throwable): String { + val parts = mutableListOf() + val seen = HashSet() + var current: Throwable? = t + var lastMessage: String? = null + while (current != null && seen.add(current)) { + val message = current.message + if (message != lastMessage) { + parts.add("${current.javaClass.simpleName}: ${message ?: "(no message)"}") + lastMessage = message + } + current = current.cause + } + return parts.joinToString(" -> ") + } + internal fun collectDefineOrder( frames: List, seen: MutableSet, diff --git a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/manager/CqlCompilationManager.kt b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/manager/CqlCompilationManager.kt index bd02eeb6..8638b731 100644 --- a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/manager/CqlCompilationManager.kt +++ b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/manager/CqlCompilationManager.kt @@ -167,6 +167,7 @@ class CqlCompilationManager( root: URI, modelManager: ModelManager, ): LibraryManager { + log.info("Registered ContentServiceModelInfoProvider (compile) root={}", root) modelManager.modelInfoLoader.registerModelInfoProvider( ContentServiceModelInfoProvider(root, contentService), ) diff --git a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/provider/ContentServiceModelInfoProvider.kt b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/provider/ContentServiceModelInfoProvider.kt index 0198dc7d..ae8c2c15 100644 --- a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/provider/ContentServiceModelInfoProvider.kt +++ b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/provider/ContentServiceModelInfoProvider.kt @@ -9,6 +9,7 @@ import org.opencds.cqf.cql.ls.core.utility.Converters import org.opencds.cqf.cql.ls.core.utility.Uris import org.slf4j.LoggerFactory import java.net.URI +import java.util.concurrent.ConcurrentHashMap class ContentServiceModelInfoProvider( private val root: URI, @@ -16,21 +17,110 @@ class ContentServiceModelInfoProvider( ) : ModelInfoProvider { companion object { private val log = LoggerFactory.getLogger(ContentServiceModelInfoProvider::class.java) + + /** + * Renders a ModelInfo's declared dependencies (`requiredModelInfo`) as + * `[name version, ...]`. Logged after a successful parse so version conflicts are visible: + * e.g. a model that requires `USCore 7.0.0` while the content loads `USCore 6.1.0-derived` + * surfaces as a mismatch in the log. Returns `[]` when there are no declared dependencies. + */ + internal fun formatRequiredModels(modelInfo: ModelInfo): String = + modelInfo.requiredModelInfo + .joinToString(prefix = "[", postfix = "]") { req -> + "${req.name}${req.version?.let { " $it" } ?: ""}" + } + + /** + * Tracks the versions each model has been seen at, per provider root: `(rootKey, modelId)` + * → `version` → `source`. Populated as models are requested and as their declared + * dependencies (`requiredModelInfo`) are parsed. The CQL compiler allows exactly one + * version of a model per translation, so seeing a model at two versions under the same + * root is a genuine conflict (e.g. the content loads `USCore 6.1.0-derived` while a + * developer-supplied C4BB ModelInfo requires `USCore 7.0.0`). Keyed by root so unrelated + * projects in the same workspace do not cross-contaminate. + */ + private val observedModelVersions = + ConcurrentHashMap, ConcurrentHashMap>() + + /** + * Records that [modelId] was seen at [version] (attributed to [source]) under [rootKey], + * and returns a human-readable conflict description (`v1 (src1), v2 (src2)`) when that + * model is now known at more than one version under the same root — i.e. an actual model + * version conflict. Returns null when there is no conflict or [version] is null/blank. + */ + internal fun recordVersionAndDetectConflict( + rootKey: String, + modelId: String, + version: String?, + source: String, + ): String? { + if (version.isNullOrBlank()) return null + val versions = observedModelVersions.getOrPut(rootKey to modelId) { ConcurrentHashMap() } + versions.putIfAbsent(version, source) + return if (versions.size > 1) { + versions.entries.joinToString(", ") { "${it.key} (${it.value})" } + } else { + null + } + } + + /** Clears the cross-request version tracking. Intended for tests. */ + internal fun clearObservedVersions() = observedModelVersions.clear() } override fun load(modelIdentifier: ModelIdentifier): ModelInfo? { val modelName = modelIdentifier.id val modelVersion = modelIdentifier.version + log.info( + "ContentServiceModelInfoProvider: resolving model '{}' version '{}' (root={})", + modelName, + modelVersion, + root, + ) + + // Record the requested version; warn if this model is now known at two versions. + recordVersionAndDetectConflict(root.toString(), modelName, modelVersion, "requested")?.let { + log.warn("ContentServiceModelInfoProvider: model version conflict for '{}': {}", modelName, it) + } + return try { val modelUri = Uris.addPath( root, "/${modelName.lowercase()}-modelinfo${modelVersion?.let { "-$it" } ?: ""}.xml", - ) ?: return null - val modelInputStream = contentService.read(modelUri) ?: return null - parseModelInfoXml(Converters.inputStreamToString(modelInputStream)) + ) ?: run { + log.info( + "ContentServiceModelInfoProvider: could not build model info URI for '{}' from root {}", + modelName, + root, + ) + return null + } + log.info("ContentServiceModelInfoProvider: attempting to read model info for '{}' from {}", modelName, modelUri) + val modelInputStream = + contentService.read(modelUri) ?: run { + log.info("ContentServiceModelInfoProvider: NOT FOUND — no content at {}", modelUri) + return null + } + val modelInfo = parseModelInfoXml(Converters.inputStreamToString(modelInputStream)) + log.info("ContentServiceModelInfoProvider: FOUND and parsed model info for '{}' at {}", modelName, modelUri) + log.warn( + "ContentServiceModelInfoProvider: '{}' requires {}", + modelName, + formatRequiredModels(modelInfo), + ) + // Record each declared dependency's version; warn on any actual version conflict + // (the same model now known at two versions under this root). + for (req in modelInfo.requiredModelInfo) { + val depName = req.name ?: continue + recordVersionAndDetectConflict(root.toString(), depName, req.version, "required by $modelName")?.let { + log.warn("ContentServiceModelInfoProvider: model version conflict for '{}': {}", depName, it) + } + } + modelInfo } catch (e: Exception) { + log.error("ContentServiceModelInfoProvider: error loading model info for '{}' from root {}", modelName, root, e) throw IllegalArgumentException("Could not load definition for model info ${modelIdentifier.id}.", e) } } diff --git a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/command/CqlEvaluatorTest.kt b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/command/CqlEvaluatorTest.kt index 3b06e908..9d16b309 100644 --- a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/command/CqlEvaluatorTest.kt +++ b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/command/CqlEvaluatorTest.kt @@ -25,13 +25,16 @@ import org.junit.jupiter.api.io.TempDir import org.opencds.cqf.cql.engine.debug.BreakpointAction import org.opencds.cqf.cql.engine.debug.BreakpointHandler import org.opencds.cqf.cql.engine.execution.State +import org.opencds.cqf.cql.ls.core.ContentService import org.opencds.cqf.cql.ls.server.manager.IgContextManager import org.opencds.cqf.cql.ls.server.manager.LibraryResolutionManager import org.opencds.cqf.cql.ls.server.service.TestContentService import org.opencds.cqf.fhir.cql.CqlOptions import org.opencds.cqf.fhir.cql.EvaluationSettings import org.opencds.cqf.fhir.utility.repository.ProxyRepository +import java.io.InputStream import java.math.BigDecimal +import java.net.URI import java.nio.file.Files import java.nio.file.Path @@ -993,6 +996,114 @@ class CqlEvaluatorTest { private fun createNoOpRepo(): IRepository = NoOpRepository(r4Context) + // ------------------------------------------------------------------------- + // ModelInfo resolution root + // Regression: the client sends libraryUri as the CQL *directory* (e.g. .../input/cql), + // so the ContentServiceModelInfoProvider must be rooted there and read + // .../input/cql/{model}-modelinfo-{version}.xml. A prior bug applied Uris.getHead(libraryUri), + // stripping a segment and reading from .../input/{model}-modelinfo-{version}.xml — which + // failed during execution (but not during editing, where the provider is already rooted at + // input/cql). See CqlEvaluator.evaluateBatch. + // ------------------------------------------------------------------------- + + private class RecordingContentService : ContentService { + val reads = mutableListOf() + private val delegate = TestContentService() + + override fun locate( + root: URI, + libraryIdentifier: VersionedIdentifier, + ): Set = delegate.locate(root, libraryIdentifier) + + override fun read(uri: URI): InputStream? { + reads.add(uri) + return delegate.read(uri) + } + } + + // ------------------------------------------------------------------------- + // Cause-chain surfacing (describeCauseChain / deepestCause) + // ------------------------------------------------------------------------- + + @Test + fun `deepestCause returns the root cause`() { + val root = IllegalStateException("root") + val mid = IllegalArgumentException("mid", root) + val top = RuntimeException("top", mid) + assertEquals("root", CqlEvaluator.deepestCause(top).message) + } + + @Test + fun `deepestCause returns self when there is no cause`() { + val e = RuntimeException("only") + assertSame(e, CqlEvaluator.deepestCause(e)) + } + + @Test + fun `describeCauseChain includes each distinct level deepest last`() { + val root = IllegalStateException("version 7.0.0 conflicts with 6.1.0-derived") + val top = RuntimeException("Could not load model information for model C4BB", root) + val chain = CqlEvaluator.describeCauseChain(top) + assertTrue(chain.contains("Could not load model information for model C4BB"), chain) + assertTrue(chain.contains("version 7.0.0 conflicts with 6.1.0-derived"), chain) + // deepest cause rendered after the top-level message + assertTrue( + chain.indexOf("Could not load model information") < chain.indexOf("conflicts with"), + chain, + ) + } + + @Test + fun `describeCauseChain collapses consecutive identical messages`() { + val root = IllegalStateException("same") + val top = RuntimeException("same", root) + // Both levels share the message "same"; it should appear once. + assertEquals(1, CqlEvaluator.describeCauseChain(top).split(" -> ").size) + } + + @Test + fun `evaluate resolves model info relative to the libraryUri directory not its parent`() { + val recording = RecordingContentService() + val libDir = "file:///project/input/cql" + val request = + ExecuteCqlRequest( + fhirVersion = "R4", + rootDir = null, + optionsPath = null, + libraries = + listOf( + LibraryRequest( + libraryName = "UsesCustomModel", + libraryUri = libDir, + libraryVersion = "1", + terminologyUri = null, + model = null, + context = null, + parameters = emptyList(), + ), + ), + ) + + // Evaluation is expected to fail to resolve the Custom model (no real model info on the + // classpath); we only assert WHERE the provider looked for it. + CqlEvaluator.evaluate(request, recording, igContextManager, libraryResolutionManager) + + val modelInfoReads = recording.reads.filter { it.toString().contains("custom-modelinfo") } + assertTrue( + modelInfoReads.isNotEmpty(), + "Expected a model info read attempt for the Custom model, got reads: ${recording.reads}", + ) + assertTrue( + modelInfoReads.any { it.toString() == "$libDir/custom-modelinfo-1.0.0.xml" }, + "ModelInfo must be read from the libraryUri directory. Attempts: $modelInfoReads", + ) + // The parent-directory path is the regression signature — it must never be attempted. + assertFalse( + modelInfoReads.any { it.toString() == "file:///project/input/custom-modelinfo-1.0.0.xml" }, + "ModelInfo must not be read from the parent of libraryUri. Attempts: $modelInfoReads", + ) + } + // ------------------------------------------------------------------------- // evaluateDetailed // ------------------------------------------------------------------------- diff --git a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/provider/ContentServiceModelInfoProviderTest.kt b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/provider/ContentServiceModelInfoProviderTest.kt index b029fd54..0d959662 100644 --- a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/provider/ContentServiceModelInfoProviderTest.kt +++ b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/provider/ContentServiceModelInfoProviderTest.kt @@ -2,6 +2,8 @@ package org.opencds.cqf.cql.ls.server.provider import org.hl7.cql.model.ModelIdentifier import org.hl7.elm.r1.VersionedIdentifier +import org.hl7.elm_modelinfo.r1.serializing.parseModelInfoXml +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue @@ -174,4 +176,74 @@ class ContentServiceModelInfoProviderTest { val fileProvider = ContentServiceModelInfoProvider(libraryFile, servingService) assertNull(fileProvider.load(ModelIdentifier(id = "C4BB", version = "2.1.1"))) } + + // ----------------------------------------------------------------------- + // formatRequiredModels — surfaces a model's declared dependencies so that + // version conflicts (e.g. C4BB requires USCore 7.0.0 while content loads + // USCore 6.1.0-derived) are visible in the logs. + // ----------------------------------------------------------------------- + + @Test + fun formatRequiredModels_rendersNameAndVersionForEachDependency() { + val modelInfo = + parseModelInfoXml( + """""", + ) + + val formatted = ContentServiceModelInfoProvider.formatRequiredModels(modelInfo) + + assertEquals("[System 1.0.0, FHIR 4.0.1, USCore 7.0.0]", formatted) + } + + @Test + fun formatRequiredModels_rendersEmptyBracketsWhenNoDependencies() { + val modelInfo = + parseModelInfoXml( + """""", + ) + + assertEquals("[]", ContentServiceModelInfoProvider.formatRequiredModels(modelInfo)) + } + + // ----------------------------------------------------------------------- + // recordVersionAndDetectConflict — surfaces an actual model version conflict + // (same model at two versions under one root), e.g. content loads + // USCore 6.1.0-derived while a C4BB ModelInfo requires USCore 7.0.0. + // ----------------------------------------------------------------------- + + @Test + fun recordVersionAndDetectConflict_detectsTwoVersionsOfSameModel() { + ContentServiceModelInfoProvider.clearObservedVersions() + val root = "file:///proj/input/cql" + // First sighting (e.g. requested during edit) — no conflict yet. + assertNull( + ContentServiceModelInfoProvider.recordVersionAndDetectConflict( + root, "ConflictUSCore", "6.1.0-derived", "requested", + ), + ) + // Second, different version (e.g. required by C4BB) — conflict. + val conflict = + ContentServiceModelInfoProvider.recordVersionAndDetectConflict( + root, "ConflictUSCore", "7.0.0", "required by C4BB", + ) + assertNotNull(conflict) + assertTrue(conflict!!.contains("6.1.0-derived (requested)"), conflict) + assertTrue(conflict.contains("7.0.0 (required by C4BB)"), conflict) + } + + @Test + fun recordVersionAndDetectConflict_noConflictForSameVersionOrNull() { + ContentServiceModelInfoProvider.clearObservedVersions() + val root = "file:///proj/input/cql" + assertNull(ContentServiceModelInfoProvider.recordVersionAndDetectConflict(root, "SameVer", "1.0.0", "requested")) + assertNull(ContentServiceModelInfoProvider.recordVersionAndDetectConflict(root, "SameVer", "1.0.0", "required by X")) + assertNull(ContentServiceModelInfoProvider.recordVersionAndDetectConflict(root, "NullVer", null, "requested")) + } + + @Test + fun recordVersionAndDetectConflict_differentRootsDoNotConflict() { + ContentServiceModelInfoProvider.clearObservedVersions() + assertNull(ContentServiceModelInfoProvider.recordVersionAndDetectConflict("file:///a/input/cql", "M", "1.0.0", "requested")) + assertNull(ContentServiceModelInfoProvider.recordVersionAndDetectConflict("file:///b/input/cql", "M", "2.0.0", "requested")) + } } diff --git a/ls/server/src/test/resources/org/opencds/cqf/cql/ls/server/UsesCustomModel.cql b/ls/server/src/test/resources/org/opencds/cqf/cql/ls/server/UsesCustomModel.cql new file mode 100644 index 00000000..2a003c80 --- /dev/null +++ b/ls/server/src/test/resources/org/opencds/cqf/cql/ls/server/UsesCustomModel.cql @@ -0,0 +1,8 @@ +library UsesCustomModel version '1' + +// Declares a model that is neither bundled nor in npm, forcing the +// ContentServiceModelInfoProvider to be consulted for its model info. +using Custom version '1.0.0' + +define "X": + 1 From 82555b978faed13e86101fa62572685891b3dc0c Mon Sep 17 00:00:00 2001 From: raleigh-g-thompson Date: Thu, 2 Jul 2026 14:30:34 -0600 Subject: [PATCH 3/7] fix model info file resolution in debugger --- .../opencds/cqf/cql/debug/CqlDebugServer.kt | 7 +- .../manager/LibraryResolutionManager.kt | 4 +- .../ContentServiceModelInfoProvider.kt | 80 ++++++++++--------- .../cqf/cql/debug/CqlDebugServerHelperTest.kt | 36 +++++++++ .../ContentServiceModelInfoProviderTest.kt | 52 +++++++----- 5 files changed, 122 insertions(+), 57 deletions(-) diff --git a/ls/server/src/main/kotlin/org/opencds/cqf/cql/debug/CqlDebugServer.kt b/ls/server/src/main/kotlin/org/opencds/cqf/cql/debug/CqlDebugServer.kt index 475dab25..5db8b036 100644 --- a/ls/server/src/main/kotlin/org/opencds/cqf/cql/debug/CqlDebugServer.kt +++ b/ls/server/src/main/kotlin/org/opencds/cqf/cql/debug/CqlDebugServer.kt @@ -57,6 +57,7 @@ import org.hl7.elm.r1.VersionedIdentifier import org.hl7.fhir.instance.model.api.IBase import org.opencds.cqf.cql.engine.execution.State import org.opencds.cqf.cql.ls.core.ContentService +import org.opencds.cqf.cql.ls.core.utility.Uris import org.opencds.cqf.cql.ls.server.command.ContextRequest import org.opencds.cqf.cql.ls.server.command.CqlEvaluator import org.opencds.cqf.cql.ls.server.command.DetailedExpressionResult @@ -643,7 +644,11 @@ open class CqlDebugServer( listOf( LibraryRequest( libraryName = args.libraryName, - libraryUri = args.libraryUri, + // libraryUri must be the CQL *directory* (CqlEvaluator contract); the DAP + // launch config gives us the .cql file, so strip to its parent — mirrors + // CqlCompilationManager.createLibraryManager and the Execute CQL flow. + // Fixes model-info lookups landing at ".cql/-modelinfo.xml". + libraryUri = Uris.getHead(URI.create(args.libraryUri)).toString(), libraryVersion = null, terminologyUri = args.terminologyUri, model = args.testCaseUri?.let { ModelRequest("FHIR", it) }, diff --git a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/manager/LibraryResolutionManager.kt b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/manager/LibraryResolutionManager.kt index bb4577ec..1ca4df0c 100644 --- a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/manager/LibraryResolutionManager.kt +++ b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/manager/LibraryResolutionManager.kt @@ -109,11 +109,13 @@ open class LibraryResolutionManager( /** * Reads the package ID and canonical base URL from an ig.ini file. - * Returns null when either field is absent; throws on I/O or parse failure + * Returns null when either field is absent, or when the ini file is missing + * the [IG] section. May still throw on I/O or deeper parse failure * (caller catches and skips the folder). * Declared protected open for test overriding — see [IgContextManager.findIgContext]. */ protected open fun readIgContextInfo(igIniFile: File): Pair? { + if (!igIniFile.useLines { lines -> lines.any { it.trim().equals("[IG]") } }) return null val igContext = IGContext(LoggerAdapter(log)) igContext.initializeFromIni(igIniFile.path) val packageId = igContext.packageId ?: return null diff --git a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/provider/ContentServiceModelInfoProvider.kt b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/provider/ContentServiceModelInfoProvider.kt index ae8c2c15..7994f092 100644 --- a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/provider/ContentServiceModelInfoProvider.kt +++ b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/provider/ContentServiceModelInfoProvider.kt @@ -9,7 +9,6 @@ import org.opencds.cqf.cql.ls.core.utility.Converters import org.opencds.cqf.cql.ls.core.utility.Uris import org.slf4j.LoggerFactory import java.net.URI -import java.util.concurrent.ConcurrentHashMap class ContentServiceModelInfoProvider( private val root: URI, @@ -29,43 +28,50 @@ class ContentServiceModelInfoProvider( .joinToString(prefix = "[", postfix = "]") { req -> "${req.name}${req.version?.let { " $it" } ?: ""}" } + } - /** - * Tracks the versions each model has been seen at, per provider root: `(rootKey, modelId)` - * → `version` → `source`. Populated as models are requested and as their declared - * dependencies (`requiredModelInfo`) are parsed. The CQL compiler allows exactly one - * version of a model per translation, so seeing a model at two versions under the same - * root is a genuine conflict (e.g. the content loads `USCore 6.1.0-derived` while a - * developer-supplied C4BB ModelInfo requires `USCore 7.0.0`). Keyed by root so unrelated - * projects in the same workspace do not cross-contaminate. - */ - private val observedModelVersions = - ConcurrentHashMap, ConcurrentHashMap>() + /** + * Tracks the versions each model has been seen at during this provider's lifetime: + * `modelId` → `version` → `source`. Populated as models are requested and as their declared + * dependencies (`requiredModelInfo`) are parsed. The CQL compiler allows exactly one version + * of a model per translation, so seeing a model at two versions is a genuine conflict (e.g. + * the content loads `USCore 6.1.0-derived` while a developer-supplied C4BB ModelInfo requires + * `USCore 7.0.0`). + * + * This is instance state, not static: a fresh provider is constructed per compilation + * (see `CqlCompilationManager.createLibraryManager` and `CqlEvaluator`), so version tracking + * starts empty each compile and never carries stale versions across runs. Each instance is + * scoped to a single [root], so keying by `modelId` alone is sufficient. + */ + private val observedModelVersions = HashMap>() - /** - * Records that [modelId] was seen at [version] (attributed to [source]) under [rootKey], - * and returns a human-readable conflict description (`v1 (src1), v2 (src2)`) when that - * model is now known at more than one version under the same root — i.e. an actual model - * version conflict. Returns null when there is no conflict or [version] is null/blank. - */ - internal fun recordVersionAndDetectConflict( - rootKey: String, - modelId: String, - version: String?, - source: String, - ): String? { - if (version.isNullOrBlank()) return null - val versions = observedModelVersions.getOrPut(rootKey to modelId) { ConcurrentHashMap() } - versions.putIfAbsent(version, source) - return if (versions.size > 1) { - versions.entries.joinToString(", ") { "${it.key} (${it.value})" } - } else { - null - } - } + /** + * Records that [modelId] was seen at [version] (attributed to [source]), and returns a + * human-readable conflict description when that model is now known at more than one version — + * i.e. an actual model version conflict. When the observed versions differ *only* by case + * (e.g. `6.1.0-Derived` vs `6.1.0-derived`), the message calls that out explicitly, since the + * CQL engine matches model versions with exact, case-sensitive string equality and such a + * mismatch is a common, easily-missed authoring error. Returns null when there is no conflict + * or [version] is null/blank. + */ + internal fun recordVersionAndDetectConflict( + modelId: String, + version: String?, + source: String, + ): String? { + if (version.isNullOrBlank()) return null + val versions = observedModelVersions.getOrPut(modelId) { mutableMapOf() } + versions.putIfAbsent(version, source) + if (versions.size <= 1) return null - /** Clears the cross-request version tracking. Intended for tests. */ - internal fun clearObservedVersions() = observedModelVersions.clear() + val rendered = versions.entries.joinToString(", ") { "${it.key} (${it.value})" } + val caseOnly = versions.keys.map { it.lowercase() }.distinct().size == 1 + return if (caseOnly) { + "versions differ only by case — $rendered. The CQL engine matches model versions " + + "case-sensitively; make them identical." + } else { + rendered + } } override fun load(modelIdentifier: ModelIdentifier): ModelInfo? { @@ -80,7 +86,7 @@ class ContentServiceModelInfoProvider( ) // Record the requested version; warn if this model is now known at two versions. - recordVersionAndDetectConflict(root.toString(), modelName, modelVersion, "requested")?.let { + recordVersionAndDetectConflict(modelName, modelVersion, "requested")?.let { log.warn("ContentServiceModelInfoProvider: model version conflict for '{}': {}", modelName, it) } @@ -114,7 +120,7 @@ class ContentServiceModelInfoProvider( // (the same model now known at two versions under this root). for (req in modelInfo.requiredModelInfo) { val depName = req.name ?: continue - recordVersionAndDetectConflict(root.toString(), depName, req.version, "required by $modelName")?.let { + recordVersionAndDetectConflict(depName, req.version, "required by $modelName")?.let { log.warn("ContentServiceModelInfoProvider: model version conflict for '{}': {}", depName, it) } } diff --git a/ls/server/src/test/kotlin/org/opencds/cqf/cql/debug/CqlDebugServerHelperTest.kt b/ls/server/src/test/kotlin/org/opencds/cqf/cql/debug/CqlDebugServerHelperTest.kt index 50760088..4aae1905 100644 --- a/ls/server/src/test/kotlin/org/opencds/cqf/cql/debug/CqlDebugServerHelperTest.kt +++ b/ls/server/src/test/kotlin/org/opencds/cqf/cql/debug/CqlDebugServerHelperTest.kt @@ -16,6 +16,7 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.mockito.Mockito import org.opencds.cqf.cql.ls.core.ContentService +import org.opencds.cqf.cql.ls.server.command.ExecuteCqlRequest import org.opencds.cqf.cql.ls.server.manager.CqlCompilationManager import org.opencds.cqf.cql.ls.server.manager.IgContextManager import org.opencds.cqf.cql.ls.server.manager.LibraryResolutionManager @@ -823,4 +824,39 @@ class CqlDebugServerHelperTest { val result = method.invoke(server, null) as Map assertTrue(result.isEmpty()) } + + // -- buildExecuteCqlRequest ----------------------------------------------- + // The DAP launch config supplies libraryUri as the .cql FILE, but CqlEvaluator (and its + // ContentServiceModelInfoProvider) expects the CQL *directory* — otherwise model-info lookups + // land at ".cql/-modelinfo.xml" ("Not a directory") and C4BB fails to load. The + // request builder must strip the filename to its parent, matching the Execute CQL flow. + + private fun buildRequestLibraryUri(libraryUri: String): String { + val server = makeServer() + val method = + CqlDebugServer::class.java.getDeclaredMethod( + "buildExecuteCqlRequest", + DebugLaunchArgs::class.java, + ) + method.isAccessible = true + val args = DebugLaunchArgs(libraryUri = libraryUri, libraryName = "MyLibrary", fhirVersion = "R4") + val request = method.invoke(server, args) as ExecuteCqlRequest + return request.libraries.first().libraryUri + } + + @Test + fun `buildExecuteCqlRequest strips cql filename to its parent directory`() { + assertEquals( + "file:///workspace/input/cql", + buildRequestLibraryUri("file:///workspace/input/cql/MyLibrary.cql"), + ) + } + + @Test + fun `buildExecuteCqlRequest strips cql filename on windows forward-slash uri`() { + assertEquals( + "file:///C:/work/input/cql", + buildRequestLibraryUri("file:///C:/work/input/cql/MyLibrary.cql"), + ) + } } diff --git a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/provider/ContentServiceModelInfoProviderTest.kt b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/provider/ContentServiceModelInfoProviderTest.kt index 0d959662..c631737c 100644 --- a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/provider/ContentServiceModelInfoProviderTest.kt +++ b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/provider/ContentServiceModelInfoProviderTest.kt @@ -207,43 +207,59 @@ class ContentServiceModelInfoProviderTest { // ----------------------------------------------------------------------- // recordVersionAndDetectConflict — surfaces an actual model version conflict - // (same model at two versions under one root), e.g. content loads + // (same model at two versions on one provider instance), e.g. content loads // USCore 6.1.0-derived while a C4BB ModelInfo requires USCore 7.0.0. // ----------------------------------------------------------------------- @Test fun recordVersionAndDetectConflict_detectsTwoVersionsOfSameModel() { - ContentServiceModelInfoProvider.clearObservedVersions() - val root = "file:///proj/input/cql" + val provider = ContentServiceModelInfoProvider(root, nullContentService()) // First sighting (e.g. requested during edit) — no conflict yet. assertNull( - ContentServiceModelInfoProvider.recordVersionAndDetectConflict( - root, "ConflictUSCore", "6.1.0-derived", "requested", - ), + provider.recordVersionAndDetectConflict("ConflictUSCore", "6.1.0-derived", "requested"), ) // Second, different version (e.g. required by C4BB) — conflict. val conflict = - ContentServiceModelInfoProvider.recordVersionAndDetectConflict( - root, "ConflictUSCore", "7.0.0", "required by C4BB", - ) + provider.recordVersionAndDetectConflict("ConflictUSCore", "7.0.0", "required by C4BB") assertNotNull(conflict) assertTrue(conflict!!.contains("6.1.0-derived (requested)"), conflict) assertTrue(conflict.contains("7.0.0 (required by C4BB)"), conflict) + // A genuinely different version is NOT reported as a case-only difference. + assertTrue(!conflict.contains("differ only by case"), conflict) + } + + // A case-only mismatch (6.1.0-Derived vs 6.1.0-derived) is the exact bug that made C4BB fail + // to load: the engine compares model versions case-sensitively. The message should call it out. + @Test + fun recordVersionAndDetectConflict_flagsCaseOnlyDifferenceExplicitly() { + val provider = ContentServiceModelInfoProvider(root, nullContentService()) + assertNull( + provider.recordVersionAndDetectConflict("USCore", "6.1.0-derived", "requested"), + ) + val conflict = + provider.recordVersionAndDetectConflict("USCore", "6.1.0-Derived", "required by C4BB") + assertNotNull(conflict) + assertTrue(conflict!!.contains("differ only by case"), conflict) + assertTrue(conflict.contains("6.1.0-derived (requested)"), conflict) + assertTrue(conflict.contains("6.1.0-Derived (required by C4BB)"), conflict) } @Test fun recordVersionAndDetectConflict_noConflictForSameVersionOrNull() { - ContentServiceModelInfoProvider.clearObservedVersions() - val root = "file:///proj/input/cql" - assertNull(ContentServiceModelInfoProvider.recordVersionAndDetectConflict(root, "SameVer", "1.0.0", "requested")) - assertNull(ContentServiceModelInfoProvider.recordVersionAndDetectConflict(root, "SameVer", "1.0.0", "required by X")) - assertNull(ContentServiceModelInfoProvider.recordVersionAndDetectConflict(root, "NullVer", null, "requested")) + val provider = ContentServiceModelInfoProvider(root, nullContentService()) + assertNull(provider.recordVersionAndDetectConflict("SameVer", "1.0.0", "requested")) + assertNull(provider.recordVersionAndDetectConflict("SameVer", "1.0.0", "required by X")) + assertNull(provider.recordVersionAndDetectConflict("NullVer", null, "requested")) } + // Version tracking is instance state: a fresh provider (one per compilation) starts empty and + // never inherits versions observed by a previous instance/run. @Test - fun recordVersionAndDetectConflict_differentRootsDoNotConflict() { - ContentServiceModelInfoProvider.clearObservedVersions() - assertNull(ContentServiceModelInfoProvider.recordVersionAndDetectConflict("file:///a/input/cql", "M", "1.0.0", "requested")) - assertNull(ContentServiceModelInfoProvider.recordVersionAndDetectConflict("file:///b/input/cql", "M", "2.0.0", "requested")) + fun recordVersionAndDetectConflict_separateInstancesDoNotShareState() { + val a = ContentServiceModelInfoProvider(root, nullContentService()) + val b = ContentServiceModelInfoProvider(root, nullContentService()) + assertNull(a.recordVersionAndDetectConflict("M", "1.0.0", "requested")) + // b never saw 1.0.0, so recording a different version on b is not a conflict. + assertNull(b.recordVersionAndDetectConflict("M", "2.0.0", "requested")) } } From b2650f988aae8b4810d593227ccef9330db4f850 Mon Sep 17 00:00:00 2001 From: raleigh-g-thompson Date: Thu, 2 Jul 2026 14:32:11 -0600 Subject: [PATCH 4/7] bump version to 4.10.0 --- ls/server/pom.xml | 2 +- pom.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ls/server/pom.xml b/ls/server/pom.xml index a5a3b3f1..edb01c25 100644 --- a/ls/server/pom.xml +++ b/ls/server/pom.xml @@ -12,7 +12,7 @@ org.opencds.cqf.cql.ls cql-ls - 4.10.0-SNAPSHOT + 4.10.0 ../../pom.xml diff --git a/pom.xml b/pom.xml index 944db98a..54f87b3b 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.opencds.cqf.cql.ls cql-ls pom - 4.10.0-SNAPSHOT + 4.10.0 CQL Language Server A Language Server for CQL implementing the LSP From 7b99309d3e9ffe987a87ff70886fcadf8462894b Mon Sep 17 00:00:00 2001 From: raleigh-g-thompson Date: Thu, 2 Jul 2026 14:37:44 -0600 Subject: [PATCH 5/7] update changelog --- CHANGELOG.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da979a9e..f41e2163 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,20 @@ + +## v4.10.0 + +Date: 2026-07-02 + +* bump version to 4.10.0 +* fix model info file resolution + + ## v4.9.0 Date: 2026-06-25 -* bump version to cql-language-server to 4.9.0 +* bump version to cql-language-server to 4.9.0 * change clinical-reasoning to version 4.8.0 * fix issue with library names with hyphens From c18295baa24042f14f230ed00359cdc16c776358 Mon Sep 17 00:00:00 2001 From: raleigh-g-thompson Date: Thu, 2 Jul 2026 15:14:55 -0600 Subject: [PATCH 6/7] spotless apply --- .../provider/ContentServiceModelInfoProviderTest.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/provider/ContentServiceModelInfoProviderTest.kt b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/provider/ContentServiceModelInfoProviderTest.kt index c631737c..b59a93c7 100644 --- a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/provider/ContentServiceModelInfoProviderTest.kt +++ b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/provider/ContentServiceModelInfoProviderTest.kt @@ -100,7 +100,7 @@ class ContentServiceModelInfoProviderTest { assertNotNull(capturedUri) assertTrue( capturedUri!!.toString().contains("MyLibrary.cql/c4bb-modelinfo-2.1.1.xml"), - "File URI root causes the .cql file to be treated as a directory: ${capturedUri}", + "File URI root causes the .cql file to be treated as a directory: $capturedUri", ) } @@ -134,7 +134,7 @@ class ContentServiceModelInfoProviderTest { assertNotNull(capturedUri) assertTrue( capturedUri!!.toString().endsWith("c4bb-modelinfo-2.1.1.xml"), - "Directory URI root produces correct flat path: ${capturedUri}", + "Directory URI root produces correct flat path: $capturedUri", ) } @@ -162,9 +162,11 @@ class ContentServiceModelInfoProviderTest { override fun read(uri: URI): InputStream? { // Only serve from the exact correct path — file-root constructs // "MyLibrary.cql/c4bb-modelinfo-2.1.1.xml" which won't match - return if (uri == correctModelinfoUri) + return if (uri == correctModelinfoUri) { validModelinfo.byteInputStream() - else null + } else { + null + } } } From d62b137b285b5f39eb67c99b836483059e460826 Mon Sep 17 00:00:00 2001 From: raleigh-g-thompson Date: Thu, 2 Jul 2026 16:23:42 -0600 Subject: [PATCH 7/7] fix issue with testing on windows add more test coverage --- .../cql/ls/server/command/CqlEvaluatorTest.kt | 73 ++++++++-- .../manager/LibraryResolutionManagerTest.kt | 21 +++ .../ContentServiceModelInfoProviderTest.kt | 126 +++++++++++++++++- 3 files changed, 211 insertions(+), 9 deletions(-) diff --git a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/command/CqlEvaluatorTest.kt b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/command/CqlEvaluatorTest.kt index 9d16b309..362c9ffc 100644 --- a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/command/CqlEvaluatorTest.kt +++ b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/command/CqlEvaluatorTest.kt @@ -523,6 +523,7 @@ class CqlEvaluatorTest { return method.invoke(CqlEvaluator, value) as Quantity } + @Suppress("UnstableApiUsage") private fun createRepository( fhirContext: FhirContext, terminologyRepo: IRepository, @@ -765,9 +766,7 @@ class CqlEvaluatorTest { // ------------------------------------------------------------------------- @Test - fun `createRepository with null modelPath returns ProxyRepository wrapping NoOpRepository`( - @TempDir tempDir: Path, - ) { + fun `createRepository with null modelPath returns ProxyRepository wrapping NoOpRepository`() { val repo = createRepository(r4Context, createNoOpRepo(), null) assertInstanceOf(ProxyRepository::class.java, repo) } @@ -906,7 +905,7 @@ class CqlEvaluatorTest { val isDebugMethod = innerClass.getDeclaredMethod("isDebugLogging") isDebugMethod.isAccessible = true val result = isDebugMethod.invoke(logger) - assertInstanceOf(java.lang.Boolean::class.java, result) + assertInstanceOf(Boolean::class.javaObjectType, result) } // ------------------------------------------------------------------------- @@ -994,6 +993,7 @@ class CqlEvaluatorTest { assertEquals("mg", q.unit) } + @Suppress("UnstableApiUsage") private fun createNoOpRepo(): IRepository = NoOpRepository(r4Context) // ------------------------------------------------------------------------- @@ -1012,8 +1012,8 @@ class CqlEvaluatorTest { override fun locate( root: URI, - libraryIdentifier: VersionedIdentifier, - ): Set = delegate.locate(root, libraryIdentifier) + identifier: VersionedIdentifier, + ): Set = delegate.locate(root, identifier) override fun read(uri: URI): InputStream? { reads.add(uri) @@ -1039,6 +1039,17 @@ class CqlEvaluatorTest { assertSame(e, CqlEvaluator.deepestCause(e)) } + @Test + fun `deepestCause terminates when cause is self`() { + // Override the Kotlin `cause` property to return `this`, exercising the `next === current` guard + val selfCausing = + object : RuntimeException("self-referential") { + override val cause: Throwable get() = this + } + val result = CqlEvaluator.deepestCause(selfCausing) + assertSame(selfCausing, result, "deepestCause should return the self-causing exception without looping") + } + @Test fun `describeCauseChain includes each distinct level deepest last`() { val root = IllegalStateException("version 7.0.0 conflicts with 6.1.0-derived") @@ -1061,6 +1072,43 @@ class CqlEvaluatorTest { assertEquals(1, CqlEvaluator.describeCauseChain(top).split(" -> ").size) } + @Test + fun `describeCauseChain uses no message placeholder when cause has a null message`() { + // lastMessage starts as null, so a root-level null message is deduped away. + // A null message IS rendered when it follows a non-null message (null != "top"). + val nullMessageCause = RuntimeException(null as String?) + val top = RuntimeException("top message", nullMessageCause) + val chain = CqlEvaluator.describeCauseChain(top) + assertTrue(chain.contains("(no message)"), "Expected '(no message)' placeholder for null-message cause, got: $chain") + } + + @Test + fun `evaluate produces Error expression when library cannot be found`() { + val request = + ExecuteCqlRequest( + fhirVersion = "R4", + rootDir = null, + optionsPath = null, + libraries = + listOf( + LibraryRequest( + libraryName = "NonExistentLib", + libraryUri = "file:///nonexistent/path", + libraryVersion = "1", + terminologyUri = null, + model = null, + context = null, + parameters = emptyList(), + ), + ), + ) + val response = CqlEvaluator.evaluate(request, contentService, igContextManager, libraryResolutionManager) + assertEquals(1, response.results.size) + val errorExpr = response.results[0].expressions.find { it.name == "Error" } + assertNotNull(errorExpr, "Expected an Error expression in results") + assertTrue(errorExpr!!.value.isNotEmpty(), "Error message should not be empty") + } + @Test fun `evaluate resolves model info relative to the libraryUri directory not its parent`() { val recording = RecordingContentService() @@ -1093,13 +1141,22 @@ class CqlEvaluatorTest { modelInfoReads.isNotEmpty(), "Expected a model info read attempt for the Custom model, got reads: ${recording.reads}", ) + // Use rawPath (path component only) so the assertion is platform-agnostic. + // On Windows, file:///project/... becomes file:////project/... (UNC form) after + // Uris.parseOrNull, so toString() equality against the 3-slash form fails. + // rawPath strips the scheme+authority and is identical on both platforms. assertTrue( - modelInfoReads.any { it.toString() == "$libDir/custom-modelinfo-1.0.0.xml" }, + modelInfoReads.any { + it.rawPath?.endsWith("/project/input/cql/custom-modelinfo-1.0.0.xml") == true + }, "ModelInfo must be read from the libraryUri directory. Attempts: $modelInfoReads", ) // The parent-directory path is the regression signature — it must never be attempted. + // The /cql/ segment in the correct path prevents a false positive here. assertFalse( - modelInfoReads.any { it.toString() == "file:///project/input/custom-modelinfo-1.0.0.xml" }, + modelInfoReads.any { + it.rawPath?.endsWith("/project/input/custom-modelinfo-1.0.0.xml") == true + }, "ModelInfo must not be read from the parent of libraryUri. Attempts: $modelInfoReads", ) } diff --git a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/manager/LibraryResolutionManagerTest.kt b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/manager/LibraryResolutionManagerTest.kt index 8791ed3d..b6f5e217 100644 --- a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/manager/LibraryResolutionManagerTest.kt +++ b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/manager/LibraryResolutionManagerTest.kt @@ -463,6 +463,27 @@ class LibraryResolutionManagerTest { assertTrue(m.igProjects().isEmpty(), "Folder with null readIgContextInfo result should be skipped") } + // ----------------------------------------------------------------------- + // readIgContextInfo — returns null when ig.ini has no [IG] section + // + // The guard added in the patch returns null early (before calling + // IGContext.initializeFromIni) when the file lacks the required [IG] + // section header. This prevents NPE / hang on non-IG ini files. + // ----------------------------------------------------------------------- + + @Test + fun readIgContextInfo_returnsNull_whenIgIniHasNoIgSection( + @TempDir tempDir: File, + ) { + val igIniFile = File(tempDir, "ig.ini").also { it.writeText("# not an IG ini\nfoo=bar\n") } + // Use a concrete subclass to access the protected method without overriding it + val m = + object : LibraryResolutionManager(emptyList()) { + fun testRead(f: File) = readIgContextInfo(f) + } + assertNull(m.testRead(igIniFile), "Should return null when ig.ini has no [IG] section") + } + // ----------------------------------------------------------------------- // buildNamespaceIndex — two workspace folders indexed independently // ----------------------------------------------------------------------- diff --git a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/provider/ContentServiceModelInfoProviderTest.kt b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/provider/ContentServiceModelInfoProviderTest.kt index b59a93c7..952ed462 100644 --- a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/provider/ContentServiceModelInfoProviderTest.kt +++ b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/provider/ContentServiceModelInfoProviderTest.kt @@ -63,7 +63,7 @@ class ContentServiceModelInfoProviderTest { identifier: VersionedIdentifier, ): Set = emptySet() - override fun read(uri: URI): InputStream? = "not valid xml {{{{".byteInputStream() + override fun read(uri: URI): InputStream = "not valid xml {{{{".byteInputStream() }, ) assertThrows { provider.load(ModelIdentifier(id = "Bad")) } @@ -207,6 +207,18 @@ class ContentServiceModelInfoProviderTest { assertEquals("[]", ContentServiceModelInfoProvider.formatRequiredModels(modelInfo)) } + @Test + fun formatRequiredModels_rendersNameOnly_whenRequiredModelInfoVersionIsAbsent() { + val modelInfo = + parseModelInfoXml( + """""", + ) + + val formatted = ContentServiceModelInfoProvider.formatRequiredModels(modelInfo) + // System has no version → no space suffix; FHIR has a version → space + version + assertEquals("[System, FHIR 4.0.1]", formatted) + } + // ----------------------------------------------------------------------- // recordVersionAndDetectConflict — surfaces an actual model version conflict // (same model at two versions on one provider instance), e.g. content loads @@ -254,6 +266,118 @@ class ContentServiceModelInfoProviderTest { assertNull(provider.recordVersionAndDetectConflict("NullVer", null, "requested")) } + // ----------------------------------------------------------------------- + // load() — version conflict warning path + // When load() is called twice for the same model at different versions, + // recordVersionAndDetectConflict returns a non-null description and the + // conflict is logged. This exercises the `.let { log.warn(...) }` branch + // on lines 89–91 of ContentServiceModelInfoProvider. + // ----------------------------------------------------------------------- + + @Test + fun load_triggersConflictWarning_whenSameModelLoadedAtDifferentVersions() { + val provider = ContentServiceModelInfoProvider(root, nullContentService()) + // First load: no conflict yet (only one version observed) + assertNull(provider.load(ModelIdentifier(id = "FHIR", version = "4.0.1"))) + // Second load at a different version: conflict is detected and logged internally. + // load() still returns null (no content), but the conflict log.warn path is exercised. + assertNull(provider.load(ModelIdentifier(id = "FHIR", version = "3.0.0"))) + // Both versions are now tracked; a third distinct version is also a conflict. + val conflict = provider.recordVersionAndDetectConflict("FHIR", "2.0.0", "test") + assertNotNull(conflict, "Three distinct FHIR versions should still produce a conflict description") + } + + // ----------------------------------------------------------------------- + // load() — requiredModelInfo loop + // When load() successfully parses a ModelInfo that declares dependencies, + // it iterates over requiredModelInfo and calls recordVersionAndDetectConflict + // for each dependency. This covers lines 114–126. + // ----------------------------------------------------------------------- + + @Test + fun load_iteratesRequiredModelInfoDependencies_whenModelInfoHasDependencies() { + val c4bbWithDeps = + """ + + + + + + """.trimIndent() + + val servingService = + object : ContentService { + override fun locate( + root: URI, + identifier: VersionedIdentifier, + ): Set = emptySet() + + override fun read(uri: URI): InputStream? = + if (uri.toString().contains("c4bb-modelinfo")) { + c4bbWithDeps.byteInputStream() + } else { + null + } + } + + val cqlDir = URI.create("file:///workspace/input/cql/") + val provider = ContentServiceModelInfoProvider(cqlDir, servingService) + val result = provider.load(ModelIdentifier(id = "C4BB", version = "2.1.1")) + + // Model loaded successfully and dependencies were processed + assertNotNull(result) + // FHIR and USCore versions are now recorded — a second load with a different + // FHIR version should be detected as a conflict. + val conflict = provider.recordVersionAndDetectConflict("FHIR", "3.0.0", "external") + assertNotNull(conflict, "FHIR 3.0.0 should conflict with the 4.0.1 seen in requiredModelInfo") + } + + @Test + fun load_logsConflictForRequiredDependency_whenVersionConflictsWithPreviousObservation() { + val c4bbRequiringOldFhir = + """ + + + + + """.trimIndent() + + val servingService = + object : ContentService { + override fun locate( + root: URI, + identifier: VersionedIdentifier, + ): Set = emptySet() + + override fun read(uri: URI): InputStream? = + if (uri.toString().contains("c4bb-modelinfo")) { + c4bbRequiringOldFhir.byteInputStream() + } else { + null + } + } + + val cqlDir = URI.create("file:///workspace/input/cql/") + val provider = ContentServiceModelInfoProvider(cqlDir, servingService) + + // First, record FHIR 4.0.1 as observed (simulates it having been requested earlier) + provider.recordVersionAndDetectConflict("FHIR", "4.0.1", "requested") + + // Now load C4BB, whose requiredModelInfo declares FHIR 3.0.0 — a conflict with 4.0.1 + val result = provider.load(ModelIdentifier(id = "C4BB", version = "2.1.1")) + + // C4BB itself loaded successfully + assertNotNull(result) + // The provider's state now reflects both FHIR 4.0.1 and 3.0.0 — verify the conflict + provider.recordVersionAndDetectConflict("FHIR", "3.0.0", "required by C4BB") + // Recording the same version again doesn't add a new conflict (idempotent), + // but the map still has two entries from the prior calls + assertNotNull( + provider.recordVersionAndDetectConflict("FHIR", "5.0.0", "other"), + "A third FHIR version should still be detected as a conflict", + ) + } + // Version tracking is instance state: a fresh provider (one per compilation) starts empty and // never inherits versions observed by a previous instance/run. @Test