fhirsmith 0.12.0 → 0.13.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 (56) hide show
  1. package/CHANGELOG.md +305 -0
  2. package/README.md +1 -1
  3. package/config-template.json +13 -0
  4. package/library/languages.js +191 -34
  5. package/library/path-safety.js +51 -0
  6. package/library/request-token.js +56 -0
  7. package/package.json +2 -2
  8. package/packages/packages.js +17 -118
  9. package/publisher/publisher.js +60 -30
  10. package/publisher/validation.js +15 -3
  11. package/server.js +12 -3
  12. package/translations/Messages.properties +1 -0
  13. package/tx/README.md +21 -0
  14. package/tx/cs/cs-api.js +30 -1
  15. package/tx/cs/cs-cs.js +1 -1
  16. package/tx/cs/cs-icd11.js +805 -0
  17. package/tx/cs/cs-lang.js +331 -85
  18. package/tx/cs/cs-loinc.js +45 -4
  19. package/tx/cs/cs-mimetypes.js +492 -21
  20. package/tx/cs/cs-snomed.js +38 -4
  21. package/tx/cs/cs-ucum.js +33 -3
  22. package/tx/cs/icd11-expressions.js +243 -0
  23. package/tx/importers/export-icd11-codesystem.js +823 -0
  24. package/tx/importers/icd11-schema.md +285 -0
  25. package/tx/importers/import-icd11.js +861 -0
  26. package/tx/importers/import-loinc.module.js +14 -7
  27. package/tx/importers/import-sct.module.js +10 -1
  28. package/tx/library/codesystem.js +21 -27
  29. package/tx/library/nesting-limit.js +79 -0
  30. package/tx/library/operation-outcome.js +97 -3
  31. package/tx/library/valueset.js +16 -20
  32. package/tx/library.js +23 -1
  33. package/tx/ocl/cache/cache-utils.cjs +5 -3
  34. package/tx/ocl/cs-ocl.cjs +73 -17
  35. package/tx/ocl/jobs/background-queue.cjs +6 -3
  36. package/tx/ocl/mappers/concept-mapper.cjs +9 -1
  37. package/tx/ocl/shared/patches.cjs +48 -1
  38. package/tx/ocl/vs-ocl.cjs +15 -13
  39. package/tx/sct/expressions.js +194 -17
  40. package/tx/tx.fhir.org.yml +2 -1
  41. package/tx/tx.js +15 -42
  42. package/tx/workers/batch-validate.js +6 -16
  43. package/tx/workers/batch.js +16 -28
  44. package/tx/workers/cache-control.js +6 -12
  45. package/tx/workers/closure.js +3 -8
  46. package/tx/workers/compare.js +6 -34
  47. package/tx/workers/expand.js +36 -45
  48. package/tx/workers/lookup.js +21 -39
  49. package/tx/workers/read.js +6 -40
  50. package/tx/workers/search.js +2 -8
  51. package/tx/workers/subsumes.js +58 -41
  52. package/tx/workers/translate.js +11 -17
  53. package/tx/workers/validate.js +76 -77
  54. package/tx/workers/worker.js +70 -1
  55. package/tx/xml/xml-base.js +243 -76
  56. package/tx/xversion/xv-valueset.js +39 -1
