babelfhir-ts 1.5.16 → 1.5.18
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/generator/core/generatedPackageDeps.js +78 -0
- package/out/src/generator/emitters/interface/processNestedField.js +14 -8
- package/out/src/generator/emitters/prefab/prefabEmitter.js +7 -3
- package/out/src/generator/emitters/prefab/prefabRenderer.js +13 -6
- package/out/src/generator/emitters/prefab/prefabRuntimeAssets.js +1 -1
- package/out/src/generator/emitters/validator/fhirpathStubInstaller.js +6 -1
- package/out/src/generator/emitters/validator/sliceBackboneValidation.js +11 -13
- package/out/src/generator/emitters/validator/sliceExtensionValidation.js +22 -19
- package/out/src/generator/emitters/validator/sliceValidatorGenerator.js +6 -6
- package/out/src/generator/emitters/validator/sliceValidatorUtils.js +23 -8
- package/out/src/generator/emitters/validator/validatorBindingLeafEmitters.js +29 -31
- package/out/src/generator/emitters/validator/validatorConstraintBuilders.js +1 -1
- package/out/src/generator/emitters/validator/validatorFieldBuilders.js +49 -51
- package/out/src/generator/emitters/validator/validatorGenerator.js +56 -5
- package/out/src/generator/emitters/valueset/valueSetGenerator.js +45 -26
- package/out/src/generator/fhir/versionContext.js +14 -0
- package/out/src/generator/fhir/versionContext.ts +15 -0
- package/out/src/generator/generationHelpers.js +12 -26
- package/out/src/generator/index.js +10 -51
- package/out/src/generator/sdProcessor.js +1 -1
- package/out/src/main.js +2 -2
- package/package.json +29 -22
- package/parity-matrix.json +186 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency ranges written into a generated package's `package.json`.
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth: the generated-package manifest is assembled in two
|
|
5
|
+
* places (the archive pipeline and the directory pipeline in
|
|
6
|
+
* `src/generator/index.ts`), and both must emit identical ranges. Keep the
|
|
7
|
+
* ranges here, not inline at the call sites.
|
|
8
|
+
*
|
|
9
|
+
* These ranges describe what the *consumer* of a generated package installs,
|
|
10
|
+
* so they are deliberately wider than this repo's own devDependencies.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Runtime/peer ranges emitted into generated packages.
|
|
14
|
+
*
|
|
15
|
+
* `fhirpath` is a peer so the host decides the version. All of 3.x/4.x/5.x are
|
|
16
|
+
* accepted: generated validators only call `fhirpath.evaluate()` and import
|
|
17
|
+
* `fhirpath/fhir-context/<model>/index.js`, both of which are stable across
|
|
18
|
+
* those majors. The `/index.js` filename is required — fhirpath >= 5 declares
|
|
19
|
+
* an `exports` map, so extensionless deep paths no longer resolve.
|
|
20
|
+
*/
|
|
21
|
+
export const EMITTED_RANGES = {
|
|
22
|
+
/** FHIR type declarations backing the emitted `.d.ts` files. */
|
|
23
|
+
fhirTypes: '^0.0.41',
|
|
24
|
+
/** FHIRPath engine used by emitted constraint validators. */
|
|
25
|
+
fhirpath: '^3.0.0 || ^4.0.0 || ^5.0.0',
|
|
26
|
+
/** Schema runtime for `--schema zod` output. */
|
|
27
|
+
zod: '^4.0.0',
|
|
28
|
+
/**
|
|
29
|
+
* Prefab UI runtime for `--prefab` output.
|
|
30
|
+
*
|
|
31
|
+
* 0.3.0 is the floor: it introduced wire protocol 0.3, where the theme is
|
|
32
|
+
* compiled into the top-level `css` array, `stylesheets` carries external
|
|
33
|
+
* URLs, and `mode` forces a colour scheme. Emitted renderers pass `theme`
|
|
34
|
+
* and spread `PrefabAppOptions` into `PrefabApp`, so they rely on a >= 0.3
|
|
35
|
+
* serializer/renderer pair to apply themes. A 0.2.x renderer would silently
|
|
36
|
+
* drop them.
|
|
37
|
+
*/
|
|
38
|
+
prefab: '^0.3.0',
|
|
39
|
+
/** Generated FHIR client base package (`@babelfhir-ts/client-<slug>`). */
|
|
40
|
+
client: '^0.2.0',
|
|
41
|
+
/** Shared base zod schemas (`@babelfhir-ts/zod`). */
|
|
42
|
+
zodBase: '^0.2.0',
|
|
43
|
+
/** DICOMweb helpers (`@babelfhir-ts/dicomweb`). */
|
|
44
|
+
dicomweb: '^0.1.0',
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Build the dependency sections of a generated package's `package.json` from
|
|
48
|
+
* the active generation flags.
|
|
49
|
+
*
|
|
50
|
+
* @param flags Generation flags for this run.
|
|
51
|
+
* @param clientSlug FHIR version slug used in the client package name.
|
|
52
|
+
*/
|
|
53
|
+
export function buildGeneratedPackageDeps(flags, clientSlug) {
|
|
54
|
+
// Type definitions for FHIR are needed for the emitted .d.ts files.
|
|
55
|
+
const dependencies = {
|
|
56
|
+
'@types/fhir': EMITTED_RANGES.fhirTypes,
|
|
57
|
+
};
|
|
58
|
+
// fhirpath is required at runtime by validators; keep as peer so the host
|
|
59
|
+
// decides the version.
|
|
60
|
+
const peerDependencies = {
|
|
61
|
+
fhirpath: EMITTED_RANGES.fhirpath,
|
|
62
|
+
};
|
|
63
|
+
// The generated client extends the versioned base client package.
|
|
64
|
+
if (!flags?.noClient) {
|
|
65
|
+
dependencies[`@babelfhir-ts/client-${clientSlug}`] = EMITTED_RANGES.client;
|
|
66
|
+
}
|
|
67
|
+
if (flags?.schema === 'zod') {
|
|
68
|
+
dependencies['@babelfhir-ts/zod'] = EMITTED_RANGES.zodBase;
|
|
69
|
+
peerDependencies.zod = EMITTED_RANGES.zod;
|
|
70
|
+
}
|
|
71
|
+
if (flags?.dicomweb) {
|
|
72
|
+
dependencies['@babelfhir-ts/dicomweb'] = EMITTED_RANGES.dicomweb;
|
|
73
|
+
}
|
|
74
|
+
if (flags?.prefab) {
|
|
75
|
+
dependencies['@maxhealth.tech/prefab'] = EMITTED_RANGES.prefab;
|
|
76
|
+
}
|
|
77
|
+
return { dependencies, peerDependencies };
|
|
78
|
+
}
|
|
@@ -29,14 +29,6 @@ export function handleNestedField(args) {
|
|
|
29
29
|
const fieldPathParts = field.name.split('.');
|
|
30
30
|
const isExtensionField = fieldPathParts[fieldPathParts.length - 1] === 'extension' ||
|
|
31
31
|
(field.sliceName && fieldPathParts[fieldPathParts.length - 2] === 'extension');
|
|
32
|
-
if (field.name.includes('bodySite')) {
|
|
33
|
-
logger.debug('[DEBUG bodySite treatAsNested]', {
|
|
34
|
-
fieldName: field.name,
|
|
35
|
-
sliceName: field.sliceName,
|
|
36
|
-
isExtensionField,
|
|
37
|
-
treatAsNested
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
32
|
if (isExtensionField) {
|
|
41
33
|
const hasExtensionSlices = fields.some(f => (f.name === field.name || f.name === `${field.name}.extension`) &&
|
|
42
34
|
f.sliceName &&
|
|
@@ -46,6 +38,20 @@ export function handleNestedField(args) {
|
|
|
46
38
|
treatAsNested = false;
|
|
47
39
|
}
|
|
48
40
|
}
|
|
41
|
+
// TODO(bug): `treatAsNested` is computed but never consulted when deciding
|
|
42
|
+
// whether to emit the nested interface below — both `= false` guards (payor
|
|
43
|
+
// without an explicit profile, and extension fields carrying slices) are
|
|
44
|
+
// therefore dead, contrary to their comments above. Honouring it would change
|
|
45
|
+
// emitted interfaces for those fields, so it needs a parity run rather than a
|
|
46
|
+
// drive-by fix. The debug log below is currently its only reader.
|
|
47
|
+
if (field.name.includes('bodySite')) {
|
|
48
|
+
logger.debug('[DEBUG bodySite treatAsNested]', {
|
|
49
|
+
fieldName: field.name,
|
|
50
|
+
sliceName: field.sliceName,
|
|
51
|
+
isExtensionField,
|
|
52
|
+
treatAsNested
|
|
53
|
+
});
|
|
54
|
+
}
|
|
49
55
|
const nestedInterfaceName = `${parentInterfaceName}${capitalize(fieldName)}`;
|
|
50
56
|
const nestedFields = fields.filter((f) => f.name.startsWith(`${field.name}.`));
|
|
51
57
|
// Debug answer nested interface issue
|
|
@@ -124,9 +124,13 @@ function buildMcpViewsTs(profiles) {
|
|
|
124
124
|
const detailEntries = [];
|
|
125
125
|
for (const [resourceType, interfaceName] of byResourceType) {
|
|
126
126
|
const varBase = lowerFirst(interfaceName);
|
|
127
|
-
imports.push(`import { ${varBase}Table, ${varBase}Detail } from './${interfaceName}Prefab.js';`);
|
|
128
|
-
|
|
129
|
-
|
|
127
|
+
imports.push(`import { ${varBase}Table, ${varBase}Detail } from './${interfaceName}Prefab.js';`, `import type { ${interfaceName} } from '../${interfaceName}.js';`);
|
|
128
|
+
// Narrow the registry's `unknown` payload to the profile interface rather
|
|
129
|
+
// than widening to `any`: the entry has already been selected by
|
|
130
|
+
// `resourceType`, and asserting to the concrete type keeps every downstream
|
|
131
|
+
// property access type-checked inside the view function.
|
|
132
|
+
tableEntries.push(` ${JSON.stringify(resourceType)}: (resources) => ${varBase}Table(resources as ${interfaceName}[]),`);
|
|
133
|
+
detailEntries.push(` ${JSON.stringify(resourceType)}: (resource) => ${varBase}Detail(resource as ${interfaceName}),`);
|
|
130
134
|
}
|
|
131
135
|
return `/**
|
|
132
136
|
* AUTO-GENERATED by babelfhir-ts --prefab — do not edit.
|
|
@@ -248,7 +248,7 @@ export function generatePrefabRenderer(interfaceName, fields, options) {
|
|
|
248
248
|
? `rowRecord.${titleDescriptor.name} ?? '${interfaceName}'`
|
|
249
249
|
: `'${interfaceName}'`;
|
|
250
250
|
// --- Browser (interactive master/detail) variant -----------------------
|
|
251
|
-
// Uses prefab
|
|
251
|
+
// Uses prefab reactive primitives: `collection`, `signal`, `Ref`,
|
|
252
252
|
// `MasterDetail`, `Detail`, and the `find` pipe to resolve the selected
|
|
253
253
|
// row. Columns and detail rows consume FHIR datatype pipes registered by
|
|
254
254
|
// `./registerFhirPipes.js` (`humanName`, `contactPointBySystem`, etc.).
|
|
@@ -375,7 +375,7 @@ ${prefabImportLines}
|
|
|
375
375
|
} from '@maxhealth.tech/prefab';
|
|
376
376
|
${prefabTypeImportLine}
|
|
377
377
|
import type { ${interfaceName} } from '../${interfaceName}.js';
|
|
378
|
-
${formatterImportBlock}import { styles } from './styles.js';
|
|
378
|
+
${formatterImportBlock}import { styles, theme } from './styles.js';
|
|
379
379
|
// Self-contained FHIR datatype pipes. Passed through \`PrefabApp({ pipes })\`
|
|
380
380
|
// so the renderer can serialize them into the wire format and re-hydrate
|
|
381
381
|
// with \`new Function()\` in the browser — closures are not preserved, so
|
|
@@ -527,7 +527,9 @@ ${browserDetailRowsLiteral}
|
|
|
527
527
|
* own outer \`PrefabApp\`.
|
|
528
528
|
*
|
|
529
529
|
* Prerequisites (auto-imported at the top of this module):
|
|
530
|
-
* - \`@maxhealth.tech/prefab\` >= 0.
|
|
530
|
+
* - \`@maxhealth.tech/prefab\` >= 0.3.0 (wire protocol 0.3: the theme is
|
|
531
|
+
* compiled into the top-level \`css\` array, \`stylesheets\` carries
|
|
532
|
+
* external URLs, and \`mode\` forces a colour scheme)
|
|
531
533
|
* - \`./registerFhirPipes.js\` (FHIR datatype pipes: humanName, …)
|
|
532
534
|
*/
|
|
533
535
|
export function ${varBase}Browser(
|
|
@@ -535,18 +537,23 @@ export function ${varBase}Browser(
|
|
|
535
537
|
options?: Partial<Omit<PrefabAppOptions, 'view'>> & { masterWidth?: string },
|
|
536
538
|
) {
|
|
537
539
|
// Destructure the Browser-only \`masterWidth\` away from \`PrefabAppOptions\`
|
|
538
|
-
// passthrough so remaining keys (
|
|
540
|
+
// passthrough so remaining keys (css, mode, scripts, onMount, …) spread
|
|
539
541
|
// cleanly into the PrefabApp constructor.
|
|
540
|
-
const { masterWidth, title, pipes: userPipes, ...appOptions } = options ?? {};
|
|
542
|
+
const { masterWidth, title, pipes: userPipes, theme: userTheme, ...appOptions } = options ?? {};
|
|
541
543
|
|
|
542
544
|
const { view } = ${varBase}BrowserView(resources, { masterWidth });
|
|
543
545
|
|
|
544
546
|
return new PrefabApp({
|
|
545
547
|
...appOptions,
|
|
546
548
|
title: title ?? '${interfaceName} Browser',
|
|
549
|
+
// The theme from \`prefab/styles.ts\` is applied by default so editing that
|
|
550
|
+
// file (or passing \`--prefab-styles\`) actually takes effect. prefab >= 0.3
|
|
551
|
+
// compiles it into the wire \`css\` array; an empty theme contributes
|
|
552
|
+
// nothing. A caller-supplied \`theme\` replaces it wholesale.
|
|
553
|
+
theme: userTheme ?? theme,
|
|
547
554
|
// FHIR datatype pipes ship inline with the wire format so the browser
|
|
548
555
|
// renderer hydrates them at mount time. User-supplied \`pipes\` override
|
|
549
|
-
// individual entries by name
|
|
556
|
+
// individual entries by name.
|
|
550
557
|
pipes: { ...FHIR_PIPES, ...(userPipes ?? {}) },
|
|
551
558
|
view,
|
|
552
559
|
});
|
|
@@ -170,7 +170,7 @@ export type PrefabTheme = typeof theme;
|
|
|
170
170
|
* of self-contained (closure-free) pipe functions plus a `registerFhirPipes`
|
|
171
171
|
* convenience for Node-side registration.
|
|
172
172
|
*
|
|
173
|
-
* **Why self-contained?** prefab
|
|
173
|
+
* **Why self-contained?** prefab serializes pipes via `fn.toString()`
|
|
174
174
|
* and hydrates them in the browser with `new Function(source)` — closures are
|
|
175
175
|
* not preserved. Every helper in this module therefore inlines the logic it
|
|
176
176
|
* needs (no cross-function calls, no imports from `./fhirFormat.js`). The
|
|
@@ -9,7 +9,12 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import fs from 'fs';
|
|
11
11
|
import path from 'path';
|
|
12
|
-
|
|
12
|
+
// Mirrors the `fhir-context/` models the real fhirpath package ships (dstu2,
|
|
13
|
+
// stu3, r4, r5 — there is no r4b model). Only the two we can emit are stubbed:
|
|
14
|
+
// stubbing a model fhirpath does not ship would let a bad import specifier pass
|
|
15
|
+
// `tsc` here and then fail at runtime in the consumer's install.
|
|
16
|
+
// Keep in sync with fhirpathModelSlug() in src/generator/fhir/versionContext.ts.
|
|
17
|
+
const FHIR_CONTEXT_VERSIONS = ['r4', 'r5'];
|
|
13
18
|
export function installFhirpathStub(outputDir) {
|
|
14
19
|
const pkgDir = path.join(outputDir, 'node_modules', 'fhirpath');
|
|
15
20
|
fs.mkdirSync(pkgDir, { recursive: true });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* BackboneElement and binding-codes slice validation strategies.
|
|
3
3
|
*/
|
|
4
|
-
import { safeAccessor, sliceElseBranch, detectNestedArray, FHIR_PRIMITIVE_STRING_TYPES, collectWithinSliceMaxChildren, generateWithinSliceMaxChecks } from './sliceValidatorUtils.js';
|
|
4
|
+
import { safeAccessor, safeList, sliceElseBranch, detectNestedArray, FHIR_PRIMITIVE_STRING_TYPES, collectWithinSliceMaxChildren, generateWithinSliceMaxChecks } from './sliceValidatorUtils.js';
|
|
5
5
|
/** Collect required direct children of a BackboneElement slice for per-element validation. */
|
|
6
6
|
export function collectRequiredSliceChildren(sliceElementId, fields, discriminatorProp, profileName, relPath) {
|
|
7
7
|
const result = [];
|
|
@@ -199,7 +199,6 @@ export function generateBackboneElementValidation(slice, relPath, errorPath, sli
|
|
|
199
199
|
}).join('');
|
|
200
200
|
const maxChildren = collectWithinSliceMaxChildren(slice, fields);
|
|
201
201
|
const maxChecksCode = generateWithinSliceMaxChecks(maxChildren, `${varName}Elements`, resourceType, relPath, sliceLabel);
|
|
202
|
-
const hasAnyChildChecks = requiredChildren.length > 0 || nestedChildren.length > 0 || maxChildren.length > 0;
|
|
203
202
|
const childChecksCode = (requiredChildren.length > 0 || nestedChildren.length > 0
|
|
204
203
|
? `\n for (const _matched of ${varName}Elements) {
|
|
205
204
|
const _m = _matched as any;${directChecksInner}${nestedChecksInner}
|
|
@@ -240,8 +239,8 @@ export function generateBackboneElementValidation(slice, relPath, errorPath, sli
|
|
|
240
239
|
const bbeElseBranch = sliceElseBranch(relPath, errorPath, sliceLabel);
|
|
241
240
|
out.push(`
|
|
242
241
|
// Required BackboneElement slice validation for ${relPath}:${sliceLabel} (discriminated by ${discriminatorProp})
|
|
243
|
-
if (
|
|
244
|
-
const ${varName}Elements = ${
|
|
242
|
+
if (Array.isArray(${safeAccessor(relPath)})) {
|
|
243
|
+
const ${varName}Elements = ${safeList(relPath)}.filter((item: any) => ${checks});
|
|
245
244
|
if (${varName}Elements.length < ${min}) {
|
|
246
245
|
errors.push("No elements matched required slice: '${errorPath}:${sliceLabel}'");
|
|
247
246
|
}${childChecksCode}
|
|
@@ -309,7 +308,6 @@ export function generateFixedValueSliceValidation(slice, relPath, errorPath, sli
|
|
|
309
308
|
}).join('');
|
|
310
309
|
const maxChildren = collectWithinSliceMaxChildren(slice, fields);
|
|
311
310
|
const maxChecksCode = generateWithinSliceMaxChecks(maxChildren, `${varName}Elements`, resourceType, relPath, sliceLabel);
|
|
312
|
-
const hasChildChecks = requiredChildren.length > 0 || nestedChildren.length > 0 || fixedChildren.length > 0 || maxChildren.length > 0;
|
|
313
311
|
const childChecksCode = (requiredChildren.length > 0 || nestedChildren.length > 0 || fixedChildren.length > 0
|
|
314
312
|
? `\n for (const _matched of ${varName}Elements) {
|
|
315
313
|
const _m = _matched as any;${directChecksInner}${nestedChecksInner}${fixedChecksInner}
|
|
@@ -374,8 +372,8 @@ export function generateFixedValueSliceValidation(slice, relPath, errorPath, sli
|
|
|
374
372
|
const bbeElseBranch = sliceElseBranch(relPath, errorPath, sliceLabel);
|
|
375
373
|
out.push(`
|
|
376
374
|
// Required fixedValue slice validation for ${relPath}:${sliceLabel} (discriminated by ${discriminatorProp} === ${fixedVal})
|
|
377
|
-
if (
|
|
378
|
-
const ${varName}Elements = ${
|
|
375
|
+
if (Array.isArray(${safeAccessor(relPath)})) {
|
|
376
|
+
const ${varName}Elements = ${safeList(relPath)}.filter((item: any) => ${checks});
|
|
379
377
|
if (${varName}Elements.length < ${min}) {
|
|
380
378
|
errors.push("No elements matched required slice: '${errorPath}:${sliceLabel}'");
|
|
381
379
|
}${childChecksCode}
|
|
@@ -424,8 +422,8 @@ export function generateBindingCodesValidation(slice, baseName, relPath, errorPa
|
|
|
424
422
|
if (slice.isArray) {
|
|
425
423
|
out.push(`
|
|
426
424
|
// Slice cardinality check for ${baseName}:${sliceLabel} (primitive code array)
|
|
427
|
-
if (${
|
|
428
|
-
const ${varName}Count = ${
|
|
425
|
+
if (${safeList(relPath)}.length > 0) {
|
|
426
|
+
const ${varName}Count = ${safeList(relPath)}.filter(item =>
|
|
429
427
|
[${codes}].includes(item as string)
|
|
430
428
|
).length;
|
|
431
429
|
if (${varName}Count < ${min}) {
|
|
@@ -452,8 +450,8 @@ export function generateBindingCodesValidation(slice, baseName, relPath, errorPa
|
|
|
452
450
|
if (isCodingType) {
|
|
453
451
|
out.push(`
|
|
454
452
|
// Slice cardinality check for ${baseName}:${sliceLabel} (Coding array)
|
|
455
|
-
if (${
|
|
456
|
-
const ${varName}Count = ${
|
|
453
|
+
if (${safeList(relPath)}.length > 0) {
|
|
454
|
+
const ${varName}Count = ${safeList(relPath)}.filter((item: any) =>
|
|
457
455
|
[${codes}].includes(item.code as string)
|
|
458
456
|
).length;
|
|
459
457
|
if (${varName}Count < ${min}) {
|
|
@@ -464,8 +462,8 @@ export function generateBindingCodesValidation(slice, baseName, relPath, errorPa
|
|
|
464
462
|
else {
|
|
465
463
|
out.push(`
|
|
466
464
|
// Slice cardinality check for ${baseName}:${sliceLabel}
|
|
467
|
-
if (${
|
|
468
|
-
const ${varName}Count = ${
|
|
465
|
+
if (${safeList(relPath)}.length > 0) {
|
|
466
|
+
const ${varName}Count = ${safeList(relPath)}.filter((item: any) =>
|
|
469
467
|
(item.coding as Array<{ code?: string }> | undefined)?.some(coding => [${codes}].includes(coding.code as string))
|
|
470
468
|
).length;
|
|
471
469
|
if (${varName}Count < ${min}) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Extension and ValueSet slice validation strategies.
|
|
3
3
|
*/
|
|
4
|
-
import { safeAccessor, sliceElseBranch, detectNestedArray, findArrayAncestor, isArrayPath, FHIR_PRIMITIVE_STRING_TYPES } from './sliceValidatorUtils.js';
|
|
4
|
+
import { safeAccessor, safeList, sliceElseBranch, detectNestedArray, findArrayAncestor, isArrayPath, FHIR_PRIMITIVE_STRING_TYPES } from './sliceValidatorUtils.js';
|
|
5
5
|
export function generateValueSetValidation(slice, baseName, relPath, errorPath, sliceLabel, varName, min, minText, getValueSetForBinding, valueSetImports, fields, out) {
|
|
6
6
|
const vsInfo = getValueSetForBinding(slice.binding.uri);
|
|
7
7
|
const vsInfoFull = vsInfo && !('systemOnly' in vsInfo && vsInfo.systemOnly) ? vsInfo : undefined;
|
|
@@ -45,8 +45,8 @@ export function generateValueSetValidation(slice, baseName, relPath, errorPath,
|
|
|
45
45
|
if (isPrimitiveString) {
|
|
46
46
|
out.push(`
|
|
47
47
|
// Slice validation for ${baseName}:${sliceLabel} using ValueSet validator (primitive string array)
|
|
48
|
-
if (${
|
|
49
|
-
const ${varName}Count = ${
|
|
48
|
+
if (${safeList(relPath)}.length > 0) {
|
|
49
|
+
const ${varName}Count = ${safeList(relPath)}.filter((item: any) =>
|
|
50
50
|
typeof item === 'string' && ${vsInfoFull.validatorFn}(item)
|
|
51
51
|
).length;
|
|
52
52
|
if (${varName}Count < ${min}) {
|
|
@@ -57,8 +57,8 @@ export function generateValueSetValidation(slice, baseName, relPath, errorPath,
|
|
|
57
57
|
else if (isCodingType) {
|
|
58
58
|
out.push(`
|
|
59
59
|
// Slice validation for ${baseName}:${sliceLabel} using ValueSet validator (Coding array)
|
|
60
|
-
if (${
|
|
61
|
-
const ${varName}Count = ${
|
|
60
|
+
if (${safeList(relPath)}.length > 0) {
|
|
61
|
+
const ${varName}Count = ${safeList(relPath)}.filter((item: any) =>
|
|
62
62
|
item.code && ${vsInfoFull.validatorFn}(item.code)
|
|
63
63
|
).length;
|
|
64
64
|
if (${varName}Count < ${min}) {
|
|
@@ -69,8 +69,8 @@ export function generateValueSetValidation(slice, baseName, relPath, errorPath,
|
|
|
69
69
|
else {
|
|
70
70
|
out.push(`
|
|
71
71
|
// Slice validation for ${baseName}:${sliceLabel} using ValueSet validator
|
|
72
|
-
if (${
|
|
73
|
-
const ${varName}Count = ${
|
|
72
|
+
if (${safeList(relPath)}.length > 0) {
|
|
73
|
+
const ${varName}Count = ${safeList(relPath)}.filter((item: any) =>
|
|
74
74
|
(item.coding as Array<{ code?: string }> | undefined)?.some(coding => coding.code && ${vsInfoFull.validatorFn}(coding.code))
|
|
75
75
|
).length;
|
|
76
76
|
if (${varName}Count < ${min}) {
|
|
@@ -147,8 +147,8 @@ export function generateValueSetValidation(slice, baseName, relPath, errorPath,
|
|
|
147
147
|
: `(item.coding as Array<{ system?: string }> | undefined)?.some(coding => coding.system !== undefined && ${systemsArr}.includes(coding.system))`;
|
|
148
148
|
out.push(`
|
|
149
149
|
// Slice system-only validation for ${baseName}:${sliceLabel} (ValueSet '${vsName}')
|
|
150
|
-
if (${
|
|
151
|
-
const ${varName}Count = ${
|
|
150
|
+
if (${safeList(relPath)}.length > 0) {
|
|
151
|
+
const ${varName}Count = ${safeList(relPath)}.filter((item: any) =>
|
|
152
152
|
${systemCheck}
|
|
153
153
|
).length;
|
|
154
154
|
if (${varName}Count < ${min}) {
|
|
@@ -234,8 +234,8 @@ export function generateExistsExtensionValidation(slice, relPath, errorPath, sli
|
|
|
234
234
|
if (slice.isArray || baseFieldIsArray) {
|
|
235
235
|
out.push(`
|
|
236
236
|
// Required slice validation for ${relPath}:${sliceLabel} (exists extension discriminator)
|
|
237
|
-
if (
|
|
238
|
-
const ${varName}Elements = ${
|
|
237
|
+
if (Array.isArray(${safeAccessor(relPath)})) {
|
|
238
|
+
const ${varName}Elements = ${safeList(relPath)}.filter((item: any) =>
|
|
239
239
|
item.extension?.some((ext: { url?: string }) => ext.url === "${extensionUrl}")
|
|
240
240
|
);
|
|
241
241
|
if (${varName}Elements.length < ${min}) {
|
|
@@ -322,8 +322,8 @@ export function generateExtensionUrlSliceValidation(slice, relPath, errorPath, s
|
|
|
322
322
|
const elseGuard = parentPath ? `else if (${safeAccessor(parentPath)})` : 'else';
|
|
323
323
|
out.push(`
|
|
324
324
|
// Required extension slice validation for ${relPath}:${sliceLabel} (url discriminator)
|
|
325
|
-
if (${parentGuard}
|
|
326
|
-
const ${varName}Elements = ${
|
|
325
|
+
if (${parentGuard}Array.isArray(${safeAccessor(relPath)})) {
|
|
326
|
+
const ${varName}Elements = ${safeList(relPath)}.filter((item: any) =>
|
|
327
327
|
item.url === "${extensionUrl}"
|
|
328
328
|
);
|
|
329
329
|
if (${varName}Elements.length < ${min}) {
|
|
@@ -367,7 +367,7 @@ function generateExtensionValueChecks(matchedArrayVar, extensionUrl, extMeta, ct
|
|
|
367
367
|
lines.push(`${indent}for (const _ext of ${matchedArrayVar}) {`);
|
|
368
368
|
// -- Value type check --
|
|
369
369
|
// Use Object.entries to get both key and value without re-indexing into the typed object
|
|
370
|
-
lines.push(`${indent} const _valEntry = Object.entries(_ext).find(([k]) => k.startsWith("value"));`);
|
|
370
|
+
lines.push(`${indent} const _valEntry = Object.entries(node(_ext)).find(([k]) => k.startsWith("value"));`);
|
|
371
371
|
lines.push(`${indent} if (_valEntry && !${allowedSet}.includes(_valEntry[0])) {`);
|
|
372
372
|
lines.push(`${indent} const _found = _valEntry[0].replace("value", "").toLowerCase();`);
|
|
373
373
|
lines.push(`${indent} errors.push("The Profile '${profileOrExtUrl}' definition allows for ${typesClause} but found type " + _found);`);
|
|
@@ -386,22 +386,25 @@ function generateExtensionValueChecks(matchedArrayVar, extensionUrl, extMeta, ct
|
|
|
386
386
|
// when valueCode was expected). This mirrors the HL7 validator which
|
|
387
387
|
// checks the value against the binding regardless of the value type.
|
|
388
388
|
if (extMeta.valueTypes.length === 1 && (extMeta.valueTypes[0] === 'code' || extMeta.valueTypes[0] === 'string' || extMeta.valueTypes[0] === 'uri')) {
|
|
389
|
-
const propName = `value${valuePropertySuffix(extMeta.valueTypes[0])}`;
|
|
390
389
|
lines.push(`${indent} if (_valEntry && typeof _valEntry[1] === "string" && !${vsInfoFull.validatorFn}(_valEntry[1])) {`);
|
|
391
390
|
lines.push(`${indent} errors.push("The value provided ('" + _valEntry[1] + "') was not found in the value set '${vsInfoFull.displayName}' (${vsUri})");`);
|
|
392
391
|
lines.push(`${indent} }`);
|
|
393
392
|
}
|
|
394
393
|
else if (extMeta.valueTypes.includes('CodeableConcept')) {
|
|
395
|
-
|
|
396
|
-
|
|
394
|
+
// `_ext` is an element of an `arr()` list and therefore `unknown`; reach the
|
|
395
|
+
// coding through `at()` and assert only the shape actually read.
|
|
396
|
+
lines.push(`${indent} const _extCoding = at(_ext, 'valueCodeableConcept', 'coding') as Array<{ code?: string }> | undefined;`);
|
|
397
|
+
lines.push(`${indent} if (_extCoding) {`);
|
|
398
|
+
lines.push(`${indent} const _hasValid = _extCoding.some(c => c.code && ${vsInfoFull.validatorFn}(c.code));`);
|
|
397
399
|
lines.push(`${indent} if (!_hasValid) {`);
|
|
398
400
|
lines.push(`${indent} errors.push("None of the codings in the extension value are from the value set '${vsInfoFull.displayName}' (${vsUri})");`);
|
|
399
401
|
lines.push(`${indent} }`);
|
|
400
402
|
lines.push(`${indent} }`);
|
|
401
403
|
}
|
|
402
404
|
else if (extMeta.valueTypes.includes('Coding')) {
|
|
403
|
-
lines.push(`${indent}
|
|
404
|
-
lines.push(`${indent}
|
|
405
|
+
lines.push(`${indent} const _extCode = at(_ext, 'valueCoding', 'code') as string | undefined;`);
|
|
406
|
+
lines.push(`${indent} if (_extCode && !${vsInfoFull.validatorFn}(_extCode)) {`);
|
|
407
|
+
lines.push(`${indent} errors.push("The value provided ('" + _extCode + "') was not found in the value set '${vsInfoFull.displayName}' (${vsUri})");`);
|
|
405
408
|
lines.push(`${indent} }`);
|
|
406
409
|
}
|
|
407
410
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* (patternCodeableConcept, patternCoding, BackboneElement, binding, ValueSet).
|
|
5
5
|
*/
|
|
6
6
|
import { addDiagnostic } from '../../core/sdDiagnostics.js';
|
|
7
|
-
import { safeAccessor, sliceElseBranch, rewritePrimitiveExtensionPath, isArrayPath, collectWithinSliceMaxChildren, generateWithinSliceMaxChecks } from './sliceValidatorUtils.js';
|
|
7
|
+
import { safeAccessor, safeList, sliceElseBranch, rewritePrimitiveExtensionPath, isArrayPath, collectWithinSliceMaxChildren, generateWithinSliceMaxChecks } from './sliceValidatorUtils.js';
|
|
8
8
|
import { generateBackboneElementValidation, generateBindingCodesValidation, generateFixedValueSliceValidation } from './sliceBackboneValidation.js';
|
|
9
9
|
import { generateValueSetValidation, detectExistsExtensionDiscriminator, generateExistsExtensionValidation, detectExtensionUrlSlice, generateExtensionUrlSliceValidation, } from './sliceExtensionValidation.js';
|
|
10
10
|
/**
|
|
@@ -235,7 +235,7 @@ function generateCrossSliceExclusivityValidations(slicesByBase, fields, fieldPat
|
|
|
235
235
|
if (!isArrayPath(relPath, fieldPathMap, arrayFieldPaths))
|
|
236
236
|
return;
|
|
237
237
|
let code = `\n // Cross-slice exclusivity validation for ${relPath}`;
|
|
238
|
-
code += `\n if (
|
|
238
|
+
code += `\n if (Array.isArray(${safeAccessor(relPath)})) {`;
|
|
239
239
|
code += `\n for (const _el of ${safeAccessor(relPath)}) {`;
|
|
240
240
|
code += `\n const _matchedSlices: string[] = [];`;
|
|
241
241
|
for (const m of matchers) {
|
|
@@ -374,8 +374,8 @@ function generatePatternCodeableConceptValidation(slice, relPath, errorPath, sli
|
|
|
374
374
|
const elseBranch = sliceElseBranch(relPath, errorPath, sliceLabel);
|
|
375
375
|
out.push(`
|
|
376
376
|
// Required slice validation for ${relPath}:${sliceLabel}
|
|
377
|
-
if (
|
|
378
|
-
const ${varName}Elements = ${
|
|
377
|
+
if (Array.isArray(${safeAccessor(relPath)})) {
|
|
378
|
+
const ${varName}Elements = ${safeList(relPath)}.filter((item: any) => ${checks});
|
|
379
379
|
if (${varName}Elements.length < ${min}) {
|
|
380
380
|
errors.push("No elements matched required slice: '${errorPath}:${sliceLabel}'");
|
|
381
381
|
}${childMaxChecks}
|
|
@@ -698,8 +698,8 @@ function generateReferenceProfileDiscriminatorValidation(slice, relPath, errorPa
|
|
|
698
698
|
const childMaxChecks = generateWithinSliceMaxChecks(maxChildren, `_${varName}Refs`, resType, relPath, sliceLabel);
|
|
699
699
|
out.push(`
|
|
700
700
|
// Required Reference-profile slice validation for ${relPath}:${sliceLabel} (target → ${targetResourceType})
|
|
701
|
-
if (
|
|
702
|
-
const _${varName}Refs = ${
|
|
701
|
+
if (Array.isArray(${safeAccessor(relPath)})) {
|
|
702
|
+
const _${varName}Refs = ${safeList(relPath)}.filter((e: any) =>
|
|
703
703
|
e?.type === '${targetResourceType}' ||
|
|
704
704
|
(typeof e?.reference === 'string' && (e.reference.startsWith('${targetResourceType}/') || new RegExp('/${targetResourceType}/').test(e.reference)))
|
|
705
705
|
);
|
|
@@ -12,17 +12,32 @@ export const FHIR_PRIMITIVES = new Set([
|
|
|
12
12
|
export const FHIR_PRIMITIVE_STRING_TYPES = new Set([
|
|
13
13
|
'code', 'uri', 'url', 'string', 'id', 'oid', 'uuid', 'canonical',
|
|
14
14
|
]);
|
|
15
|
+
/** Render a dotted path as `at()` arguments: `'channel', 'payload', 'extension'`. */
|
|
16
|
+
export function atArgs(relPath) {
|
|
17
|
+
return relPath.split('.').map(p => `'${p}'`).join(', ');
|
|
18
|
+
}
|
|
15
19
|
/**
|
|
16
20
|
* Convert a dotted relPath into a safe accessor expression for generated code.
|
|
17
21
|
* For single-segment paths (e.g., "category"), returns `resource.category`.
|
|
18
22
|
* For multi-segment paths (e.g., "channel.payload.extension"), returns
|
|
19
|
-
* `(resource
|
|
23
|
+
* `at(resource, 'channel', 'payload', 'extension')` — typed as `unknown`, so
|
|
24
|
+
* the emitted code stays checked instead of being widened by an `as any` cast.
|
|
20
25
|
*/
|
|
21
26
|
export function safeAccessor(relPath) {
|
|
22
27
|
if (!relPath.includes('.'))
|
|
23
28
|
return `resource.${relPath}`;
|
|
24
|
-
|
|
25
|
-
|
|
29
|
+
return `at(resource, ${atArgs(relPath)})`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Like {@link safeAccessor} but typed as a list, for call sites that iterate,
|
|
33
|
+
* `.filter()` or read `.length`.
|
|
34
|
+
*
|
|
35
|
+
* `arr()` yields `[]` for absent/non-array values, so
|
|
36
|
+
* `safeList(p) + '.length > 0'` is equivalent to the
|
|
37
|
+
* `x && Array.isArray(x) && x.length > 0` guard it replaces.
|
|
38
|
+
*/
|
|
39
|
+
export function safeList(relPath) {
|
|
40
|
+
return `arr(${safeAccessor(relPath)})`;
|
|
26
41
|
}
|
|
27
42
|
/**
|
|
28
43
|
* Generate an else branch for required slice validation that guards on parent existence.
|
|
@@ -31,8 +46,8 @@ export function sliceElseBranch(relPath, errorPath, sliceLabel) {
|
|
|
31
46
|
if (!relPath.includes('.')) {
|
|
32
47
|
return `} else {\n errors.push("No elements matched required slice: '${errorPath}:${sliceLabel}'");\n }`;
|
|
33
48
|
}
|
|
34
|
-
const parentPath = relPath.split('.').slice(0, -1).join('
|
|
35
|
-
return `} else if ((resource
|
|
49
|
+
const parentPath = relPath.split('.').slice(0, -1).join('.');
|
|
50
|
+
return `} else if (at(resource, ${atArgs(parentPath)})) {\n errors.push("No elements matched required slice: '${errorPath}:${sliceLabel}'");\n }`;
|
|
36
51
|
}
|
|
37
52
|
/** Detect whether a dotted relPath traverses an array-typed parent field. */
|
|
38
53
|
export function detectNestedArray(relPath, fields) {
|
|
@@ -148,7 +163,7 @@ export function generateWithinSliceMaxChecks(children, elementsVarName, resType,
|
|
|
148
163
|
// max=0 on choice type (e.g., value[x]): check no key starting with prefix exists
|
|
149
164
|
code += `
|
|
150
165
|
for (const _wsElem of ${elementsVarName}) {
|
|
151
|
-
if (Object.keys(_wsElem).some(k => k.startsWith("${childProp}"))) {
|
|
166
|
+
if (Object.keys(node(_wsElem)).some(k => k.startsWith("${childProp}"))) {
|
|
152
167
|
errors.push("${resType}.${relPath}:${sliceLabel}.${fhirPath}: max allowed = 0, but found 1");
|
|
153
168
|
}
|
|
154
169
|
}`;
|
|
@@ -157,7 +172,7 @@ export function generateWithinSliceMaxChecks(children, elementsVarName, resType,
|
|
|
157
172
|
// max=0: field must not exist at all — handles both scalar and array
|
|
158
173
|
code += `
|
|
159
174
|
for (const _wsElem of ${elementsVarName}) {
|
|
160
|
-
const _wsVal = (_wsElem
|
|
175
|
+
const _wsVal = at(_wsElem, '${childProp}');
|
|
161
176
|
if (_wsVal !== undefined && _wsVal !== null) {
|
|
162
177
|
errors.push("${resType}.${relPath}:${sliceLabel}.${fhirPath}: max allowed = 0, but found " + (Array.isArray(_wsVal) ? _wsVal.length : 1));
|
|
163
178
|
}
|
|
@@ -167,7 +182,7 @@ export function generateWithinSliceMaxChecks(children, elementsVarName, resType,
|
|
|
167
182
|
// array field: check count doesn't exceed max
|
|
168
183
|
code += `
|
|
169
184
|
for (const _wsElem of ${elementsVarName}) {
|
|
170
|
-
const _wsArr = (_wsElem
|
|
185
|
+
const _wsArr = at(_wsElem, '${childProp}');
|
|
171
186
|
if (Array.isArray(_wsArr) && _wsArr.length > ${maxVal}) {
|
|
172
187
|
errors.push("${resType}.${relPath}:${sliceLabel}.${fhirPath}: max allowed = ${maxVal}, but found " + _wsArr.length);
|
|
173
188
|
}
|