Skip to content

Commit 31e0d64

Browse files
author
records-bot
committed
sync: monorepo @ validator 0.1.4 / validation-types 0.1.1
Snapshot of medvertical/records monorepo state, exported via oss:export-validator. Tracks the npm publication of: @records-fhir/validator@0.1.4 @records-fhir/validation-types@0.1.1 Run npm i @records-fhir/validator@0.1.4 for the matching distribution. The conformance evidence in conformance-results/ matches the published tarball.
1 parent d3078a6 commit 31e0d64

49 files changed

Lines changed: 1818 additions & 122 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/validator/CHANGELOG.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,34 @@ ship together; package-only changes are noted under each release.
1010

1111
## [Unreleased]
1212

13+
## [0.1.4] — 2026-05-06
14+
15+
Patch release for the Firely validation triage and public package
16+
sync.
17+
18+
### Fixes
19+
20+
- Fixed false positives in slice matching for `$this` Coding slices and
21+
slice child constraints.
22+
- Hardened StructureDefinition loading so R4/R5 core definitions and
23+
cached package scans do not cross-contaminate validation runs.
24+
- Tightened unknown-property detection so internal enhancer fields and
25+
valid primitive companion fields are not reported as structural
26+
errors.
27+
- Improved terminology parity: display comparisons now ignore
28+
case/whitespace-only differences, missing CodeSystem values are
29+
warnings, and unvalidated terminology coverage uses stable issue
30+
codes.
31+
- Improved reference parsing for contained references, absolute URLs,
32+
versioned references, and Bundle entry contexts.
33+
34+
### Tests
35+
36+
- Added focused regression coverage for StructureDefinition cache
37+
versioning, unknown-property walking, slice element matching,
38+
terminology issue classification, and reference format/type
39+
extraction.
40+
1341
## [0.1.3] — 2026-05-05
1442

1543
Patch release fixing a public-export gap that shipped in 0.1.2. No

