fhirsmith 0.18.2 → 1.0.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 (203) hide show
  1. package/ACCESSIBILITY.md +119 -0
  2. package/CHANGELOG.md +149 -0
  3. package/CONFIGURATION.md +305 -0
  4. package/LICENSE +29 -0
  5. package/README.md +29 -122
  6. package/cluster.js +1220 -0
  7. package/cluster.md +707 -0
  8. package/config-template.json +77 -8
  9. package/extension-tracker/extension-tracker-template.html +5 -8
  10. package/extension-tracker/extension-tracker.js +0 -2
  11. package/folder/folder.js +7 -6
  12. package/ips/analysis.js +560 -0
  13. package/ips/ips-template.html +5 -8
  14. package/ips/ips.js +132 -59
  15. package/ips/nationality.js +120 -0
  16. package/ips/pipeline.js +74 -21
  17. package/ips/presenter.js +1 -1
  18. package/ips/readme.md +132 -25
  19. package/ips/render.js +466 -77
  20. package/ips/store.js +79 -22
  21. package/ips/structure.js +247 -0
  22. package/ips/terminology.js +1 -1
  23. package/library/body-limit.js +22 -0
  24. package/library/cache-generation.js +15 -0
  25. package/library/cgroup-memory.js +133 -1
  26. package/library/cluster-counts.js +121 -0
  27. package/library/cluster-events.js +153 -0
  28. package/library/cluster-metrics.js +193 -0
  29. package/library/cluster-status.js +468 -0
  30. package/library/config-reader.js +96 -0
  31. package/library/daily-report.js +100 -2
  32. package/library/html-server.js +27 -1
  33. package/library/i18nsupport.js +23 -1
  34. package/library/keep-alive.js +18 -0
  35. package/library/languages.js +17 -3
  36. package/library/logger.js +31 -0
  37. package/library/memory-guard.js +428 -0
  38. package/library/native-memory.js +52 -0
  39. package/library/npm-audit.js +51 -25
  40. package/library/openapi-doc.js +7 -5
  41. package/library/package-manager.js +8 -0
  42. package/library/role.js +197 -0
  43. package/library/server-credentials.js +169 -0
  44. package/library/sqlite-utilities.js +59 -0
  45. package/library/ssrf-guard.js +100 -0
  46. package/library/static-response-stats.js +16 -0
  47. package/library/version-utilities.js +50 -0
  48. package/library/worker-count.js +192 -0
  49. package/nginx.md +30 -11
  50. package/npmprojector/indexer.js +18 -10
  51. package/package.json +11 -8
  52. package/packages/feed-check-page.js +117 -0
  53. package/packages/feed-check.js +279 -0
  54. package/packages/npm-check.js +57 -3
  55. package/packages/npm-signing.js +219 -0
  56. package/packages/openapi.yaml +207 -1
  57. package/packages/package-crawler.js +322 -41
  58. package/packages/packages-template.html +6 -8
  59. package/packages/packages.js +604 -57
  60. package/packages/readme.md +113 -1
  61. package/publisher/publisher-template.html +2 -5
  62. package/publisher/publisher.js +121 -110
  63. package/publisher/readme.md +75 -0
  64. package/registry/crawler.js +84 -108
  65. package/registry/documentation/configuration.md +34 -0
  66. package/registry/registry-template.html +5 -8
  67. package/registry/registry.js +5 -4
  68. package/root-bare-template.html +7 -4
  69. package/root-template.html +5 -8
  70. package/server.js +398 -198
  71. package/shl/shl.js +38 -37
  72. package/static/a11y/a11y-check.js +832 -0
  73. package/static/a11y/axe.min.js +12 -0
  74. package/static/assets/css/bootstrap-fhir.css +15 -15
  75. package/static/assets/images/flags/au.svg +18 -0
  76. package/static/assets/images/flags/unknown.svg +11 -0
  77. package/static/assets/images/flags/us.svg +63 -0
  78. package/static/assets/images/icon_audit.png +0 -0
  79. package/static/assets/images/icon_clinical.png +0 -0
  80. package/static/assets/images/icon_document.png +0 -0
  81. package/static/assets/images/icon_external.png +0 -0
  82. package/static/assets/images/icon_medication.png +0 -0
  83. package/static/assets/images/icon_organization.png +0 -0
  84. package/static/assets/images/icon_other.png +0 -0
  85. package/static/assets/images/icon_person.png +0 -0
  86. package/static/assets/images/icon_section.png +0 -0
  87. package/stats-db.js +45 -0
  88. package/stats.js +197 -10
  89. package/testing/testing-template.html +5 -8
  90. package/token/nginx_passport_setup.md +1 -1
  91. package/token/security_guide.md +1 -1
  92. package/token/token-template.html +2 -5
  93. package/token/token.js +40 -36
  94. package/translations/Messages.properties +19 -0
  95. package/tx/README.md +154 -4
  96. package/tx/closure/closure-store.js +126 -3
  97. package/tx/cm/cm-database.js +69 -57
  98. package/tx/cm/cm-package.js +29 -1
  99. package/tx/cs/cs-api.js +284 -19
  100. package/tx/cs/cs-api.md +41 -4
  101. package/tx/cs/cs-areacode.js +1 -1
  102. package/tx/cs/cs-country.js +17 -5
  103. package/tx/cs/cs-cpt.js +33 -16
  104. package/tx/cs/cs-cs.js +148 -14
  105. package/tx/cs/cs-currency.js +1 -1
  106. package/tx/cs/cs-icd11.js +21 -13
  107. package/tx/cs/cs-icf.js +22 -3
  108. package/tx/cs/cs-lang.js +2 -2
  109. package/tx/cs/cs-loinc.js +185 -66
  110. package/tx/cs/cs-mimetypes.js +12 -2
  111. package/tx/cs/cs-ndc.js +40 -25
  112. package/tx/cs/cs-omop.js +54 -29
  113. package/tx/cs/cs-rxnorm.js +300 -196
  114. package/tx/cs/cs-snomed.js +608 -64
  115. package/tx/cs/cs-ucum.js +14 -4
  116. package/tx/cs/cs-unii.js +18 -14
  117. package/tx/cs/cs-virtual.js +210 -0
  118. package/tx/ecosystem-resolver.js +413 -0
  119. package/tx/html/cluster-dash-metrics.liquid +26 -0
  120. package/tx/html/cluster-home-metrics.liquid +31 -0
  121. package/tx/html/cluster-metrics-script.liquid +251 -0
  122. package/tx/html/codesystem-operations.liquid +3 -3
  123. package/tx/html/ecl-panel.liquid +1 -1
  124. package/tx/html/external-metrics.liquid +17 -0
  125. package/tx/html/operations-form.liquid +17 -17
  126. package/tx/html/search-form.liquid +11 -11
  127. package/tx/html/status-line.liquid +58 -0
  128. package/tx/html/status-panel.liquid +140 -0
  129. package/tx/html/status-tasks.liquid +25 -0
  130. package/tx/html/tx-template.html +2 -5
  131. package/tx/importers/atc-to-fhir.js +17 -16
  132. package/tx/importers/export-icd11-codesystem.js +3 -2
  133. package/tx/importers/import-icd11.js +4 -3
  134. package/tx/importers/import-loinc.module.js +118 -56
  135. package/tx/importers/import-ndc.module.js +56 -44
  136. package/tx/importers/import-rxnorm.module.js +118 -79
  137. package/tx/importers/import-sct.module.js +57 -58
  138. package/tx/importers/import-unii.module.js +37 -36
  139. package/tx/importers/subset-loinc.module.js +49 -48
  140. package/tx/importers/subset-rxnorm.module.js +29 -28
  141. package/tx/importers/terminal.js +18 -0
  142. package/tx/importers/tx-import-base.js +13 -12
  143. package/tx/importers/tx-import-settings.js +5 -4
  144. package/tx/importers/tx-import.js +53 -52
  145. package/tx/library/canonical-resource.js +49 -0
  146. package/tx/library/codesystem.js +94 -5
  147. package/tx/library/designations.js +45 -2
  148. package/tx/library/extensions.js +13 -7
  149. package/tx/library/implicit-valuesets.js +66 -0
  150. package/tx/library/renderer.js +77 -21
  151. package/tx/library/sqlite-utilities.js +2 -0
  152. package/tx/library/ucum-parsers.js +11 -1
  153. package/tx/library/wait-for-cache.js +43 -0
  154. package/tx/library.js +321 -22
  155. package/tx/openapi-operations.js +6 -4
  156. package/tx/operation-context.js +460 -99
  157. package/tx/params.js +25 -0
  158. package/tx/problems.js +3 -5
  159. package/tx/provider.js +278 -42
  160. package/tx/resource-cache-sqlite.js +1050 -0
  161. package/tx/sct/ecl.js +47 -6
  162. package/tx/sct/editions.js +39 -0
  163. package/tx/sct/expressions.js +10 -1
  164. package/tx/sct/load-config.js +107 -0
  165. package/tx/sct/mmap.js +109 -0
  166. package/tx/sct/structures.js +166 -13
  167. package/tx/sct/text-index.js +5 -1
  168. package/tx/syndication/syndication-feed.js +2 -2
  169. package/tx/tx-html.js +44 -8
  170. package/tx/tx.fhir.org.yml +8 -8
  171. package/tx/tx.js +282 -80
  172. package/tx/usage-tracker.js +39 -11
  173. package/tx/vs/vs-api.js +19 -0
  174. package/tx/vs/vs-database.js +386 -62
  175. package/tx/vs/vs-package.js +27 -4
  176. package/tx/vs/vs-vsac.js +648 -100
  177. package/tx/workers/batch-validate.js +37 -10
  178. package/tx/workers/batch.js +6 -1
  179. package/tx/workers/cache-control.js +15 -4
  180. package/tx/workers/closure.js +156 -13
  181. package/tx/workers/compare.js +8 -5
  182. package/tx/workers/expand.js +1173 -289
  183. package/tx/workers/metadata.js +35 -0
  184. package/tx/workers/search.js +53 -1
  185. package/tx/workers/supplement-filters.js +145 -0
  186. package/tx/workers/translate.js +169 -4
  187. package/tx/workers/validate.js +223 -119
  188. package/tx/workers/worker.js +123 -31
  189. package/tx/xversion/xv-valueset.js +7 -4
  190. package/utilities/submit-test-report.js +3 -1
  191. package/utilities/tx-stress-test.js +482 -0
  192. package/vcl/readme.md +37 -0
  193. package/vcl/vcl-parser.js +15 -0
  194. package/xig/readme.md +52 -0
  195. package/xig/xig-template.html +5 -8
  196. package/xig/xig.js +238 -422
  197. package/changelog-template.md +0 -22
  198. package/registry/test-runner.js +0 -237
  199. package/release.sh +0 -80
  200. package/test-scripts/repro-re2-wasm-leak.js +0 -34
  201. package/tsconfig.eslint.json +0 -9
  202. package/tx/html/dash-metrics.liquid +0 -147
  203. package/tx/html/home-metrics.liquid +0 -225
