fhirsmith 0.16.1 → 0.18.0

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.
Files changed (142) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +105 -30
  3. package/config-template.json +130 -4
  4. package/extension-tracker/extension-tracker.js +111 -3
  5. package/extension-tracker/readme.md +3 -0
  6. package/ips/ai-client.js +263 -0
  7. package/ips/composer.js +805 -0
  8. package/ips/editor-schema.js +289 -0
  9. package/ips/editor.js +290 -0
  10. package/ips/fhirpath-ips.js +397 -0
  11. package/ips/ips-codeable-concepts.md +1742 -0
  12. package/ips/ips-template.html +125 -0
  13. package/ips/ips-valuesets.js +159 -0
  14. package/ips/ips.js +1736 -0
  15. package/ips/membership.js +179 -0
  16. package/ips/pipeline.js +1012 -0
  17. package/ips/presenter.js +974 -0
  18. package/ips/profile-editor.js +578 -0
  19. package/ips/profiles.js +506 -0
  20. package/ips/readme.md +460 -0
  21. package/ips/render.js +1482 -0
  22. package/ips/search-params.js +83 -0
  23. package/ips/search.js +667 -0
  24. package/ips/share.js +178 -0
  25. package/ips/store.js +650 -0
  26. package/ips/terminology.js +395 -0
  27. package/ips/tx-client.js +202 -0
  28. package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
  29. package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
  30. package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
  31. package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
  32. package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
  33. package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
  34. package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
  35. package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
  36. package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
  37. package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
  38. package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
  39. package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
  40. package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
  41. package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
  42. package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
  43. package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
  44. package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
  45. package/ips/valuesets/ValueSet-ips-medication.json +50 -0
  46. package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
  47. package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
  48. package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
  49. package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
  50. package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
  51. package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
  52. package/ips/valuesets/ValueSet-ips-route.json +46 -0
  53. package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
  54. package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
  55. package/ips/valuesets/elements.json +44 -0
  56. package/library/daily-report.js +422 -0
  57. package/library/fhir-search-values.js +138 -0
  58. package/library/html-server.js +32 -23
  59. package/library/html.js +9 -0
  60. package/library/mailer.js +77 -0
  61. package/library/narrative-check.js +222 -0
  62. package/library/npm-audit.js +25 -0
  63. package/library/package-manager.js +4 -2
  64. package/library/sqlite-session-store.js +29 -1
  65. package/package.json +8 -2
  66. package/packages/package-crawler.js +189 -82
  67. package/packages/packages.js +36 -63
  68. package/packages/utilities/dedupe-versions.js +22 -8
  69. package/publisher/publisher.js +29 -0
  70. package/registry/auth-store.js +366 -0
  71. package/registry/auth.js +872 -0
  72. package/registry/crawler.js +48 -1
  73. package/registry/documentation/api.md +162 -0
  74. package/registry/documentation/configuration.md +93 -0
  75. package/registry/documentation/documents.md +74 -0
  76. package/registry/documentation/internals.md +151 -0
  77. package/registry/documentation/readme.md +100 -0
  78. package/registry/documentation/security.md +199 -0
  79. package/registry/documents.js +309 -0
  80. package/registry/openapi.yaml +30 -0
  81. package/registry/registry-template.html +1 -1
  82. package/registry/registry.js +247 -54
  83. package/registry/uptime.js +154 -0
  84. package/release.sh +80 -0
  85. package/server.js +105 -44
  86. package/shl/shl.js +64 -9
  87. package/stats-db.js +93 -0
  88. package/stats.js +158 -1
  89. package/testing/readme.md +1 -1
  90. package/testing/search.js +2 -108
  91. package/testing/store.js +5 -0
  92. package/testing/testing.js +20 -0
  93. package/token/token-template.html +5 -4
  94. package/token/token.js +17 -10
  95. package/translations/Messages.properties +9 -0
  96. package/tx/README.md +52 -0
  97. package/tx/cm/cm-database.js +62 -0
  98. package/tx/cm/cm-package.js +20 -3
  99. package/tx/cs/cs-api.js +90 -14
  100. package/tx/cs/cs-cs.js +142 -19
  101. package/tx/cs/cs-icd11.js +366 -112
  102. package/tx/cs/cs-icf.js +418 -0
  103. package/tx/cs/cs-loinc.js +79 -39
  104. package/tx/cs/cs-omop.js +10 -3
  105. package/tx/cs/cs-snomed.js +918 -55
  106. package/tx/cs/cs-ucum.js +6 -0
  107. package/tx/cs/icd11-expressions.js +191 -78
  108. package/tx/importers/export-icd11-codesystem.js +58 -12
  109. package/tx/importers/icd11-schema.md +9 -5
  110. package/tx/library.js +107 -6
  111. package/tx/ocl/README.md +62 -6
  112. package/tx/ocl/cm-ocl.cjs +166 -44
  113. package/tx/ocl/cs-ocl.cjs +163 -13
  114. package/tx/ocl/resolve/public-access.js +143 -0
  115. package/tx/ocl/resolve/reference-resolver.js +458 -0
  116. package/tx/ocl/vs-ocl.cjs +167 -4
  117. package/tx/openapi-operations.js +4 -0
  118. package/tx/params.js +8 -1
  119. package/tx/problems.js +7 -5
  120. package/tx/provider.js +21 -5
  121. package/tx/sct/documentation.md +251 -0
  122. package/tx/sct/ecl.js +758 -46
  123. package/tx/sct/expressions.js +113 -52
  124. package/tx/syndication/syndication-feed.js +218 -0
  125. package/tx/syndication/syndication-store.js +415 -0
  126. package/tx/tx-html.js +37 -8
  127. package/tx/tx.fhir.org.yml +2 -3
  128. package/tx/tx.js +148 -5
  129. package/tx/vs/vs-database.js +26 -0
  130. package/tx/vs/vs-package.js +42 -10
  131. package/tx/vs/vs-vsac.js +25 -6
  132. package/tx/workers/expand.js +34 -7
  133. package/tx/workers/lookup.js +61 -6
  134. package/tx/workers/metadata.js +6 -1
  135. package/tx/workers/subsumes.js +1 -1
  136. package/tx/workers/translate.js +166 -15
  137. package/tx/workers/validate.js +128 -6
  138. package/utilities/make-sct-test-extension.js +264 -0
  139. package/utilities/submit-test-report.js +1 -1
  140. package/xig/xig.js +120 -12
  141. package/gr.sh +0 -3
  142. package/registry/readme.md +0 -124