@@ -0,0 +1,243 @@
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:
5
+ *
6
+ * 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
11
+ *
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.
18
+ *
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
+ */
24
+
25
+ 'use strict';
26
+
27
+ const { Issue } = require('../library/operation-outcome');
28
+
29
+ const SCHEMA_NS = 'http://id.who.int/icd/schema';
30
+
31
+ /** One element of the written expression, with the separator that introduced it. */
32
+ class ExpressionToken {
33
+ constructor(text, sep) {
34
+ this.text = text;
35
+ this.sep = sep; // null for the first token, otherwise '&', '/' or '.'
36
+ this.concept = null; // the ICD11Concept it names, once resolved
37
+ }
38
+ }
39
+
40
+ /**
41
+ * A parsed expression. `tokens` and `members` are the syntax; `stems` is the semantics.
42
+ * The context object the provider hands around for a postcoordinated code.
43
+ */
44
+ class ICD11Expression {
45
+ constructor(raw, tokens, members, stems) {
46
+ 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
50
+ }
51
+
52
+ /** The short-code spelling: the form $lookup echoes back, whichever form came in. */
53
+ get canonicalCode() {
54
+ return this.tokens.map((t, i) => (i ? t.sep : '') + t.concept.canonicalCode).join('');
55
+ }
56
+
57
+ /**
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.
60
+ */
61
+ get uriForm() {
62
+ return this.tokens
63
+ .map((t, i) => (i ? ` ${t.sep === '.' ? '&' : t.sep} ` : '') + t.concept.uri)
64
+ .join('');
65
+ }
66
+
67
+ /** True when nothing was postcoordinated after all -- a bare code in expression clothing. */
68
+ get isTrivial() {
69
+ return this.tokens.length === 1;
70
+ }
71
+ }
72
+
73
+ /**
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 `&`.
78
+ *
79
+ * Returns null if the text is not shaped like an expression at all.
80
+ */
81
+ function tokenize(text) {
82
+ const tokens = [];
83
+ const n = text.length;
84
+ let i = 0, sep = null;
85
+ while (i < n) {
86
+ while (i < n && text[i] === ' ') i++;
87
+ if (i >= n) return null; // trailing separator
88
+ const start = i;
89
+ const isUri = /^https?:\/\//.test(text.slice(i));
90
+ while (i < n && text[i] !== '&' && text[i] !== ' ' && (isUri || text[i] !== '/')) i++;
91
+ tokens.push(new ExpressionToken(text.slice(start, i), sep));
92
+ while (i < n && text[i] === ' ') i++;
93
+ if (i >= n) break;
94
+ if (text[i] !== '&' && text[i] !== '/') return null;
95
+ sep = text[i];
96
+ i++;
97
+ }
98
+ return tokens.length ? tokens : null;
99
+ }
100
+
101
+ /**
102
+ * ICF writes `d5409.qp3` where MMS writes `d5409&qp3`. A dot is only a separator when the
103
+ * whole token is not itself a code -- `1D01.0Y` is one code, and `BD11.2` is another -- so
104
+ * the longest prefix that resolves wins, and what is left becomes dot-separated values.
105
+ *
106
+ * Returns the tokens this one expands to, or null if nothing resolves.
107
+ */
108
+ async function resolveToken(ctx, token) {
109
+ const whole = await ctx.resolve(token.text);
110
+ if (whole) {
111
+ token.concept = whole;
112
+ return [token];
113
+ }
114
+ if (!token.text.includes('.')) return null;
115
+ const parts = token.text.split('.');
116
+ for (let take = parts.length - 1; take >= 1; take--) {
117
+ const head = parts.slice(0, take).join('.');
118
+ const concept = await ctx.resolve(head);
119
+ if (!concept) continue;
120
+ const out = [new ExpressionToken(head, token.sep)];
121
+ out[0].concept = concept;
122
+ for (const rest of parts.slice(take)) {
123
+ const value = await ctx.resolve(rest);
124
+ if (!value) return null; // e.g. the pre-2026 ICF form d5409.3
125
+ const t = new ExpressionToken(rest, '.');
126
+ t.concept = value;
127
+ out.push(t);
128
+ }
129
+ return out;
130
+ }
131
+ return null;
132
+ }
133
+
134
+ /**
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.
141
+ */
142
+ async function axesOf(ctx, concept) {
143
+ return ctx.all(
144
+ `SELECT id, axis, axis_name, required, allow_multiple, value_set_uri
145
+ FROM pc_scale WHERE concept = ? ORDER BY required DESC, seq`, [concept.id]);
146
+ }
147
+
148
+ /** Is `value` one of the permitted values of this axis -- a scale root or a descendant? */
149
+ async function permitted(ctx, axis, value) {
150
+ const row = await ctx.get(
151
+ `SELECT 1 AS ok FROM pc_scale_entity e
152
+ JOIN concept_closure cl ON cl.ancestor = e.target
153
+ WHERE e.scale = ? AND cl.descendant = ? LIMIT 1`, [axis.id, value.id]);
154
+ return !!row;
155
+ }
156
+
157
+ /**
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.
163
+ *
164
+ * Returns the axis, or null when the value is on none of them.
165
+ */
166
+ async function bind(ctx, stem, token) {
167
+ let fallback = null;
168
+ for (const axis of stem.axes) {
169
+ if (!await permitted(ctx, axis, token.concept)) continue;
170
+ if (!stem.used.has(axis.id)) {
171
+ stem.used.add(axis.id);
172
+ stem.bindings.push({ axis, token });
173
+ return axis;
174
+ }
175
+ if (fallback === null) fallback = axis;
176
+ }
177
+ 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.
180
+ stem.bindings.push({ axis: fallback, token });
181
+ return fallback;
182
+ }
183
+
184
+ /**
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.
189
+ */
190
+ async function parseExpression(ctx, text) {
191
+ const raw = tokenize(text);
192
+ if (!raw) return null;
193
+
194
+ const tokens = [];
195
+ for (const t of raw) {
196
+ const resolved = await resolveToken(ctx, t);
197
+ if (!resolved) return null;
198
+ tokens.push(...resolved);
199
+ }
200
+ if (tokens.length === 1) return null; // a plain code, already handled
201
+
202
+ // syntax: cluster members, split at '/'
203
+ const members = [];
204
+ for (const t of tokens) {
205
+ if (t.sep === '/' || members.length === 0) members.push({ head: t, tail: [] });
206
+ else members[members.length - 1].tail.push(t);
207
+ }
208
+
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
+ }
224
+ }
225
+ return new ICD11Expression(text, tokens, members, stems);
226
+ }
227
+
228
+ /**
229
+ * `&` asserted that this value belongs on an axis of the stem, and it does not. Say which
230
+ * value, and which axes the stem actually has: "could not be found in the CodeSystem" is
231
+ * the answer that sends people looking in the wrong place (doco.txt item 15).
232
+ */
233
+ function notOnAnyAxis(ctx, stem, token) {
234
+ const axes = stem.axes.map(a => a.axis_name).join(', ');
235
+ const msg = axes
236
+ ? `'${token.text}' is not a permitted value on any postcoordination axis of `
237
+ + `'${stem.stem.text}'. That stem declares: ${axes}`
238
+ : `'${token.text}' cannot be postcoordinated onto '${stem.stem.text}': that concept `
239
+ + `declares no postcoordination axes`;
240
+ return new Issue('error', 'code-invalid', null, null, msg, 'invalid-code', 400).handleAsOO(400);
241
+ }
242
+
243
+ module.exports = { ICD11Expression, ExpressionToken, parseExpression, tokenize, SCHEMA_NS };