@@ -0,0 +1,560 @@
1
+ //
2
+ // Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
3
+ //
4
+ // Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
5
+ //
6
+
7
+ /**
8
+ * The analysis step: a look at each resource in the document (and at the Composition
9
+ * section by section) for the things a reader should know before trusting it - extensions
10
+ * (with their definitions, where they can be found), modifier extensions and implicitRules,
11
+ * records that are inactive, entered in error or of unknown status, narrative or the lack of
12
+ * it, a subject other than the document's or a focus, dates in the future, and for a few
13
+ * resources, things particular to them (a Patient's links, a Composition that isn't final,
14
+ * a section's emptyReason or author). And for the document as a whole, on the Composition: how
15
+ * it says there's nothing to report (negation) - with a section's emptyReason (the IPS
16
+ * pattern), with entries whose codes say so, like No known allergy (the US Core pattern), or
17
+ * not at all - and a warning when it uses both; and an error when it has SNOMED CT codes that
18
+ * don't say which edition they are from, and its nationality is unknown (so the edition can't
19
+ * be worked out: the International Edition is assumed).
20
+ *
21
+ * It walks the same tree as the structure view (structure.js), and keeps a list of findings
22
+ * for each resource and section, by the node's key; the analysis view shows the tree with
23
+ * the findings under each node.
24
+ *
25
+ * A finding is { kind, text } - or for an extension { kind, url, path, found, title?,
26
+ * description? } - where kind is one of
27
+ * error SNOMED CT codes with no edition in a document of unknown nationality (red)
28
+ * ok nothing to comment on
29
+ * narrative whether there's narrative (always there; doesn't count against ok)
30
+ * extension an extension (but not textLink)
31
+ * modifier a modifier extension, or implicitRules (maroon)
32
+ * status inactive, entered in error, unknown, or missing status (navy)
33
+ * subject a subject other than the document's, or a focus (dark green)
34
+ * attention a date in the future where one isn't expected, a Patient's link, a
35
+ * Composition that isn't final, a document that uses more than one approach
36
+ * to negation
37
+ * negation how the document does negation (on the Composition; like narrative, always
38
+ * there, and doesn't count against ok)
39
+ * note anything else (a section's emptyReason, a section's author)
40
+ *
41
+ * @module ips/analysis
42
+ */
43
+
44
+ const { structureTree, flatten, isObject, text } = require('./structure');
45
+ const { typeOf } = require('./terminology');
46
+
47
+ // the resources whose status (or lifecycleStatus) is required, as the element that holds it
48
+ const STATUS_ELEMENTS = {
49
+ Observation: 'status', DiagnosticReport: 'status', MedicationStatement: 'status', MedicationUsage: 'status', MedicationRequest: 'status',
50
+ MedicationAdministration: 'status', MedicationDispense: 'status', Immunization: 'status', Procedure: 'status', CarePlan: 'status',
51
+ ClinicalImpression: 'status', Flag: 'status', Consent: 'status', Encounter: 'status', DeviceUseStatement: 'status', DeviceUsage: 'status',
52
+ Composition: 'status', Goal: 'lifecycleStatus', ServiceRequest: 'status', ImagingStudy: 'status', FamilyMemberHistory: 'status',
53
+ RiskAssessment: 'status', DocumentReference: 'status', QuestionnaireResponse: 'status', NutritionOrder: 'status', ImmunizationRecommendation: null
54
+ };
55
+
56
+ // extensions not worth mentioning
57
+ const IGNORED_EXTENSIONS = new Set(['http://hl7.org/fhir/StructureDefinition/textLink']);
58
+
59
+ // ---- dates in the future ----
60
+ //
61
+ // Only elements the (R4) type model says are dates (date, dateTime, instant) are looked at,
62
+ // so a string or an identifier that happens to look like a date isn't one. Some dates are
63
+ // about the future by their nature, wherever they are; some resources are about the future
64
+ // - always, or while their status says the thing hasn't happened yet.
65
+
66
+ // the statuses of a request (or plan) that is over: its dates should be past
67
+ const CLOSED = new Set(['completed', 'revoked', 'cancelled', 'entered-in-error', 'stopped', 'failed', 'rejected']);
68
+ const open = (r) => !CLOSED.has(r.status);
69
+
70
+ // resources whose dates may be in the future, by type: a function of the resource that says
71
+ // whether they may (for that resource as it is)
72
+ const FUTURE_INTENT = {
73
+ // about the future, whatever their status
74
+ Appointment: () => true,
75
+ AppointmentResponse: () => true,
76
+ ImmunizationRecommendation: () => true,
77
+ Schedule: () => true,
78
+ Slot: () => true,
79
+ // requests and plans, until they are over
80
+ ServiceRequest: open,
81
+ MedicationRequest: open,
82
+ DeviceRequest: open,
83
+ NutritionOrder: open,
84
+ CommunicationRequest: open,
85
+ SupplyRequest: open,
86
+ VisionPrescription: open,
87
+ CarePlan: open,
88
+ Task: open,
89
+ Goal: (r) => !CLOSED.has(r.lifecycleStatus),
90
+ // events, while they are still to come
91
+ Procedure: (r) => r.status === 'preparation',
92
+ Encounter: (r) => r.status === 'planned',
93
+ MedicationStatement: (r) => r.status === 'intended',
94
+ MedicationUsage: (r) => r.status === 'intended',
95
+ DeviceUseStatement: (r) => r.status === 'intended',
96
+ DeviceUsage: (r) => r.status === 'intended'
97
+ };
98
+
99
+ /** Whether a resource's dates may be in the future. */
100
+ function mayBeFuture(r) {
101
+ const f = FUTURE_INTENT[r.resourceType];
102
+ return !!f && f(r);
103
+ }
104
+
105
+ // dates that are about the future by their nature, wherever they are: the end of a period
106
+ // (a name, an address, an identifier valid until; a prescription's validity), an expiry,
107
+ // a due date. By element name, or by the type they are in (a Timing is a schedule)
108
+ const FUTURE_ELEMENTS = new Set(['end', 'expirationDate', 'dueDate']);
109
+ const FUTURE_TYPES = new Set(['Timing']);
110
+ const DATE_TYPES = new Set(['date', 'dateTime', 'instant']);
111
+
112
+ const DATE_VALUE = /^(\d{4})(?:-(\d\d)(?:-(\d\d)(?:T(\d\d):(\d\d)(?::(\d\d)(?:\.\d+)?)?(Z|[+-]\d\d:\d\d)?)?)?)?$/;
113
+
114
+ /** The earliest moment a date/dateTime/instant can mean, as ms, or null if it isn't one. */
115
+ function earliest(v) {
116
+ const m = DATE_VALUE.exec(v);
117
+ if (!m) {
118
+ return null;
119
+ }
120
+ const [, y, mo, d, h, mi, sec, tz] = m;
121
+ if (h && tz) {
122
+ const t = Date.parse(v);
123
+ return Number.isNaN(t) ? null : t;
124
+ }
125
+ // no time zone: the earliest it could be anywhere (UTC+14)
126
+ return Date.UTC(+y, mo ? +mo - 1 : 0, d ? +d : 1, h ? +h : 0, mi ? +mi : 0, sec ? +sec : 0) - 14 * 3600000;
127
+ }
128
+
129
+ /**
130
+ * Walk a resource (or a section), calling visit(key, value, path) for every property, at
131
+ * any depth, but not into contained resources (they are nodes of their own) nor into the
132
+ * keys skipped. path is FHIRPath-like, from prefix; a primitive's extensions (_birthDate)
133
+ * are at the primitive's path.
134
+ */
135
+ function walk(node, prefix, skip, visit) {
136
+ const go = (v, path) => {
137
+ if (Array.isArray(v)) {
138
+ v.forEach((x, i) => go(x, `${path}[${i}]`));
139
+ } else if (isObject(v)) {
140
+ for (const [k, x] of Object.entries(v)) {
141
+ if (k === 'contained' || (path === prefix && skip.includes(k))) {
142
+ continue;
143
+ }
144
+ const name = k.startsWith('_') ? k.substring(1) : k;
145
+ visit(k, x, path, `${path}.${name}`);
146
+ go(x, `${path}.${name}`);
147
+ }
148
+ }
149
+ };
150
+ go(node, prefix);
151
+ }
152
+
153
+ /** The extensions and modifier extensions on a resource or section, as [kind, url, path]. */
154
+ function extensionsIn(node, prefix, skip) {
155
+ const out = [];
156
+ walk(node, prefix, skip, (k, v, at) => {
157
+ if ((k === 'extension' || k === 'modifierExtension') && Array.isArray(v)) {
158
+ for (const ext of v) {
159
+ // an extension's own parts (relative urls) are part of it, not extensions of their own
160
+ if (isObject(ext) && text(ext.url) && /^[a-z][a-z0-9+.-]*:/i.test(ext.url) && !IGNORED_EXTENSIONS.has(ext.url)) {
161
+ out.push([k === 'modifierExtension' ? 'modifier' : 'extension', ext.url, at]);
162
+ }
163
+ }
164
+ }
165
+ });
166
+ return out;
167
+ }
168
+
169
+ /**
170
+ * The dates after now in a resource or section, as [path, value]: the elements whose type is
171
+ * date, dateTime or instant, walking the resource with the type model - but not those about
172
+ * the future by their nature (FUTURE_ELEMENTS, FUTURE_TYPES), nor inside extensions (what
173
+ * their dates mean isn't known), nor elements the model doesn't know.
174
+ * @param {Object} node - the resource, or a section
175
+ * @param {string} modelPath - the model's path for node: the resource type, or Composition.section
176
+ * @param {string} prefix - the path shown for node
177
+ * @param {string[]} skip - keys of node not to look in (a Composition's sections)
178
+ * @param {number} now - in ms
179
+ */
180
+ function futureDates(node, modelPath, prefix, skip, now) {
181
+ const out = [];
182
+ const go = (obj, model, shown, top) => {
183
+ for (const [k, v] of Object.entries(obj)) {
184
+ if (v === null || k === 'resourceType' || k === 'contained' || k === 'extension' || k === 'modifierExtension' ||
185
+ k.startsWith('_') || (top && skip.includes(k)) || FUTURE_ELEMENTS.has(k)) {
186
+ continue;
187
+ }
188
+ const resolved = typeOf(`${model}.${k}`);
189
+ if (!resolved || FUTURE_TYPES.has(resolved.type)) {
190
+ continue;
191
+ }
192
+ const items = Array.isArray(v) ? v : [v];
193
+ items.forEach((item, i) => {
194
+ const path = `${shown}.${k}${Array.isArray(v) ? `[${i}]` : ''}`;
195
+ if (DATE_TYPES.has(resolved.type)) {
196
+ const t = typeof item === 'string' ? earliest(item) : null;
197
+ if (t !== null && t > now) {
198
+ out.push([path, item]);
199
+ }
200
+ } else if (isObject(item)) {
201
+ if (resolved.type === 'BackboneElement' || resolved.type === 'Element') {
202
+ go(item, resolved.path, path, false);
203
+ } else if (/^[A-Z]/.test(resolved.type) && !['Resource', 'Narrative', 'Extension'].includes(resolved.type)) {
204
+ // a datatype: its own elements are defined on the type
205
+ go(item, resolved.type, path, false);
206
+ }
207
+ }
208
+ });
209
+ }
210
+ };
211
+ go(node, modelPath, prefix, true);
212
+ return out;
213
+ }
214
+
215
+ /** A CodeableConcept or Coding's code is one of codes. */
216
+ const hasCode = (cc, codes) => (Array.isArray(cc?.coding) ? cc.coding : []).some(c => isObject(c) && codes.includes(c.code));
217
+
218
+ /** A CodeableConcept, as words. */
219
+ function conceptWords(cc) {
220
+ if (!isObject(cc)) {
221
+ return '';
222
+ }
223
+ const codings = Array.isArray(cc.coding) ? cc.coding.filter(isObject) : [];
224
+ return text(cc.text) || text(codings.find(c => text(c.display))?.display) || text(codings[0]?.code) || '';
225
+ }
226
+
227
+ /** A Reference, as words. */
228
+ function referenceWords(ref) {
229
+ if (!isObject(ref)) {
230
+ return '';
231
+ }
232
+ const id = isObject(ref.identifier) ? [text(ref.identifier.system), text(ref.identifier.value)].filter(Boolean).join('|') : '';
233
+ return [text(ref.reference) || id, text(ref.display) ? `"${ref.display}"` : ''].filter(Boolean).join(' ') || '(empty reference)';
234
+ }
235
+
236
+ /**
237
+ * Whether two references are to the same thing: they resolve to the same resource in the
238
+ * document, or are the same string, or have the same identifier (system and value).
239
+ */
240
+ function sameReference(a, fromA, b, fromB, resolve) {
241
+ if (text(a.reference) && text(b.reference)) {
242
+ const ta = resolve(fromA, a.reference);
243
+ const tb = resolve(fromB, b.reference);
244
+ if (ta && tb) {
245
+ return ta.key === tb.key;
246
+ }
247
+ if (a.reference === b.reference) {
248
+ return true;
249
+ }
250
+ }
251
+ const ia = a.identifier;
252
+ const ib = b.identifier;
253
+ return isObject(ia) && isObject(ib) && text(ia.value) !== '' && ia.system === ib.system && ia.value === ib.value;
254
+ }
255
+
256
+ /** The findings common to resources and sections: extensions, narrative, focus, future dates. */
257
+ async function common(findings, thing, modelPath, prefix, skip, options, cache) {
258
+ for (const [kind, url, path] of extensionsIn(thing, prefix, skip)) {
259
+ if (!cache.has(url)) {
260
+ let def = null;
261
+ if (options.lookupExtension) {
262
+ try {
263
+ def = await options.lookupExtension(url);
264
+ } catch (e) {
265
+ def = null;
266
+ }
267
+ }
268
+ cache.set(url, def);
269
+ }
270
+ const def = cache.get(url);
271
+ findings.push({ kind, url, path, found: !!def, ...(def ? { title: text(def.title) || text(def.name), description: text(def.description) } : {}) });
272
+ }
273
+ const focus = Array.isArray(thing.focus) ? thing.focus.filter(isObject) : isObject(thing.focus) ? [thing.focus] : [];
274
+ if (focus.length) {
275
+ findings.push({ kind: 'subject', text: `Has a focus: ${focus.map(referenceWords).join(', ')}` });
276
+ }
277
+ if (!mayBeFuture(thing)) {
278
+ for (const [path, v] of futureDates(thing, modelPath, prefix, skip, options.now)) {
279
+ findings.push({ kind: 'attention', text: `A date in the future: ${path} = ${v}` });
280
+ }
281
+ }
282
+ }
283
+
284
+ /** The narrative finding. */
285
+ function narrativeOf(thing) {
286
+ const div = text(thing.text?.div);
287
+ return div ? { kind: 'narrative', text: `Has narrative${text(thing.text.status) ? ` (${thing.text.status})` : ''}` } : { kind: 'narrative', text: 'No narrative' };
288
+ }
289
+
290
+ /** What to say about a resource (the Composition without its sections). */
291
+ async function analyseResource(node, ctx) {
292
+ const r = node.resource;
293
+ const type = r.resourceType;
294
+ const prefix = type || 'Resource';
295
+ const skip = node.kind === 'root' ? ['section'] : [];
296
+ const findings = [];
297
+ if (text(r.implicitRules)) {
298
+ findings.push({ kind: 'modifier', text: `implicitRules: ${r.implicitRules}` });
299
+ }
300
+ // status: inactive, entered in error, unknown, or missing
301
+ if (r.active === false) {
302
+ findings.push({ kind: 'status', text: 'Not active (active = false)' });
303
+ }
304
+ const statusElement = Object.prototype.hasOwnProperty.call(STATUS_ELEMENTS, type) ? STATUS_ELEMENTS[type] : 'status';
305
+ const status = statusElement ? r[statusElement] : undefined;
306
+ if (status === 'entered-in-error') {
307
+ findings.push({ kind: 'status', text: `Entered in error (${statusElement} = entered-in-error)` });
308
+ } else if (status === 'unknown') {
309
+ findings.push({ kind: 'status', text: `Status unknown (${statusElement} = unknown)` });
310
+ } else if (status === undefined && statusElement && STATUS_ELEMENTS[type]) {
311
+ findings.push({ kind: 'status', text: `No ${statusElement} (a ${type} should have one)` });
312
+ }
313
+ if ((type === 'Condition' || type === 'AllergyIntolerance') && hasCode(r.verificationStatus, ['entered-in-error'])) {
314
+ findings.push({ kind: 'status', text: 'Entered in error (verificationStatus = entered-in-error)' });
315
+ }
316
+ if (type === 'Composition' && text(status) && status !== 'final' && status !== 'entered-in-error' && status !== 'unknown') {
317
+ findings.push({ kind: 'attention', text: `The Composition's status is ${status}, not final` });
318
+ }
319
+ // the subject: is it the document's?
320
+ if (node.kind !== 'root') {
321
+ const subject = isObject(r.subject) ? r.subject : isObject(r.patient) ? r.patient : null;
322
+ const docSubject = ctx.comp.subject;
323
+ if (subject && isObject(docSubject) && !sameReference(subject, node.entry, docSubject, ctx.rootEntry, ctx.resolve)) {
324
+ findings.push({ kind: 'subject', text: `The subject (${referenceWords(subject)}) is not the document's subject (${referenceWords(docSubject)})` });
325
+ }
326
+ }
327
+ if (type === 'Patient') {
328
+ for (const link of Array.isArray(r.link) ? r.link.filter(isObject) : []) {
329
+ const target = isObject(link.other) && text(link.other.reference) ? ctx.resolve(node.entry, link.other.reference) : null;
330
+ findings.push({ kind: 'attention', text: `Linked${text(link.type) ? ` (${link.type})` : ''} to ${referenceWords(link.other)}${target ? '' : ' - not in the document'}` });
331
+ }
332
+ }
333
+ await common(findings, r, type || 'Resource', prefix, skip, ctx.options, ctx.cache);
334
+ return finish(findings, narrativeOf(r));
335
+ }
336
+
337
+ /** What to say about a section. */
338
+ async function analyseSection(node, ctx) {
339
+ const sec = node.section;
340
+ const findings = [];
341
+ if (isObject(sec.emptyReason)) {
342
+ findings.push({ kind: 'note', text: `Empty: ${conceptWords(sec.emptyReason) || '(no reason given)'}` });
343
+ }
344
+ const own = Array.isArray(sec.author) ? sec.author.filter(isObject) : [];
345
+ if (own.length) {
346
+ const parent = node.parentAuthors || [];
347
+ const same = own.length === parent.length && own.every(a => parent.some(b => sameReference(a, ctx.rootEntry, b, ctx.rootEntry, ctx.resolve)));
348
+ if (!same) {
349
+ findings.push({ kind: 'note', text: `The author (${own.map(referenceWords).join(', ')}) is not ${node.nested ? 'the containing section\'s' : 'the Composition\'s'}` +
350
+ (parent.length ? ` (${parent.map(referenceWords).join(', ')})` : ' (none)') });
351
+ }
352
+ }
353
+ await common(findings, sec, 'Composition.section', 'section', ['section'], ctx.options, ctx.cache);
354
+ return finish(findings, narrativeOf(sec));
355
+ }
356
+
357
+ // ---- saying there's nothing to report ----
358
+ //
359
+ // There are two ways: the section is empty, and its emptyReason says why (nilknown - nothing
360
+ // known - or notasked / unavailable - no information), which is the IPS pattern; or the
361
+ // section has an entry whose code says so (No known allergy; No information about allergies),
362
+ // which is the US Core pattern. Either is fine, but a document that uses both makes the
363
+ // reader (and a program) look for it in two places.
364
+
365
+ // the emptyReasons that say there's nothing to report (not withheld, closed or not started)
366
+ const NOTHING_REASONS = new Set(['nilknown', 'notasked', 'unavailable']);
367
+ // the IPS absent-unknown codes: no-known-* (a negation) and no-*-info (no information)
368
+ const IPS_ABSENT_UNKNOWN = 'http://hl7.org/fhir/uv/ips/CodeSystem/absent-unknown-uv-ips';
369
+ const SCT = 'http://snomed.info/sct';
370
+ // SNOMED CT codes that say there's nothing to report: the US Core refutation codes (No known
371
+ // allergy, ...drug, environmental, food, latex and insect allergy), No current problems or
372
+ // disability, and Patient not asked
373
+ const SCT_NOTHING = new Set(['716186003', '409137002', '428607008', '429625007', '716184000', '1003774007', '160245001', '1631000175102']);
374
+ // where a resource's code that would say so is
375
+ const CODE_ELEMENTS = ['code', 'medicationCodeableConcept', 'vaccineCode'];
376
+
377
+ /** The coding in a resource that says there's nothing to report, if there is one. */
378
+ function nothingCoding(r) {
379
+ for (const e of CODE_ELEMENTS) {
380
+ for (const c of Array.isArray(r[e]?.coding) ? r[e].coding.filter(isObject) : []) {
381
+ if ((c.system === IPS_ABSENT_UNKNOWN && text(c.code)) || (c.system === SCT && SCT_NOTHING.has(c.code))) {
382
+ return c;
383
+ }
384
+ }
385
+ }
386
+ return null;
387
+ }
388
+
389
+ /** A section node's title, as plain text. */
390
+ const sectionTitle = (node) => text(node.section.title) ? node.section.title : text(node.section.code?.text) ? node.section.code.text :
391
+ (Array.isArray(node.section.code?.coding) && node.section.code.coding.find(c => text(c?.display))?.display) || node.path;
392
+
393
+ /**
394
+ * How the document does negation: a finding - kind negation for none, or one approach, and
395
+ * attention for more than one.
396
+ * @param {Object} top - the structure tree
397
+ */
398
+ function negationOf(top) {
399
+ const reasons = [];
400
+ const codes = [];
401
+ const sectionOf = new Map();
402
+ for (const [node, , parent] of flatten(top)) {
403
+ const section = node.kind === 'section' ? node : parent ? sectionOf.get(parent) : null;
404
+ sectionOf.set(node, section);
405
+ if (node.kind === 'section') {
406
+ const reason = (Array.isArray(node.section.emptyReason?.coding) ? node.section.emptyReason.coding : []).find(c => isObject(c) && NOTHING_REASONS.has(c.code));
407
+ if (reason) {
408
+ reasons.push(`${sectionTitle(node)}: ${conceptWords(node.section.emptyReason)}`);
409
+ }
410
+ } else if (node.kind === 'resource' && section && isObject(node.resource)) {
411
+ const c = nothingCoding(node.resource);
412
+ if (c) {
413
+ codes.push(`${sectionTitle(section)}: ${text(c.display) || c.code}`);
414
+ }
415
+ }
416
+ }
417
+ const byReason = `section emptyReason (${reasons.join('; ')})`;
418
+ const byCode = `codes in entries (${[...new Set(codes)].join('; ')})`;
419
+ if (reasons.length && codes.length) {
420
+ return { kind: 'attention', text: `Negation: more than one approach - ${byReason}, and ${byCode}. A document should use one approach throughout` };
421
+ }
422
+ if (reasons.length || codes.length) {
423
+ return { kind: 'negation', text: `Negation: ${reasons.length ? byReason : byCode}` };
424
+ }
425
+ return { kind: 'negation', text: 'Negation: none observed' };
426
+ }
427
+
428
+ // ---- SNOMED CT codes with no edition ----
429
+ //
430
+ // A SNOMED CT code means what its edition says it means, and only the version says which edition
431
+ // that is (http://snomed.info/sct/[module](/version/[date])). With none, the edition comes from the
432
+ // nation the document is from (the nationality step, and the national profile's edition) - and
433
+ // when that isn't known either, there's no telling: the International Edition is assumed.
434
+
435
+ // a version that names an edition
436
+ const SCT_EDITION = /^http:\/\/snomed\.info\/x?sct\/\d+(\/version\/\d+)?$/;
437
+ // how many codes the finding names
438
+ const MAX_NAMED_CODES = 3;
439
+
440
+ /** The SNOMED CT codings (and Quantities) anywhere in a resource that don't name an edition. */
441
+ function noEdition(node, out) {
442
+ if (Array.isArray(node)) {
443
+ for (const n of node) {
444
+ noEdition(n, out);
445
+ }
446
+ } else if (isObject(node)) {
447
+ if (node.system === SCT && text(node.code) && !(typeof node.version === 'string' && SCT_EDITION.test(node.version))) {
448
+ out.push(node);
449
+ }
450
+ for (const v of Object.values(node)) {
451
+ if (typeof v === 'object') {
452
+ noEdition(v, out);
453
+ }
454
+ }
455
+ }
456
+ return out;
457
+ }
458
+
459
+ /**
460
+ * The error when the document has SNOMED CT codes with no edition and its nationality is unknown,
461
+ * or null.
462
+ * @param {Object} bundle - the document
463
+ */
464
+ function unknownEdition(bundle) {
465
+ const codes = noEdition(Array.isArray(bundle.entry) ? bundle.entry.map(e => isObject(e) ? e.resource : null) : [], []);
466
+ if (!codes.length) {
467
+ return null;
468
+ }
469
+ const named = [...new Set(codes.map(c => text(c.display) ? `${c.code} ${c.display}` : c.code))];
470
+ const list = named.slice(0, MAX_NAMED_CODES).join(', ') + (named.length > MAX_NAMED_CODES ? `, and ${named.length - MAX_NAMED_CODES} more` : '');
471
+ return { kind: 'error', text: `${codes.length} SNOMED CT code${codes.length === 1 ? ' does' : 's do'} not say which edition ${codes.length === 1 ? 'it is' : 'they are'} from (${list}), ` +
472
+ 'and the nationality of the document is unknown, so the edition cannot be worked out. The International Edition of SNOMED CT is assumed' };
473
+ }
474
+
475
+ /** All OK when there's nothing but the narrative to say; the narrative last. */
476
+ function finish(findings, narrative) {
477
+ return findings.length === 0 ? [{ kind: 'ok', text: 'All OK' }, narrative] : [...findings, narrative];
478
+ }
479
+
480
+ /**
481
+ * Analyse a document.
482
+ * @param {Object} bundle - the document
483
+ * @param {{lookupExtension?: Function, now?: number, jurisdiction?: string|null}} options -
484
+ * lookupExtension(url) -> a definition ({title, name, description}) or null, perhaps async;
485
+ * now in ms; jurisdiction: the nation the document is from (the nationality step) - null when
486
+ * it isn't known, and then SNOMED CT codes with no edition are an error. Left out, they aren't
487
+ * looked at
488
+ * @returns {Promise<{items: Object, counts: Object}|null>} the findings by node key, or
489
+ * null when the document has no Composition
490
+ */
491
+ async function analyseDocument(bundle, options = {}) {
492
+ const top = structureTree(bundle);
493
+ if (!top) {
494
+ return null;
495
+ }
496
+ const ctx = {
497
+ options: { ...options, now: typeof options.now === 'number' ? options.now : Date.now() },
498
+ cache: new Map(), comp: top.resource, rootEntry: top.entry, resolve: top.resolve
499
+ };
500
+ const items = {};
501
+ const counts = { nodes: 0, ok: 0, extensions: 0, notFound: 0 };
502
+ for (const [node] of flatten(top)) {
503
+ if (node.kind !== 'root' && node.kind !== 'resource' && node.kind !== 'section') {
504
+ continue;
505
+ }
506
+ const findings = node.kind === 'section' ? await analyseSection(node, ctx) : await analyseResource(node, ctx);
507
+ items[node.key] = findings;
508
+ counts.nodes++;
509
+ for (const f of findings) {
510
+ if (f.kind === 'ok') {
511
+ counts.ok++;
512
+ } else if (f.url) {
513
+ counts.extensions++;
514
+ if (!f.found) {
515
+ counts.notFound++;
516
+ }
517
+ }
518
+ }
519
+ }
520
+ // the document as a whole, on the Composition, before its narrative
521
+ const whole = [];
522
+ if (options.jurisdiction === null) {
523
+ const edition = unknownEdition(bundle);
524
+ if (edition) {
525
+ whole.push(edition);
526
+ }
527
+ }
528
+ whole.push(negationOf(top));
529
+ const findings = items[top.key];
530
+ if (whole.some(f => f.kind !== 'negation') && findings[0].kind === 'ok') {
531
+ findings.shift();
532
+ counts.ok--;
533
+ }
534
+ findings.splice(findings.length - 1, 0, ...whole);
535
+ return { items, counts };
536
+ }
537
+
538
+ /**
539
+ * The analysis step: the document as submitted (the master - it runs first, before anything
540
+ * adorns the analysed copy) is analysed, and the findings kept as the step's result.
541
+ */
542
+ const analysisStep = {
543
+ name: 'analysis',
544
+ async run(row, project, module) {
545
+ const started = Date.now();
546
+ const lookupExtension = typeof module.lookupExtension === 'function' ? (url) => module.lookupExtension(url) : null;
547
+ // the nation the document is from: the nationality step runs first (null when it isn't known)
548
+ const jurisdiction = module.store.readResult(row.id, 'nationality')?.result?.jurisdiction || null;
549
+ const out = await analyseDocument(row.bundle, { lookupExtension, jurisdiction });
550
+ if (!out) {
551
+ throw new Error('The document has no Composition');
552
+ }
553
+ module.store.saveResult(row.id, 'analysis', { ms: Date.now() - started, ...out });
554
+ const { nodes, ok, extensions } = out.counts;
555
+ return `analysis: ${nodes - ok} of ${nodes} resources and sections with something to note` +
556
+ (extensions ? `, ${extensions} extension${extensions === 1 ? '' : 's'}` : '');
557
+ }
558
+ };
559
+
560
+ module.exports = { analyseDocument, analysisStep, extensionsIn, futureDates, mayBeFuture, earliest, STATUS_ELEMENTS, FUTURE_INTENT };
@@ -54,8 +54,6 @@
54
54
  <div class="container"> <!-- container -->