package/tx/cs/cs-ucum.js CHANGED
@@ -69,6 +69,12 @@ class UcumCodeSystemProvider extends BaseCSServices {
69
69
  return 'UCUM';
70
70
  }
71
71
 
72
+ /** $lookup's display name: the code system's title. */
73
+ // eslint-disable-next-line no-unused-vars
74
+ lookupName(languages) {
75
+ return 'Unified Code for Units of Measure (UCUM)';
76
+ }
77
+
72
78
  totalCount() {
73
79
  return -1; // Unbounded due to grammar
74
80
  }
@@ -1,25 +1,37 @@
1
1
  /**
2
- * icd11-expressions.js -- postcoordinated ICD-11 expressions: parse, bind to axes, render.
3
- *
4
- * ICD-11 postcoordination has two levels of punctuation and one spelling per linearization:
2
+ * icd11-expressions.js -- postcoordinated ICD-11 expressions: parse, bind to axes, normalise,
3
+ * render.
5
4
  *
6
5
  * 1A00&XN8P1 MMS: a stem, then a value on one of its axes
7
- * NC53.Y&XK8G&XJ778/PA71&XE5UF&XE4TZ a cluster: two stems, each with its own values
8
- * 1D01.0Y/1G41/1G40 a cluster whose later members are values of the first
9
- * d5409.qp3 ICF spells the same thing with a dot
10
- * http://.../257068234 & http://.../194483911 any of it may be written as entity uris
6
+ * NA00.2&XK8G&XJ9NV/PG50&XE3HC&XE266 a root with its qualifiers, then a cluster member with its own
7
+ * CA40.00/MG51.00/1B10.0 a root and two cluster members
8
+ * d5409.qp3 ICF spells a qualifier with a dot
9
+ * 257068234&194483911 the same, written with entity ids (the code form)
10
+ * 1363559646/other&194483911 a residual's id carries its own slash
11
+ *
12
+ * There are no spaces in the syntax, and entity uris are not part of it.
11
13
  *
12
- * `&` says "this is a postcoordination value of the stem to my left": if it is not a value
13
- * on any axis of that stem, the expression is wrong and says so. `/` says only "this is
14
- * another element of the cluster": a member that happens to be a value on an unfilled axis
15
- * of the current stem is taken as one (this is what the ICD Browser does, and it is what
16
- * makes 1D01.0Y/1G41/1G40 mean what a coder means by it), and a member that is not simply
17
- * starts a new stem.
14
+ * How it is read (the October 2026 WHO meeting):
15
+ * - the first code is the root. Everything after a `/` qualifies the root: there is no
16
+ * nesting, and nothing qualifies a qualifier.
17
+ * - `&` (and ICF's `.`) attaches qualifiers to the code before it: the root, or the `/`
18
+ * member they follow. That grouping matters; the order within a group does not, and nor
19
+ * does the order of the `/` members.
20
+ * - a `&` qualifier must be a value on an axis of the code it qualifies, or the expression
21
+ * is wrong. A `/` member is placed on an axis of the root if it fits one, and may fit none.
18
22
  *
19
- * The syntax and the semantics are kept apart on purpose. The display renders the syntax --
20
- * cluster members joined by " / ", values in brackets -- because that is what the coder
21
- * wrote. The properties report the semantics: which value went on which axis. The two
22
- * disagree for 1D01.0Y/1G41/1G40, and both are right.
23
+ * The normal form (the code) fixes an order that carries no meaning:
24
+ * - the root first;
25
+ * - each group of qualifiers in the order the qualified code defines its axes, and within
26
+ * one axis by entity id (string order - the ids are tails of the entity uris);
27
+ * - the `/` members the same way against the root's axes, a member on no axis after all
28
+ * those that are, each member carrying its own qualifiers, normalised the same way;
29
+ * - duplicates deleted: the same code twice in one group, or two identical members. A member
30
+ * with different qualifiers is a different member, not a duplicate.
31
+ * - the syntax is kept: `&`, `/` and ICF's `.` are not interchangeable. ICF dot qualifiers
32
+ * stay in the order written, directly after the code they qualify.
33
+ * Which axis a value goes on is decided in id order too, so that the binding - and so the
34
+ * normal form - does not depend on the order the codes were written in.
23
35
  */
