From 9de0ccdd4b25a46dcf0d17fc48ea11cc45717618 Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Fri, 13 Mar 2026 08:07:57 +1100 Subject: [PATCH 1/6] support for general version comparison --- library/version-utilities.js | 85 ++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/library/version-utilities.js b/library/version-utilities.js index f51c5093..879b0215 100644 --- a/library/version-utilities.js +++ b/library/version-utilities.js @@ -1051,6 +1051,91 @@ class VersionUtilities { return url; } } + + + static isAnInteger(version) { + return /^\d+$/.test(version); + } + + static appearsToBeDate(version) { + if (!version || typeof version !== 'string') return false; + // Strip optional time portion (T...) before checking + const datePart = version.split('T')[0]; + return /^\d{4}-?\d{2}(-?\d{2})?$/.test(datePart); + + } + + static guessVersionAlgorithmFromVersion(version) { + if (VersionUtilities.isSemVerWithWildcards(version)) { + return 'semver'; + } + if (this.appearsToBeDate(version)) { + return 'date'; + } + if (this.isAnInteger(version)) { + return 'integer'; + } + return 'alpha'; + } + + static dateIsMoreRecent(date, date2) { + return VersionUtilities.normaliseDateString(date) > VersionUtilities.normaliseDateString(date2); + } + + static normaliseDateString(date) { + // Strip time portion, then remove dashes so all formats compare uniformly as YYYYMMDD or YYYYMM + return date.split('T')[0].replace(/-/g, ''); + } + + + /** + * guesses the correct format, then compares accordingly + */ + static compareVersionsGeneral(version1, version2) { + if (version1 && version2) { + if (version1 == version2) { + return 0; + } + const fmt1 = VersionUtilities.guessVersionAlgorithmFromVersion(version1); + const fmt2 = VersionUtilities.guessVersionAlgorithmFromVersion(version2); + if (fmt1 != fmt2) { + return version1.localeCompare(version2); + } + switch (fmt1) { + case 'semver': { + let b1 = VersionUtilities.isThisOrLater(version1, version2, VersionPrecision.PATCH); + let b2 = VersionUtilities.isThisOrLater(version2, version1, VersionPrecision.PATCH); + if (b1 && b2) { + return 0; + } else if (b2) { + return 1; + } else { + return -1; + } + } + case 'date': + if (VersionUtilities.dateIsMoreRecent(version1, version2)) { + return 1; + } else if (VersionUtilities.dateIsMoreRecent(version2, version1)) { + return -1; + } else { + return 0; + } + case 'integer': + return parseInt(version1, 10) - parseInt(version2, 10); + case 'alpha': + return version1.localeCompare(version2); + default: + return version1.localeCompare(version2); + } + } else if (version1) { + return 1; + } else if (version2) { + return -1; + } else { + return 0; + } + } } module.exports = { VersionUtilities, VersionPrecision, SemverParser }; From 07fbc2417b788f890d6cdd23c416906141f04913 Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Fri, 13 Mar 2026 08:08:25 +1100 Subject: [PATCH 2/6] Handle tx-registry crawler failure better after tx.fhir.org crash --- registry/crawler.js | 31 +++++++++++++++++++++++-------- registry/registry.js | 3 +++ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/registry/crawler.js b/registry/crawler.js index d01fe97a..e526b5b3 100644 --- a/registry/crawler.js +++ b/registry/crawler.js @@ -31,6 +31,7 @@ class RegistryCrawler { this.errors = []; this.totalBytes = 0; this.log = console; + this.abortController = null; } useLog(logv) { @@ -48,6 +49,7 @@ class RegistryCrawler { this.addLogEntry('warn', 'Crawl already in progress, skipping...'); return this.currentData; } + this.abortController = new AbortController(); this.isCrawling = true; const startTime = new Date(); @@ -75,6 +77,7 @@ class RegistryCrawler { // Process each registry const registries = masterJson.registries || []; for (const registryConfig of registries) { + if (this.abortController?.signal.aborted) break; const registry = await this.processRegistry(registryConfig); if (registry) { newData.registries.push(registry); @@ -86,7 +89,7 @@ class RegistryCrawler { // Update the current data this.currentData = newData; } catch (error) { - console.log(error); + console.log(error.message); this.addLogEntry('error', 'Exception Scanning:', error); this.currentData.outcome = `Error: ${error.message}`; this.errors.push({ @@ -134,6 +137,7 @@ class RegistryCrawler { // Process each server in the registry const servers = registryJson.servers || []; for (const serverConfig of servers) { + if (this.abortController?.signal.aborted) break; const server = await this.processServer(serverConfig, registry.address); if (server) { registry.servers.push(server); @@ -141,7 +145,7 @@ class RegistryCrawler { } } catch (error) { - console.log(error); + console.log(error.message); registry.error = error.message; this.addLogEntry('error', `Exception processing registry ${registry.name}: ${error.message}`, registry.address); } @@ -177,6 +181,7 @@ class RegistryCrawler { // Process each FHIR version const fhirVersions = serverConfig.fhirVersions || []; for (const versionConfig of fhirVersions) { + if (this.abortController?.signal.aborted) break; const version = await this.processServerVersion(versionConfig, server, serverConfig.exclusions); if (version) { server.versions.push(version); @@ -231,7 +236,7 @@ class RegistryCrawler { this.addLogEntry('info', ` Server ${version.address}: ${version.lastTat} for ${version.codeSystems.length} CodeSystems and ${version.valueSets.length} ValueSets`); } catch (error) { - console.log(error); + console.log(error.message); const elapsed = Date.now() - startTime; this.addLogEntry('error', `Server ${version.address}: Error after ${elapsed}ms: ${error.message}`); version.error = error.message; @@ -276,10 +281,11 @@ class RegistryCrawler { }); } } catch (error) { - console.log(error); + console.log(error.message); this.addLogEntry('error', `Could not fetch terminology capabilities: ${error.message}`); } - + + if (this.abortController?.signal.aborted) return; // Search for value sets await this.fetchValueSets(version, server, exclusions); } @@ -324,7 +330,7 @@ class RegistryCrawler { }); } } catch (error) { - console.log(error); + console.log(error.message); this.addLogEntry('error', `Could not fetch terminology capabilities: ${error.message}`); } @@ -350,6 +356,7 @@ class RegistryCrawler { // Continue fetching while we have a URL while (searchUrl) { + if (this.abortController?.signal.aborted) break; this.log.debug(`Fetching value sets from ${searchUrl}`); const bundle = await this.fetchJson(searchUrl, server.code); @@ -382,7 +389,7 @@ class RegistryCrawler { version.valueSets = Array.from(valueSetUrls).sort(); } catch (error) { - console.log(error); + console.log(error.message); this.addLogEntry('error', `Could not fetch value sets: ${error.message} from ${searchUrl}`); } } @@ -441,6 +448,7 @@ class RegistryCrawler { const response = await axios.get(fetchUrl, { timeout: this.config.timeout, headers: headers, + signal: this.abortController?.signal, validateStatus: (status) => status < 500 // Don't throw on 4xx }); @@ -459,7 +467,7 @@ class RegistryCrawler { return response.data; } catch (error) { - console.log(error); + console.log(error.message); if (error.response) { throw new Error(`HTTP ${error.response.status}: ${error.response.statusText}`); } else if (error.request) { @@ -648,6 +656,13 @@ class RegistryCrawler { } return false; } + + shutdown() { + if (this.abortController) { + this.abortController.abort(); + } + } + } module.exports = RegistryCrawler; \ No newline at end of file diff --git a/registry/registry.js b/registry/registry.js index 86ffa8e1..3e1b62df 100644 --- a/registry/registry.js +++ b/registry/registry.js @@ -926,6 +926,9 @@ class RegistryModule { this.crawlInterval = null; } + if (this.crawler) { + this.crawler.shutdown(); + } // Save current data if (this.crawler && this.currentData) { await this.saveData(); From b7811f83bd0cc94bf153cb8c2b84906e4abc4e34 Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Fri, 13 Mar 2026 08:08:42 +1100 Subject: [PATCH 3/6] fix headers sent multiple times error --- server.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server.js b/server.js index 13d136f5..f0842f5b 100644 --- a/server.js +++ b/server.js @@ -436,9 +436,11 @@ app.get('/', async (req, res) => { const html = htmlServer.renderPage('root', escape(config.hostName) || 'FHIRsmith Server', content, stats); res.setHeader('Content-Type', 'text/html'); res.send(html); + return; } catch (error) { serverLog.error('Error rendering root page:', error); htmlServer.sendErrorResponse(res, 'root', error); + return; } } return serveFhirsmithHome(req, res); @@ -623,9 +625,11 @@ async function serveFhirsmithHome(req, res) { const html = htmlServer.renderPage('root', escape(config.hostName) || 'FHIRsmith Server', content, stats); res.setHeader('Content-Type', 'text/html'); res.send(html); + return; } catch (error) { serverLog.error('Error rendering root page:', error); htmlServer.sendErrorResponse(res, 'root', error); + return; } } else { // Return JSON response for API clients From cff112145c6bafc74c616183963ab704daade308 Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Fri, 13 Mar 2026 08:09:00 +1100 Subject: [PATCH 4/6] update tx test cases --- tests/library/version-utilities.test.js | 96 ++++++++ tests/tx/test-cases.test.js | 285 ++++++++++++++++++++++++ 2 files changed, 381 insertions(+) diff --git a/tests/library/version-utilities.test.js b/tests/library/version-utilities.test.js index 49d900bc..5fac0b25 100644 --- a/tests/library/version-utilities.test.js +++ b/tests/library/version-utilities.test.js @@ -702,4 +702,100 @@ describe('VersionUtilities', () => { function test_compareVersions(ver1, ver2, expected) { expect(VersionUtilities.compareVersions(ver1, ver2)).toBe(expected); } + + describe('compareVersionsGeneral', () => { + + // Basic sanity - same format comparisons + test('should compare semver versions correctly', () => { + expect(VersionUtilities.compareVersionsGeneral("1.0.0", "2.0.0")).toBe(-1); + expect(VersionUtilities.compareVersionsGeneral("2.0.0", "1.0.0")).toBe(1); + expect(VersionUtilities.compareVersionsGeneral("1.0.0", "1.0.0")).toBe(0); + expect(VersionUtilities.compareVersionsGeneral("1.0.0", "1.0.1")).toBe(-1); + expect(VersionUtilities.compareVersionsGeneral("1.1.0", "1.0.9")).toBe(1); + }); + + test('should compare integer versions correctly', () => { + expect(VersionUtilities.compareVersionsGeneral("1", "2")).toBe(-1); + expect(VersionUtilities.compareVersionsGeneral("2", "1")).toBe(1); + expect(VersionUtilities.compareVersionsGeneral("5", "5")).toBe(0); + expect(VersionUtilities.compareVersionsGeneral("9", "10")).toBeLessThan(0); + expect(VersionUtilities.compareVersionsGeneral("10", "9")).toBeGreaterThan(0); + }); + + test('should compare date versions correctly', () => { + expect(VersionUtilities.compareVersionsGeneral("2024-01-01", "2024-06-15")).toBe(-1); + expect(VersionUtilities.compareVersionsGeneral("2024-06-15", "2024-01-01")).toBe(1); + expect(VersionUtilities.compareVersionsGeneral("2024-01-01", "2024-01-01")).toBe(0); + }); + + test('should compare alpha versions correctly', () => { + expect(VersionUtilities.compareVersionsGeneral("abc", "def")).toBeLessThan(0); + expect(VersionUtilities.compareVersionsGeneral("def", "abc")).toBeGreaterThan(0); + expect(VersionUtilities.compareVersionsGeneral("abc", "abc")).toBe(0); + }); + + // Bug: date normalisation can make different strings equal, but code returns -1 + test('should return 0 for equivalent date formats', () => { + expect(VersionUtilities.compareVersionsGeneral("2024-01", "202401")).toBe(0); + expect(VersionUtilities.compareVersionsGeneral("2024-01-15", "20240115")).toBe(0); + }); + + // Bug: null/undefined asymmetry — version present vs absent + // A real version should arguably sort AFTER null (i.e., null is "less") + test('should handle null/undefined versions consistently', () => { + const v1null = VersionUtilities.compareVersionsGeneral(null, "1.0.0"); + const v2null = VersionUtilities.compareVersionsGeneral("1.0.0", null); + // At minimum these should be opposite signs + expect(Math.sign(v1null)).toBe(-Math.sign(v2null)); + expect(VersionUtilities.compareVersionsGeneral(null, null)).toBe(0); + expect(VersionUtilities.compareVersionsGeneral(undefined, undefined)).toBe(0); + }); + + // Bug: format guessed from version1 only — mismatched formats + test('should handle mismatched version formats', () => { + // semver vs integer + const r1 = VersionUtilities.compareVersionsGeneral("1.0.0", "2"); + const r2 = VersionUtilities.compareVersionsGeneral("2", "1.0.0"); + expect(Math.sign(r1)).toBe(-Math.sign(r2)); + + // semver vs date + const r3 = VersionUtilities.compareVersionsGeneral("1.0.0", "2024-01-01"); + const r4 = VersionUtilities.compareVersionsGeneral("2024-01-01", "1.0.0"); + expect(Math.sign(r3)).toBe(-Math.sign(r4)); + + // date vs integer + const r5 = VersionUtilities.compareVersionsGeneral("2024-01-01", "5"); + const r6 = VersionUtilities.compareVersionsGeneral("5", "2024-01-01"); + expect(Math.sign(r5)).toBe(-Math.sign(r6)); + }); + + // Bug: semver equal-after-normalization but not string-equal + test('should handle semver versions that differ only in part count', () => { + // "1.0" and "1.0.0" are not string-equal, so the == guard won't catch them + const r1 = VersionUtilities.compareVersionsGeneral("1.0", "1.0.0"); + const r2 = VersionUtilities.compareVersionsGeneral("1.0.0", "1.0"); + // Should be 0 or at least antisymmetric + expect(Math.sign(r1)).toBe(-Math.sign(r2)); + }); + + // Symmetry: compare(a,b) should always be -compare(b,a) + test('should be antisymmetric', () => { + const pairs = [ + ["1.0.0", "2.0.0"], + ["2024-01-01", "2025-06-15"], + ["3", "7"], + ["alpha", "beta"], + ["1.0.0-alpha", "1.0.0"], + ]; + for (const [a, b] of pairs) { + const ab = VersionUtilities.compareVersionsGeneral(a, b); + const ba = VersionUtilities.compareVersionsGeneral(b, a); + if ((ab == 0)) { + expect(ba).toBe(0); + } else { + expect(Math.sign(ab)).toBe(-Math.sign(ba)); + } + } + }); + }); }); \ No newline at end of file diff --git a/tests/tx/test-cases.test.js b/tests/tx/test-cases.test.js index 5d4f6b56..7415fba2 100644 --- a/tests/tx/test-cases.test.js +++ b/tests/tx/test-cases.test.js @@ -2980,6 +2980,243 @@ describe('version', () => { }); +describe('overload', () => { + // A set of tests that test out handling of value sets that cross versions of the same code system + + it('expand-allR5', async () => { + await runTest({"suite":"overload","test":"expand-all"}, "5.0"); + }); + + it('expand-allR4', async () => { + await runTest({"suite":"overload","test":"expand-all"}, "4.0"); + }); + + it('expand-all-versionedR5', async () => { + await runTest({"suite":"overload","test":"expand-all-versioned"}, "5.0"); + }); + + it('expand-all-versionedR4', async () => { + await runTest({"suite":"overload","test":"expand-all-versioned"}, "4.0"); + }); + + it('expand-all-mergedR5', async () => { + await runTest({"suite":"overload","test":"expand-all-merged"}, "5.0"); + }); + + it('expand-all-mergedR4', async () => { + await runTest({"suite":"overload","test":"expand-all-merged"}, "4.0"); + }); + + it('expand-enum-goodR5', async () => { + await runTest({"suite":"overload","test":"expand-enum-good"}, "5.0"); + }); + + it('expand-enum-goodR4', async () => { + await runTest({"suite":"overload","test":"expand-enum-good"}, "4.0"); + }); + + it('expand-enum-badR5', async () => { + await runTest({"suite":"overload","test":"expand-enum-bad"}, "5.0"); + }); + + it('expand-enum-badR4', async () => { + await runTest({"suite":"overload","test":"expand-enum-bad"}, "4.0"); + }); + + it('expand-excludeR5', async () => { + await runTest({"suite":"overload","test":"expand-exclude"}, "5.0"); + }); + + it('expand-excludeR4', async () => { + await runTest({"suite":"overload","test":"expand-exclude"}, "4.0"); + }); + + it('expand-exclude-versionedR5', async () => { + await runTest({"suite":"overload","test":"expand-exclude-versioned"}, "5.0"); + }); + + it('expand-exclude-versionedR4', async () => { + await runTest({"suite":"overload","test":"expand-exclude-versioned"}, "4.0"); + }); + + it('expand-exclude-mergedR5', async () => { + await runTest({"suite":"overload","test":"expand-exclude-merged"}, "5.0"); + }); + + it('expand-exclude-mergedR4', async () => { + await runTest({"suite":"overload","test":"expand-exclude-merged"}, "4.0"); + }); + + it('validate-all-goodR5', async () => { + await runTest({"suite":"overload","test":"validate-all-good"}, "5.0"); + }); + + it('validate-all-goodR4', async () => { + await runTest({"suite":"overload","test":"validate-all-good"}, "4.0"); + }); + + it('validate-all-good2R5', async () => { + await runTest({"suite":"overload","test":"validate-all-good2"}, "5.0"); + }); + + it('validate-all-good2R4', async () => { + await runTest({"suite":"overload","test":"validate-all-good2"}, "4.0"); + }); + + it('validate-all-good3R5', async () => { + await runTest({"suite":"overload","test":"validate-all-good3"}, "5.0"); + }); + + it('validate-all-good3R4', async () => { + await runTest({"suite":"overload","test":"validate-all-good3"}, "4.0"); + }); + + it('validate-all-good4R5', async () => { + await runTest({"suite":"overload","test":"validate-all-good4"}, "5.0"); + }); + + it('validate-all-good4R4', async () => { + await runTest({"suite":"overload","test":"validate-all-good4"}, "4.0"); + }); + + it('validate-all-bad2R5', async () => { + await runTest({"suite":"overload","test":"validate-all-bad2"}, "5.0"); + }); + + it('validate-all-bad2R4', async () => { + await runTest({"suite":"overload","test":"validate-all-bad2"}, "4.0"); + }); + + it('validate-all-bad2vR5', async () => { + await runTest({"suite":"overload","test":"validate-all-bad2v"}, "5.0"); + }); + + it('validate-all-bad2vR4', async () => { + await runTest({"suite":"overload","test":"validate-all-bad2v"}, "4.0"); + }); + + it('expand-all-sysverR5', async () => { + await runTest({"suite":"overload","test":"expand-all-sysver"}, "5.0"); + }); + + it('expand-all-sysverR4', async () => { + await runTest({"suite":"overload","test":"expand-all-sysver"}, "4.0"); + }); + + it('expand-exclude-enumR5', async () => { + await runTest({"suite":"overload","test":"expand-exclude-enum"}, "5.0"); + }); + + it('expand-exclude-enumR4', async () => { + await runTest({"suite":"overload","test":"expand-exclude-enum"}, "4.0"); + }); + + it('expand-mixedR5', async () => { + await runTest({"suite":"overload","test":"expand-mixed"}, "5.0"); + }); + + it('expand-mixedR4', async () => { + await runTest({"suite":"overload","test":"expand-mixed"}, "4.0"); + }); + + it('validate-bad-enum-code1R5', async () => { + await runTest({"suite":"overload","test":"validate-bad-enum-code1"}, "5.0"); + }); + + it('validate-bad-enum-code1R4', async () => { + await runTest({"suite":"overload","test":"validate-bad-enum-code1"}, "4.0"); + }); + + it('validate-bad-exclude-code1R5', async () => { + await runTest({"suite":"overload","test":"validate-bad-exclude-code1"}, "5.0"); + }); + + it('validate-bad-exclude-code1R4', async () => { + await runTest({"suite":"overload","test":"validate-bad-exclude-code1"}, "4.0"); + }); + + it('validate-bad-unknownR5', async () => { + await runTest({"suite":"overload","test":"validate-bad-unknown"}, "5.0"); + }); + + it('validate-bad-unknownR4', async () => { + await runTest({"suite":"overload","test":"validate-bad-unknown"}, "4.0"); + }); + + it('validate-v1code2-wrongdisplayR5', async () => { + await runTest({"suite":"overload","test":"validate-v1code2-wrongdisplay"}, "5.0"); + }); + + it('validate-v1code2-wrongdisplayR4', async () => { + await runTest({"suite":"overload","test":"validate-v1code2-wrongdisplay"}, "4.0"); + }); + + it('validate-bad-v1code4R5', async () => { + await runTest({"suite":"overload","test":"validate-bad-v1code4"}, "5.0"); + }); + + it('validate-bad-v1code4R4', async () => { + await runTest({"suite":"overload","test":"validate-bad-v1code4"}, "4.0"); + }); + + it('validate-bad-v2code3R5', async () => { + await runTest({"suite":"overload","test":"validate-bad-v2code3"}, "5.0"); + }); + + it('validate-bad-v2code3R4', async () => { + await runTest({"suite":"overload","test":"validate-bad-v2code3"}, "4.0"); + }); + + it('validate-good-code2-v1displayR5', async () => { + await runTest({"suite":"overload","test":"validate-good-code2-v1display"}, "5.0"); + }); + + it('validate-good-code2-v1displayR4', async () => { + await runTest({"suite":"overload","test":"validate-good-code2-v1display"}, "4.0"); + }); + + it('validate-good-enum-code3R5', async () => { + await runTest({"suite":"overload","test":"validate-good-enum-code3"}, "5.0"); + }); + + it('validate-good-enum-code3R4', async () => { + await runTest({"suite":"overload","test":"validate-good-enum-code3"}, "4.0"); + }); + + it('validate-good-exclude-code4R5', async () => { + await runTest({"suite":"overload","test":"validate-good-exclude-code4"}, "5.0"); + }); + + it('validate-good-exclude-code4R4', async () => { + await runTest({"suite":"overload","test":"validate-good-exclude-code4"}, "4.0"); + }); + + it('validate-good-v1code1R5', async () => { + await runTest({"suite":"overload","test":"validate-good-v1code1"}, "5.0"); + }); + + it('validate-good-v1code1R4', async () => { + await runTest({"suite":"overload","test":"validate-good-v1code1"}, "4.0"); + }); + + it('validate-good-v1code2-displayR5', async () => { + await runTest({"suite":"overload","test":"validate-good-v1code2-display"}, "5.0"); + }); + + it('validate-good-v1code2-displayR4', async () => { + await runTest({"suite":"overload","test":"validate-good-v1code2-display"}, "4.0"); + }); + + it('validate-good2aR5', async () => { + await runTest({"suite":"overload","test":"validate-good2a"}, "5.0"); + }); + + it('validate-good2aR4', async () => { + await runTest({"suite":"overload","test":"validate-good2a"}, "4.0"); + }); + +}); + describe('fragment', () => { // Testing handling a code system fragment @@ -3889,6 +4126,22 @@ describe('exclude', () => { await runTest({"suite":"exclude","test":"exclude-all"}, "4.0"); }); + it('exclude-comboR5', async () => { + await runTest({"suite":"exclude","test":"exclude-combo"}, "5.0"); + }); + + it('exclude-comboR4', async () => { + await runTest({"suite":"exclude","test":"exclude-combo"}, "4.0"); + }); + + it('include-comboR5', async () => { + await runTest({"suite":"exclude","test":"include-combo"}, "5.0"); + }); + + it('include-comboR4', async () => { + await runTest({"suite":"exclude","test":"include-combo"}, "4.0"); + }); + }); describe('search', () => { @@ -4597,6 +4850,38 @@ describe('snomed', () => { await runTest({"suite":"snomed","test":"expand-pc-filter"}, "4.0"); }); + it('validate-code-implied-1R5', async () => { + await runTest({"suite":"snomed","test":"validate-code-implied-1"}, "5.0"); + }); + + it('validate-code-implied-1R4', async () => { + await runTest({"suite":"snomed","test":"validate-code-implied-1"}, "4.0"); + }); + + it('validate-code-implied-1bR5', async () => { + await runTest({"suite":"snomed","test":"validate-code-implied-1b"}, "5.0"); + }); + + it('validate-code-implied-1bR4', async () => { + await runTest({"suite":"snomed","test":"validate-code-implied-1b"}, "4.0"); + }); + + it('validate-code-implied-2R5', async () => { + await runTest({"suite":"snomed","test":"validate-code-implied-2"}, "5.0"); + }); + + it('validate-code-implied-2R4', async () => { + await runTest({"suite":"snomed","test":"validate-code-implied-2"}, "4.0"); + }); + + it('validate-code-implied-2bR5', async () => { + await runTest({"suite":"snomed","test":"validate-code-implied-2b"}, "5.0"); + }); + + it('validate-code-implied-2bR4', async () => { + await runTest({"suite":"snomed","test":"validate-code-implied-2b"}, "4.0"); + }); + }); describe('batch', () => { From 849692bab528501cafe875b9a61142a475d6b0c2 Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Fri, 13 Mar 2026 08:09:49 +1100 Subject: [PATCH 5/6] fix many bugs in expansion and validation for value sets that include two different versions of the same code system --- tx/params.js | 27 +++++- tx/tests/test-cases-version.js | 2 +- tx/workers/expand.js | 68 ++++++++++++-- tx/workers/validate.js | 157 ++++++++++++++++++++++++++------- 4 files changed, 210 insertions(+), 44 deletions(-) diff --git a/tx/params.js b/tx/params.js index c37524df..dbb1f5a8 100644 --- a/tx/params.js +++ b/tx/params.js @@ -66,6 +66,7 @@ class TxParameters { this.FDisplayWarning = false; this.FMembershipOnly = false; this.FDiagnostics = false; + this.FVersionsMatch = false; this.hasActiveOnly = false; this.hasExcludeNested = false; @@ -77,6 +78,7 @@ class TxParameters { this.hasDefaultToLatestVersion = false; this.hasDisplayWarning = false; this.hasMembershipOnly = false; + this.hasVersionsMatch = false; } readParams(params) { @@ -199,6 +201,10 @@ class TxParameters { if (getValuePrimitive(p) == true) this.membershipOnly = true; break; } + case 'versionsMatch' : { + if (getValuePrimitive(p) == true) this.FVersionsMatch = true; + break; + } case 'profile' : { let value = p.resource; if (value && (value.resourceType === 'Parameters' || value.resourceType === 'ExpansionProfile')) { @@ -387,6 +393,15 @@ class TxParameters { this.hasMembershipOnly = true; } + get versionsMatch() { + return this.FVersionsMatch; + } + + set versionsMatch(value) { + this.FVersionsMatch = value; + this.hasVersionsMatch = true; + } +e get versionRules() { return this.FVersionRules; } @@ -412,6 +427,10 @@ class TxParameters { if (name === 'designation') { this.designations.push(getValuePrimitive(value)); } + + if (name === 'versionsMatch') { + this.versionsMatch = getValuePrimitive(value) === 'true'; + } } } @@ -502,6 +521,7 @@ class TxParameters { b('include-designations', this.FIncludeDesignations); b('include-definition', this.FIncludeDefinition); b('membership-only', this.FMembershipOnly); + b('versions-match', this.FVersionsMatch); b('default-to-latest', this.FDefaultToLatestVersion); b('display-warning', this.FDisplayWarning); @@ -526,11 +546,11 @@ class TxParameters { }; let s = '|'+this.count+'|'+this.limit+'|'+this.offset+ - this.FUid + '|' + b(this.FMembershipOnly) + '|' + this.FProperties.join(',') + '|' + + this.FUid + '|' + b(this.FMembershipOnly) + '|' + b(this.FVersionsMatch)+'|' + this.FProperties.join(',') + '|' + b(this.FActiveOnly) + b(this.FDisplayWarning) + b(this.FExcludeNested) + b(this.FGenerateNarrative) + b(this.FExcludeNotForUI) + b(this.FExcludePostCoordinated) + b(this.FIncludeDesignations) + b(this.FIncludeDefinition) + b(this.hasActiveOnly) + b(this.hasExcludeNested) + b(this.hasGenerateNarrative) + b(this.hasExcludeNotForUI) + b(this.hasExcludePostCoordinated) + b(this.hasIncludeDesignations) + this.sort+'|'+ - b(this.hasIncludeDefinition) + b(this.hasDefaultToLatestVersion) + b(this.hasDisplayWarning) + b(this.hasExcludeNotForUI) + b(this.hasMembershipOnly) + b(this.FDefaultToLatestVersion); + b(this.hasIncludeDefinition) + b(this.hasDefaultToLatestVersion) + b(this.hasDisplayWarning) + b(this.hasExcludeNotForUI) + b(this.hasMembershipOnly) + b(this.hasVersionsMatch) + b(this.FDefaultToLatestVersion); if (this.hasHTTPLanguages) { s = s + this.FHTTPLanguages.asString(true) + '|'; @@ -577,6 +597,7 @@ class TxParameters { this.FIncludeDefinition = other.FIncludeDefinition; this.FUid = other.FUid; this.FMembershipOnly = other.FMembershipOnly; + this.FVersionsMatch = other.FVersionsMatch; this.FDefaultToLatestVersion = other.FDefaultToLatestVersion; this.FDisplayWarning = other.FDisplayWarning; this.FDiagnostics = other.FDiagnostics; @@ -588,7 +609,7 @@ class TxParameters { this.hasIncludeDesignations = other.hasIncludeDesignations; this.hasIncludeDefinition = other.hasIncludeDefinition; this.hasDefaultToLatestVersion = other.hasDefaultToLatestVersion; - this.hasMembershipOnly = other.hasMembershipOnly; + this.hasVersionsMatch = other.hasVersionsMatch; this.hasDisplayWarning = other.hasDisplayWarning; this.sort = other.sort; diff --git a/tx/tests/test-cases-version.js b/tx/tests/test-cases-version.js index a1964e95..c1e95cac 100644 --- a/tx/tests/test-cases-version.js +++ b/tx/tests/test-cases-version.js @@ -3,6 +3,6 @@ // Regenerate with: node generate-tests.js function txTestVersion() { - return '1.9.0'; + return '1.9.1-SNAPSHOT'; } module.exports = { txTestVersion }; diff --git a/tx/workers/expand.js b/tx/workers/expand.js index 14ab2ac7..54ea309f 100644 --- a/tx/workers/expand.js +++ b/tx/workers/expand.js @@ -197,6 +197,7 @@ class ValueSetCounter { class ValueSetExpander { worker; params; + doingVersion = true; excludedSystems = new Set(); excluded = new Set(); hasExclusions = false; @@ -495,7 +496,8 @@ class ValueSetExpander { } } - this.excluded.add(system + '|' + version + '#' + code); + let key = (this.doingVersion && !this.params.versionsMatch ? system + '|' + version : system) + '#' + code + this.excluded.add(key); } async checkCanExpandValueSet(uri, version) { @@ -922,8 +924,20 @@ class ValueSetExpander { if (!cset.concept && !cset.filter) { this.worker.opContext.log('handle system'); if (!cset.valueSet) { - // excluding a whole system - we don't list the codes in this case - this.excludedSystems.add(cset.system + (cset.version ? '|'+cset.version : '')); + if (!this.excludeSpecialCase) { + // excluding a whole system - we don't list the codes in this case + this.excludedSystems.add(cset.system + (this.doingVersion && cset.version ? '|' + cset.version : '')); + } else { + const iter = await cs.iteratorAll(); + if (iter) { + let c = await cs.nextContext(iter); + while (c) { + this.worker.deadCheck('processCodes#3aa'); + this.excludeCode(cs, cs.system(), cs.version(), await cs.code(c), expansion, valueSets, vsSrc.url); + c = await cs.nextContext(iter); + } + } + } } else { if (cs.isNotClosed(filter)) { if (cs.specialEnumeration()) { @@ -1081,6 +1095,7 @@ class ValueSetExpander { async handleCompose(source, filter, expansion, notClosed, vsInfo) { this.worker.opContext.log('compose #1'); + this.doingVersion = false; const ts = new Map(); for (const c of source.jsonObj.compose.include || []) { this.worker.deadCheck('handleCompose#2'); @@ -1097,6 +1112,8 @@ class ValueSetExpander { if (vsInfo.handleByCS) { await this.processCodes("ValueSet.compose", source, source.jsonObj.compose, filter, expansion, this.excludeInactives(source), notClosed, vsInfo); } else { + this.checkForExclusionVersionSpecialCase(source, expansion); + let i = 0; for (const c of source.jsonObj.compose.exclude || []) { this.worker.deadCheck('handleCompose#4'); @@ -1104,7 +1121,14 @@ class ValueSetExpander { } i = 0; - for (const c of source.jsonObj.compose.include || []) { + const includes = [...(source.jsonObj.compose.include || [])]; + includes.sort((a, b) => { + if (a.system === b.system && a.version && b.version) { + return -VersionUtilities.compareVersionsGeneral(a.version, b.version); + } + return 0; + }); + for (const c of includes) { this.worker.deadCheck('handleCompose#5'); await this.includeCodes(c, "ValueSet.compose.include[" + i + "]", source, source.jsonObj.compose, filter, expansion, this.excludeInactives(source), notClosed); i++; @@ -1197,6 +1221,9 @@ class ValueSetExpander { if (this.params.hasExcludeNested) { this.addParamBool(exp, 'excludeNested', this.params.excludeNested); } + if (this.params.versionsMatch) { + this.addParamBool(exp, 'versionsMatch', this.params.versionsMatch); + } if (this.params.hasActiveOnly) { this.addParamBool(exp, 'activeOnly', this.params.activeOnly); } @@ -1481,14 +1508,16 @@ class ValueSetExpander { if (this.excludedSystems.has(system)) { return true; } - if (this.excludedSystems.has(system+'|'+version)) { + let key = this.doingVersion && !this.params.versionsMatch? system+'|'+version : system; + if (this.excludedSystems.has(key)) { return true; } - return this.excluded.has(system+'|'+version+'#'+code); + key = (this.doingVersion && !this.params.versionsMatch ? system+'|'+version : system)+'#'+code; + return this.excluded.has(key); } keyS(system, version, code) { - return system+"~"+(this.doingVersion ? version+"~" : "")+code; + return system+"~"+(this.doingVersion && !this.params.versionsMatch ? version+"~" : "")+code; } keyC(contains) { @@ -1636,6 +1665,31 @@ class ValueSetExpander { } return null; } + + + // special case: excluding a different version from the include + checkForExclusionVersionSpecialCase(source, exp) { + if (!this.params.hasVersionsMatch) { + + + const includes = source.jsonObj.compose.include || []; + const excludes = source.jsonObj.compose.exclude || []; + + if (includes.length > 0 && excludes.length > 0) { + const system = includes[0].system; + const allSameSystem = includes.every(i => i.system === system && i.version) + && excludes.every(e => e.system === system && e.version); + const noOverlap = !includes.some(i => excludes.some(e => e.version === i.version)); + + if (allSameSystem && noOverlap) { + this.params.versionsMatch = true; + this.excludeSpecialCase = true; + this.addParamBool(exp, 'versionsMatch', this.params.versionsMatch); + + } + } + } + } } class ExpandWorker extends TerminologyWorker { diff --git a/tx/workers/validate.js b/tx/workers/validate.js index 83008569..d1a44db7 100644 --- a/tx/workers/validate.js +++ b/tx/workers/validate.js @@ -23,6 +23,7 @@ const ValueSet = require("../library/valueset"); const {ValueSetExpander} = require("./expand"); const {FhirCodeSystemProvider} = require("../cs/cs-cs"); const {CodeSystem} = require("../library/codesystem"); +const {VersionUtilities} = require("../../library/version-utilities"); const DEV_IGNORE_VALUESET = false; // todo: what's going on with this (ported from pascal) @@ -444,7 +445,7 @@ class ValueSetChecker { return await this.check(issuePath, system, version, code, null, unknownSystems, ver, inactive, normalForm, vstatus, it, op, null, null, contentMode, impliedSystem, ts, msgs, defLang); } - async check(path, system, version, code, displays, unknownSystems, ver, inactive, normalForm, vstatus, cause, op, vcc, params, contentMode, impliedSystem, unkCodes, messages, defLang) { + async check(path, system, version, code, displays, unknownSystems, ver, inactive, normalForm, vstatus, cause, op, vcc, params, contentMode, impliedSystem, unkCodes, messages, defLang, display) { defLang.value = new Language('en'); this.worker.opContext.addNote(this.valueSet, 'Check "' + this.worker.renderer.displayCoded(system, version, code) + '"', this.indentCount); @@ -677,11 +678,28 @@ class ValueSetChecker { if (Extensions.checkNoModifiers(this.valueSet.jsonObj.compose, 'ValueSetChecker.prepare', 'ValueSet.compose')) { result = false; - for (let cc of this.valueSet.jsonObj.compose.include || []) { + let determinedVersion = undefined; + if (!version) { + // if we don't have a fixed version, and we have more than one possible version, we have to pick the version + // now, by looking to see which version we can find the value in, starting from the most revent. + let includes = (this.valueSet.jsonObj.compose.include || []).filter(inc => inc.system == system); + let vset = new Set(includes.map(inc => inc.version).filter(Boolean)); + if (vset.size > 1) { + determinedVersion = await this.pickApplicableVersion(vset, system, code, display); + } + } + const includes = [...(this.valueSet.jsonObj.compose.include || [])]; + includes.sort((a, b) => { + if (a.system === b.system && a.version && b.version) { + return -VersionUtilities.compareVersionsGeneral(a.version, b.version); + } + return 0; + }); + for (let cc of includes) { this.worker.deadCheck('check#2'); if (!cc.system) { result = true; - } else if (cc.system === system || system === '%%null%%') { + } else if ((cc.system === system || system === '%%null%%') && (!determinedVersion || cc.version == determinedVersion) && this.useThisVersion(cc, version)) { let v = await this.determineVersion(path, cc.system, cc.version, version, op, unknownSystems, messages); let cs = await this.worker.findCodeSystem(system, v, this.params, ["complete", "fragment"], op,true, true, false, this.worker.requiredSupplements); if (cs === null) { @@ -720,12 +738,12 @@ class ValueSetChecker { defLang.value = new Language(cs.defLang()); this.worker.opContext.addNote(this.valueSet, 'CodeSystem found: ' + this.worker.renderer.displayCoded(cs) + ' for ' + this.worker.renderer.displayCoded(cc.system, v), this.indentCount); await this.checkCanonicalStatusCS(path, op, cs, this.valueSet); - ver.value = cs.version(); this.worker.checkSupplements(cs, cc, this.worker.requiredSupplements, this.worker.usedSupplements); contentMode.value = cs.contentMode(); let msg = ''; if ((system === '%%null%%' || cs.system() === system) && await this.checkConceptSet(path, 'in', cs, cc, code, displays, this.valueSet, msg, inactive, normalForm, vstatus, op, vcc, messages)) { + ver.value = cs.version(); result = true; } else { result = false; @@ -766,7 +784,6 @@ class ValueSetChecker { } await this.checkCanonicalStatus(path, op, cs, this.valueSet); this.worker.checkSupplements(cs, cc, this.worker.requiredSupplements, this.worker.usedSupplements); - ver.value = cs.version(); contentMode.value = cs.contentMode(); let msg = ''; excluded = (system === '%%null%%' || cs.system() === system) && await this.checkConceptSet(path, 'not in', cs, cc, code, displays, this.valueSet, msg, inactive, normalForm, vstatus, op, vcc); @@ -1037,7 +1054,8 @@ class ValueSetChecker { if (this.worker.opContext.usageTracker) { this.worker.opContext.usageTracker.seeConcept(c.system, c.code); } - const csd = await this.worker.findCodeSystem(c.system, null, this.params, ['complete', 'fragment'], false, true, false, false, this.worker.requiredSupplements); + let vsImpliedVersion = this.findVSVersionForSystem(c.system); + const csd = await this.worker.findCodeSystem(c.system, vsImpliedVersion, this.params, ['complete', 'fragment'], false, true, false, false, this.worker.requiredSupplements); this.worker.seeSourceProvider(csd, c.system); this.worker.deadCheck('check-b#1'); let path; @@ -1055,7 +1073,7 @@ class ValueSetChecker { let ver = { value: '' }; let contentMode = { value: null }; let defLang = { value: null }; - let v = await this.check(path, c.system, c.version, c.code, list, unknownSystems, ver, inactive, normalForm, vstatus, cause, op, vcc, result, contentMode, impliedSystem, ts, mt, defLang); + let v = await this.check(path, c.system, c.version, c.code, list, unknownSystems, ver, inactive, normalForm, vstatus, cause, op, vcc, result, contentMode, impliedSystem, ts, mt, defLang, c.display); if (v === false) { cause.value = 'code-invalid'; } @@ -1509,14 +1527,16 @@ class ValueSetChecker { async checkConceptSet(path, role, cs, cset, code, displays, vs, message, inactive, normalForm, vstatus, op, vcc, messages) { this.worker.opContext.addNote(vs, 'check code ' + role + ' ' + this.worker.renderer.displayValueSetInclude(cset) + ' at ' + path, this.indentCount); - inactive.value = false; + if (role !== 'not in') { + inactive.value = false; + } let result = false; if (!cset.concept && !cset.filter) { let loc = await cs.locate(code); result = false; if (loc.context == null) { this.worker.opContext.addNote(this.valueSet, 'Code "' + code + '" not found in ' + this.worker.renderer.displayCoded(cs)+": "+loc.mesage, this.indentCount); - if (!this.params.membershipOnly) { + if (!this.params.membershipOnly && role !== 'not in') { if (cs.contentMode() !== 'complete') { op.addIssue(new Issue('warning', 'code-invalid', addToPath(path, 'code'), 'UNKNOWN_CODE_IN_FRAGMENT', this.worker.i18n.translate('UNKNOWN_CODE_IN_FRAGMENT', this.params.HTTPLanguages, [code, cs.system(), cs.version()]), 'invalid-code')); result = true; @@ -1548,16 +1568,18 @@ class ValueSetChecker { if (!(this.params.abstractOk || !(await cs.isAbstract(loc.context)))) { this.worker.opContext.addNote(this.valueSet, 'Code "' + code + '" found in ' + this.worker.renderer.displayCoded(cs) + ' but is abstract', this.indentCount); - if (!this.params.membershipOnly) { + if (!this.params.membershipOnly && role !== 'not in') { op.addIssue(new Issue('error', 'business-rule', addToPath(path, 'code'), 'ABSTRACT_CODE_NOT_ALLOWED', this.worker.i18n.translate('ABSTRACT_CODE_NOT_ALLOWED', this.params.HTTPLanguages, [cs.system(), code]), 'code-rule')); } } else if (this.excludeInactives() && await cs.isInactive(loc.context)) { this.worker.opContext.addNote(this.valueSet, 'Code "' + code + '" found in ' + this.worker.renderer.displayCoded(cs) + ' but is inactive', this.indentCount); - let msg = this.worker.i18n.translate('STATUS_CODE_WARNING_CODE', this.params.HTTPLanguages, ['not active', code]); - op.addIssue(new Issue('error', 'business-rule', addToPath(path, 'code'), 'STATUS_CODE_WARNING_CODE', msg, 'code-rule')); + if (role !== 'not in') { + let msg = this.worker.i18n.translate('STATUS_CODE_WARNING_CODE', this.params.HTTPLanguages, ['not active', code]); + op.addIssue(new Issue('error', 'business-rule', addToPath(path, 'code'), 'STATUS_CODE_WARNING_CODE', msg, 'code-rule')); + messages.push(msg); + } result = false; - messages.push(msg); - if (!this.params.membershipOnly) { + if (!this.params.membershipOnly && role !== 'not in') { inactive.value = true; inactive.path = path; if (inactive.value) { @@ -1567,28 +1589,31 @@ class ValueSetChecker { } else if (this.params.activeOnly && await cs.isInactive(loc.context)) { this.worker.opContext.addNote(this.valueSet, 'Code "' + code + '" found in ' + this.worker.renderer.displayCoded(cs) + ' but is inactive', this.indentCount); result = false; - inactive.value = true; - inactive.path = path; - vstatus.value = await cs.getStatus(loc.context); - let msg = this.worker.i18n.translate('STATUS_CODE_WARNING_CODE', this.params.HTTPLanguages, ['not active', code]); - messages.push(msg); - op.addIssue(new Issue('error', 'business-rule', addToPath(path, 'code'), 'STATUS_CODE_WARNING_CODE', msg, 'code-rule')); + if (role !== 'not in') { + inactive.value = true; + inactive.path = path; + vstatus.value = await cs.getStatus(loc.context); + let msg = this.worker.i18n.translate('STATUS_CODE_WARNING_CODE', this.params.HTTPLanguages, ['not active', code]); + messages.push(msg); + op.addIssue(new Issue('error', 'business-rule', addToPath(path, 'code'), 'STATUS_CODE_WARNING_CODE', msg, 'code-rule')); + } } else { result = true; - inactive.value = await cs.isInactive(loc.context); - inactive.path = path; - vstatus.value = await cs.getStatus(loc.context); - - if (vcc !== null) { - if (!vcc.coding) { - vcc.coding = []; + if (role !== 'not in') { + inactive.value = await cs.isInactive(loc.context); + inactive.path = path; + vstatus.value = await cs.getStatus(loc.context); + if (vcc !== null) { + if (!vcc.coding) { + vcc.coding = []; + } + vcc.coding.push({ + system: cs.system(), + version: cs.version(), + code: await cs.code(loc.context), + display: displays.preferredDisplay(this.params.workingLanguages()) + }); } - vcc.coding.push({ - system: cs.system(), - version: cs.version(), - code: await cs.code(loc.context), - display: displays.preferredDisplay(this.params.workingLanguages()) - }); } return result; } @@ -1739,6 +1764,72 @@ class ValueSetChecker { return list.join(","); } + findVSVersionForSystem(system) { + let set = new Set(); + for (let inc of this.valueSet.jsonObj.compose?.include || []) { + if (inc.system == system && inc.version) { + set.add(inc.version); + } + } + let v = null; + for (let t of set) { + if (!v || VersionUtilities.compareVersionsGeneral(t, v) > 0) { + v = t; + } + } + return v; + } + + async pickApplicableVersion(vset, system, code, display) { + let found = []; + for (let v of vset) { + let cs = await this.worker.findCodeSystem(system, v, this.params, ["complete", "fragment"], null, true, true, false, this.worker.requiredSupplements); + if (cs != null) { + let loc = await cs.locate(code); + if (loc.context) { + if (!display || await this.displayIsOk(cs, loc.context, display)) { + found.push(v); + } + } + } + } + // if it was found in none or all of them, we don't do anything + if (found.length == vset.size) { + return undefined; + } + if (found.length > 0) { + let sorted = found.sort((a, b) => -VersionUtilities.compareVersionsGeneral(a, b)); + return sorted[0]; + } else { // well, none of them, we'll go with the latest + let sorted = [...vset].sort((a, b) => -VersionUtilities.compareVersionsGeneral(a, b)); + return sorted[0]; + } + } + + async displayIsOk(cs, context, display) { + if (display == await cs.display(context)) { + return true; + } + const cds = new Designations(this.worker.i18n.languageDefinitions); + await cs.designations(context, cds); + return cds.designations.find(cd => cd.value == display); + } + + hasMatchForVersion(includes, version) { + for (let inc of includes) { + if (inc.version == version) { + return true; + } + } + return false; + } + + useThisVersion(cc, version) { + if (!version || cc.version == version) { + return true; + } + return !this.hasMatchForVersion(this.valueSet.jsonObj.compose.include || [], version); + } } function addToPath(path, name) { From 644775ebb743f4f0e5e516fd170f4bf446634b70 Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Fri, 13 Mar 2026 08:10:20 +1100 Subject: [PATCH 6/6] change how system is treated in code system searches to avoid user confusion --- tx/workers/search.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tx/workers/search.js b/tx/workers/search.js index 6cd8579b..3f9ec2ba 100644 --- a/tx/workers/search.js +++ b/tx/workers/search.js @@ -158,10 +158,6 @@ class SearchWorker extends TerminologyWorker { // Check each search parameter for partial match let isMatch = true; for (const [param, searchValue] of Object.entries(searchParams)) { - // 'system' doesn't do anything for CodeSystem search - if (param === 'system') { - continue; - } // Map content-mode to content property const jsonProp = param === 'content-mode' ? 'content' : param; @@ -178,7 +174,7 @@ class SearchWorker extends TerminologyWorker { isMatch = false; break; } - } else if (param === 'url') { // exact match + } else if (param === 'url' || param === 'system') { // exact match const propValue = json.url; if (propValue !== searchValue) { isMatch = false;