55
55
  <div style="background-color: #ad1f2f; padding: 6px; color: white;"> <!-- container -->
56
56
 
57
- <a href="http://www.hl7.org/fhir" style="color: gold" title="Fast Healthcare Interoperability Resources - Home Page"><img border="0" src="/icon-fhir-16.png" style="vertical-align: text-bottom"/> <b>FHIR</b></a> &copy; HL7.org &nbsp;|&nbsp;
58
- <a href="https://github.com/HealthIntersections/FHIRsmith/blob/main/README.md" style="color: gold"><img border="0" src="/FHIRsmith16.png" style="vertical-align: text-bottom"/> FHIRsmith</a> [%ver%] &nbsp;|&nbsp;
59
57
  <a href="/" style="color: gold"> Server Home</a> &nbsp;|&nbsp;
60
58
  <a href="/ips" style="color: gold">IPS</a>[%context-link%]
61
59
  </div> <!-- /container -->
@@ -70,7 +68,7 @@
70
68
  <div class="inner-wrapper">
71
69
  <div class="col-12">
72
70
 
73
- <h2><img border="0" src="/FHIRsmith32.png" style="vertical-align: text-bottom"/> [%title%] </h2>
71
+ <h2><a href="https://github.com/HealthIntersections/FHIRsmith/blob/main/README.md" title="FHIRsmith [%ver%]"><img border="0" src="/FHIRsmith32.png" alt="FHIRsmith Logo" style="vertical-align: text-bottom"/></a> [%title%] </h2>
74
72
 
