fhirsmith 0.11.2 → 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 (68) hide show
  1. package/CHANGELOG.md +377 -2
  2. package/README.md +1 -1
  3. package/config-template.json +31 -1
  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 +208 -30
  10. package/publisher/validation.js +210 -0
  11. package/server.js +24 -15
  12. package/stats-db.js +127 -0
  13. package/stats.js +172 -6
  14. package/translations/Messages.properties +5 -1
  15. package/tx/README.md +21 -0
  16. package/tx/cs/cs-api.js +30 -1
  17. package/tx/cs/cs-api.md +1 -1
  18. package/tx/cs/cs-cs.js +1 -1
  19. package/tx/cs/cs-icd11.js +805 -0
  20. package/tx/cs/cs-lang.js +331 -85
  21. package/tx/cs/cs-loinc.js +45 -4
  22. package/tx/cs/cs-mimetypes.js +492 -21
  23. package/tx/cs/cs-snomed.js +141 -58
  24. package/tx/cs/cs-ucum.js +33 -3
  25. package/tx/cs/icd11-expressions.js +243 -0
  26. package/tx/importers/export-icd11-codesystem.js +823 -0
  27. package/tx/importers/icd11-schema.md +285 -0
  28. package/tx/importers/import-icd11.js +861 -0
  29. package/tx/importers/import-loinc.module.js +14 -7
  30. package/tx/importers/import-sct.module.js +10 -1
  31. package/tx/library/codesystem.js +21 -27
  32. package/tx/library/conceptmap.js +39 -0
  33. package/tx/library/nesting-limit.js +79 -0
  34. package/tx/library/operation-outcome.js +97 -3
  35. package/tx/library/parameter-check.js +139 -0
  36. package/tx/library/valueset.js +16 -20
  37. package/tx/library.js +23 -1
  38. package/tx/ocl/cache/cache-utils.cjs +5 -3
  39. package/tx/ocl/cs-ocl.cjs +73 -17
  40. package/tx/ocl/jobs/background-queue.cjs +6 -3
  41. package/tx/ocl/mappers/concept-mapper.cjs +9 -1
  42. package/tx/ocl/shared/patches.cjs +48 -1
  43. package/tx/ocl/vs-ocl.cjs +15 -13
  44. package/tx/operation-context.js +220 -10
  45. package/tx/params.js +226 -175
  46. package/tx/sct/expressions.js +677 -15
  47. package/tx/tx.fhir.org.yml +3 -2
  48. package/tx/tx.js +72 -92
  49. package/tx/workers/batch-validate.js +11 -18
  50. package/tx/workers/batch.js +16 -28
  51. package/tx/workers/cache-control.js +87 -17
  52. package/tx/workers/closure.js +3 -8
  53. package/tx/workers/compare.js +6 -34
  54. package/tx/workers/expand.js +108 -60
  55. package/tx/workers/lookup.js +35 -40
  56. package/tx/workers/read.js +6 -40
  57. package/tx/workers/search.js +2 -8
  58. package/tx/workers/subsumes.js +58 -41
  59. package/tx/workers/translate.js +472 -47
  60. package/tx/workers/validate.js +83 -78
  61. package/tx/workers/worker.js +92 -11
  62. package/tx/xml/xml-base.js +264 -77
  63. package/tx/xversion/xv-conceptmap.js +81 -0
  64. package/tx/xversion/xv-parameters.js +8 -0
  65. package/tx/xversion/xv-valueset.js +39 -1
  66. package/utilities/merge-audit.py +178 -0
  67. package/utilities/why-merge-audit.md +39 -0
  68. package/tx/data/snomed-subset.txt +0 -84
