fhirsmith 0.13.3 → 0.14.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 (49) hide show
  1. package/CHANGELOG.md +47 -1
  2. package/README.md +48 -4
  3. package/config-template.json +70 -1
  4. package/library/cgroup-memory.js +113 -0
  5. package/library/folder-setup.js +8 -1
  6. package/nginx.md +391 -0
  7. package/package.json +3 -2
  8. package/publisher/git-remote.js +141 -0
  9. package/publisher/github-release.js +195 -0
  10. package/publisher/publisher.js +146 -14
  11. package/server.js +51 -14
  12. package/stats.js +19 -13
  13. package/testing/readme.md +112 -0
  14. package/testing/render.js +482 -0
  15. package/testing/search.js +378 -0
  16. package/testing/store.js +266 -0
  17. package/testing/testing-template.html +126 -0
  18. package/testing/testing.js +486 -0
  19. package/translations/Messages.properties +32 -0
  20. package/tx/closure/closure-store.js +396 -0
  21. package/tx/cs/cs-api.js +6 -1
  22. package/tx/cs/cs-rxnorm.js +35 -8
  23. package/tx/cs/cs-snomed.js +142 -27
  24. package/tx/html/codesystem-operations.liquid +177 -0
  25. package/tx/html/ecl-panel.liquid +93 -0
  26. package/tx/html/home-metrics.liquid +39 -76
  27. package/tx/html/tx-template.html +1 -0
  28. package/tx/library/conceptmap.js +22 -6
  29. package/tx/library/designations.js +12 -3
  30. package/tx/library/extensions.js +2 -2
  31. package/tx/library/operation-outcome.js +3 -1
  32. package/tx/library/renderer.js +42 -5
  33. package/tx/operation-context.js +111 -15
  34. package/tx/provider.js +85 -0
  35. package/tx/sct/expressions.js +128 -44
  36. package/tx/sct/text-index.js +419 -0
  37. package/tx/tx-html.js +426 -44
  38. package/tx/tx.js +117 -9
  39. package/tx/workers/cache-control.js +2 -2
  40. package/tx/workers/closure.js +483 -13
  41. package/tx/workers/expand.js +20 -0
  42. package/tx/workers/metadata.js +29 -4
  43. package/tx/workers/read.js +18 -3
  44. package/tx/workers/subsumes.js +18 -2
  45. package/tx/workers/translate.js +167 -20
  46. package/tx/workers/validate.js +61 -182
  47. package/tx/workers/worker.js +9 -0
  48. package/tx/xversion/xv-conceptmap.js +17 -8
  49. package/utilities/submit-test-report.js +150 -0
@@ -26,6 +26,8 @@ const MRCM_ATTRIBUTE_RANGE_REFSET = 723562003n;
26
26
  // rules about authoring precoordinated concepts and do not constrain expressions.
27
27
  const MRCM_CONTENT_TYPE_ALL = 723596005n;
28
28
  const MRCM_CONTENT_TYPE_POSTCOORDINATED = 723594008n;
29
+ // ruleStrengthId value for an optional rule (the other value, 723597001, is mandatory).
30
+ const MRCM_RULE_STRENGTH_OPTIONAL = 723598006n;
29
31
 
30
32
  // Expression status enumeration
31
33
  const SnomedExpressionStatus = {
@@ -44,6 +46,17 @@ class SnomedExpressionBase {
44
46
  }
45
47
  }
46
48
 
49
+ /**
50
+ * Whether an attribute value has to be written in brackets: the compositional grammar
51
+ * only allows a bare conceptReference as an attribute value, so anything with more than
52
+ * one focus concept, or with refinements of its own, is a bracketed subExpression.
53
+ */
54
+ function needsBrackets(value) {
55
+ return !!value && ((value.concepts && value.concepts.length > 1) ||
56
+ (value.refinements && value.refinements.length > 0) ||
57
+ (value.refinementGroups && value.refinementGroups.length > 0));
58
+ }
59
+
47
60
  /**
48
61
  * Represents a SNOMED concept with optional code, description, literal, or decimal value
49
62
  */
