Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,13 @@ declarations override engine defaults, matching runtime option merging.
Infer literal `env` values before applying `envTypes`; explicit declarations
remain the override for widened values and Reference targets.

`Tokenize` collapses each instance selector, `Type { ... }`, into one `selector`
token, typed `unknown[]`, so the rest of the expression stays typed. Do not type
it as the named interface: the interfaces list the codes of required bindings,
and the runtime does not check those in a built value (#133). Keep selector
detection in the tokenizer: a `ParseOperand` branch for a name before `{` cost
about 4k API surface instantiations even for expressions without selectors.

An engine method infers `Expr` from its expression argument only. A parameter
typed from `Expr` is an inference site too: `FhirpathInput` reads a literal
`resourceType` back into `Expr`. Type such an input as its own type parameter
Expand Down Expand Up @@ -230,6 +237,14 @@ precedence in `dtoCallOptions` and the type-level merge in `DtoContext` equal.
DTO `vars` remain projection-only. A variable is evaluated against a row; a
registered function call has a focus but no row.

## Instance selectors

`resolveInstanceType` and `acceptingElementType` in
`src/engine/instance-selector.ts` decide the built type and which element type
takes a value (and so a choice element's JSON key). The evaluator and the
analyzer both call them, so a static diagnostic and a runtime error always
describe the same rule.

## Criteria booleans

A `this.criteria()` column registers a function with `criteria: true`. The evaluator applies
Expand Down
45 changes: 45 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,51 @@ See [RELEASING.md](RELEASING.md) for how a version gets cut and published.

### Added

- FHIRPath 3.0.0 functions and arguments:
- `matches()`, `matchesFull()`, and `replaceMatches()` take an optional
`flags` argument: `i` ignores case and `m` makes `^` and `$` match at line
breaks. Any other flag is an error, which the analyzer reports for a
literal ([#111](https://github.com/vintasoftware/fhirpath-ts/issues/111)).
A custom `EvaluateOptions.regex` engine now also receives the `i` and `m`
flags.
- `repeatAll(projection)` is `repeat()` without the duplicate check. It
fails after 10,000 items, as `repeat()` does, so a projection that keeps
returning a value, such as `'abc'.repeatAll(replace('a', 'A'))`, stops
with an error ([#112](https://github.com/vintasoftware/fhirpath-ts/issues/112)).
- `duration(value, precision)` counts whole calendar periods and
`difference(value, precision)` counts period boundaries (weeks start on
Sunday) between two Dates, DateTimes, or Times. A precision the operand
types do not allow, such as `'hour'` between two Dates, is an error, which
the analyzer reports for a literal
([#113](https://github.com/vintasoftware/fhirpath-ts/issues/113)).
- `toDate()`, `toDateTime()`, `convertsToDate()`, and `convertsToDateTime()`
take an optional `format` for a String input, such as
`'01/15/2025'.toDate('MM/dd/yyyy')`. Every code the spec requires is
supported, plus `yy`, `M`, `d`, `h`, `H`, `m`, `s`, English month names
(`MMM`, `MMMM`), and `z` for an IANA time zone id such as
`America/Los_Angeles`, which takes the zone's offset at that date and
time. A format the conversion cannot use is an error
([#114](https://github.com/vintasoftware/fhirpath-ts/issues/114)).
- `pathname([short])` returns the path of each input item inside the input
resource, such as `Observation.component[0].code[0].coding[0]`. Computed
values have no path. Navigation records where items come from only when an
evaluation can call `pathname()`
([#115](https://github.com/vintasoftware/fhirpath-ts/issues/115)).
- Instance selectors build FHIR values:
`Coding { system: 'http://loinc.org', code: '8480-6' }`, and `Period {:}`
for a value with no elements. The model checks the type, element names,
value types, and whether an element repeats, at runtime and in the static
checkers, and each primitive value against its FHIR type's pattern (a
literal one statically). `ModelProvider.valuePattern()` supplies the
patterns. A choice element takes the key of its value's type, so
`Observation { value: 5 'mg' }` sets `valueQuantity`. Type inference gives
a selector `unknown[]`, since the runtime does not check the required-binding
codes the generated interfaces list
([#116](https://github.com/vintasoftware/fhirpath-ts/issues/116),
[#133](https://github.com/vintasoftware/fhirpath-ts/issues/133)).
- `combine(other, preserveOrder)` and `encode('ascii')`. `combine()` keeps
its sources' order with or without `preserveOrder`, as `union()` does
([#117](https://github.com/vintasoftware/fhirpath-ts/issues/117)).
- `engine.compile(expression, type)` declares the type a relative expression
runs against, as the package-root `compile(expression, type)` does. The input
must be that type or an array of it, the result is inferred against it, and
Expand Down
66 changes: 57 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# fhirpath-ts

A TypeScript-native [FHIRPath](https://hl7.org/fhirpath/) engine for application
development. It has zero runtime dependencies and includes:
A TypeScript-native [FHIRPath 3.0.0](https://hl7.org/fhirpath/STU3/) engine for
application development. It has zero runtime dependencies and includes:

- typed results for literal expressions in plain TypeScript;
- DTOs that transform FHIR resources into typed application data;
Expand Down Expand Up @@ -247,6 +247,30 @@ all depths, so this reads each questionnaire item's `linkId`.
r4.evaluate('Questionnaire.repeat(item).linkId', questionnaire)
```

### Build values

An instance selector builds one FHIR value from the current item. An element
whose value is empty is left out, and `Period {:}` builds a value with no
elements (`{}` is the empty collection).

```ts
r4.evaluate(
"Patient.select(Coding { system: 'http://hl7.org/fhir/administrative-gender', code: gender })",
patient,
)
```

The selector needs at most one item in its focus, so use `select()` to build
one value per item. Unknown element names, values of the wrong type, and
several items for an element that does not repeat throw, and the static checkers
report them. Each primitive value must also match its FHIR type's pattern: a
`code` without leading spaces, a non-empty `string`, a `positiveInt` above zero,
a `dateTime` with seconds and a time zone when it has a time. The static
checkers report a literal that does not. Type inference gives the result
`unknown[]`: the runtime does not yet check the codes of required bindings that
the generated interfaces list
([#133](https://github.com/vintasoftware/fhirpath-ts/issues/133)).

### Deterministic tests and debugging

`now` fixes the evaluation clock. `trace()` sends values to the sink you provide
Expand All @@ -262,6 +286,14 @@ r4.evaluate("Patient.name.trace('names').given", patient, {
})
```

`pathname()` gives the path of each item inside the resource. Paths locate a
problem without carrying patient data, so they suit traces and reports.

```ts
r4.evaluate('Patient.name.where(family.empty()).pathname()', patient)
// ['Patient.name[1]']
```

## Important gotchas

- FHIRPath always evaluates collections. Use `first()` when application code
Expand Down Expand Up @@ -318,8 +350,9 @@ The CLI imports exported DTOs and records engines constructed by their modules.
This checks runtime context, registered functions, and cross-DTO calls that
TypeScript and source-only ESLint cannot see.

The analyzer is also public for editors, tests, and other tools. It follows the
[FHIRPath §11 rules](https://hl7.org/fhirpath/en/index.html#type-safety-and-strict-evaluation)
The analyzer is also public for editors, tests, and other tools. It follows
FHIRPath's
[type safety and strict evaluation rules](https://hl7.org/fhirpath/STU3/en/index.html#type-safety-and-strict-evaluation)
and is tested against the official valid and invalid cases.
See [Static checking](docs/static-checking.md) for configuration, supported call
sites, DTO discovery, and cases where source-only checks stay quiet.
Expand Down Expand Up @@ -396,19 +429,34 @@ These features are deferred and fail with a clear error today:
| `conformsTo()` beyond base StructureDefinitions | Profile-aware validation |
| `slice()`, `elementDefinition()`, `checkModifiers()` | Profile definitions in the model |
| `weight()` | Code-system `itemWeight` lookups |
| `%factory` | Demand for the current R5 draft API |
| `%factory` | Demand for the current R5 draft API; [instance selectors](#build-values) already build FHIR values |
| CDA mode | A CDA `ModelProvider` |
| Full UCUM | A full UCUM implementation behind the current interface |
| R5 model package | Generated R5 definitions and types |

Parts of FHIRPath 3.0.0 that do not work yet:

| Behavior | Issue |
| --- | --- |
| `sort()` with `asc`/`desc` and empty keys first; `-key` sorts descending today | [#127](https://github.com/vintasoftware/fhirpath-ts/issues/127) |
| `min()` and `max()` on Date, DateTime, Time, and String | [#128](https://github.com/vintasoftware/fhirpath-ts/issues/128) |
| A backslash that starts no escape, as in `'\p'`, read as the next character | [#129](https://github.com/vintasoftware/fhirpath-ts/issues/129) |
| Instance selectors for backbone elements, `BackboneElement { ... }` | [#132](https://github.com/vintasoftware/fhirpath-ts/issues/132) |
| `power()` returning a Decimal, `ceiling()`/`floor()`/`round()`/`truncate()` on a Quantity, `log()` errors, empty on overflow, and same-type `sum()`/`min()`/`max()` | [#134](https://github.com/vintasoftware/fhirpath-ts/issues/134) |

`union()`, `|`, and `combine()` keep the order of their sources, where 3.0.0
gives them none; see
[Conformance](docs/conformance.md#deviations-from-the-specification).

## Security guidelines

### Expression trust

Parser depth, tokenization, decimal and UCUM exponents, and property navigation
have limits suitable for untrusted input. `repeat()` fails after it collects
10,000 items, so a projection that keeps producing new values, such as
`1.repeat($this + 1)`, cannot loop forever. Regular expressions need one extra
have limits suitable for untrusted input. `repeat()` and `repeatAll()` fail
after they collect 10,000 items, so a projection that keeps producing values,
such as `1.repeat($this + 1)` or `'abc'.repeatAll(replace('a', 'A'))`, cannot
loop forever. Regular expressions need one extra
step. By default, `matches()`, `matchesFull()`, and `replaceMatches()` use the
host `RegExp`, so a pattern with catastrophic backtracking can block the event
loop.
Expand Down Expand Up @@ -456,4 +504,4 @@ settings override the adapter's defaults one by one.

`trace()` does nothing unless a trace sink is provided. Traced values may contain
patient data. Never send PHI values to console output or production logs; use
record identifiers instead.
record identifiers or element paths from `pathname()` instead.
3 changes: 2 additions & 1 deletion demo/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# fhirpath-ts playground

The playground is a client-only demonstration of the engine, TypeScript
inference, and the [specification §11 analyzer](https://hl7.org/fhirpath/en/index.html#type-safety-and-strict-evaluation).
inference, and the analyzer for FHIRPath's
[type safety and strict evaluation rules](https://hl7.org/fhirpath/STU3/en/index.html#type-safety-and-strict-evaluation).
It runs entirely in the browser.
No FHIR data is sent to a server.
Type inference computes safe TypeScript types; the analyzer reports expression errors.
Expand Down
4 changes: 2 additions & 2 deletions demo/src/playground/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/**
* The live Monaco playground. Its TypeScript worker uses the package declarations
* for normal type checks and finds FHIRPath literals for the §11 analyzer. Each
* for normal type checks and finds FHIRPath literals for the strict evaluation analyzer. Each
* sample owns its editor model, markers, and output.
* See: https://hl7.org/fhirpath/en/index.html#type-safety-and-strict-evaluation
* See: https://hl7.org/fhirpath/STU3/en/index.html#type-safety-and-strict-evaluation
*/

import { analyzeSite } from 'fhirpath-ts/analyzer'
Expand Down
29 changes: 26 additions & 3 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,30 @@ fp.evaluate("name.trace('names').given", patient, {
})
```

Traced values may contain PHI. Keep them out of production logs.
Traced values may contain PHI. Keep them out of production logs. To log where
values are without the values, trace their paths: `trace('missing', pathname())`.

### Element paths

`pathname()` returns the path of each input item inside the input resource,
written with element names and indexers, such as
`Observation.component[0].code[0].coding[0]`. Every element gets an indexer.
`pathname(true)` leaves the indexer out when an element is not an array in the
data or in the model: `Observation.component[0].code.coding[0]`.

- Only items read by navigation have a path. Computed values (`upper()`, `&`,
arithmetic) and values from `env` are left out of the result.
- A resource reached with `resolve()` has a path when it is a contained resource
or a Bundle entry of the input, such as `Patient.contained[1]` or
`Bundle.entry[3].resource[0]`.
- When the input has no `resourceType`, paths start at its elements, such as
`given[0]`, and the input itself has no path.
- With a model, a choice element uses its FHIRPath name (`value`). Without one,
it uses the JSON key (`valueQuantity`).

Navigation records where items come from only when the evaluation can call
`pathname()`: in the expression, in a `vars` body, or in an expression-defined
function body. In that case `evaluateTyped()` items also carry an `origin`.

## Custom functions

Expand Down Expand Up @@ -962,9 +985,9 @@ Engine-generated failures use these exported `FhirPathError` subclasses:
| `FhirPathRuntimeError` | `r4.test(patient, 'Patient.name.given')` | A criteria result must contain at most one item. |

This follows FHIRPath's
[empty propagation and singleton evaluation rules](https://hl7.org/fhirpath/N1/#singleton-evaluation-of-collections)
[empty propagation and singleton evaluation rules](https://hl7.org/fhirpath/STU3/en/index.html#singleton-evaluation-of-collections)
and its
[type-safety and strict evaluation model](https://hl7.org/fhirpath/N1/#type-safety-and-strict-evaluation).
[type-safety and strict evaluation model](https://hl7.org/fhirpath/STU3/en/index.html#type-safety-and-strict-evaluation).
Caller-supplied callbacks, including
custom functions, conversions, regular expression engines, and trace sinks, may
throw their own errors; the engine does not swallow them. Use
Expand Down
32 changes: 27 additions & 5 deletions docs/conformance.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Conformance

The test suite checks official FHIRPath behavior, error phases, static analysis,
and known differences from other implementations.
The engine follows [FHIRPath 3.0.0](https://hl7.org/fhirpath/STU3/). The test
suite checks official FHIRPath behavior, error phases, static analysis, and known
differences from other implementations.

## Official suites

Expand Down Expand Up @@ -56,19 +57,40 @@ The package also runs tests from

| Corpus | Passing | Skipped with a reason |
| --- | ---: | ---: |
| fhirpath.js cases plus fhirpath-py additions | 2,297 | 1,370 |
| fhirpath.js cases plus fhirpath-py additions | 2,306 | 1,363 |

Most skips require a model other than R4 or copy official-suite cases that
upstream disabled; `official.test.ts` runs those cases directly. Other cases
disabled upstream run here. Another 243 cases are intentional differences
disabled upstream run here. Another 236 cases are intentional differences
recorded in `test-data/fhirpathjs/quirk-manifest.ts`, including four disabled
upstream whose expected result is wrong. Each group includes its specification or
official-suite evidence. A maintenance test runs every listed case and fails when
one starts passing, so the manifest lists only differences that still exist.

The fhirpath-rs corpus was also reviewed. Its official R5 cases are already
covered by the official suite. Its additional cases are included in
`src/reference-crosschecks.test.ts`, together with selected Medplum comparisons.
`src/reference-crosschecks.test.ts`, together with selected Helios (hfs) and
Medplum comparisons and the FHIRPath 3.0.0 cases from Josh Mandel's
hand-written fhirpath.zig tests. Three fhirpath-rs `repeatAll()` cases expect
the input items in the output, or a constant projection to end; that file tests
the specification's behavior instead.

`src/crosschecks-3.0.0.test.ts` runs 179 FHIRPath 3.0.0 cases from
[gofhir/fhirpath](https://github.com/gofhir/fhirpath),
[Pathling](https://github.com/aehrc/pathling),
[Ignixa](https://github.com/brendankowitz/ignixa-fhir), and the fhirpath.js
`pathname()` and instance selector tests. `test-data/crosschecks/README.md`
lists their sources and commits.

## Deviations from the specification

`union()`, `|`, and `combine()` keep the order of their sources: the input's
items, then the argument's. FHIRPath 3.0.0 gives `union()` no order, and
`combine()` an order only when its `preserveOrder` argument is `true`. The
runtime appends in order either way, and the analyzer treats these results as
ordered, so `(a | b).first()` and `a.combine(b).first()` are not
`order-dependent` errors. Reading them as unordered would reject many common
expressions that rely on that order.

## Property and differential tests

Expand Down
2 changes: 1 addition & 1 deletion docs/engine-comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ outside that tool's purpose.
| Tests from other engines | yes, with reasons for each difference | no | no | no | no | no | no | no | Not applicable |
| Compile-time result types | plain `tsc` | no | no | no | no | no | no | no | no |
| Default unknown-member result | `[]` | `[]` | `[]` | `[]` | `[]` | `[]` | `[]` | `[]` | `[]` through fhirpath.js |
| [Optional invalid-member check](https://hl7.org/fhirpath/N1/#type-safety-and-strict-evaluation) | strict runtime mode, CLI, ESLint, and API | no | no | analyzer API | no | `check()` API | strict runtime mode | strict runtime mode | editor inference |
| [Optional invalid-member check](https://hl7.org/fhirpath/STU3/en/index.html#type-safety-and-strict-evaluation) | strict runtime mode, CLI, ESLint, and API | no | no | analyzer API | no | `check()` API | strict runtime mode | strict runtime mode | editor inference |
| Terminology, async evaluation, `%factory` | deferred | yes | partial | `%factory` | no | yes | — | — | Not applicable |
| FHIR models | R4; provider interface | DSTU2 through R5 | DSTU2 through R5 | R5 | R4 | DSTU2 through R5 | R4 and R5 | R4, R4B, and R5 | — |

Expand Down
Loading
Loading