24
36
 
25
37
  'use strict';
@@ -34,6 +46,7 @@ class ExpressionToken {
34
46
  this.text = text;
35
47
  this.sep = sep; // null for the first token, otherwise '&', '/' or '.'
36
48
  this.concept = null; // the ICD11Concept it names, once resolved
49
+ this.axis = null; // the axis it was bound to, if any
37
50
  }
38
51
  }
39
52
 
@@ -42,26 +55,32 @@ class ExpressionToken {
42
55
  * The context object the provider hands around for a postcoordinated code.
43
56
  */
44
57
  class ICD11Expression {
45
- constructor(raw, tokens, members, stems) {
58
+ constructor(raw, tokens, members, stems, issues) {
46
59
  this.raw = raw;
47
- this.tokens = tokens;
48
- this.members = members; // [{ head: Token, tail: [Token] }] -- as written
49
- this.stems = stems; // [{ stem: Token, bindings: [{ axis, token }] }] -- as meant
60
+ this.tokens = tokens; // the normal form, in order (idForm and classificationCode)
61
+ this.members = members; // [{ head: Token, tail: [Token] }] -- as written (the display)
62
+ this.stems = stems; // [{ stem: Token, axes, bindings: [{ axis, token }] }] -- as meant: the
63
+ // root first, then one per `/` member
64
+ this.issues = issues; // [{ severity, msgId, params }] - things validation should say
50
65
  }
51
66
 
52
- /** The short-code spelling: the form $lookup echoes back, whichever form came in. */
67
+ /** The expression's code: the id form (the October 2026 decisions; see idForm). */
53
68
  get canonicalCode() {
54
- return this.tokens.map((t, i) => (i ? t.sep : '') + t.concept.canonicalCode).join('');
69
+ return this.idForm;
55
70
  }
56
71
 
57
72
  /**
58
- * The entity-uri spelling. ICF's dot is the same operator as MMS's `&`, so it renders as
59
- * `&` here -- the uri form has only the one spelling.
73
+ * The expression written with short codes (1A00&XN8P1), or null when any part of it has
74
+ * no short code - then there is no classification-code form to report.
60
75
  */
61
- get uriForm() {
62
- return this.tokens
63
- .map((t, i) => (i ? ` ${t.sep === '.' ? '&' : t.sep} ` : '') + t.concept.uri)
64
- .join('');
76
+ get classificationCode() {
77
+ if (!this.tokens.every(t => t.concept.code)) return null;
78
+ return this.tokens.map((t, i) => (i ? t.sep : '') + t.concept.code).join('');
79
+ }
80
+
81
+ /** The entity-id spelling, in normal order - the expression's code: 257068234&194483911. */
82
+ get idForm() {
83
+ return this.tokens.map((t, i) => (i ? t.sep : '') + t.concept.canonicalCode).join('');
65
84
  }
66
85
 
67
86
  /** True when nothing was postcoordinated after all -- a bare code in expression clothing. */
@@ -70,28 +89,37 @@ class ICD11Expression {
70
89
  }
71
90
  }
72
91
 
92
+ // A residual's entity id is its parent's id with /other or /unspecified after it.
93
+ const RESIDUAL = /^\/(other|unspecified)(?=$|[&/.])/;
94
+
73
95
  /**
74
- * Split the written form into tokens. A uri is one token including its slashes -- the `/`
75
- * inside `http://id.who.int/icd/release/11/mms/257068234` is not a cluster separator -- so
76
- * a uri runs to the next `&` or space. That leaves `/` between two uris ambiguous, which is
77
- * a property of WHO's syntax rather than of this parser; the uri form in practice uses `&`.
96
+ * Split the written form into tokens at `&` and `/`. One `/` is not a separator: the one
97
+ * in a residual's entity id, 1363559646/other or 1363559646/unspecified. That is read as
98
+ * part of the id when it follows an all-digit token (an entity id; no short code is all
99
+ * digits) and is followed by the end or a separator, so 1363559646/other&XN8P1 is two
100
+ * tokens and 1363559646/1G41 is a cluster.
78
101
  *
79
- * Returns null if the text is not shaped like an expression at all.
102
+ * Returns null if the text is not shaped like an expression at all -- an empty part, or a
103
+ * space anywhere: the syntax has none.
80
104
  */
81
105
  function tokenize(text) {
106
+ if (!text || /\s/.test(text)) return null;
82
107
  const tokens = [];
83
108
  const n = text.length;
84
109
  let i = 0, sep = null;
85
- while (i < n) {
86
- while (i < n && text[i] === ' ') i++;
87
- if (i >= n) return null; // trailing separator
110
+ while (i <= n) {
88
111
  const start = i;
89
- const isUri = /^https?:\/\//.test(text.slice(i));
90
- while (i < n && text[i] !== '&' && text[i] !== ' ' && (isUri || text[i] !== '/')) i++;
112
+ while (i < n && text[i] !== '&' && text[i] !== '/') i++;
113
+ if (i < n && text[i] === '/' && /^\d+$/.test(text.slice(start, i))) {
114
+ const m = RESIDUAL.exec(text.slice(i));
115
+ if (m) {
116
+ i += m[0].length;
117
+ while (i < n && text[i] !== '&' && text[i] !== '/') i++; // e.g. an ICF .qualifier
118
+ }
119
+ }
120
+ if (i === start) return null; // empty part, or a trailing separator
91
121
  tokens.push(new ExpressionToken(text.slice(start, i), sep));
92
- while (i < n && text[i] === ' ') i++;
93
122
  if (i >= n) break;
94
- if (text[i] !== '&' && text[i] !== '/') return null;
95
123
  sep = text[i];
96
124
  i++;
97
125
  }
@@ -132,16 +160,14 @@ async function resolveToken(ctx, token) {
132
160
  }
133
161
 
134
162
  /**
135
- * The postcoordination axes a stem declares. Required axes come first, then WHO's own
136
- * order. That ordering is what decides where an ambiguous value lands: 1D01.0Y declares
137
- * hasManifestation (optional, second) and hasCausingCondition (required, fourth), 1G40 and
138
- * 1G41 are both legal on either, and the ICD Browser reads 1D01.0Y/1G41/1G40 as causing
139
- * condition then manifestation. An axis that must be filled is the one a coder is most
140
- * likely to have been filling, so it gets first refusal.
163
+ * The postcoordination axes a stem declares, in binding preference: required axes first, then
164
+ * WHO's own order. When a value fits more than one free axis, an axis that must be filled is
165
+ * the one a coder is most likely to have been filling, so it gets first refusal. `seq` keeps
166
+ * the definition order, which is the order the normal form sorts by.
141
167
  */
142
168
  async function axesOf(ctx, concept) {
143
169
  return ctx.all(
144
- `SELECT id, axis, axis_name, required, allow_multiple, value_set_uri
170
+ `SELECT id, seq, axis, axis_name, required, allow_multiple, value_set_uri
145
171
  FROM pc_scale WHERE concept = ? ORDER BY required DESC, seq`, [concept.id]);
146
172
  }
147
173
 
@@ -155,11 +181,11 @@ async function permitted(ctx, axis, value) {
155
181
  }
156
182
 
157
183
  /**
158
- * Put a value on an axis of the stem. An axis not yet used is always preferred over one
159
- * that is, which is what keeps the two values of 1D01.0Y/1G41/1G40 apart: both are legal on
160
- * hasCausingCondition and on hasManifestation, and coalescing them onto the first is the
161
- * thing the ICD-API gets wrong (see doco.txt item 13). The same rule sends the second
162
- * XM4SL9 of PB20&XM4SL9&XM4SL9&... to a second axis rather than dropping it.
184
+ * Put a value on an axis of the stem. An axis not yet used is always preferred over one that
185
+ * is, which is what keeps the two values of 1D01.0Y/1G41/1G40 apart: both are legal on
186
+ * hasCausingCondition and on hasManifestation. When every axis the value fits is already
187
+ * taken it goes on the first of them anyway; if that axis does not allow several values the
188
+ * expression still parses, and validation says so.
163
189
  *
164
190
  * Returns the axis, or null when the value is on none of them.
165
191
  */
@@ -168,24 +194,77 @@ async function bind(ctx, stem, token) {
168
194
  for (const axis of stem.axes) {
169
195
  if (!await permitted(ctx, axis, token.concept)) continue;
170
196
  if (!stem.used.has(axis.id)) {
171
- stem.used.add(axis.id);
197
+ stem.used.set(axis.id, 1);
172
198
  stem.bindings.push({ axis, token });
199
+ token.axis = axis;
173
200
  return axis;
174
201
  }
175
202
  if (fallback === null) fallback = axis;
176
203
  }
177
204
  if (fallback === null) return null;
178
- // every axis this value fits is already carrying one. allowMultipleValues decides whether
179
- // that is legal; nothing in the suite pins it yet, so the value is kept rather than lost.
205
+ stem.used.set(fallback.id, stem.used.get(fallback.id) + 1);
180
206
  stem.bindings.push({ axis: fallback, token });
207
+ token.axis = fallback;
181
208
  return fallback;
182
209
  }
183
210
 
211
+ /** Entity ids compare as strings: they are the tails of the entity uris. */
212
+ const byId = (a, b) => {
213
+ const x = a.concept.entityId, y = b.concept.entityId;
214
+ return x < y ? -1 : x > y ? 1 : 0;
215
+ };
216
+
217
+ /** Normal-form order of things bound (or not) to one code's axes: axis definition order, then id. */
218
+ const byAxisThenId = (a, b) => {
219
+ const sa = a.axis ? a.axis.seq : Infinity, sb = b.axis ? b.axis.seq : Infinity;
220
+ return sa !== sb ? sa - sb : byId(a, b);
221
+ };
222
+
223
+ /** A group with its duplicates deleted - the same code with the same separator. */
224
+ function dedupe(tokens) {
225
+ const seen = new Set();
226
+ return tokens.filter(t => {
227
+ const k = t.sep + '|' + t.concept.id;
228
+ if (seen.has(k)) return false;
229
+ seen.add(k);
230
+ return true;
231
+ });
232
+ }
233
+
234
+ /**
235
+ * A qualifier group in normal order. ICF's dot qualifiers keep the order written and come
236
+ * first; the `&` ones are sorted by axis, then id.
237
+ */
238
+ function orderGroup(tokens) {
239
+ return [...tokens.filter(t => t.sep === '.'), ...tokens.filter(t => t.sep !== '.').sort(byAxisThenId)];
240
+ }
241
+
184
242
  /**
185
- * Parse and bind. Returns an ICD11Expression, or null when the text does not resolve (which
186
- * the caller reports as an ordinary unknown code). Throws an Issue when the text does
187
- * resolve but says something that is not true -- a value on no axis of its stem -- because
188
- * "not found" would hide the reason.
243
+ * Bind one code's `&`/`.` qualifiers to its axes, in id order. Throws when one is on none of
244
+ * them: the `&` asserted it was.
245
+ */
246
+ async function bindQualifiers(ctx, stem, tail) {
247
+ for (const t of [...tail].sort(byId)) {
248
+ if (!await bind(ctx, stem, t)) throw notOnAnyAxis(ctx, stem, t);
249
+ }
250
+ }
251
+
252
+ /** What validation has to say about a stem: several values on an axis that takes one. */
253
+ function axisIssues(stem, issues) {
254
+ for (const axis of stem.axes) {
255
+ if ((stem.used.get(axis.id) || 0) > 1 && axis.allow_multiple === 'NotAllowed') {
256
+ const values = stem.bindings.filter(b => b.axis === axis).map(b => b.token.text);
257
+ issues.push({ severity: 'warning', msgId: 'ICD11_PC_AXIS_NOT_MULTIPLE',
258
+ params: [stem.stem.text, axis.axis_name, values.join(', ')] });
259
+ }
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Parse, bind and normalise. Returns an ICD11Expression, or null when the text does not
265
+ * resolve (which the caller reports as an ordinary unknown code). Throws an Issue when the
266
+ * text does resolve but says something that is not true -- a qualifier on no axis of the
267
+ * code it qualifies -- because "not found" would hide the reason.
189
268
  */
190
269
  async function parseExpression(ctx, text) {
191
270
  const raw = tokenize(text);
@@ -199,30 +278,49 @@ async function parseExpression(ctx, text) {
199
278
  }
200
279
  if (tokens.length === 1) return null; // a plain code, already handled
201
280
 
202
- // syntax: cluster members, split at '/'
281
+ // syntax: the members as written, split at '/'; the first is the root
203
282
  const members = [];
204
283
  for (const t of tokens) {
205
284
  if (t.sep === '/' || members.length === 0) members.push({ head: t, tail: [] });
206
285
  else members[members.length - 1].tail.push(t);
207
286
  }
208
287
 
209
- // semantics: which member is a stem, and which value went on which axis
210
- const stems = [];
211
- let cur = null;
212
- for (let k = 0; k < members.length; k++) {
213
- const m = members[k];
214
- if (k > 0 && m.tail.length === 0 && cur && await bind(ctx, cur, m.head)) {
215
- continue; // a cluster member that is a value
216
- }
217
- cur = { stem: m.head, axes: await axesOf(ctx, m.head.concept), bindings: [], used: new Set() };
218
- stems.push(cur);
219
- for (const t of m.tail) {
220
- if (!await bind(ctx, cur, t)) {
221
- throw notOnAnyAxis(ctx, cur, t);
222
- }
223
- }
288
+ // duplicates go before anything is bound, so a repeated code never takes a second axis
289
+ const groups = members.map(m => ({ head: m.head, tail: dedupe(m.tail) }));
290
+ const root = groups[0];
291
+ const others = [];
292
+ const seenMember = new Set();
293
+ for (const g of groups.slice(1)) {
294
+ const k = g.head.concept.id + '|' + g.tail.map(t => t.sep + t.concept.id).sort().join(',');
295
+ if (seenMember.has(k)) continue;
296
+ seenMember.add(k);
297
+ others.push(g);
224
298
  }
225
- return new ICD11Expression(text, tokens, members, stems);
299
+
300
+ // semantics: the root's qualifiers, then the members, go on the root's axes; each member's
301
+ // own qualifiers go on its axes. All in id order, so the order written changes nothing.
302
+ const issues = [];
303
+ const rootStem = { stem: root.head, axes: await axesOf(ctx, root.head.concept), bindings: [], used: new Map() };
304
+ await bindQualifiers(ctx, rootStem, root.tail);
305
+ for (const g of [...others].sort((a, b) => byId(a.head, b.head))) {
306
+ await bind(ctx, rootStem, g.head); // may fit no axis of the root: that is allowed
307
+ }
308
+ // every member is a code in its own right, with its own axes (and its qualifiers on them)
309
+ const stems = [rootStem];
310
+ for (const g of others) {
311
+ const st = { stem: g.head, axes: await axesOf(ctx, g.head.concept), bindings: [], used: new Map() };
312
+ await bindQualifiers(ctx, st, g.tail);
313
+ stems.push(st);
314
+ }
315
+ for (const st of stems) axisIssues(st, issues);
316
+
317
+ // the normal form
318
+ const memberKey = g => g.head.concept.entityId + orderGroup(g.tail).map(t => t.sep + t.concept.entityId).join('');
319
+ const ordered = [...others].sort((a, b) => byAxisThenId(a.head, b.head) || (memberKey(a) < memberKey(b) ? -1 : memberKey(a) > memberKey(b) ? 1 : 0));
320
+ const normal = [root.head, ...orderGroup(root.tail)];
321
+ for (const g of ordered) normal.push(g.head, ...orderGroup(g.tail));
322
+
323
+ return new ICD11Expression(text, normal, members, stems, issues);
226
324
  }
227
325
 
228
326
  /**
@@ -239,4 +337,19 @@ function notOnAnyAxis(ctx, stem, token) {
239
337
  'invalid-code', 400).handleAsOO(400);
240
338
  }
241
339
 
242
- module.exports = { ICD11Expression, ExpressionToken, parseExpression, tokenize, SCHEMA_NS };
340
+ /**
341
+ * A display for an axis, from WHO's name for it - WHO publishes no labels for the axes:
342
+ * specificAnatomy -> Specific anatomy, hasGCSEyeScore -> Has GCS eye score.
343
+ */
344
+ function axisLabel(name) {
345
+ const words = name
346
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
347
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
348
+ .replace(/([A-Za-z])(\d)/g, '$1 $2')
349
+ .split(' ')
350
+ .map(w => (w.length > 1 && w === w.toUpperCase()) ? w : w.toLowerCase());
351
+ const text = words.join(' ');
352
+ return text.charAt(0).toUpperCase() + text.slice(1);
353
+ }
354
+
355
+ module.exports = { ICD11Expression, ExpressionToken, parseExpression, tokenize, axisLabel, SCHEMA_NS };
@@ -23,6 +23,7 @@ const zlib = require('zlib');
23
23
  const crypto = require('crypto');
24
24
  const Database = require('better-sqlite3');
25
25
  const { Command } = require('commander');
26
+ const { axisLabel } = require('../cs/icd11-expressions');
26
27
 
27
28
  const TOOL_VERSION = '1.0.0';
28
29
  const SCHEMA_NS = 'http://id.who.int/icd/schema';
@@ -158,9 +159,13 @@ class CodeSystemExporter {
158
159
  * short : the short code; concepts without one are dropped from the resource
159
160
  * uri : the unversioned entity uri, always
160
161
  * id : the bare entity id, always (straw poll option 4)
162
+ *
163
+ * Without --code-form, each system gets the form the server uses for it: id for MMS and
164
+ * ICF (the October 2026 WHO meeting), uri for the Foundation, which that decision did not
165
+ * cover.
161
166
  */
162
167
  codeOf(c) {
163
- switch (this.opts.codeForm) {
168
+ switch (this.codeForm) {
164
169
  case 'short': return c.code;
165
170
  case 'uri': return c.uri;
166
171
  case 'id': return c.entity_id;
@@ -194,13 +199,13 @@ class CodeSystemExporter {
194
199
  { code: 'parent', uri: 'http://hl7.org/fhir/concept-properties#parent', type: 'code',
195
200
  description: 'A parent of this concept.' },
196
201
  ];
197
- if (this.opts.codeForm !== 'short') {
198
- p.push({ code: 'shortCode', uri: `${SCHEMA_NS}/code`, type: 'string',
199
- description: 'The short "pretty" code, where the entity has one. Short codes may change between releases; entity ids do not.' });
202
+ if (this.codeForm !== 'short') {
203
+ p.push({ code: 'classificationCode', uri: `${SCHEMA_NS}/code`, type: 'code',
204
+ description: 'The short classification code (e.g. 1A00), where the entity has one. Short codes may change between releases; entity ids do not.' });
200
205
  }
201
206
  if (sys.code !== 'foundation') {
202
207
  p.push(
203
- { code: 'foundationUri', uri: `${SCHEMA_NS}/source`, type: 'string',
208
+ { code: 'foundationUri', uri: `${SCHEMA_NS}/source`, type: 'Coding',
204
209
  description: 'The Foundation entity this linearization entry was derived from.' },
205
210
  { code: 'blockId', uri: `${SCHEMA_NS}/blockId`, type: 'string' },
206
211
  { code: 'codeRange', uri: `${SCHEMA_NS}/codeRange`, type: 'string' },
@@ -300,13 +305,14 @@ class CodeSystemExporter {
300
305
  const put = (c_, k, v) => { if (v != null && v !== '') property.push({ code: c_, [k]: v }); };
301
306
  put('entityId', 'valueString', c.entity_id);
302
307
  put('uri', 'valueString', c.uri);
303
- if (this.opts.codeForm !== 'short') put('shortCode', 'valueString', c.code);
308
+ if (this.codeForm !== 'short') put('classificationCode', 'valueCode', c.code);
304
309
  if (c.class_kind) property.push({ code: 'classKind',
305
310
  valueCoding: { system: SCHEMA_NS, code: c.class_kind } });
306
311
  if (!c.selectable) property.push({ code: 'notSelectable', valueBoolean: true });
307
312
  put('blockId', 'valueString', c.block_id);
308
313
  put('codeRange', 'valueString', c.code_range);
309
- put('foundationUri', 'valueString', c.source_uri);
314
+ if (c.source_uri) property.push({ code: 'foundationUri',
315
+ valueCoding: { system: 'http://id.who.int/icd/entity', code: c.source_uri } });
310
316
  put('browserUrl', 'valueString', c.browser_url);
311
317
  for (const s of ctx.scales.get(c.id) || []) {
312
318
  property.push({ code: 'postcoordinationScale', valueString: s.value_set_uri });
@@ -391,7 +397,6 @@ class CodeSystemExporter {
391
397
  // Depth-first, emitting each concept with its children inline. Children are written
392
398
  // by hand rather than nested into the parent object, so no subtree is ever fully
393
399
  // materialised in memory.
394
- const self = this;
395
400
  const walk = async (id, depth) => {
396
401
  const c = ctx.byId.get(id);
397
402
  const json = this.conceptJson(c, ctx, { nested: true });
@@ -430,7 +435,7 @@ class CodeSystemExporter {
430
435
  await finished;
431
436
  const size = fs.statSync(target).size;
432
437
  this.log(` ${path.basename(target)} ${written} concepts, ${(size / 1048576).toFixed(1)} MB`
433
- + ` [${nested ? 'nested' : 'flat'}, ${this.opts.codeForm}]`);
438
+ + ` [${nested ? 'nested' : 'flat'}, ${this.codeForm}]`);
434
439
  return { target, written, ctx };
435
440
  }
436
441
 
@@ -532,6 +537,12 @@ class CodeSystemExporter {
532
537
  { code: 'block', display: 'Block', definition: 'A grouper. Has no short code and cannot be used to code with.' },
533
538
  { code: 'window', display: 'Window', definition: 'A presentation grouper. Cannot be used to code with.' },
534
539
  { code: 'category', display: 'Category', definition: 'A codeable entity.' },
540
+ // the postcoordination axes, named as WHO names them; $lookup on an expression
541
+ // reports each axis as a Coding in this code system. WHO publishes no labels for
542
+ // them, so the display is made from the name (specificAnatomy -> Specific anatomy)
543
+ ...this.db.prepare('SELECT DISTINCT axis_name FROM pc_scale ORDER BY axis_name').all()
544
+ .map(r => ({ code: r.axis_name, display: axisLabel(r.axis_name),
545
+ definition: 'A postcoordination axis.' })),
535
546
  ],
536
547
  };
537
548
  cs.count = cs.concept.length;
@@ -540,6 +551,40 @@ class CodeSystemExporter {
540
551
  this.index.push({ filename: 'CodeSystem-icd11-schema.json', resourceType: 'CodeSystem',
541
552
  id: cs.id, url: cs.url, version: cs.version });
542
553
  this.log(` ${path.basename(file)} ${cs.count} concepts [schema vocabulary]`);
554
+ this.writeAllowMultipleCodeSystem();
555
+ }
556
+
557
+ /**
558
+ * The values of a postcoordination axis's allowMultipleValues - WHO's own three codes. $lookup
559
+ * on an expression reports them as Codings in this code system.
560
+ */
561
+ writeAllowMultipleCodeSystem() {
562
+ const cs = {
563
+ resourceType: 'CodeSystem',
564
+ id: 'icd11-allow-multiple-values',
565
+ url: `${SCHEMA_NS}/allowMultipleValues`,
566
+ version: this.meta('release') || '2026-01',
567
+ name: 'ICD11AllowMultipleValues',
568
+ title: 'ICD-11 postcoordination: whether an axis takes several values',
569
+ status: 'active',
570
+ experimental: false,
571
+ publisher: 'World Health Organization',
572
+ description: 'Whether a postcoordination axis of an ICD-11 entity may be given more than one value.',
573
+ caseSensitive: true,
574
+ content: 'complete',
575
+ concept: [
576
+ { code: 'AllowAlways', display: 'Allow always', definition: 'The axis may take any number of values.' },
577
+ { code: 'NotAllowed', display: 'Not allowed', definition: 'The axis takes at most one value.' },
578
+ { code: 'AllowedExceptFromSameBlock', display: 'Allowed except from the same block',
579
+ definition: 'The axis may take several values, but not two from the same block.' },
580
+ ],
581
+ };
582
+ cs.count = cs.concept.length;
583
+ const file = path.join(this.dir, 'CodeSystem-icd11-allow-multiple-values.json');
584
+ fs.writeFileSync(file, JSON.stringify(cs, null, 2) + '\n');
585
+ this.index.push({ filename: 'CodeSystem-icd11-allow-multiple-values.json', resourceType: 'CodeSystem',
586
+ id: cs.id, url: cs.url, version: cs.version });
587
+ this.log(` ${path.basename(file)} ${cs.count} concepts [allowMultipleValues]`);
543
588
  }
544
589
 
545
590
  // -- postcoordination value sets -----------------------------------------
@@ -745,7 +790,7 @@ class CodeSystemExporter {
745
790
  this.log(`ICD-11 FHIR export`);
746
791
  this.log(` source : ${this.opts.src}`);
747
792
  this.log(` out : ${this.dir}`);
748
- this.log(` code form : ${this.opts.codeForm}`);
793
+ this.log(` code form : ${this.opts.codeForm || 'id (mms, icf), uri (foundation)'}`);
749
794
  this.log(` languages : ${languages.join(', ')} (base ${this.baseLang})`);
750
795
  this.log(` value sets: ${this.opts.valueSets ? 'yes' : 'no'}`
751
796
  + ` package: ${this.opts.package ? `${this.opts.packageId}#${this.opts.packageVersion}` : 'no'}`);
@@ -759,6 +804,7 @@ class CodeSystemExporter {
759
804
  const sys = this.db.prepare('SELECT * FROM system WHERE code=?').get(code);
760
805
  if (!sys) { this.log(` ! ${code} is not in this database, skipping`); continue; }
761
806
  this.log(`\n${sys.code} -- ${sys.title}`);
807
+ this.codeForm = this.opts.codeForm || (sys.code === 'foundation' ? 'uri' : 'id');
762
808
  const { ctx } = await this.exportSystem(sys, languages);
763
809
  for (const lang of languages.slice(1)) await this.exportSupplement(sys, lang, ctx);
764
810
  if (this.opts.valueSets) this.writeValueSets(sys);
@@ -792,7 +838,7 @@ function main(argv) {
792
838
  .requiredOption('-o, --out <dir>', 'output directory')
793
839
  .option('--systems <list>', 'which code systems to export', 'mms,icf,foundation')
794
840
  .option('-l, --languages <list>', "comma separated, or 'all'; the first is the base language", 'all')
795
- .option('--code-form <form>', 'hybrid | short | uri | id -- what goes in concept.code', 'hybrid')
841
+ .option('--code-form <form>', 'id | uri | hybrid | short -- what goes in concept.code (default: id, uri for the Foundation)')
796
842
  .option('--hierarchy <how>', 'nested | flat (the Foundation is always flat)', 'nested')
797
843
  .option('--no-value-set', 'do not declare an all-codes value set in CodeSystem.valueSet')
798
844
  .option('--case-sensitive <b>', 'value for CodeSystem.caseSensitive', v => v !== 'false', true)
@@ -805,7 +851,7 @@ function main(argv) {
805
851
 
806
852
  program.parse(argv);
807
853
  const opts = program.opts();
808
- if (!['hybrid', 'short', 'uri', 'id'].includes(opts.codeForm)) {
854
+ if (opts.codeForm && !['hybrid', 'short', 'uri', 'id'].includes(opts.codeForm)) {
809
855
  throw new Error(`--code-form must be hybrid, short, uri or id`);
810
856
  }
811
857
  // A package holds plain JSON; the tarball does the compressing.
@@ -190,16 +190,20 @@ tree and avoids repeating parent codes; the Foundation is a DAG so it is emitted
190
190
  keeps the base file usable on its own and matches WHO treating translations as separately
191
191
  governed. `--languages en,fr` picks a subset.
192
192
 
193
- **Codes.** `--code-form` decides what goes in `concept.code`, because the community has not:
193
+ **Codes.** `--code-form` decides what goes in `concept.code`. The October 2026 decisions
194
+ made the bare entity id the code in MMS and ICF (the uri is the system url + `/` + the id)
195
+ and the short code its `classificationCode`. The Foundation was not part of that decision
196
+ and keeps the entity uri. Without `--code-form` each system gets its decided form; the
197
+ option forces one form for all of them, for comparison:
194
198
 
195
199
  | form | `concept.code` | notes |
196
200
  |---|---|---|
197
- | `hybrid` (default) | short code where there is one, entity uri otherwise | what the WHO FHIR server does today |
201
+ | `id` (default for MMS, ICF) | the bare entity id, always | the decided form; Geneva straw poll option 4, which won 7-1 |
202
+ | `uri` (default for the Foundation) | the unversioned entity uri, always | uniform, always present |
203
+ | `hybrid` | short code where there is one, entity uri otherwise | what the WHO FHIR server did before the decisions |
198
204
  | `short` | the short code | drops the 1,519 MMS concepts that have none |
199
- | `uri` | the unversioned entity uri, always | uniform, always present |
200
- | `id` | the bare entity id, always | Geneva straw poll option 4, which won 7-1 |
201
205
 
202
- Whichever is chosen, the others are still available as `entityId`, `uri` and `shortCode`
206
+ Whichever is chosen, the others are still available as `entityId`, `uri` and `classificationCode`
203
207
  properties, so nothing is lost and the four can be generated and compared.
204
208
 
205
209
  **Declarations.** The exported resources declare `filter` (concept is-a/descendent-of,