@@ -161,7 +174,9 @@ class SnomedRefinement extends SnomedExpressionBase {
161
174
  * Get a string description of this refinement
162
175
  */
163
176
  describe() {
164
- return this.name.describe() + '=' + this.value.describe();
177
+ // A refined or conjoined value has to be bracketed - see attributeValue() in the parser
178
+ const v = this.value.describe();
179
+ return this.name.describe() + '=' + (needsBrackets(this.value) ? '(' + v + ')' : v);
165
180
  }
166
181
 
167
182
  /**
@@ -647,16 +662,33 @@ class SnomedExpressionParser {
647
662
 
648
663
  /**
649
664
  * Parse an attribute value
665
+ *
666
+ * The compositional grammar ABNF is
667
+ * attributeValue = expressionValue / QM stringValue QM / "#" numericValue / booleanValue
668
+ * expressionValue = ws ( conceptReference / "(" ws subExpression ws ")" ) ws
669
+ * so a value that is anything more than a single concept - a refined concept, or a
670
+ * conjunction - must be in brackets. This parser used to take an unbracketed
671
+ * sub-expression too (367430006:{405813007=85562004:272741003=24028007}), but that is not
672
+ * just non-conformant, it is ambiguous: in A:{B=C:D=E,F=G} nothing says whether F=G
673
+ * refines C or A, and the old parser silently picked C. Other parsers reject it, so we
674
+ * do too, and the renderer brackets nested values so that what we emit re-parses anywhere.
650
675
  */
651
676
  attributeValue() {
652
677
  this.ws();
653
678
 
654
679
  if (this.gchar('(')) {
655
680
  const result = this.expression();
681
+ this.ws();
656
682
  this.fixed(')');
657
683
  return result;
658
684
  } else {
659
- return this.expression();
685
+ const result = new SnomedExpression();
686
+ result.start = this.cursor;
687
+ result.concepts.push(this.concept());
688
+ this.rule(this.peek() !== ':' && this.peek() !== '+',
689
+ 'A refined or conjoined attribute value must be enclosed in brackets, e.g. 405813007=(85562004:272741003=24028007) (in "' + this.source + '")');
690
+ result.stop = this.cursor;
691
+ return result;
660
692
  }
661
693
  }
662
694
 
@@ -1625,7 +1657,7 @@ class SnomedExpressionServices {
1625
1657
  this.checkRefinement(refinement);
1626
1658
  this.checkRefinementDomain(expression, refinement);
1627
1659
  this.checkRefinementRange(refinement);
1628
- this.checkRefinementGrouped(refinement);
1660
+ this.checkRefinementGrouped(expression, refinement);
1629
1661
  }
1630
1662
  }
1631
1663
 