75
73
  [%content%]
76
74
 
@@ -88,8 +86,7 @@
88
86
  <div class="container"> <!-- container -->
89
87
  <div class="inner-wrapper">
90
88
  <p>
91
- <a href="http://www.hl7.org/fhir" style="color: gold" title="Fast Healthcare Interoperability Resources - Home Page"><img border="0" src="/icon-fhir-16.png" style="vertical-align: text-bottom"/> <b>FHIR</b></a> &copy; HL7.org 2011+. &nbsp;|&nbsp;
92
- <a href="https://github.com/HealthIntersections/FHIRsmith/blob/main/README.md" style="color: gold"><img border="0" src="/FHIRsmith16.png" style="vertical-align: text-bottom"/> FHIRsmith</a> [%ver%] &copy; HealthIntersections.com.au 2023+ &nbsp;|&nbsp;
89
+ <a href="https://github.com/HealthIntersections/FHIRsmith/blob/main/README.md" style="color: gold"><img border="0" src="/FHIRsmith16.png" alt="FHIRsmith Logo" style="vertical-align: text-bottom"/> FHIRsmith</a> [%ver%] &copy; HealthIntersections.com.au 2023+ &nbsp;|&nbsp;
93
90
  &nbsp; ([%ms%] ms)
94
91
  [%sponsorMessage%]
95
92
  </p>
@@ -112,10 +109,10 @@
112
109
  ================================================== -->
113
110
  <!-- Placed at the end of the document so the pages load faster -->
114
111
  <!-- <script src="/assets/js/jquery.js"/> -->
115
- <script src="/assets/js/bootstrap.min.js"/>
116
- <script src="/assets/js/respond.min.js"/>
112
+ <script src="/assets/js/bootstrap.min.js"></script>
113
+ <script src="/assets/js/respond.min.js"></script>
117
114
 
118
- <script src="/assets/js/fhir.js"/>
115
+ <script src="/assets/js/fhir.js"></script>
119
116
 
120
117
  <!-- Analytics Below
121
118
  ================================================== -->