@@ -1,4 +1,4 @@
1
- const { CodeSystemContentMode, CodeSystemFactoryProvider} = require('./cs-api');
1
+ const { CodeSystemContentMode, CodeSystemFactoryProvider, cannotDetermineSubsumption} = require('./cs-api');
2
2
  const {
3
3
  SnomedStrings, SnomedWords, SnomedStems, SnomedReferences,
4
4
  SnomedDescriptions, SnomedDescriptionIndex, SnomedConceptList,
@@ -148,6 +148,58 @@ class SnomedServices {
148
148
  refSetIndex: this.refSetIndex
149
149
  }, this.isAIndex);
150
150
 
151
+ // The expression services check the MRCM attribute ranges, but those are ECL
152
+ // and the ECL engine lives here, so hand them an evaluator. Returning null
153
+ // means "could not decide", and leaves the value unchecked.
154
+ this.expressionServices.eclConstraintMatcher = (constraint, value) => this.mrcmRangeSatisfied(constraint, value);
155
+ this._mrcmRangeCache = new Map();
156
+ }
157
+
158
+ /**
159
+ * Does this refinement value satisfy an MRCM range constraint?
160
+ *
161
+ * @param {string} constraint - the rangeConstraint ECL, e.g. "<< 182353008 |Side|"
162
+ * @param {SnomedExpression} value - the refinement's value expression
163
+ * @returns {boolean|null} null when the constraint cannot be evaluated
164
+ */
165
+ mrcmRangeSatisfied(constraint, value) {
166
+ let entry = this._mrcmRangeCache.get(constraint);
167
+ if (entry === undefined) {
168
+ entry = null;
169
+ try {
170
+ const ast = new ECLParser(new ECLLexer(constraint).tokenize()).parse();
171
+ const ctxt = this._evalECLNode(ast, null);
172
+ entry = {
173
+ ast: ast,
174
+ wildcard: !!ctxt.eclWildcard,
175
+ members: new Set(this._eclToIndexArray(ctxt))
176
+ };
177
+ } catch (error) {
178
+ // Concrete value ranges (dec/int/str) and anything else the ECL engine
179
+ // does not support: remembered as unusable so it is parsed only once.
180
+ entry = null;
181
+ }
182
+ this._mrcmRangeCache.set(constraint, entry);
183
+ }
184
+ if (!entry) {
185
+ return null;
186
+ }
187
+ if (entry.wildcard) {
188
+ return true;
189
+ }
190
+
191
+ const concepts = (value.concepts || []).filter(c => c.reference != null && c.reference !== NO_REFERENCE);
192
+ const refined = value.hasRefinements() || value.hasRefinementGroups();
193
+ if (!refined) {
194
+ if (concepts.length === 0) {
195
+ return null; // a concrete value, or nothing we can resolve
196
+ }
197
+ // Every focus concept of a plain value must be in the constraint's set.
198
+ return concepts.every(c => entry.members.has(c.reference));
199
+ }
200
+ // A refined value: ask the ECL engine whether the expression satisfies the
201
+ // constraint (it knows that a refinement of X is still << X).
202
+ return this._eclExpressionSatisfies({ expression: value }, entry.ast, null);
151
203
  }
152
204
 
