babelfhir-ts 1.5.20 → 1.5.21
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.
- package/out/src/cli/updateCommand.js +19 -12
- package/out/src/generator/core/extensionSdReader.js +99 -0
- package/out/src/generator/core/utils.js +57 -2
- package/out/src/generator/emitters/class/classTemplate.js +8 -2
- package/out/src/generator/emitters/client/clientGenerator.js +22 -6
- package/out/src/generator/emitters/client/smartAuthGenerator.js +4 -1
- package/out/src/generator/emitters/interface/backboneSliceTyping.js +2 -2
- package/out/src/generator/emitters/interface/postProcessExtensions.js +1 -0
- package/out/src/generator/emitters/validator/closedSlicingValidation.js +146 -0
- package/out/src/generator/emitters/validator/sliceExtensionValidation.js +200 -35
- package/out/src/generator/emitters/validator/sliceValidatorGenerator.js +98 -14
- package/out/src/generator/emitters/validator/subExtensionScope.js +100 -0
- package/out/src/generator/emitters/validator/validatorConstraintBuilders.js +13 -1
- package/out/src/generator/emitters/validator/validatorFieldBuilders.js +218 -10
- package/out/src/generator/emitters/validator/validatorGenerator.js +4 -2
- package/out/src/generator/emitters/validator/validatorRuntime.js +17 -7
- package/out/src/generator/index.js +21 -11
- package/out/src/generator/parser/sdParser.js +1 -0
- package/out/src/generator/sdProcessor.js +209 -21
- package/out/src/generator/sdProcessorRequiredFields.js +32 -7
- package/package.json +2 -1
|
@@ -11,6 +11,7 @@ import { generateClass } from './emitters/class/classGenerator.js';
|
|
|
11
11
|
import { generateInterfaces } from './emitters/interface/interfaceGenerator.js';
|
|
12
12
|
import { fetchStructureDefinition, parseStructureDefinition } from './parser/sdParser.js';
|
|
13
13
|
import { writeInterfaceAndValidatorToFile, mergeFields, alignFields, writeToFile, getBaseResource, sanitizeIdentifier, toPascalCase, stripVersionFromCanonicalUrl } from './core/utils.js';
|
|
14
|
+
import { extensionElements, collectSubExtensionSlices, readSubExtensionMin, readBinding } from './core/extensionSdReader.js';
|
|
14
15
|
import { generateValidateProfileFunction } from './emitters/validator/validatorGenerator.js';
|
|
15
16
|
import { generateZodSchema } from './emitters/zod/zodSchemaGenerator.js';
|
|
16
17
|
import { extractFieldPatternsAndMetadata } from './emitters/class/fieldPatternExtractor.js';
|
|
@@ -22,10 +23,172 @@ import { extractAndDeduplicateRequiredFields, resolveChoiceSegments } from './sd
|
|
|
22
23
|
// Re-export public API consumed by index.ts / generationHelpers.ts
|
|
23
24
|
export { resetFetchFailureTracking, getFetchFailureCount, getFetchFailureWarning };
|
|
24
25
|
const log = logger.withTag('generator');
|
|
26
|
+
/**
|
|
27
|
+
* Fill in what a profile's differential never states, from the base resource.
|
|
28
|
+
*
|
|
29
|
+
* A differential element says only what it *changes*. KBV constrains
|
|
30
|
+
* `Medication.amount.numerator.value` to max=0 and says nothing about its type, so
|
|
31
|
+
* the merged field read `any` and every type-aware check skipped it — HL7 reported
|
|
32
|
+
* "The value 'Example value' is not a valid decimal" where we were silent. The same
|
|
33
|
+
* silence on `max` left `Medication.ingredient` non-repeating despite being 0..* in
|
|
34
|
+
* the base, so nothing beneath it was ever walked.
|
|
35
|
+
*
|
|
36
|
+
* The base resource snapshot supplies the top of each path (`Medication.amount` is a
|
|
37
|
+
* Ratio); below that it stops, because a resource never expands its datatypes'
|
|
38
|
+
* children. Those come from the datatype tables — `Ratio.numerator` is a Quantity,
|
|
39
|
+
* `Quantity.value` a decimal — walked down from the deepest ancestor whose type is
|
|
40
|
+
* already known. An unresolvable hop leaves the field alone rather than guessing.
|
|
41
|
+
*
|
|
42
|
+
* Only unstated properties are filled; anything the differential declares still wins.
|
|
43
|
+
*/
|
|
44
|
+
async function fillUnstatedFromBaseResource(fields, resourceType) {
|
|
45
|
+
if (!resourceType)
|
|
46
|
+
return;
|
|
47
|
+
let baseSd;
|
|
48
|
+
try {
|
|
49
|
+
baseSd = await fetchStructureDefinition(`http://hl7.org/fhir/StructureDefinition/${resourceType}`);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const baseElements = baseSd?.snapshot?.element;
|
|
55
|
+
if (!baseElements?.length)
|
|
56
|
+
return;
|
|
57
|
+
const baseByPath = new Map();
|
|
58
|
+
for (const el of baseElements) {
|
|
59
|
+
const id = el.id ?? el.path;
|
|
60
|
+
if (!id)
|
|
61
|
+
continue;
|
|
62
|
+
const rel = id.replace(/^[^.]+\./, '');
|
|
63
|
+
if (!rel || rel === id || rel.includes(':'))
|
|
64
|
+
continue;
|
|
65
|
+
baseByPath.set(rel, { type: el.type?.[0]?.code, max: el.max });
|
|
66
|
+
}
|
|
67
|
+
// Container types name no datatype to walk into, so they seed nothing.
|
|
68
|
+
const knownType = new Map();
|
|
69
|
+
for (const [rel, meta] of baseByPath) {
|
|
70
|
+
if (meta.type && meta.type !== 'BackboneElement' && meta.type !== 'Element') {
|
|
71
|
+
knownType.set(rel, meta.type);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const unstated = (type) => !type || type === 'any';
|
|
75
|
+
// Shallow paths first, so a deeper one can walk down from an ancestor this pass
|
|
76
|
+
// has just resolved.
|
|
77
|
+
const ordered = [...fields].sort((a, b) => (a.name || '').split('.').length - (b.name || '').split('.').length);
|
|
78
|
+
for (const field of ordered) {
|
|
79
|
+
const rel = (field.name || '').replace(/^[^.]+\./, '');
|
|
80
|
+
if (!rel)
|
|
81
|
+
continue;
|
|
82
|
+
const base = baseByPath.get(rel);
|
|
83
|
+
if (base) {
|
|
84
|
+
if (unstated(field.type) && base.type)
|
|
85
|
+
field.type = base.type;
|
|
86
|
+
if (field.max === undefined && base.max !== undefined) {
|
|
87
|
+
const unbounded = base.max === '*';
|
|
88
|
+
const parsed = unbounded ? Infinity : Number.parseInt(base.max, 10);
|
|
89
|
+
if (unbounded || Number.isFinite(parsed)) {
|
|
90
|
+
field.max = parsed;
|
|
91
|
+
field.isArray = unbounded || parsed > 1;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (!unstated(field.type))
|
|
95
|
+
knownType.set(rel, field.type);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (!unstated(field.type)) {
|
|
99
|
+
knownType.set(rel, field.type);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const segments = rel.split('.');
|
|
103
|
+
let current;
|
|
104
|
+
let next = 0;
|
|
105
|
+
for (let depth = segments.length - 1; depth >= 1; depth--) {
|
|
106
|
+
const ancestorType = knownType.get(segments.slice(0, depth).join('.'));
|
|
107
|
+
if (ancestorType) {
|
|
108
|
+
current = ancestorType;
|
|
109
|
+
next = depth;
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (!current)
|
|
114
|
+
continue;
|
|
115
|
+
for (let i = next; i < segments.length && current; i++) {
|
|
116
|
+
current = resolveComplexTypeChildType(current, segments[i]);
|
|
117
|
+
}
|
|
118
|
+
if (current) {
|
|
119
|
+
field.type = current;
|
|
120
|
+
knownType.set(rel, current);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Fill in extension-slice canonicals a derived profile does not restate.
|
|
126
|
+
*
|
|
127
|
+
* An extension slice is recognised at runtime by the canonical its `type.profile`
|
|
128
|
+
* names. A derived profile that only tightens cardinality omits it:
|
|
129
|
+
* KBV_PR_ERP_Medication_PZN redeclares `extension:Kategorie` and
|
|
130
|
+
* `extension:Normgroesse` with no profile, while its base KBV_PR_Base_Medication
|
|
131
|
+
* gives them KBV_EX_Base_Medication_Type and .../normgroesse. Two of four slices
|
|
132
|
+
* were therefore unidentifiable, and the closed-slicing check on
|
|
133
|
+
* `Medication.extension` could not build an allowed set — so it stayed silent where
|
|
134
|
+
* HL7 reports "does not match any known slice … and slicing is CLOSED".
|
|
135
|
+
*
|
|
136
|
+
* Walks the baseDefinition chain, nearest ancestor first, and stops at the first
|
|
137
|
+
* that names a canonical. Depth-bounded: a malformed chain must not loop.
|
|
138
|
+
*/
|
|
139
|
+
async function fillSliceProfileUrlsFromBaseProfiles(fields, baseDefinition) {
|
|
140
|
+
const needing = fields.filter(f => f.sliceName && !f.profileUrls?.length && f.elementId);
|
|
141
|
+
if (needing.length === 0)
|
|
142
|
+
return;
|
|
143
|
+
const seen = new Set();
|
|
144
|
+
let canonical = baseDefinition;
|
|
145
|
+
for (let depth = 0; depth < 5 && canonical; depth++) {
|
|
146
|
+
const key = stripVersionFromCanonicalUrl(canonical);
|
|
147
|
+
if (seen.has(key))
|
|
148
|
+
break;
|
|
149
|
+
seen.add(key);
|
|
150
|
+
let baseSd;
|
|
151
|
+
try {
|
|
152
|
+
baseSd = await fetchStructureDefinition(canonical);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (!baseSd)
|
|
158
|
+
return;
|
|
159
|
+
const byElementId = new Map();
|
|
160
|
+
for (const el of (baseSd.snapshot?.element ?? baseSd.differential?.element ?? [])) {
|
|
161
|
+
const id = el.id;
|
|
162
|
+
if (!id)
|
|
163
|
+
continue;
|
|
164
|
+
const profiles = (el.type ?? []).flatMap(t => t.profile ?? []);
|
|
165
|
+
if (profiles.length)
|
|
166
|
+
byElementId.set(id, profiles);
|
|
167
|
+
}
|
|
168
|
+
for (const field of needing) {
|
|
169
|
+
if (field.profileUrls?.length)
|
|
170
|
+
continue;
|
|
171
|
+
const inherited = byElementId.get(field.elementId);
|
|
172
|
+
if (inherited?.length)
|
|
173
|
+
field.profileUrls = inherited;
|
|
174
|
+
}
|
|
175
|
+
if (needing.every(f => f.profileUrls?.length))
|
|
176
|
+
return;
|
|
177
|
+
canonical = baseSd.baseDefinition;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
25
180
|
// ── Extension metadata resolution for validator emission ──────────────────
|
|
26
181
|
/**
|
|
27
182
|
* Scan fields for extension slices with profile URLs, resolve their
|
|
28
183
|
* StructureDefinitions, and extract value[x] type + binding metadata.
|
|
184
|
+
*
|
|
185
|
+
* Keyed by the *version-stripped* canonical. A slice names its extension twice and
|
|
186
|
+
* the two forms disagree: `type.profile` carries a version suffix
|
|
187
|
+
* (`…/sdc-questionnaire-contextExpression|4.0.0`) while the pinned `url` child
|
|
188
|
+
* carries the bare canonical. Keying on whichever arrived first left the other
|
|
189
|
+
* lookup empty, and an absent entry means "SD unreadable, conclude nothing" — so
|
|
190
|
+
* DTRStdQuestionnaire's contextExpression emitted no validation at all.
|
|
191
|
+
* Callers must strip before reading; use `lookupExtensionMetadata`.
|
|
29
192
|
*/
|
|
30
193
|
async function resolveExtensionMetadata(fields) {
|
|
31
194
|
const extUrls = new Set();
|
|
@@ -41,11 +204,35 @@ async function resolveExtensionMetadata(fields) {
|
|
|
41
204
|
return undefined;
|
|
42
205
|
const map = new Map();
|
|
43
206
|
await Promise.all([...extUrls].map(async (url) => {
|
|
207
|
+
// Fetch by the url as written — the version suffix selects the right SD — but
|
|
208
|
+
// key the result by the stripped form so either spelling finds it.
|
|
209
|
+
const key = stripVersionFromCanonicalUrl(url);
|
|
44
210
|
try {
|
|
45
211
|
const extSd = await fetchStructureDefinition(url);
|
|
46
|
-
|
|
212
|
+
const els = extensionElements(extSd);
|
|
213
|
+
if (!els)
|
|
47
214
|
return;
|
|
48
|
-
const valueEl =
|
|
215
|
+
const valueEl = els.find(el => el.path === 'Extension.value[x]' || el.id === 'Extension.value[x]');
|
|
216
|
+
const subExtensions = collectSubExtensionSlices(els);
|
|
217
|
+
const subExtensionMin = readSubExtensionMin(els);
|
|
218
|
+
// A complex extension carries sub-extensions instead of a value, and forbids a
|
|
219
|
+
// value outright (ext-1: extension.exists() != value.exists()). Recorded as an
|
|
220
|
+
// empty type list — different from no entry at all, which would mean the SD
|
|
221
|
+
// could not be read and nothing can be concluded.
|
|
222
|
+
//
|
|
223
|
+
// Recognised two ways because published SDs state it two ways: value[x] pinned
|
|
224
|
+
// to max=0 (sdc-questionnaire-contextExpression), or a required
|
|
225
|
+
// Extension.extension with no value[x] element at all
|
|
226
|
+
// (KBV_EX_ERP_PracticeSupply_Payor, whose differential omits value[x] entirely —
|
|
227
|
+
// the earlier `if (!valueEl) return` dropped it before anything was read).
|
|
228
|
+
if (valueEl?.max === '0' || (subExtensionMin ?? 0) >= 1) {
|
|
229
|
+
map.set(key, {
|
|
230
|
+
valueTypes: [],
|
|
231
|
+
subExtensionMin,
|
|
232
|
+
subExtensions: subExtensions.length ? subExtensions : undefined,
|
|
233
|
+
});
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
49
236
|
if (!valueEl)
|
|
50
237
|
return;
|
|
51
238
|
const types = (valueEl.type || []).map((t) => t.code).filter(Boolean);
|
|
@@ -54,25 +241,17 @@ async function resolveExtensionMetadata(fields) {
|
|
|
54
241
|
const entry = { valueTypes: types };
|
|
55
242
|
// Check binding on value[x] first, then fall back to typed slices
|
|
56
243
|
// (e.g., Extension.valueCode may carry the binding instead of Extension.value[x])
|
|
57
|
-
let binding = valueEl
|
|
58
|
-
if (!binding
|
|
244
|
+
let binding = readBinding(valueEl);
|
|
245
|
+
if (!binding && types.length === 1) {
|
|
59
246
|
const suffix = types[0].charAt(0).toUpperCase() + types[0].slice(1);
|
|
60
|
-
const sliceEl =
|
|
247
|
+
const sliceEl = els.find(el => el.id === `Extension.value[x]:value${suffix}` ||
|
|
61
248
|
el.path === `Extension.value${suffix}` ||
|
|
62
249
|
el.id === `Extension.value${suffix}`);
|
|
63
|
-
|
|
64
|
-
binding = sliceEl.binding;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
if (binding?.strength && binding?.valueSet) {
|
|
68
|
-
const vsUri = typeof binding.valueSet === 'string'
|
|
69
|
-
? binding.valueSet
|
|
70
|
-
: binding.valueSet?.reference;
|
|
71
|
-
if (vsUri) {
|
|
72
|
-
entry.binding = { strength: binding.strength, uri: vsUri.split('|')[0] };
|
|
73
|
-
}
|
|
250
|
+
binding = readBinding(sliceEl);
|
|
74
251
|
}
|
|
75
|
-
|
|
252
|
+
if (binding)
|
|
253
|
+
entry.binding = binding;
|
|
254
|
+
map.set(key, entry);
|
|
76
255
|
}
|
|
77
256
|
catch {
|
|
78
257
|
// Extension SD not resolvable — skip
|
|
@@ -187,7 +366,10 @@ export async function processStructureDefinition(sd, ctx) {
|
|
|
187
366
|
// ── External profile resolution ──────────────────────────────────
|
|
188
367
|
for (const [typeName, profileInfo] of interfaceResult.referencedExternalProfiles) {
|
|
189
368
|
const normalizedTypeName = toPascalCase(sanitizeIdentifier(typeName));
|
|
190
|
-
|
|
369
|
+
// Tolerates the `type` modifier the interface emitter writes; the second
|
|
370
|
+
// clause already did, but leaving the first matching only the bare form
|
|
371
|
+
// makes the two disagree for no reason.
|
|
372
|
+
const isUsedHere = new RegExp(`import(?:\\s+type)?\\s+\\{\\s*${normalizedTypeName}\\s*\\}\\s+from\\s+"\\./${normalizedTypeName}"`).test(interfaceContent) ||
|
|
191
373
|
(interfaceContent.includes(`from "./${normalizedTypeName}"`) && interfaceContent.includes(`{ ${normalizedTypeName} }`));
|
|
192
374
|
if (!isUsedHere)
|
|
193
375
|
continue;
|
|
@@ -265,9 +447,13 @@ export async function processStructureDefinition(sd, ctx) {
|
|
|
265
447
|
let finalInterfaceContent = interfaceContent;
|
|
266
448
|
for (const [originalName, actualName] of profileIdToName?.entries() || []) {
|
|
267
449
|
if (originalName !== actualName) {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
450
|
+
// Matched by pattern, not by a literal string: the emitted form carries a
|
|
451
|
+
// `type` modifier, and a literal that omitted it renamed the *usages*
|
|
452
|
+
// while leaving the import pointing at the old module — which generated
|
|
453
|
+
// `Cannot find module './BodySite'` alongside `Cannot find name
|
|
454
|
+
// 'BodyStructureReference'` in the same file.
|
|
455
|
+
const importRegex = new RegExp(`import(\\s+type)?\\s+\\{\\s*${originalName}\\s*\\}\\s+from\\s+"\\./${originalName}"`, 'g');
|
|
456
|
+
finalInterfaceContent = finalInterfaceContent.replace(importRegex, (_m, typeModifier) => `import${typeModifier ?? ''} { ${actualName} } from "./${actualName}"`);
|
|
271
457
|
const typeUsageRegex = new RegExp(`(:\\s+|Array<|\\|\\s+|&\\s+|extends\\s+)${originalName}(?=\\s*[;,>\\|&\\)\\n\\[\\s{])`, 'g');
|
|
272
458
|
finalInterfaceContent = finalInterfaceContent.replace(typeUsageRegex, `$1${actualName}`);
|
|
273
459
|
}
|
|
@@ -287,6 +473,8 @@ export async function processStructureDefinition(sd, ctx) {
|
|
|
287
473
|
finalInterfaceContent = dedupedLines.join('\n');
|
|
288
474
|
// ── Required fields ──────────────────────────────────────────────
|
|
289
475
|
const merged = mergeFields(aligned.alignedNewFields, parsed.oldFields);
|
|
476
|
+
await fillUnstatedFromBaseResource(merged, sd.type);
|
|
477
|
+
await fillSliceProfileUrlsFromBaseProfiles(merged, sd.baseDefinition);
|
|
290
478
|
// ── Pre-resolve Extension SDs for validator emission ─────────────
|
|
291
479
|
const extensionMetadata = await resolveExtensionMetadata(merged);
|
|
292
480
|
const datatypeProfilePins = await resolveDatatypeProfilePins(merged);
|
|
@@ -173,16 +173,41 @@ function choiceSuffix(type) {
|
|
|
173
173
|
* The trailing `[x]` is also tried stripped, because a profile that pins the
|
|
174
174
|
* choice may record it under the bare name.
|
|
175
175
|
*/
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
176
|
+
/**
|
|
177
|
+
* Narrowed types by field name, built once per field list.
|
|
178
|
+
*
|
|
179
|
+
* The scan used to run per `[x]` segment, which is O(fields × segments): us-core
|
|
180
|
+
* has thousands of both, and generation crossed the 300s subprocess timeout in CI
|
|
181
|
+
* (`spawnSync /bin/sh ETIMEDOUT`) while every package small enough to finish still
|
|
182
|
+
* passed. Keyed on the array itself so callers need not thread an index through.
|
|
183
|
+
*/
|
|
184
|
+
const narrowedTypeIndexes = new WeakMap();
|
|
185
|
+
function narrowedTypeIndex(candidateFields) {
|
|
186
|
+
const cached = narrowedTypeIndexes.get(candidateFields);
|
|
187
|
+
if (cached)
|
|
188
|
+
return cached;
|
|
189
|
+
const index = new Map();
|
|
179
190
|
for (const candidate of candidateFields) {
|
|
180
|
-
if (candidate.name !== id && candidate.name !== bare)
|
|
181
|
-
continue;
|
|
182
191
|
const type = singleType(candidate);
|
|
183
|
-
if (type)
|
|
184
|
-
|
|
192
|
+
if (!type || !candidate.name)
|
|
193
|
+
continue;
|
|
194
|
+
let types = index.get(candidate.name);
|
|
195
|
+
if (!types)
|
|
196
|
+
index.set(candidate.name, (types = new Set()));
|
|
197
|
+
types.add(type);
|
|
185
198
|
}
|
|
199
|
+
narrowedTypeIndexes.set(candidateFields, index);
|
|
200
|
+
return index;
|
|
201
|
+
}
|
|
202
|
+
function narrowedSegmentType(id, candidateFields) {
|
|
203
|
+
const index = narrowedTypeIndex(candidateFields);
|
|
204
|
+
const bare = id.replace(/\[x\]$/, '');
|
|
205
|
+
// Both spellings, unioned: the same element appears under `value[x]` and, once a
|
|
206
|
+
// profile pins it, under `value`. Disagreement still names nothing.
|
|
207
|
+
const narrowed = new Set([
|
|
208
|
+
...(index.get(id) ?? []),
|
|
209
|
+
...(bare === id ? [] : index.get(bare) ?? []),
|
|
210
|
+
]);
|
|
186
211
|
return narrowed.size === 1 ? [...narrowed][0] : undefined;
|
|
187
212
|
}
|
|
188
213
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "babelfhir-ts",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.21",
|
|
4
4
|
"description": "BabelFHIR-TS: generate TypeScript interfaces, validators, and helper classes from FHIR R4/R4B/R5 StructureDefinitions (profiles) directly inside package archives.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "out/src/main.js",
|
|
@@ -86,6 +86,7 @@
|
|
|
86
86
|
},
|
|
87
87
|
"devDependencies": {
|
|
88
88
|
"@eslint/js": "^10.0.1",
|
|
89
|
+
"@max-health/config": "github:max-health-inc/config",
|
|
89
90
|
"@maxhealth.tech/prefab": "^0.3.5",
|
|
90
91
|
"@types/node": "^25.9.5",
|
|
91
92
|
"@types/semver": "^7.7.1",
|