fhirsmith 0.13.2 → 0.13.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,33 @@ All notable changes to the Health Intersections Node Server will be documented i
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [v0.13.3] - 2026-09-10
9
+
10
+ ### Fixed
11
+
12
+ - Fix SNOMED CT display selection on national editions. `_displayRefsetOrder()` knew a display
13
+ language reference set for only six editions (International, US, UK and the test edition);
14
+ every other edition fell through to "the first description marked Preferred in any language
15
+ reference set", which on a multi-language edition means whichever description happened to be
16
+ stored first. The Belgian edition (11000172109) marks a Preferred synonym in Belgian French
17
+ (21000172104) and Belgian Dutch (31000172101) as well as in English, so a request that named
18
+ no `displayLanguage` got French for one concept, Dutch for the next and English for a third.
19
+ Editions not in the table now default to US then GB English, and the $lookup designation
20
+ order ranks the edition's display reference set ahead of "preferred anywhere", so the choice
21
+ no longer depends on import order. An edition with no English language reference set at all
22
+ still falls back to its own preferred synonym. This changes one tx-ecosystem expectation:
23
+ `bugs/sct-display-2` recorded "Counselling" for 409063005 on the International edition, which
24
+ is the GB English preferred term picked only because it is stored before the US one - the same
25
+ concept already expanded as "Counseling". The expectation is now "Counseling".
26
+ - Fix the language tag on the display designation in $expand. It was hard-coded to `en-US`
27
+ while its value came from the edition display, so on a multi-language edition a French or
28
+ Dutch term was published as English - which put it in an implementation guide's English
29
+ display column and made the real French designation read as a duplicate of it.
30
+
31
+ ### Tx Conformance Statement
32
+
33
+ FHIRsmith passed all 3507 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed+mimetypes+icd-11, tests v1.9.5-SNAPSHOT, runner v6.10.4
34
+
8
35
  ## [v0.13.2] - 2026-09-05
9
36
 
10
37
  ### Added
@@ -94,7 +94,14 @@
94
94
  // (>100MB). Only applies to websites whose publish-setup.json has $.website.github = true.
95
95
  // Omit to leave them in place - the push will then fail. Relative paths resolve under the
96
96
  // publisher data folder
97
- "large-file-archive": "/absolute/path/to/large-file-archive"
97
+ "large-file-archive": "/absolute/path/to/large-file-archive",
98
+ // where to clone the IG registry from. Omit for the default,
99
+ // git@github.com:FHIR/ig-registry.git. Set this to an ssh_config Host alias when the
100
+ // server should push the registry with a repo-scoped deploy key instead of an account
101
+ // key - e.g. "git@github-ig-registry:FHIR/ig-registry.git", with a matching Host block
102
+ // in the service user's ~/.ssh/config carrying IdentityFile and IdentitiesOnly yes.
103
+ // The alias is what lets ssh tell this remote apart from the website remote.
104
+ "ig-registry-url": "git@github.com:FHIR/ig-registry.git"
98
105
  },