packages/validator/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@records-fhir/validator",
3-
"version": "0.1.3",
3+
"version": "0.1.4",
44
"description": "Pure-TypeScript FHIR R4/R5/R6 validation engine. Validates resources against StructureDefinitions, terminology, references, and custom rules with no database required.",
55
"type": "module",
66
"license": "Apache-2.0",
@@ -10,7 +10,7 @@
1010
},
1111
"repository": {
1212
"type": "git",
13-
"url": "https://github.com/medvertical/records-fhir-validator.git",
13+
"url": "git+https://github.com/medvertical/records-fhir-validator.git",
1414
"directory": "packages/validator"
1515
},
1616
"homepage": "https://github.com/medvertical/records-fhir-validator/tree/main/packages/validator#readme",
@@ -90,7 +90,7 @@
9090
"pack:dry": "npm pack --dry-run"
9191
},
9292
"dependencies": {
93-
"@records-fhir/validation-types": "^0.1.0",
93+
"@records-fhir/validation-types": "^0.1.1",
9494
"axios": "^1.10.0",
9595
"date-fns": "^3.6.0",
9696
"fhirpath": "^4.8.2",
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { validateObservationStatusValueConsistency } from '../observation-validators';
3+
4+
describe('validateObservationStatusValueConsistency', () => {
5+
it('does not warn for final panel observations with component values', async () => {
6+
const issues = await validateObservationStatusValueConsistency({
7+
resourceType: 'Observation',
8+
status: 'final',
9+
code: {
10+
coding: [{ system: 'http://loinc.org', code: '85354-9' }],
11+
},
12+
component: [
13+
{
14+
code: { coding: [{ system: 'http://loinc.org', code: '8480-6' }] },
15+
valueQuantity: { value: 112, system: 'http://unitsofmeasure.org', code: 'mm[Hg]' },
16+
},
17+
{
18+
code: { coding: [{ system: 'http://loinc.org', code: '8462-4' }] },
19+
valueQuantity: { value: 68, system: 'http://unitsofmeasure.org', code: 'mm[Hg]' },
20+
},
21+
],
22+
}, 'Observation');
23+
24+
expect(issues).toEqual([]);
25+
});
26+
27+
it('does not warn when a final observation explains the missing value', async () => {
28+
const issues = await validateObservationStatusValueConsistency({
29+
resourceType: 'Observation',
30+
status: 'final',
31+
code: {
32+
coding: [{ system: 'http://loinc.org', code: '72166-2' }],
33+
},
34+
dataAbsentReason: {
35+
coding: [{ system: 'http://terminology.hl7.org/CodeSystem/data-absent-reason', code: 'unknown' }],
36+
},
37+
}, 'Observation');
38+
39+
expect(issues).toEqual([]);
40+
});
41+
42+
it('still warns for final observations without value, components, or dataAbsentReason', async () => {
43+
const issues = await validateObservationStatusValueConsistency({
44+
resourceType: 'Observation',
45+
status: 'final',
46+
code: {
47+
coding: [{ system: 'http://loinc.org', code: '72166-2' }],
48+
},
49+
}, 'Observation');
50+
51+
expect(issues).toHaveLength(1);
52+
expect(issues[0].code).toBe('final-status-no-value');
53+
});
54+
});

packages/validator/src/business-rules/validators/observation-validators.ts

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -224,21 +224,7 @@ export async function validateObservationEffectiveDate(resource: any, resourceTy
224224
export async function validateObservationStatusValueConsistency(resource: any, resourceType: string): Promise<ValidationIssue[]> {
225225
const issues: ValidationIssue[] = [];
226226

227-
// Check if observation has status 'final' but no value
228-
// Check all possible value[x] types in FHIR Observation
229-
const hasValue = !!(
230-
resource.valueQuantity ||
231-
resource.valueCodeableConcept ||
232-
resource.valueString ||
233-
resource.valueBoolean ||
234-
resource.valueInteger ||
235-
resource.valueRange ||
236-
resource.valueRatio ||
237-
resource.valueSampledData ||
238-
resource.valueTime ||
239-
resource.valueDateTime ||
240-
resource.valuePeriod
241-
);
227+
const hasValue = hasObservationValue(resource);
242228

243229
if (resource.status === 'final' && !hasValue) {
244230
issues.push({
@@ -265,3 +251,28 @@ export async function validateObservationStatusValueConsistency(resource: any, r
265251
return issues;
266252
}
267253

254+
function hasObservationValue(observation: any): boolean {
255+
if (hasValueX(observation) || observation.dataAbsentReason) {
256+
return true;
257+
}
258+
259+
return Array.isArray(observation.component) && observation.component.some((component: any) =>
260+
hasValueX(component) || component.dataAbsentReason
261+
);
262+
}
263+
264+
function hasValueX(element: any): boolean {
265+
return !!(
266+
element.valueQuantity ||
267+
element.valueCodeableConcept ||
268+
element.valueString ||
269+
element.valueBoolean ||
270+
element.valueInteger ||
271+
element.valueRange ||
272+
element.valueRatio ||
273+
element.valueSampledData ||
274+
element.valueTime ||
275+
element.valueDateTime ||
276+
element.valuePeriod
277+
);
278+
}

packages/validator/src/core/__tests__/profile-loader-utils.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,51 @@ describe('loadProfileWithSnapshot', () => {
122122
expect(mocks.sdLoader.loadProfile).not.toHaveBeenCalled();
123123
});
124124

125+
it('does not load core FHIR StructureDefinitions from the target server', async () => {
126+
const coreUrl = 'http://hl7.org/fhir/StructureDefinition/Encounter';
127+
const loaderSd = makeSD({ id: 'r4-encounter', url: coreUrl, type: 'Encounter', fhirVersion: '4.0.1' } as any);
128+
mocks.profileCache.get.mockReturnValue(null);
129+
mocks.fhirClient.searchResources.mockResolvedValue({
130+
entry: [{ resource: makeSD({ id: 'r5-encounter-from-server', url: coreUrl, type: 'Encounter', fhirVersion: '5.0.0' } as any) }],
131+
});
132+
mocks.sdLoader.loadProfile.mockResolvedValue(loaderSd);
133+
134+
const result = await loadProfileWithSnapshot(
135+
mocks.sdLoader as any,
136+
mocks.profileCache as any,
137+
mocks.snapshotGenerator as any,
138+
coreUrl,
139+
FHIR_VERSION,
140+
mocks.fhirClient as any,
141+
);
142+
143+
expect(result).toBe(loaderSd);
144+
expect(mocks.fhirClient.searchResources).not.toHaveBeenCalled();
145+
expect(mocks.sdLoader.loadProfile).toHaveBeenCalledWith(coreUrl, FHIR_VERSION);
146+
});
147+
148+
it('ignores FHIR client profiles from the wrong FHIR version', async () => {
149+
const r5ClientSd = makeSD({ id: 'from-client-r5', fhirVersion: '5.0.0' } as any);
150+
const r4LoaderSd = makeSD({ id: 'from-loader-r4', fhirVersion: '4.0.1' } as any);
151+
mocks.profileCache.get.mockReturnValue(null);
152+
mocks.fhirClient.searchResources.mockResolvedValue({
153+
entry: [{ resource: r5ClientSd }],
154+
});
155+
mocks.sdLoader.loadProfile.mockResolvedValue(r4LoaderSd);
156+
157+
const result = await loadProfileWithSnapshot(
158+
mocks.sdLoader as any,
159+
mocks.profileCache as any,
160+
mocks.snapshotGenerator as any,
161+
PROFILE_URL,
162+
FHIR_VERSION,
163+
mocks.fhirClient as any,
164+
);
165+
166+
expect(result).toBe(r4LoaderSd);
167+
expect(mocks.sdLoader.loadProfile).toHaveBeenCalledWith(PROFILE_URL, FHIR_VERSION);
168+
});
169+
125170
it('falls back to loader when FHIR client returns empty bundle', async () => {
126171
const loaderSd = makeSD({ id: 'from-loader' });
127172
mocks.profileCache.get.mockReturnValue(null);
@@ -308,4 +353,25 @@ describe('loadProfileForValidation', () => {
308353
expect(result).toBe(clientSd);
309354
expect(mocks.sdLoader.loadProfile).not.toHaveBeenCalled();
310355
});
356+
357+
it('falls back to loader when FHIR client returns wrong FHIR version', async () => {
358+
const r5ClientSd = makeSD({ id: 'from-client-r5', fhirVersion: '5.0.0' } as any);
359+
const r4LoaderSd = makeSD({ id: 'from-loader-r4', fhirVersion: '4.0.1' } as any);
360+
mocks.fhirClient.searchResources.mockResolvedValue({
361+
entry: [{ resource: r5ClientSd }],
362+
});
363+
mocks.sdLoader.loadProfile.mockResolvedValue(r4LoaderSd);
364+
365+
const result = await loadProfileForValidation(
366+
mocks.sdLoader as any,
367+
mocks.snapshotGenerator as any,
368+
PROFILE_URL,
369+
FHIR_VERSION,
370+
undefined,
371+
mocks.fhirClient as any,
372+
);
373+
374+
expect(result).toBe(r4LoaderSd);
375+
expect(mocks.sdLoader.loadProfile).toHaveBeenCalledWith(PROFILE_URL, FHIR_VERSION);
376+
});
311377
});
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2+
import { mkdtemp, rm } from 'fs/promises';
3+
import { tmpdir } from 'os';
4+
import { join } from 'path';
5+
import { StructureDefinitionLoader } from '../structure-definition-loader';
6+
import type { StructureDefinition } from '../structure-definition-types';
7+
import { checkDatabaseCache } from '../sd-loader-db-cache';
8+
import { setProfileSource } from '../../persistence';
9+
10+
const CORE_URL = 'http://hl7.org/fhir/StructureDefinition/MedicationRequest';
11+
12+
function makeSd(id: string, fhirVersion: string): StructureDefinition {
13+
return {
14+
resourceType: 'StructureDefinition',
15+
id,
16+
url: CORE_URL,
17+
name: id,
18+
status: 'active',
19+
kind: 'resource',
20+
abstract: false,
21+
type: 'MedicationRequest',
22+
fhirVersion,
23+
snapshot: {
24+
element: [{ id: 'MedicationRequest', path: 'MedicationRequest' }],
25+
},
26+
} as unknown as StructureDefinition;
27+
}
28+
29+
async function makeLoader(): Promise<{ loader: StructureDefinitionLoader; dir: string }> {
30+
const dir = await mkdtemp(join(tmpdir(), 'records-sd-loader-'));
31+
const loader = new StructureDefinitionLoader(dir, null, { autoDownload: false });
32+
await loader.waitForInitialization();
33+
return { loader, dir };
34+
}
35+
36+
beforeEach(() => {
37+
setProfileSource({});
38+
});
39+
40+
afterEach(() => {
41+
setProfileSource({});
42+
});
43+
44+
describe('StructureDefinitionLoader versioned cache', () => {
45+
it('does not return bare or R5 cached profiles for R4 batch loading', async () => {
46+
const { loader, dir } = await makeLoader();
47+
try {
48+
const r4 = makeSd('medicationrequest-r4', '4.0.1');
49+
const r5 = makeSd('medicationrequest-r5', '5.0.0');
50+
51+
(loader as any).cache.set(CORE_URL, r5);
52+
(loader as any).cache.set(`${CORE_URL}:R5`, r5);
53+
54+
const r4Result = await loader.loadProfilesBatch([CORE_URL], 'R4');
55+
expect((r4Result.get(CORE_URL) as any)?.fhirVersion).toMatch(/^4\./);
56+
57+
(loader as any).cache.set(`${CORE_URL}:R4`, r4);
58+
const r4Hit = await loader.loadProfilesBatch([CORE_URL], 'R4');
59+
expect(r4Hit.get(CORE_URL)?.id).toBe('medicationrequest-r4');
60+
61+
const r5Hit = await loader.loadProfilesBatch([CORE_URL], 'R5');
62+
expect(r5Hit.get(CORE_URL)?.id).toBe('medicationrequest-r5');
63+
} finally {
64+
await rm(dir, { recursive: true, force: true });
65+
}
66+
});
67+
68+
it('stores ProfileSource warmup entries under their FHIR version family', async () => {
69+
const r5 = makeSd('medicationrequest-r5', '5.0.0');
70+
setProfileSource({
71+
async loadAllForWarmup() {
72+
return new Map([
73+
[CORE_URL, { canonicalUrl: CORE_URL, profile: r5 }],
74+
]);
75+
},
76+
});
77+
78+
const { loader, dir } = await makeLoader();
79+
try {
80+
const r4Result = await loader.loadProfilesBatch([CORE_URL], 'R4');
81+
expect((r4Result.get(CORE_URL) as any)?.fhirVersion).toMatch(/^4\./);
82+
83+
const r5Hit = await loader.loadProfilesBatch([CORE_URL], 'R5');
84+
expect(r5Hit.get(CORE_URL)?.id).toBe('medicationrequest-r5');
85+
} finally {
86+
await rm(dir, { recursive: true, force: true });
87+
}
88+
});
89+
});
90+
91+
describe('checkDatabaseCache', () => {
92+
it('rejects ProfileSource entries from the wrong FHIR version', async () => {
93+
const r5 = makeSd('medicationrequest-r5', '5.0.0');
94+
setProfileSource({
95+
async findByUrl() {
96+
return r5;
97+
},
98+
});
99+
100+
await expect(checkDatabaseCache(CORE_URL, new Set(), 'R4')).resolves.toBeNull();
101+
await expect(checkDatabaseCache(CORE_URL, new Set(), 'R5')).resolves.toBe(r5);
102+
});
103+
});

0 commit comments

Comments
 (0)