fhirsmith 0.17.0 → 0.18.1

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 (131) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/README.md +80 -13
  3. package/config-template.json +106 -1
  4. package/ips/ai-client.js +263 -0
  5. package/ips/composer.js +805 -0
  6. package/ips/editor-schema.js +289 -0
  7. package/ips/editor.js +290 -0
  8. package/ips/fhirpath-ips.js +397 -0
  9. package/ips/ips-codeable-concepts.md +1742 -0
  10. package/ips/ips-template.html +125 -0
  11. package/ips/ips-valuesets.js +159 -0
  12. package/ips/ips.js +1736 -0
  13. package/ips/membership.js +179 -0
  14. package/ips/pipeline.js +1012 -0
  15. package/ips/presenter.js +974 -0
  16. package/ips/profile-editor.js +578 -0
  17. package/ips/profiles.js +506 -0
  18. package/ips/readme.md +460 -0
  19. package/ips/render.js +1482 -0
  20. package/ips/search-params.js +83 -0
  21. package/ips/search.js +667 -0
  22. package/ips/share.js +178 -0
  23. package/ips/store.js +650 -0
  24. package/ips/terminology.js +395 -0
  25. package/ips/tx-client.js +202 -0
  26. package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
  27. package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
  28. package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
  29. package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
  30. package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
  31. package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
  32. package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
  33. package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
  34. package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
  35. package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
  36. package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
  37. package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
  38. package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
  39. package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
  40. package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
  41. package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
  42. package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
  43. package/ips/valuesets/ValueSet-ips-medication.json +50 -0
  44. package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
  45. package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
  46. package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
  47. package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
  48. package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
  49. package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
  50. package/ips/valuesets/ValueSet-ips-route.json +46 -0
  51. package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
  52. package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
  53. package/ips/valuesets/elements.json +44 -0
  54. package/library/daily-report.js +432 -0
  55. package/library/fhir-search-values.js +138 -0
  56. package/library/html-server.js +32 -23
  57. package/library/mailer.js +77 -0
  58. package/library/narrative-check.js +21 -2
  59. package/library/npm-audit.js +25 -0
  60. package/library/package-manager.js +4 -2
  61. package/library/sqlite-session-store.js +29 -1
  62. package/package.json +8 -3
  63. package/packages/packages.js +34 -3
  64. package/publisher/publisher.js +29 -0
  65. package/registry/auth-store.js +366 -0
  66. package/registry/auth.js +872 -0
  67. package/registry/crawler.js +41 -1
  68. package/registry/documentation/api.md +162 -0
  69. package/registry/documentation/configuration.md +93 -0
  70. package/registry/documentation/documents.md +74 -0
  71. package/registry/documentation/internals.md +151 -0
  72. package/registry/documentation/readme.md +100 -0
  73. package/registry/documentation/security.md +199 -0
  74. package/registry/documents.js +309 -0
  75. package/registry/openapi.yaml +30 -0
  76. package/registry/registry-template.html +1 -1
  77. package/registry/registry.js +230 -44
  78. package/registry/uptime.js +154 -0
  79. package/server.js +105 -44
  80. package/shl/shl.js +64 -9
  81. package/stats-db.js +93 -0
  82. package/stats.js +158 -1
  83. package/testing/search.js +2 -108
  84. package/testing/store.js +5 -0
  85. package/testing/testing.js +20 -0
  86. package/token/token-template.html +5 -4
  87. package/token/token.js +17 -10
  88. package/translations/Messages.properties +10 -0
  89. package/tx/README.md +52 -0
  90. package/tx/closure/closure-store.js +14 -0
  91. package/tx/cs/cs-api.js +92 -14
  92. package/tx/cs/cs-cs.js +64 -3
  93. package/tx/cs/cs-icd11.js +366 -112
  94. package/tx/cs/cs-icf.js +431 -0
  95. package/tx/cs/cs-loinc.js +296 -27
  96. package/tx/cs/cs-omop.js +10 -3
  97. package/tx/cs/cs-snomed.js +1210 -80
  98. package/tx/cs/cs-ucum.js +6 -0
  99. package/tx/cs/icd11-expressions.js +191 -78
  100. package/tx/importers/csv-records.js +35 -0
  101. package/tx/importers/export-icd11-codesystem.js +58 -12
  102. package/tx/importers/icd11-schema.md +9 -5
  103. package/tx/importers/import-loinc.module.js +177 -41
  104. package/tx/importers/import-sct.module.js +66 -1
  105. package/tx/importers/subset-loinc.module.js +25 -6
  106. package/tx/library/codesystem.js +4 -1
  107. package/tx/library/designations.js +23 -30
  108. package/tx/library.js +98 -4
  109. package/tx/openapi-operations.js +6 -1
  110. package/tx/params.js +8 -1
  111. package/tx/provider.js +24 -6
  112. package/tx/sct/documentation.md +251 -0
  113. package/tx/sct/ecl.js +758 -46
  114. package/tx/sct/expressions.js +113 -52
  115. package/tx/sct/structures.js +8 -2
  116. package/tx/syndication/syndication-feed.js +218 -0
  117. package/tx/syndication/syndication-store.js +415 -0
  118. package/tx/tx-html.js +15 -1
  119. package/tx/tx.fhir.org.yml +3 -4
  120. package/tx/tx.js +144 -2
  121. package/tx/vs/vs-vsac.js +25 -6
  122. package/tx/workers/closure.js +20 -0
  123. package/tx/workers/expand.js +35 -8
  124. package/tx/workers/lookup.js +61 -6
  125. package/tx/workers/subsumes.js +1 -1
  126. package/tx/workers/translate.js +166 -15
  127. package/tx/workers/validate.js +138 -6
  128. package/utilities/make-sct-loinc-subset.js +250 -0
  129. package/utilities/make-sct-test-extension.js +264 -0
  130. package/xig/xig.js +32 -0
  131. package/registry/readme.md +0 -124
