Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion ls/server/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<parent>
<groupId>org.opencds.cqf.cql.ls</groupId>
<artifactId>cql-ls</artifactId>
<version>4.10.0-SNAPSHOT</version>
<version>4.10.0</version>
<relativePath>../../pom.xml</relativePath>
</parent>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 "<file>.cql/<model>-modelinfo.xml".
libraryUri = Uris.getHead(URI.create(args.libraryUri)).toString(),
libraryVersion = null,
terminologyUri = args.terminologyUri,
model = args.testCaseUri?.let { ModelRequest("FHIR", it) },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,10 @@ 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(libraryUri, contentService, npmProcessor),
)
Expand Down Expand Up @@ -451,6 +455,11 @@ 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(libraryUri, contentService),
)
Expand Down Expand Up @@ -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)),
),
)
}
Expand All @@ -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<Throwable>()
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<String>()
val seen = HashSet<Throwable>()
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<TraceFrame>,
seen: MutableSet<String>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ class CqlCompilationManager(
root: URI,
modelManager: ModelManager,
): LibraryManager {
log.info("Registered ContentServiceModelInfoProvider (compile) root={}", root)
modelManager.modelInfoLoader.registerModelInfoProvider(
ContentServiceModelInfoProvider(root, contentService),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>? {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,117 @@ 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 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<String, MutableMap<String, String>>()

/**
* 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

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? {
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(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(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)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -823,4 +824,39 @@ class CqlDebugServerHelperTest {
val result = method.invoke(server, null) as Map<String, String>
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 "<file>.cql/<model>-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"),
)
}
}
Loading
Loading