99
106
  "npmprojector": {
100
107
  "enabled": true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fhirsmith",
3
- "version": "0.13.2",
3
+ "version": "0.13.3",
4
4
  "txVersion": "1.9.5-SNAPSHOT",
5
5
  "description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem",
6
6
  "main": "server.js",
@@ -46,7 +46,7 @@
46
46
  "express-rate-limit": "^7.4.1",
47
47
  "express-session": "^1.19.0",
48
48
  "fast-xml-parser": "^5.3.4",
49
- "fhir-validator-wrapper": "1.3.0",
49
+ "fhir-validator-wrapper": "1.4.0",
50
50
  "fhirpath": "^4.8.3",
51
51
  "fs-extra": "^11.3.3",
52
52
  "ini": "^6.0.0",
@@ -1117,8 +1117,15 @@ class PublisherModule {
1117
1117
  // Step 1: Clone supporting repositories into the task directory
1118
1118
  const registryDir = path.join(taskDir, 'ig-registry');
1119
1119
 
1120
- await this.runCommand('git', ['clone', 'git@github.com:FHIR/ig-registry.git', registryDir],
1121
- {}, task.id, 'Cloning ig-registry');
1120
+ // The URL is configurable so that the credential arrangement can live in server config
1121
+ // rather than here. A deployment that pushes ig-registry with a repo-scoped deploy key
1122
+ // rather than an account key points this at an ssh_config Host alias carrying that key,
1123
+ // e.g. git@github-ig-registry:FHIR/ig-registry.git - the alias is the only way ssh can
1124
+ // tell this remote apart from the website remote, both being github.com.
1125
+ const igRegistryUrl = this.config['ig-registry-url'] || 'git@github.com:FHIR/ig-registry.git';
1126
+
1127
+ await this.runCommand('git', ['clone', igRegistryUrl, registryDir],
1128
+ {}, task.id, 'Cloning ig-registry from ' + igRegistryUrl);
1122
1129
 
1123
1130
  // Use website-configured history templates path if provided, otherwise clone the default repo
1124
1131
  let historyDir;
@@ -28,6 +28,21 @@ const {debugLog} = require("../operation-context");
28
28
  // shared - it simply recomputes.
29
29
  const SNOMED_FILTER_ANALYSIS = Symbol('snomedFilterAnalysis');
30
30
 
31
+ // Description language index -> language code. The index is assigned at import
32
+ // time by SnomedImporter.mapLanguageCode(); this table is its inverse and must
33
+ // be kept in step with it.
34
+ const SCT_LANGUAGE_CODES = {
35
+ 1: 'en',
36
+ 2: 'fr',
37
+ 3: 'nl',
38
+ 4: 'es',
39
+ 5: 'sv',
40
+ 6: 'da',
41
+ 7: 'de',
42
+ 8: 'it',
43
+ 9: 'cs'
44
+ };
45
+
31
46
  // Context kinds matching Pascal enum
32
47
  const SnomedProviderContextKind = {
33
48
  CODE: 0,
@@ -346,8 +361,20 @@ class SnomedServices {
346
361
  // preferred synonym in more than one dialect at once (the International
347
362
  // edition ships both US English 509007 and GB English 508004, each marking a
348
363
  // different synonym Preferred). The edition default decides which term is
349
- // shown. Editions not listed here fall back to "preferred synonym in any
350
- // language refset" (the historical behaviour), so no edition regresses.
364
+ // shown.
365
+ //
366
+ // Editions not listed here default to English (US then GB) rather than to
367
+ // "preferred synonym in any language refset". A national edition carries a
368
+ // Preferred synonym in each of its own language refsets as well as in English
369
+ // (e.g. the Belgian edition 11000172109 marks a French term Preferred in
370
+ // 21000172104 and a Dutch one in 31000172101), so "any refset" resolved to
371
+ // whichever description happened to be stored first - French for one concept,
372
+ // Dutch for the next, English for a third. A caller who asked for no
373
+ // particular language got an arbitrary mix. English is the only defensible
374
+ // default: it is the one language every edition has, and it is what a request
375
+ // with no displayLanguage is taken to mean everywhere else in the server. An
376
+ // edition with no English at all still falls through to the any-refset step
377
+ // below, so nothing regresses.
351
378
  _displayRefsetOrder() {
352
379
  if (this._dispOrder) return this._dispOrder;
353
380
  const idx = (id) => { const r = this.concepts.findConcept(id); return r.found ? r.index : -1; };
@@ -361,7 +388,7 @@ class SnomedServices {
361
388
  '83821000000107': [GB], // UK Edition
362
389
  '999000021000000109': [GB] // UK Clinical Edition
363
390
  };
364
- const ids = byEdition[String(this.edition)] || [];
391
+ const ids = byEdition[String(this.edition)] || [US, GB];
365
392
  this._dispOrder = ids.map(idx).filter((i) => i >= 0);
366
393
  return this._dispOrder;
367
394
  }
@@ -400,59 +427,80 @@ class SnomedServices {
400
427
  return false;
401
428
  }
402
429
 
403
- // Return a concept's display: the synonym marked Preferred in the US English
404
- // language reference set; failing that the FSN; failing that the first active
405
- // description. Previously this returned the first active description outright,
406
- // which is only the preferred term by accident of import order.
407
- getDisplayName(reference = 0) {
430
+ // Return a concept's display, together with the language it is actually in:
431
+ // the synonym marked Preferred in the edition's display language reference
432
+ // set; failing that a preferred synonym in any language refset; failing that
433
+ // the FSN; failing that the first active description. Previously this
434
+ // returned the first active description outright, which is only the preferred
435
+ // term by accident of import order.
436
+ //
437
+ // The language matters as much as the term: the display is emitted as a
438
+ // designation (DesignationUse.DISPLAY) during $expand, and tagging a French
439
+ // term as en-US puts it in an IG's English display column and makes the real
440
+ // French designation look like a duplicate.
441
+ //
442
+ // Returns {term, lang} - lang is a language code, or null when no description
443
+ // could be read.
444
+ getDisplayNameEx(reference = 0) {
445
+ const none = { term: '', lang: null };
408
446
  try {
409
447
  const concept = this.concepts.getConcept(reference);
410
448
  const descriptionsRef = concept.descriptions;
411
449
 
412
450
  if (descriptionsRef === 0) {
413
- return '';
451
+ return none;
414
452
  }
415
453
 
416
454
  const descriptionIndices = this.refs.getReferences(descriptionsRef);
417
455
  const K = this._displayConstants();
456
+ const pick = (description) => ({
457
+ term: this.strings.getEntry(description.iDesc).trim(),
458
+ lang: SCT_LANGUAGE_CODES[description.lang] || 'en'
459
+ });
418
460
 
419
461
  // 1. Preferred synonym in the edition's default display refset(s), tried
420
462
  // in priority order. This is what disambiguates dialects: on the
421
463
  // International edition a concept may be Preferred in both US and GB
422
- // English, and the edition default (US English) must win.
464
+ // English, and the edition default (US English) must win. It is also
465
+ // what keeps a multi-language national edition from picking a
466
+ // different language for each concept.
423
467
  for (const refsetIdx of this._displayRefsetOrder()) {
424
468
  for (const descIndex of descriptionIndices) {
425
469
  const description = this.descriptions.getDescription(descIndex);
426
470
  if (!description.active || description.kind !== K.synonym) continue;
427
471
  if (this._descAcceptability(description, refsetIdx) === K.preferred) {
428
- return this.strings.getEntry(description.iDesc).trim();
472
+ return pick(description);
429
473
  }
430
474
  }
431
475
  }
432
476
 
433
477
  // 2. Fallback: preferred synonym in ANY language refset; then FSN; then
434
- // the first active description. Used for editions without a mapped
435
- // default refset, and for concepts with no preferred synonym there.
436
- let fsnTerm = '';
437
- let firstActive = '';
478
+ // the first active description. Reached only by editions with no
479
+ // English language refset at all, and by concepts with no preferred
480
+ // synonym in the edition's display refsets.
481
+ let fsn = null;
482
+ let firstActive = null;
438
483
  for (const descIndex of descriptionIndices) {
439
484
  const description = this.descriptions.getDescription(descIndex);
440
485
  if (!description.active) continue;
441
- const term = this.strings.getEntry(description.iDesc).trim();
442
- if (firstActive === '') firstActive = term;
486
+ if (firstActive === null) firstActive = pick(description);
443
487
  if (this._synonymIsPreferred(description)) {
444
- return term; // preferred synonym (any English language refset)
488
+ return pick(description); // preferred synonym (any language refset)
445
489
  }
446
- if (description.kind === K.fsn && fsnTerm === '') {
447
- fsnTerm = term;
490
+ if (description.kind === K.fsn && fsn === null) {
491
+ fsn = pick(description);
448
492
  }
449
493
  }
450
- return fsnTerm || firstActive || '';
494
+ return fsn || firstActive || none;
451
495
  } catch (error) {
452
- return '';
496
+ return none;
453
497
  }
454
498
  }
455
499
 
500
+ getDisplayName(reference = 0) {
501
+ return this.getDisplayNameEx(reference).term;
502
+ }
503
+
456
504
  getConceptDescendants(reference) {
457
505
  try {
458
506
  const allDescRef = this.concepts.getAllDesc(reference);
@@ -1687,8 +1735,16 @@ class SnomedProvider extends BaseCSServices {
1687
1735
  if (!prefSyns.has(langCode)) prefSyns.set(langCode, { langCode, use, term });
1688
1736
  }
1689
1737
  }
1690
- const display = this.sct.getDisplayName(ctxt.getReference());
1691
- if (display) displays.addDesignation(true, 'active', 'en-US', null, display);
1738
+ // Tagged with the language the term is actually in, not a
1739
+ // hard-coded en-US: on a multi-language edition the edition
1740
+ // display can be French or Dutch, and mislabelling it as English
1741
+ // is what puts it in an IG's English display column and makes the
1742
+ // real French designation look like a duplicate of it. English
1743
+ // stays 'en-US' (the term comes from the US English language
1744
+ // reference set), so nothing changes for English editions.
1745
+ const display = this.sct.getDisplayNameEx(ctxt.getReference());
1746
+ const displayLang = (!display.lang || display.lang === 'en') ? 'en-US' : display.lang;
1747
+ if (display.term) displays.addDesignation(true, 'active', displayLang, null, display.term);
1692
1748
  // FSNs before synonyms, matching the designation order the published
1693
1749
  // tx-ecosystem expectations were written against (sct/expand-inactive).
1694
1750
  // Which of the two is chosen as the display is settled by
@@ -1697,14 +1753,31 @@ class SnomedProvider extends BaseCSServices {
1697
1753
  for (const d of fsns.values()) displays.addDesignation(false, 'active', d.langCode, d.use, d.term);
1698
1754
  for (const d of prefSyns.values()) displays.addDesignation(false, 'active', d.langCode, d.use, d.term);
1699
1755
  } else {
1700
- // $lookup: emit every description (preferred synonym first so the
1701
- // display resolves correctly; order is not otherwise significant).
1756
+ // $lookup: emit every description. Order matters only for the
1757
+ // display: a request with no displayLanguage takes the first
1758
+ // designation Designations._isPreferred() accepts, which is any
1759
+ // SNOMED synonym. Ranking by the edition's display language
1760
+ // reference set first (rather than by "preferred in any refset")
1761
+ // keeps that pick in one language - otherwise a national edition
1762
+ // answers $lookup in French for one concept and Dutch for the
1763
+ // next, depending on description storage order.
1764
+ const K2 = this.sct._displayConstants();
1765
+ const displayRefsets = this.sct._displayRefsetOrder();
1766
+ // Buckets: 0 = preferred in the edition's display refset, 1 =
1767
+ // preferred in some other language refset, 2 = everything else
1768
+ // (FSNs and acceptable synonyms, left in their original order -
1769
+ // Array.sort is stable, so nothing else about the output moves).
1770
+ const rank = (description) => {
1771
+ if (description.kind !== K2.synonym) return 2;
1772
+ for (const refsetIdx of displayRefsets) {
1773
+ if (this.sct._descAcceptability(description, refsetIdx) === K2.preferred) return 0;
1774
+ }
1775
+ return this.sct._synonymIsPreferred(description) ? 1 : 2;
1776
+ };
1702
1777
  const orderedIndices = descriptionIndices.slice().sort((a, b) => {
1703
1778
  const da = this.sct.descriptions.getDescription(a);
1704
1779
  const db = this.sct.descriptions.getDescription(b);
1705
- const pa = this.sct._synonymIsPreferred(da) ? 0 : 1;
1706
- const pb = this.sct._synonymIsPreferred(db) ? 0 : 1;
1707
- return pa - pb;
1780
+ return rank(da) - rank(db);
1708
1781
  });
1709
1782
  for (const descIndex of orderedIndices) {
1710
1783
  const description = this.sct.descriptions.getDescription(descIndex);
@@ -1733,18 +1806,7 @@ class SnomedProvider extends BaseCSServices {
1733
1806
  }
1734
1807
 
1735
1808
  getLanguageCode(langIndex) {
1736
- const languageMap = {
1737
- 1: 'en',
1738
- 2: 'fr',
1739
- 3: 'nl',
1740
- 4: 'es',
1741
- 5: 'sv',
1742
- 6: 'da',
1743
- 7: 'de',
1744
- 8: 'it',
1745
- 9: 'cs'
1746
- };
1747
- return languageMap[langIndex] || 'en';
1809
+ return SCT_LANGUAGE_CODES[langIndex] || 'en';
1748
1810
  }
1749
1811
 
1750
1812
  // Lookup methods