@@ -0,0 +1,506 @@
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
+ * Clinical profiles: what a clinical document is expected to contain, as a profile on
9
+ * Bundle (the document) whose root element carries the expectations as constraints - each
10
+ * a human description and a FHIRPath expression on the document, most of them in one of
11
+ * three shapes:
12
+ *
13
+ * Bundle.section(system, code [, min, max]).exists()
14
+ * Bundle.section(system, code [, min, max]).entry(system, code [, min, max]).exists()
15
+ * Bundle.section(system, code [, min, max]).entryIn(valueSet [, min, max]).exists()
16
+ * Bundle.resources(system, code [, type]).{the rest of the rule}
17
+ * Bundle.resourcesIn(valueSet [, type]).{the rest of the rule}
18
+ *
19
+ * (section(), entry() and resources() are the module's own functions: fhirpath-ips.js;
20
+ * section() on a document looks in its Composition). An expression in one of the first two
21
+ * shapes is edited as a row of a table - the section's code and cardinality, the entry's
22
+ * code and cardinality; one in the third as a row of another - the type, system and code,
23
+ * and the rest of the rule; anything else as FHIRPath.
24
+ *
25
+ * The StructureDefinition's id ('profile-' + a random id), its url ([base]/StructureDefinition/{id}),
26
+ * its name (derived from the title) and the constraints' keys are the server's: the
27
+ * administrator edits the title and the constraints. An uploaded StructureDefinition must
28
+ * be no more than that - a profile on Bundle with constraints on its root element - or it
29
+ * is rejected; its id, url and keys are reassigned. Profiles made when they were on
30
+ * Composition are moved onto Bundle (migrate()).
31
+ *
32
+ * @module ips/profiles
33
+ */
34
+
35
+ const crypto = require('crypto');
36
+ const fhirpath = require('fhirpath');
37
+ const r4Model = require('fhirpath/fhir-context/r4');
38
+ const { ipsFunctions } = require('./fhirpath-ips');
39
+ const { SECTIONS, LOINC } = require('./editor-schema');
40
+
41
+ const BUNDLE = 'http://hl7.org/fhir/StructureDefinition/Bundle';
42
+ const OLD_BASE = 'http://hl7.org/fhir/StructureDefinition/Composition';
43
+ const MAX_TITLE = 200;
44
+ const MAX_HUMAN = 1000;
45
+ const MAX_EXPRESSION = 2000;
46
+ const MAX_CONSTRAINTS = 200;
47
+ const KEY_RE = /^cp-([1-9][0-9]{0,5})$/;
48
+ const ID_RE = /^[a-f0-9]{12}$/;
49
+
50
+ /** The code systems the editor offers for an entry's code (any other can be given by url). */
51
+ const SYSTEMS = [
52
+ { url: 'http://snomed.info/sct', name: 'SNOMED CT' },
53
+ { url: 'http://loinc.org', name: 'LOINC' },
54
+ { url: 'http://hl7.org/fhir/sid/icd-10', name: 'ICD-10' },
55
+ { url: 'http://id.who.int/icd/release/11/mms', name: 'ICD-11 MMS' },
56
+ { url: 'http://www.whocc.no/atc', name: 'ATC' },
57
+ { url: 'http://standardterms.edqm.eu', name: 'EDQM Standard Terms' },
58
+ { url: 'http://www.nlm.nih.gov/research/umls/rxnorm', name: 'RxNorm' },
59
+ { url: 'http://hl7.org/fhir/sid/cvx', name: 'CVX' },
60
+ { url: 'http://unitsofmeasure.org', name: 'UCUM' }
61
+ ];
62
+
63
+ /** The IPS sections, for the editor's shortcuts and the read-only view. */
64
+ const IPS_SECTIONS = SECTIONS.map(s => ({ code: s.code, title: s.title }));
65
+
66
+ function isObject(v) {
67
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
68
+ }
69
+
70
+ // ---- the two shapes ----
71
+
72
+ const STR = "'((?:[^'\\\\]|\\\\.)*)'";
73
+ const ARGS = `\\s*${STR}\\s*,\\s*${STR}\\s*(?:,\\s*(\\d{1,6})\\s*,\\s*(?:(\\d{1,6})|'\\*')\\s*)?`;
74
+ const CARD = `(?:,\\s*(\\d{1,6})\\s*,\\s*(?:(\\d{1,6})|'\\*')\\s*)?`;
75
+ // the entry part: entry(system, code [, min, max]) or entryIn(valueSet [, min, max])
76
+ const SHAPE_RE = new RegExp(`^\\s*Bundle\\s*\\.\\s*section\\s*\\(${ARGS}\\)\\s*(?:\\.\\s*(?:entry\\s*\\(${ARGS}\\)|entryIn\\s*\\(\\s*${STR}\\s*${CARD}\\))\\s*)?\\.\\s*exists\\s*\\(\\s*\\)\\s*$`);
77
+ // a value set's canonical, as entryIn() and resourcesIn() take it
78
+ const CANONICAL_RE = /^[a-z][a-z0-9+.-]*:[^\s|]{1,250}(\|[^\s|]{1,64})?$/i;
79
+
80
+ // a FHIRPath string's escapes: \' \" \` \\ \/ \f \n \r \t and \uXXXX
81
+ const ESCAPES = { n: '\n', r: '\r', t: '\t', f: '\f' };
82
+ function unquote(s) {
83
+ return s.replace(/\\(u[0-9a-fA-F]{4}|.)/g, (m, c) => (c.length === 5 ? String.fromCharCode(parseInt(c.substring(1), 16)) : (ESCAPES[c] || c)));
84
+ }
85
+
86
+ function quote(s) {
87
+ return "'" + String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'";
88
+ }
89
+
90
+ function card(min, max, hasCard) {
91
+ return { min: hasCard ? parseInt(min, 10) : null, max: hasCard ? (max === undefined ? '*' : parseInt(max, 10)) : null };
92
+ }
93
+
94
+ function part(system, code, min, max, hasCard) {
95
+ return { system: unquote(system), code: unquote(code), ...card(min, max, hasCard) };
96
+ }
97
+
98
+ /**
99
+ * The parts of an expression in one of the two shapes, or null.
100
+ * @returns {{section: {system, code, min, max}, entry: {system, code, min, max}|{valueSet, min, max}|null}|null}
101
+ * min and max are null for no bounds; max is a number or '*'; an entry has a system and
102
+ * code, or a value set
103
+ */
104
+ function parseShape(expression) {
105
+ if (typeof expression !== 'string' || expression.length > MAX_EXPRESSION) {
106
+ return null;
107
+ }
108
+ const m = SHAPE_RE.exec(expression);
109
+ if (!m) {
110
+ return null;
111
+ }
112
+ const section = part(m[1], m[2], m[3], m[4], m[3] !== undefined);
113
+ let entry = null;
114
+ if (m[5] !== undefined) {
115
+ entry = part(m[5], m[6], m[7], m[8], m[7] !== undefined);
116
+ } else if (m[9] !== undefined) {
117
+ entry = { valueSet: unquote(m[9]), ...card(m[10], m[11], m[10] !== undefined) };
118
+ if (!CANONICAL_RE.test(entry.valueSet)) {
119
+ return null;
120
+ }
121
+ }
122
+ for (const p of [section, entry].filter(Boolean)) {
123
+ if ((p.valueSet === undefined && (!p.system || !p.code)) || (p.min !== null && p.max !== '*' && p.max < p.min)) {
124
+ return null;
125
+ }
126
+ }
127
+ return { section, entry };
128
+ }
129
+
130
+ const TYPE = `(?:,\\s*'([A-Z][A-Za-z]{1,63})'\\s*)?`;
131
+ const REST = '(\\.\\s*\\S[\\s\\S]*?)';
132
+ const RSHAPE_RE = new RegExp(`^\\s*Bundle\\s*\\.\\s*resources\\s*\\(\\s*${STR}\\s*,\\s*${STR}\\s*${TYPE}\\)\\s*${REST}\\s*$`);
133
+ const RSHAPE_IN_RE = new RegExp(`^\\s*Bundle\\s*\\.\\s*resourcesIn\\s*\\(\\s*${STR}\\s*${TYPE}\\)\\s*${REST}\\s*$`);
134
+
135
+ /**
136
+ * The parts of a rule about resources, or null.
137
+ * @returns {{type: string|null, system: string, code: string, rest: string}|{type: string|null, valueSet: string, rest: string}|null}
138
+ * rest starts with '.'
139
+ */
140
+ function parseResourceShape(expression) {
141
+ if (typeof expression !== 'string' || expression.length > MAX_EXPRESSION) {
142
+ return null;
143
+ }
144
+ let m = RSHAPE_RE.exec(expression);
145
+ if (m) {
146
+ const r = { type: m[3] || null, system: unquote(m[1]), code: unquote(m[2]), rest: m[4] };
147
+ return r.system && r.code ? r : null;
148
+ }
149
+ m = RSHAPE_IN_RE.exec(expression);
150
+ if (m) {
151
+ const r = { type: m[2] || null, valueSet: unquote(m[1]), rest: m[3] };
152
+ return CANONICAL_RE.test(r.valueSet) ? r : null;
153
+ }
154
+ return null;
155
+ }
156
+
157
+ /** The expression for a rule about resources. */
158
+ function resourceShapeExpression(r) {
159
+ const rest = String(r.rest || '').trim();
160
+ const type = r.type ? `, ${quote(r.type)}` : '';
161
+ const head = r.valueSet !== undefined ? `Bundle.resourcesIn(${quote(r.valueSet)}${type})` : `Bundle.resources(${quote(r.system)}, ${quote(r.code)}${type})`;
162
+ return head + (rest.startsWith('.') ? rest : '.' + rest);
163
+ }
164
+
165
+ /** Which part of the editor an expression belongs in: 'section', 'resource' or 'other'. */
166
+ function kindOf(expression) {
167
+ return parseShape(expression) ? 'section' : (parseResourceShape(expression) ? 'resource' : 'other');
168
+ }
169
+
170
+ function cardArgs(p) {
171
+ return p.min !== null && p.min !== undefined ? `, ${p.min}, ${p.max === '*' ? "'*'" : p.max}` : '';
172
+ }
173
+
174
+ function args(p) {
175
+ return `${quote(p.system)}, ${quote(p.code)}` + cardArgs(p);
176
+ }
177
+
178
+ /** The expression for a shape. */
179
+ function shapeExpression(shape) {
180
+ const e = shape.entry;
181
+ const entry = !e ? '' : (e.valueSet !== undefined ? `.entryIn(${quote(e.valueSet)}${cardArgs(e)})` : `.entry(${args(e)})`);
182
+ return `Bundle.section(${args(shape.section)})` + entry + '.exists()';
183
+ }
184
+
185
+ /** A cardinality as written: '' for none, else 'min..max'. */
186
+ function cardText(p) {
187
+ return p.min === null || p.min === undefined ? '' : `${p.min}..${p.max}`;
188
+ }
189
+
190
+ /** 'min..max' (max a number or *), or blank: {min, max} (nulls for blank), or null when it isn't one. */
191
+ function parseCard(text) {
192
+ if (typeof text !== 'string' || !text.trim()) {
193
+ return { min: null, max: null };
194
+ }
195
+ const m = /^\s*(\d{1,6})\s*\.\.\s*(\d{1,6}|\*)\s*$/.exec(text);
196
+ if (!m) {
197
+ return null;
198
+ }
199
+ const min = parseInt(m[1], 10);
200
+ const max = m[2] === '*' ? '*' : parseInt(m[2], 10);
201
+ return max !== '*' && max < min ? null : { min, max };
202
+ }
203
+
204
+ // ---- checking ----
205
+
206
+ /** A problem with an expression (it doesn't parse, or fails on an empty document), or null. */
207
+ function expressionProblem(expression) {
208
+ if (typeof expression !== 'string' || !expression.trim()) {
209
+ return 'has no FHIRPath expression';
210
+ }
211
+ if (expression.length > MAX_EXPRESSION) {
212
+ return `has a FHIRPath expression longer than ${MAX_EXPRESSION} characters`;
213
+ }
214
+ const userInvocationTable = ipsFunctions();
215
+ // fhirpath.js only warns (and returns nothing) when a function has the wrong number of
216
+ // arguments: the trial run is synchronous, so its warnings are caught here
217
+ const warnings = [];
218
+ const warn = console.warn;
219
+ console.warn = (...a) => warnings.push(a.join(' '));
220
+ try {
221
+ const compiled = fhirpath.compile(expression, r4Model, { userInvocationTable });
222
+ // a trial run on a document with nothing in it finds unknown functions and the like
223
+ void compiled({ resourceType: 'Bundle', type: 'document', entry: [{ fullUrl: 'urn:uuid:c', resource: { resourceType: 'Composition', status: 'final', section: [] } }] });
224
+ } catch (e) {
225
+ return `has a FHIRPath expression that can't be used: ${String(e.message || e).split('\n')[0].substring(0, 200)}`;
226
+ } finally {
227
+ console.warn = warn;
228
+ }
229
+ const arity = warnings.find(w => /wrong arity/.test(w));
230
+ return arity ? `has a FHIRPath expression that can't be used: ${arity.replace(/ wrong arity: got (\d+)/, '() has the wrong number of arguments ($1)')}` : null;
231
+ }
232
+
233
+ /** A name (an identifier, as FHIR wants StructureDefinition.name) from a title. */
234
+ function nameFromTitle(title) {
235
+ const words = String(title || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/[^A-Za-z0-9 ]+/g, ' ').trim().split(/\s+/).filter(Boolean);
236
+ let name = words.map(w => w.charAt(0).toUpperCase() + w.substring(1)).join('');
237
+ if (!/^[A-Z]/.test(name)) {
238
+ name = 'Profile' + name;
239
+ }
240
+ return name.substring(0, 255);
241
+ }
242
+
243
+ /** A new private id. */
244
+ function newId() {
245
+ return crypto.randomBytes(6).toString('hex');
246
+ }
247
+
248
+ /**
249
+ * Check what the editor (or an upload) gives - a title and constraints - and give each
250
+ * constraint its key: a key it already has in the profile it replaces is kept, the others
251
+ * get new ones.
252
+ *
253
+ * @param {{title: *, constraints: *}} input
254
+ * @param {Object|null} previous - the StructureDefinition being replaced
255
+ * @returns {{title: string, constraints: Array<{key, human, expression}>, problems: string[]}}
256
+ */
257
+ function checkInput(input, previous = null) {
258
+ const problems = [];
259
+ const title = typeof input?.title === 'string' ? input.title.trim() : '';
260
+ if (!title) {
261
+ problems.push('The profile needs a title');
262
+ } else if (title.length > MAX_TITLE) {
263
+ problems.push(`The title is longer than ${MAX_TITLE} characters`);
264
+ }
265
+ const list = Array.isArray(input?.constraints) ? input.constraints : [];
266
+ if (input?.constraints !== undefined && !Array.isArray(input.constraints)) {
267
+ problems.push('The constraints must be a list');
268
+ }
269
+ if (list.length > MAX_CONSTRAINTS) {
270
+ problems.push(`There can be no more than ${MAX_CONSTRAINTS} constraints`);
271
+ }
272
+ const known = new Set(constraintsOf(previous).map(c => c.key));
273
+ let next = Math.max(0, ...[...known].map(k => parseInt(KEY_RE.exec(k)[1], 10))) + 1;
274
+ const used = new Set();
275
+ const constraints = [];
276
+ list.slice(0, MAX_CONSTRAINTS).forEach((c, i) => {
277
+ const n = i + 1;
278
+ if (!isObject(c)) {
279
+ problems.push(`Constraint ${n} is not an object`);
280
+ return;
281
+ }
282
+ const human = typeof c.human === 'string' ? c.human.trim() : '';
283
+ const expression = typeof c.expression === 'string' ? c.expression.trim() : '';
284
+ if (!human) {
285
+ problems.push(`Constraint ${n} has no description`);
286
+ } else if (human.length > MAX_HUMAN) {
287
+ problems.push(`Constraint ${n}'s description is longer than ${MAX_HUMAN} characters`);
288
+ }
289
+ const p = expressionProblem(expression);
290
+ if (p) {
291
+ problems.push(`Constraint ${n} ${p}`);
292
+ }
293
+ let key = typeof c.key === 'string' && known.has(c.key) && !used.has(c.key) ? c.key : null;
294
+ if (!key) {
295
+ key = `cp-${next++}`;
296
+ }
297
+ used.add(key);
298
+ constraints.push({ key, human, expression });
299
+ });
300
+ return { title, constraints, problems };
301
+ }
302
+
303
+ /**
304
+ * The StructureDefinition.
305
+ * @param {{id: string, url: string, title: string, constraints: Array, date?: string}} p
306
+ */
307
+ function build(p) {
308
+ return {
309
+ resourceType: 'StructureDefinition',
310
+ id: `profile-${p.id}`,
311
+ url: p.url,
312
+ name: nameFromTitle(p.title),
313
+ title: p.title,
314
+ status: 'active',
315
+ date: p.date || new Date().toISOString(),
316
+ fhirVersion: '4.0.1',
317
+ kind: 'resource',
318
+ abstract: false,
319
+ type: 'Bundle',
320
+ baseDefinition: BUNDLE,
321
+ derivation: 'constraint',
322
+ differential: {
323
+ element: [{
324
+ id: 'Bundle',
325
+ path: 'Bundle',
326
+ constraint: p.constraints.map(c => ({ key: c.key, severity: 'error', human: c.human, expression: c.expression, source: p.url }))
327
+ }]
328
+ }
329
+ };
330
+ }
331
+
332
+ /** A StructureDefinition's constraints, as the editor has them. */
333
+ function constraintsOf(sd) {
334
+ const el = sd?.differential?.element?.[0];
335
+ return (Array.isArray(el?.constraint) ? el.constraint : [])
336
+ .filter(c => isObject(c) && typeof c.key === 'string' && KEY_RE.test(c.key))
337
+ .map(c => ({ key: c.key, human: c.human || '', expression: c.expression || '' }));
338
+ }
339
+
340
+ // what an uploaded StructureDefinition may have: what build() makes, and the metadata a
341
+ // tool might add that is replaced anyway
342
+ const UPLOAD_KEYS = new Set(['resourceType', 'id', 'meta', 'text', 'url', 'version', 'name', 'title', 'status', 'date', 'publisher',
343
+ 'fhirVersion', 'kind', 'abstract', 'type', 'baseDefinition', 'derivation', 'differential']);
344
+ const ELEMENT_KEYS = new Set(['id', 'path', 'constraint']);
345
+ const CONSTRAINT_KEYS = new Set(['key', 'severity', 'human', 'expression', 'source']);
346
+
347
+ /**
348
+ * An uploaded StructureDefinition, as the editor's input (title and constraints, the
349
+ * keys dropped so they are assigned afresh), or the reasons it can't be taken.
350
+ * @returns {{input: {title, constraints}|null, problems: string[]}}
351
+ */
352
+ function fromUpload(sd) {
353
+ const problems = [];
354
+ if (!isObject(sd) || sd.resourceType !== 'StructureDefinition') {
355
+ return { input: null, problems: ['That is not a StructureDefinition'] };
356
+ }
357
+ if (sd.type === 'Composition' && sd.baseDefinition === OLD_BASE) {
358
+ // one downloaded when profiles were on Composition: moved onto the document
359
+ sd = structuredClone(sd);
360
+ sd.type = 'Bundle';
361
+ sd.baseDefinition = BUNDLE;
362
+ for (const el of Array.isArray(sd.differential?.element) ? sd.differential.element : []) {
363
+ if (isObject(el) && el.path === 'Composition') {
364
+ el.path = 'Bundle';
365
+ if (el.id === 'Composition') {
366
+ el.id = 'Bundle';
367
+ }
368
+ for (const c of Array.isArray(el.constraint) ? el.constraint : []) {
369
+ if (isObject(c) && typeof c.expression === 'string') {
370
+ c.expression = onDocument(c.expression);
371
+ }
372
+ }
373
+ }
374
+ }
375
+ }
376
+ const extra = Object.keys(sd).filter(k => !UPLOAD_KEYS.has(k));
377
+ if (extra.length) {
378
+ problems.push(`It has more than a clinical profile can hold: ${extra.join(', ')}`);
379
+ }
380
+ if (sd.type !== 'Bundle' || sd.baseDefinition !== BUNDLE || (sd.derivation !== undefined && sd.derivation !== 'constraint') ||
381
+ (sd.kind !== undefined && sd.kind !== 'resource') || (sd.abstract !== undefined && sd.abstract !== false)) {
382
+ problems.push(`It must be a profile on Bundle (type Bundle, baseDefinition ${BUNDLE}, derivation constraint)`);
383
+ }
384
+ const elements = Array.isArray(sd.differential?.element) ? sd.differential.element : null;
385
+ if (sd.differential !== undefined && (!isObject(sd.differential) || Object.keys(sd.differential).some(k => k !== 'element'))) {
386
+ problems.push('Its differential may only have elements');
387
+ }
388
+ let list = [];
389
+ if (!elements || elements.length !== 1 || !isObject(elements[0]) || elements[0].path !== 'Bundle' ||
390
+ (elements[0].id !== undefined && elements[0].id !== 'Bundle')) {
391
+ problems.push('Its differential must have just one element, the root (Bundle)');
392
+ } else {
393
+ const el = elements[0];
394
+ const extraEl = Object.keys(el).filter(k => !ELEMENT_KEYS.has(k));
395
+ if (extraEl.length) {
396
+ problems.push(`Its root element has more than constraints: ${extraEl.join(', ')}`);
397
+ }
398
+ if (el.constraint !== undefined && !Array.isArray(el.constraint)) {
399
+ problems.push('Its root element\'s constraints must be a list');
400
+ }
401
+ list = Array.isArray(el.constraint) ? el.constraint : [];
402
+ list.forEach((c, i) => {
403
+ if (!isObject(c)) {
404
+ return;
405
+ }
406
+ const extraC = Object.keys(c).filter(k => !CONSTRAINT_KEYS.has(k));
407
+ if (extraC.length) {
408
+ problems.push(`Constraint ${i + 1} has more than a key, description and expression: ${extraC.join(', ')}`);
409
+ }
410
+ if (c.severity !== undefined && c.severity !== 'error') {
411
+ problems.push(`Constraint ${i + 1} is not an error (the constraints of a clinical profile are)`);
412
+ }
413
+ });
414
+ }
415
+ const title = typeof sd.title === 'string' && sd.title.trim() ? sd.title : (typeof sd.name === 'string' ? sd.name : '');
416
+ return { input: { title, constraints: list.map(c => (isObject(c) ? { human: c.human, expression: c.expression } : c)) }, problems };
417
+ }
418
+
419
+ /**
420
+ * The read-only description of a shaped part: which section, which code or which value
421
+ * set, with bounds. titleOf(url) gives a value set's title, when it is known.
422
+ */
423
+ function describePart(p, isSection, titleOf) {
424
+ if (p.valueSet !== undefined) {
425
+ const title = typeof titleOf === 'function' ? titleOf(p.valueSet) : null;
426
+ return { what: title || p.valueSet, code: title ? p.valueSet : null, card: cardText(p), valueSet: true };
427
+ }
428
+ const named = isSection && p.system === LOINC ? IPS_SECTIONS.find(s => s.code === p.code) : null;
429
+ const sys = SYSTEMS.find(s => s.url === p.system);
430
+ const what = named ? named.title : `${sys ? sys.name : p.system} ${p.code}`;
431
+ return { what, code: named ? `${sys ? sys.name : p.system} ${p.code}` : null, card: cardText(p) };
432
+ }
433
+
434
+ /**
435
+ * An expression written for a Composition, for its document: each Composition.section(...)
436
+ * becomes Bundle.section(...) (section() on a document looks in its Composition), and any
437
+ * other Composition.x becomes Bundle.entry.resource.ofType(Composition).x - every one in the
438
+ * expression, not only a leading one, and never inside a string or a `quoted` name. A path
439
+ * that doesn't start with a type name (title.exists()) can't be told from the document's
440
+ * own, and is left as it is.
441
+ */
442
+ function onDocument(expression) {
443
+ let out = '';
444
+ let i = 0;
445
+ const n = expression.length;
446
+ while (i < n) {
447
+ const ch = expression[i];
448
+ if (ch === "'" || ch === '`') {
449
+ // a string or a delimited name, with its escapes, as it is
450
+ let j = i + 1;
451
+ while (j < n && expression[j] !== ch) {
452
+ j += expression[j] === '\\' ? 2 : 1;
453
+ }
454
+ out += expression.substring(i, j + 1);
455
+ i = j + 1;
456
+ continue;
457
+ }
458
+ // Composition at the start of a path: not part of a longer name, not after a dot
459
+ const prev = i > 0 ? expression[i - 1] : '';
460
+ if (expression.startsWith('Composition', i) && !/[A-Za-z0-9_.]/.test(prev) && !/[A-Za-z0-9_]/.test(expression[i + 11] || '')) {
461
+ const m = /^Composition\s*\.\s*(section\s*\()?/.exec(expression.substring(i));
462
+ if (m) {
463
+ out += m[1] ? 'Bundle.section(' : 'Bundle.entry.resource.ofType(Composition).';
464
+ i += m[0].length;
465
+ continue;
466
+ }
467
+ }
468
+ out += ch;
469
+ i++;
470
+ }
471
+ return out;
472
+ }
473
+
474
+ /**
475
+ * A profile made when profiles were on Composition, moved onto Bundle: the same id, url,
476
+ * title and keys, the expressions moved onto the document (onDocument).
477
+ * @returns {Object|null} the new StructureDefinition, or null when there is nothing to do
478
+ */
479
+ function migrate(sd) {
480
+ if (!isObject(sd) || sd.type !== 'Composition' || sd.baseDefinition !== OLD_BASE) {
481
+ return null;
482
+ }
483
+ const constraints = constraintsOf(sd).map(c => ({ ...c, expression: onDocument(c.expression) }));
484
+ return build({ id: String(sd.id).replace(/^profile-/, ''), url: sd.url, title: sd.title || sd.name || 'Profile', constraints, date: sd.date });
485
+ }
486
+
487
+ /** The resource types the editor offers for a rule about resources (any other can be typed). */
488
+ const RESOURCE_TYPES = ['Condition', 'AllergyIntolerance', 'MedicationStatement', 'MedicationRequest', 'MedicationAdministration', 'MedicationDispense',
489
+ 'Immunization', 'Procedure', 'Observation', 'DiagnosticReport', 'AdverseEvent', 'DetectedIssue'];
490
+
491
+ /** The read-only description of a rule about resources. */
492
+ function describeResource(r, titleOf) {
493
+ if (r.valueSet !== undefined) {
494
+ const title = typeof titleOf === 'function' ? titleOf(r.valueSet) : null;
495
+ return { type: r.type, valueSet: r.valueSet, title, rest: r.rest };
496
+ }
497
+ const sys = SYSTEMS.find(s => s.url === r.system);
498
+ return { type: r.type, code: `${sys ? sys.name : r.system} ${r.code}`, rest: r.rest };
499
+ }
500
+
501
+ module.exports = {
502
+ SYSTEMS, IPS_SECTIONS, RESOURCE_TYPES, BUNDLE, MAX_CONSTRAINTS, ID_RE, migrate,
503
+ parseResourceShape, resourceShapeExpression, kindOf, describeResource,
504
+ parseShape, shapeExpression, parseCard, cardText, expressionProblem, nameFromTitle, newId,
505
+ checkInput, build, constraintsOf, fromUpload, describePart
506
+ };