@@ -1644,12 +1676,8 @@ class SnomedExpressionServices {
1644
1676
 
1645
1677
  /**
1646
1678
  * Check one refinement against the MRCM attribute domain reference set: the
1647
- * focus concept the attribute is applied to must be in the attribute's domain.
1648
- *
1649
- * Every applicable rule must hold, mandatory or optional. Internationally
1650
- * there is exactly one optional rule - Laterality in the lateralizable body
1651
- * structure reference set - and it is the rule that stops laterality being
1652
- * applied to a structure that cannot be lateralized, so it is enforced.
1679
+ * focus concept the attribute is applied to must be in one of the attribute's
1680
+ * domains (see applicableDomainRules for how several rules combine).
1653
1681
  *
1654
1682
  * Attributes with no rule in this edition's MRCM are not checked, and neither
1655
1683
  * is cardinality (checkExpressionCardinality), grouping (checkRefinementGrouped)
@@ -1663,44 +1691,84 @@ class SnomedExpressionServices {
1663
1691
  if (attribute === undefined || attribute === null || attribute === NO_REFERENCE) {
1664
1692
  return;
1665
1693
  }
1666
- const rules = this.mrcmAttributeDomains().get(attribute);
1667
- if (!rules || rules.length === 0) {
1668
- // This edition's concept model says nothing about this attribute (3 active
1669
- // attributes internationally have no domain rule and appear in no
1670
- // postcoordination domain template), so there is nothing to check it against.
1671
- return;
1694
+ const result = this.applicableDomainRules(expression, attribute);
1695
+ if (result.failed) {
1696
+ const attrDesc = this.describeConceptForMessage(attribute);
1697
+ const focusDesc = result.focus.map(f => this.describeConceptForMessage(f)).join(' + ');
1698
+ throw new Error(`The SNOMED CT concept model does not allow the attribute ${attrDesc} on ${focusDesc}`
1699
+ + ` (the attribute is only valid ${result.failed})`);
1672
1700
  }
1701
+ }
1673
1702
 
1674
- // The focus may be a conjunction of concepts; the expression is a subtype of
1675
- // each of them, so the attribute is in domain if any focus concept is.
1703
+ /**
1704
+ * Work out which of an attribute's MRCM domain rules apply to an expression's
1705
+ * focus concept(s), and whether the attribute is allowed there at all.
1706
+ *
1707
+ * An attribute has one domain rule per domain it may be used in, so the
1708
+ * mandatory rules are ALTERNATIVES: |Due to| has one rule for |Clinical finding|
1709
+ * and one for |Event|, and is valid on a concept in either (issue #287 - these
1710
+ * were once ANDed, which rejected |Due to| everywhere).
1711
+ *
1712
+ * Optional rules are RESTRICTIONS layered on top: internationally the only one
1713
+ * is |Laterality| for the lateralizable body structure reference set, which
1714
+ * narrows the mandatory |Anatomical structure| rule, and it is what stops
1715
+ * laterality being applied to a structure that cannot be lateralized. So an
1716
+ * optional rule must also hold wherever a mandatory rule has admitted the
1717
+ * attribute. (An attribute with only optional rules treats them as
1718
+ * alternatives, since there is nothing for them to narrow.)
1719
+ *
1720
+ * The focus may be a conjunction of concepts; the expression is a subtype of
1721
+ * each of them, so a rule holds if it holds for any focus concept.
1722
+ *
1723
+ * @param {SnomedExpression} expression
1724
+ * @param {number} attribute - concept index of the attribute
1725
+ * @returns {{rules: Array, focus: number[], failed: string|null}} the rules that
1726
+ * apply (for cardinality and grouping - empty when the attribute is out of
1727
+ * domain), the focus concepts, and when the attribute is not allowed on this
1728
+ * focus, a description of the domain(s) it is restricted to
1729
+ */
1730
+ applicableDomainRules(expression, attribute) {
1731
+ const rules = this.mrcmAttributeDomains().get(attribute);
1676
1732
  const focus = [];
1677
1733
  for (const concept of expression.concepts) {
1678
1734
  if (concept.reference !== undefined && concept.reference !== null && concept.reference !== NO_REFERENCE) {
1679
1735
  focus.push(concept.reference);
1680
1736
  }
1681
1737
  }
1682
- if (focus.length === 0) {
1683
- return;
1738
+ if (!rules || rules.length === 0 || focus.length === 0) {
1739
+ // This edition's concept model says nothing about this attribute (3 active
1740
+ // attributes internationally have no domain rule and appear in no
1741
+ // postcoordination domain template), so there is nothing to check it against.
1742
+ return { rules: rules || [], focus, failed: null };
1684
1743
  }
1685
1744
 
1686
- // Report every rule that failed, not just the first: a laterality on a
1687
- // disorder breaks both the anatomical structure domain and the lateralizable
1688
- // refset rule, and naming only one of them misleads.
1689
- const failed = [];
1690
- for (const rule of rules) {
1691
- const ok = focus.some(f => rule.isRefset
1692
- ? this.conceptInRefSet(rule.domain, f)
1693
- : this.subsumes(rule.domain, f));
1694
- if (!ok) {
1695
- failed.push((rule.isRefset ? 'for concepts in ' : 'in the domain ') + this.describeConceptForMessage(rule.domain));
1745
+ const holds = rule => focus.some(f => rule.isRefset
1746
+ ? this.conceptInRefSet(rule.domain, f)
1747
+ : this.subsumes(rule.domain, f));
1748
+ const describe = rule => (rule.isRefset ? 'for concepts in ' : 'in the domain ')
1749
+ + this.describeConceptForMessage(rule.domain);
1750
+
1751
+ const mandatory = rules.filter(rule => !rule.optional);
1752
+ const alternatives = mandatory.length > 0 ? mandatory : rules;
1753
+ const restrictions = mandatory.length > 0 ? rules.filter(rule => rule.optional) : [];
1754
+
1755
+ const matched = alternatives.filter(holds);
1756
+ const failedRestrictions = restrictions.filter(rule => !holds(rule));
1757
+ if (matched.length === 0) {
1758
+ // Name every domain the attribute could have been used in, and any
1759
+ // restriction that also fails: a laterality on a disorder is outside both
1760
+ // the anatomical structure domain and the lateralizable refset, and naming
1761
+ // only one of them misleads.
1762
+ const parts = [alternatives.map(describe).join(', or ')];
1763
+ for (const rule of failedRestrictions) {
1764
+ parts.push(describe(rule));
1696
1765
  }
1766
+ return { rules: [], focus, failed: parts.join(', and ') };
1697
1767
  }
1698
- if (failed.length > 0) {
1699
- const attrDesc = this.describeConceptForMessage(attribute);
1700
- const focusDesc = focus.map(f => this.describeConceptForMessage(f)).join(' + ');
1701
- throw new Error(`The SNOMED CT concept model does not allow the attribute ${attrDesc} on ${focusDesc}`
1702
- + ` (the attribute is only valid ${failed.join(', and ')})`);
1768
+ if (failedRestrictions.length > 0) {
1769
+ return { rules: [], focus, failed: failedRestrictions.map(describe).join(', and ') };
1703
1770
  }
1771
+ return { rules: matched.concat(restrictions), focus, failed: null };
1704
1772
  }
1705
1773
 
1706
1774
  /**
@@ -1721,17 +1789,19 @@ class SnomedExpressionServices {
1721
1789
  * expression is not classifiable is honest, quietly guessing what they meant is not.
1722
1790
  *
1723
1791
  * As elsewhere in this check, an attribute with no rule in this edition's MRCM is
1724
- * left alone, and where several rules apply the strictest wins.
1792
+ * left alone. Only the rules for the domain(s) the focus concept is in count
1793
+ * (see applicableDomainRules), and where several of those apply the strictest wins.
1725
1794
  *
1795
+ * @param {SnomedExpression} expression - the expression the refinement is on
1726
1796
  * @param {SnomedRefinement} refinement - a refinement outside any group
1727
1797
  */
1728
- checkRefinementGrouped(refinement) {
1798
+ checkRefinementGrouped(expression, refinement) {
1729
1799
  const attribute = refinement.name ? refinement.name.reference : NO_REFERENCE;
1730
1800
  if (attribute === undefined || attribute === null || attribute === NO_REFERENCE) {
1731
1801
  return;
1732
1802
  }
1733
- const rules = this.mrcmAttributeDomains().get(attribute);
1734
- if (!rules || rules.length === 0) {
1803
+ const rules = this.applicableDomainRules(expression, attribute).rules;
1804
+ if (rules.length === 0) {
1735
1805
  return;
1736
1806
  }
1737
1807
  if (!rules.some(rule => rule.grouped)) {
@@ -1747,7 +1817,7 @@ class SnomedExpressionServices {
1747
1817
  * Built once per edition and cached; an edition with no MRCM yields an empty
1748
1818
  * map, which turns the domain check off rather than failing.
1749
1819
  *
1750
- * @returns {Map<number, Array<{domain: number, isRefset: boolean, grouped: boolean, max: number, maxInGroup: number}>>}
1820
+ * @returns {Map<number, Array<{domain: number, isRefset: boolean, grouped: boolean, optional: boolean, max: number, maxInGroup: number}>>}
1751
1821
  */
1752
1822
  mrcmAttributeDomains() {
1753
1823
  if (this._mrcmAttributeDomains) {
@@ -1762,6 +1832,7 @@ class SnomedExpressionServices {
1762
1832
  }
1763
1833
  const contentAll = this.conceptIndexOf(MRCM_CONTENT_TYPE_ALL);
1764
1834
  const contentPost = this.conceptIndexOf(MRCM_CONTENT_TYPE_POSTCOORDINATED);
1835
+ const strengthOptional = this.conceptIndexOf(MRCM_RULE_STRENGTH_OPTIONAL);
1765
1836
 
1766
1837
  // Every concept that defines a reference set, so a domain that is itself a
1767
1838
  // reference set (the lateralizable body structure refset) is tested by
@@ -1804,6 +1875,9 @@ class SnomedExpressionServices {
1804
1875
  // The grouped column is a plain 0/1 (type 4), not a reference into the string
1805
1876
  // table like the cardinalities beside it - reading it as one yields garbage.
1806
1877
  grouped: values[2] === 1,
1878
+ // An optional rule narrows a mandatory one rather than adding a domain
1879
+ // (see applicableDomainRules).
1880
+ optional: values[8] === strengthOptional,
1807
1881
  // Only the maximums are enforced. A minimum ("1..1") says a concept in
1808
1882
  // this domain must have the attribute in its own definition; it does not
1809
1883
  // say a refinement of that concept has to restate it.
@@ -2033,8 +2107,10 @@ class SnomedExpressionServices {
2033
2107
  * a single relationship group - a maximum of 0 there is how the MRCM says an
2034
2108
  * attribute is ungrouped (all 37 ungrouped attributes carry "0..0").
2035
2109
  *
2036
- * Rules are ANDed, so where an attribute has several rules the tightest
2037
- * maximum applies. Nested value expressions are checked separately, when
2110
+ * Only the rules for the domain(s) the focus concept is in count - |Procedure
2111
+ * device| is 0..* in a group on a procedure but 0..1 on an observable, and a
2112
+ * procedure must not get the observable's limit (see applicableDomainRules).
2113
+ * Where several of those rules apply, the tightest maximum wins. Nested value expressions are checked separately, when
2038
2114
  * checkRefinement recurses into them.
2039
2115
  *
2040
2116
  * @param {SnomedExpression} expression
@@ -2072,10 +2148,14 @@ class SnomedExpressionServices {
2072
2148
  perGroup.push(counts);
2073
2149
  }
2074
2150
 
2151
+ const applicable = new Map();
2075
2152
  const limit = (attribute, field) => {
2076
- const rules = domains.get(attribute);
2077
- if (!rules || rules.length === 0) {
2078
- return Infinity;
2153
+ if (!applicable.has(attribute)) {
2154
+ applicable.set(attribute, this.applicableDomainRules(expression, attribute).rules);
2155
+ }
2156
+ const rules = applicable.get(attribute);
2157
+ if (rules.length === 0) {
2158
+ return Infinity; // no rule, or out of domain (which checkRefinementDomain reports)
2079
2159
  }
2080
2160
  return rules.reduce((least, rule) => Math.min(least, rule[field]), Infinity);
2081
2161
  };
@@ -2529,7 +2609,11 @@ class SnomedExpressionServices {
2529
2609
  renderRefinement(parts, expr, option) {
2530
2610
  this.renderConcept(parts, expr.name, option);
2531
2611
  parts.push('=');
2612
+ // a refined or conjoined value must be bracketed, or no conformant parser will read it back
2613
+ const bracket = needsBrackets(expr.value);
2614
+ if (bracket) parts.push('(');
2532
2615
  this.renderExpressionParts(parts, expr.value, option);
2616
+ if (bracket) parts.push(')');
2533
2617
  }
2534
2618
 
2535
2619
  getDisplayName(conceptIdOrReference) {