153
205
  close() {
@@ -664,7 +716,7 @@ class SnomedServices {
664
716
  // we actually need the full concept list, otherwise filterSize returns 0
665
717
  // and the iteration yields nothing. Materialise active concepts now.
666
718
  if (forIteration && result.eclWildcard && (!result.descendants || result.descendants.length === 0)) {
667
- result.descendants = this._eclEnumerateActiveConcepts(opContext);
719
+ result.descendants = this._eclEnumerateAllConcepts(opContext);
668
720
  delete result.eclWildcard;
669
721
  // The materialised wildcard form differs from the lazy (eclWildcard) form,
670
722
  // so mark it: the filter cache must not share it across iteration modes.
@@ -677,31 +729,29 @@ class SnomedServices {
677
729
  };
678
730
 
679
731
  /**
680
- * Return every active concept's index. Used to materialise wildcard results
681
- * when the filter needs to be iterated over (e.g. $expand).
732
+ * Return every concept's index. Used to materialise wildcard results when the
733
+ * filter needs to be iterated over (e.g. $expand).
734
+ *
735
+ * `*` means every concept the server knows, so this deliberately walks the
736
+ * concept table rather than descending from 138875005 |SNOMED CT Concept|.
737
+ * Two kinds of concept are in the table but not reachable from that root:
738
+ * inactive concepts (their is-a relationships are inactive too), and the
739
+ * foundation-metadata concepts that a subset carries without their is-a
740
+ * relationships - module concepts above all. Both belong in `*`, and both are
741
+ * counted by countAllCodes, so descending from the root made $expand of a
742
+ * `constraint = *` filter disagree with $expand of the whole code system.
743
+ *
744
+ * Inactive concepts are not filtered out here: activeOnly is applied by the
745
+ * expansion itself (see ValueSetExpander.processCodes), which is what lets one
746
+ * enumeration serve both activeOnly=true and activeOnly=false.
682
747
  * @returns {number[]}
683
748
  */
684
- _eclEnumerateActiveConcepts = function (opContext) {
685
- // The wildcard `*` enumerates the SNOMED CT concept hierarchy: the root
686
- // 138875005 |SNOMED CT Concept| and its descendants. This excludes the
687
- // foundation-metadata concepts that appear in a subset's concept table
688
- // (referenced by the RF2 files as module / type / acceptability ... ids)
689
- // without their is-a relationships, so they are not reachable from the root
690
- // and are not returned by `*` (matching Snowstorm). On a full edition every
691
- // real concept is under the root, so nothing is lost.
692
- const rootRes = this.concepts.findConcept(138875005n);
693
- if (rootRes && rootRes.found) {
694
- return this.filterIsA(138875005n, true).descendants;
695
- }
696
- // Fallback (no recognisable root): every active concept.
749
+ _eclEnumerateAllConcepts = function (opContext) {
697
750
  const all = [];
698
751
  const n = this.concepts.count();
699
752
  for (let i = 0; i < n; i++) {
700
- if (opContext) opContext.deadCheck('ecl:enumerateActiveConcepts');
701
- const concept = this.concepts.getConceptByCount(i);
702
- if ((concept.flags & 0x0F) === 0) {
703
- all.push(concept.index);
704
- }
753
+ if (opContext) opContext.deadCheck('ecl:enumerateAllConcepts');
754
+ all.push(this.concepts.getConceptByCount(i).index);
705
755
  }
706
756
  return all;
707
757
  };
@@ -918,7 +968,7 @@ class SnomedServices {
918
968
  };
919
969
 
920
970
  /**
921
- * Wildcard — all active concepts. The eclWildcard flag tells filterCheck /
971
+ * Wildcard — every concept, active or not. The eclWildcard flag tells filterCheck /
922
972
  * filterLocate to accept every active concept without enumeration.
923
973
  * @returns {SnomedFilterContext}
924
974
  */
@@ -1312,15 +1362,15 @@ class SnomedServices {
1312
1362
 
1313
1363
  /**
1314
1364
  * Like _eclToIndexArray, but if the context is a bare wildcard (no
1315
- * descendants populated) it materialises the full active-concept list
1316
- * via _eclEnumerateActiveConcepts. Used by dotted/refined evaluation,
1365
+ * descendants populated) it materialises the full concept list
1366
+ * via _eclEnumerateAllConcepts. Used by dotted/refined evaluation,
1317
1367
  * which need an explicit concept set to iterate over.
1318
1368
  * @param {SnomedFilterContext} ctx
1319
1369
  * @returns {number[]}
1320
1370
  */
1321
1371
  _eclResolveSet = function (ctx, opContext) {
1322
1372
  if (ctx.eclWildcard && (!ctx.descendants || ctx.descendants.length === 0)) {
1323
- return this._eclEnumerateActiveConcepts(opContext);
1373
+ return this._eclEnumerateAllConcepts(opContext);
1324
1374
  }
1325
1375
  return this._eclToIndexArray(ctx);
1326
1376
  };
@@ -1369,9 +1419,9 @@ class SnomedServices {
1369
1419
  const rightSet = new Set(this._eclToIndexArray(right));
1370
1420
 
1371
1421
  if (left.eclWildcard) {
1372
- // Wildcard minus the right set: use the shared active-concept enumeration
1373
- // (the root hierarchy) so `*` behaves consistently everywhere.
1374
- result.descendants = this._eclEnumerateActiveConcepts(opContext)
1422
+ // Wildcard minus the right set: use the shared enumeration so `*` behaves
1423
+ // consistently everywhere.
1424
+ result.descendants = this._eclEnumerateAllConcepts(opContext)
1375
1425
  .filter(idx => !rightSet.has(idx));
1376
1426
  return result;
1377
1427
  }
@@ -1734,19 +1784,6 @@ class SnomedProvider extends BaseCSServices {
1734
1784
  }
1735
1785
  }
1736
1786
 
1737
- async incompleteValidationMessage(context) {
1738
-
1739
- const ctxt = await this.#ensureContext(context);
1740
-
1741
- if (!ctxt) return null;
1742
-
1743
- if (ctxt.isComplex()) {
1744
- return "The expression is grammatically correct and the concepts are valid, but the expression has not been checked against the SNOMED CT concept model (MRCM)";
1745
- } else {
1746
- return null;
1747
- }
1748
- }
1749
-
1750
1787
  async locateIsA(code, parent, disallowParent = false) {
1751
1788
 
1752
1789
 
@@ -1881,25 +1918,37 @@ class SnomedProvider extends BaseCSServices {
1881
1918
  if (ctxt instanceof SnomedExpressionContext) {
1882
1919
  // ignore concepts for now, but list refinements and refinement groups
1883
1920
  for (const refinement of ctxt.expression.refinements) {
1884
- const codeA = refinement.name.code;
1885
- const codeB = refinement.value.describe();
1886
- const description = await this.display(codeB);
1887
- let p = this._addCodeProperty(params, 'property', codeA, codeB, null, description);
1888
- p.part.push({name: 'code-display', valueString: await this.display(codeA)});
1921
+ await this._addRefinementProperty(params, refinement);
1889
1922
  }
1890
1923
  for (const refinementGroup of ctxt.expression.refinementGroups) {
1891
1924
  for (const refinement of refinementGroup.refinements) {
1892
- const codeA = refinement.name.code;
1893
- const codeB = refinement.value.describe();
1894
- const description = await this.display(codeB);
1895
- let p = this._addCodeProperty(params, 'property', codeA, codeB, null, description);
1896
- p.part.push({name: 'code-display', valueString: await this.display(codeA)});
1925
+ await this._addRefinementProperty(params, refinement);
1897
1926
  }
1898
1927
  }
1899
1928
  }
1900
1929
  }
1901
1930
  }
1902
1931
 
1932
+ /**
1933
+ * Report one refinement of a post-coordinated expression as a lookup property.
1934
+ *
1935
+ * The value is handled as an expression, not as a string: SnomedExpression's
1936
+ * describe() is a human-readable rendering that joins concepts and refinements
1937
+ * with commas ("85562004,272741003=24028007"), so round-tripping it through
1938
+ * display() parsed it back as SCG and failed on any refined value.
1939
+ *
1940
+ * @param {Object} params - the Parameters being built
1941
+ * @param {SnomedRefinement} refinement
1942
+ */
1943
+ async _addRefinementProperty(params, refinement) {
1944
+ const codeA = refinement.name.code;
1945
+ const value = SnomedExpressionContext.fromExpression('', refinement.value);
1946
+ const codeB = await this.code(value);
1947
+ const description = await this.display(value);
1948
+ const p = this._addCodeProperty(params, 'property', codeA, codeB, null, description);
1949
+ p.part.push({name: 'code-display', valueString: await this.display(codeA)});
1950
+ }
1951
+
1903
1952
  // Filter support
1904
1953
  async doesFilter(prop, op, value) {
1905
1954
  if (prop === 'concept') {
@@ -2131,7 +2180,7 @@ class SnomedProvider extends BaseCSServices {
2131
2180
  // A wildcard (`*`) set is lazy - it carries the eclWildcard flag rather than a
2132
2181
  // materialised descendant list - so count the active concepts it denotes.
2133
2182
  if (set.eclWildcard && (!set.descendants || set.descendants.length === 0)) {
2134
- return this.sct._eclEnumerateActiveConcepts(this.opContext).length;
2183
+ return this.sct._eclEnumerateAllConcepts(this.opContext).length;
2135
2184
  }
2136
2185
  if (set.matches && set.matches.length > 0) {
2137
2186
  return set.matches.length;
@@ -2183,7 +2232,7 @@ class SnomedProvider extends BaseCSServices {
2183
2232
  const reference = ctxt.getReference();
2184
2233
 
2185
2234
  if (set.eclWildcard) {
2186
- return this.sct.isActive(reference) ? ctxt : null;
2235
+ return ctxt; // `*` is every concept; inactive ones included
2187
2236
  }
2188
2237
 
2189
2238
  // A post-coordinated expression validated against an ECL value set: test the
@@ -2266,7 +2315,7 @@ class SnomedProvider extends BaseCSServices {
2266
2315
  return this.#descendantsHas(set, reference);
2267
2316
  }
2268
2317
  if (set.eclWildcard) {
2269
- return this.sct.isActive(reference);
2318
+ return true; // `*` is every concept; inactive ones included
2270
2319
  }
2271
2320
  return false;
2272
2321
  }
@@ -2344,9 +2393,21 @@ class SnomedProvider extends BaseCSServices {
2344
2393
  }
2345
2394
 
2346
2395
  // Subsumption testing
2396
+ /**
2397
+ * Subsumption between two codes, either of which may be a postcoordinated expression.
2398
+ *
2399
+ * PRECONDITION: both codes have already been validated. doSubsumes (the only caller)
2400
+ * runs locate() on each first, which parses and runs checkExpression - so an
2401
+ * expression that breaks the concept model is reported as an invalid code before it
2402
+ * gets here, rather than having a subsumption answer computed for it. The check is
2403
+ * deliberately not repeated here: it evaluates ECL for the MRCM range constraints, and
2404
+ * doing that twice per request is not free.
2405
+ *
2406
+ * @param {string} codeA
2407
+ * @param {string} codeB
2408
+ * @returns {Promise<string>} equivalent | subsumes | subsumed-by | not-subsumed
2409
+ */
2347
2410
  async subsumesTest(codeA, codeB) {
2348
-
2349
-
2350
2411
  try {
2351
2412
  const exprA = new SnomedExpressionParser(this.sct.concepts).parse(codeA);
2352
2413
  const exprB = new SnomedExpressionParser(this.sct.concepts).parse(codeB);
@@ -2375,10 +2436,32 @@ class SnomedProvider extends BaseCSServices {
2375
2436
  } else if (b2) {
2376
2437
  return 'subsumed-by';
2377
2438
  } else {
2378
- return 'not-subsumed';
2439
+ // No structural relationship found - which is NOT the same as there being
2440
+ // none. Structural comparison of normal forms is sound when it succeeds (a
2441
+ // proof is a proof) but not when it fails: SNOMED's OWL axiom reference set
2442
+ // carries general concept inclusions and property chains whose entailments
2443
+ // are baked into the distributed inferred relationships for precoordinated
2444
+ // concepts, but a postcoordinated expression is one the classifier never saw,
2445
+ // so those entailments are not available for it. Answering 'not-subsumed'
2446
+ // here would assert something we have not established, in the direction that
2447
+ // wrongly excludes a code from a cohort.
2448
+ //
2449
+ // $subsumes has four outcome codes and none of them means "unknown", so the
2450
+ // only honest answer is an error. Both codes are valid and the request is
2451
+ // well formed, hence 422 rather than 400.
2452
+ throw cannotDetermineSubsumption('Unable to determine the subsumption relationship between '
2453
+ + `'${codeA}' and '${codeB}': at least one is a postcoordinated expression and no `
2454
+ + 'relationship follows from comparing their normal forms. Establishing that there '
2455
+ + 'is no relationship would require classification, which this server does not do, '
2456
+ + 'so no outcome is returned rather than one that may be wrong');
2379
2457
  }
2380
2458
  }
2381
2459
  } catch (error) {
2460
+ // Errors that already say how they should be reported pass straight through -
2461
+ // wrapping them would lose the status and issue code.
2462
+ if (error.statusCode) {
2463
+ throw error;
2464
+ }
2382
2465
  throw new Error(`Error in subsumption test: ${error.message}`);
2383
2466
  }
2384
2467
  }
package/tx/cs/cs-ucum.js CHANGED
@@ -7,6 +7,7 @@ const { FilterExecutionContext, CodeSystemFactoryProvider} = require('./cs-api')
7
7
  const ValueSet = require("../library/valueset");
8
8
  const assert = require('assert');
9
9
  const {UcumService} = require("../library/ucum-service");
10
+ const {Pair, Decimal} = require("../library/ucum-types");
10
11
  const {validateArrayParameter, validateParameter, validateOptionalParameter} = require("../../library/utilities");
11
12
  const {DesignationUse} = require("../library/designations");
12
13
  const {BaseCSServices} = require("./cs-base");
@@ -397,11 +398,40 @@ class UcumCodeSystemProvider extends BaseCSServices {
397
398
  return codeA === codeB;
398
399
  }
399
400
 
401
+ /**
402
+ * UCUM has no hierarchy, so no code is ever a subtype of another. Two different codes can
403
+ * still mean the same unit though - 1/min and min-1 are the same thing written two ways -
404
+ * so when the codes differ, compare what they canonicalise to.
405
+ *
406
+ * This is the canonical *form*, not the canonical units: getCanonicalUnits() drops the
407
+ * magnitude, so m and cm both answer 'm' and would compare equal when they are different
408
+ * units. getCanonicalForm() carries the conversion factor with the unit, which is what
409
+ * distinguishes the same unit written differently from a merely comparable one.
410
+ *
411
+ * @param {string|UcumContext} codeA
412
+ * @param {string|UcumContext} codeB
413
+ * @returns {string} equivalent or not-subsumed
414
+ */
400
415
  async subsumesTest(codeA, codeB) {
416
+ const a = await this.#ensureContext(codeA);
417
+ const b = await this.#ensureContext(codeB);
401
418
 
402
- await this.#ensureContext(codeA);
403
- await this.#ensureContext(codeB);
404
- return 'not-subsumed'; // No subsumption in UCUM
419
+ if (a.code === b.code) {
420
+ return 'equivalent';
421
+ }
422
+
423
+ try {
424
+ const ca = this.ucumService.getCanonicalForm(new Pair(Decimal.one(), a.code));
425
+ const cb = this.ucumService.getCanonicalForm(new Pair(Decimal.one(), b.code));
426
+ if (ca.getCode() === cb.getCode() && ca.getValue().comparesTo(cb.getValue()) === 0) {
427
+ return 'equivalent';
428
+ }
429
+ } catch (e) {
430
+ // a unit that won't canonicalise is simply not equivalent to anything. locate() has
431
+ // already accepted both codes, so this is a unit the analyser can't reduce, not junk
432
+ }
433
+
434
+ return 'not-subsumed';
405
435
  }
406
436
 
407
437
  async extendLookup(ctxt, props, params) {
@@ -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 };