fhir-diff 0.1.2

Semantic diff of FHIR packages and profiles with a breaking-change gate
Documentation
<p align="center"><img src=".github/banner.png" alt="octo/diff — FHIR profiles compared, breaking changes caught in CI" width="100%"></p>

<p align="center"><b>Semantic diff of FHIR packages and profiles, with a breaking-change gate for CI.</b></p>

<p align="center">
  <a href="https://github.com/octofhir/fhir-diff/actions/workflows/ci.yml"><img src="https://github.com/octofhir/fhir-diff/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
  <a href="#license"><img src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg" alt="License: MIT OR Apache-2.0"></a>
</p>

```sh
fhir-diff hl7.fhir.us.core@6.1.0 hl7.fhir.us.core@9.0.0
fhir-diff ./old-ig/output ./new-ig/output --fail-on breaking --format sarif -o fhir-diff.sarif
```

The arguments are either `name@version`, fetched from the FHIR package registry into the local
package store along with the package's dependencies, or a folder holding an unpacked package (a
`package/` subfolder or the files directly).

The first report is [US Core 6.1.0 → 9.0.0](reports/us-core-6.1.0-to-9.0.0.md). It compares 70
profiles and extensions: 11 added, 1 replaced, 48 changed, with 64 breaking changes.

## How it works

1. Every StructureDefinition of both packages is converted to
   [FHIR Schema]https://github.com/octofhir/fhirschema.
2. Each profile is merged over its whole base chain, down to the core type, into an
   **effective element tree**. A change inherited from a base profile counts as much as one the
   profile makes itself. The diff never trusts a differential on its own.
3. The two trees of each profile (matched by canonical URL) are compared element by element,
   slices included (`Patient.identifier:mrn.system`).
4. Every change is given a rule and a severity.

Choice elements are compared as one element whatever form the profile uses to narrow them.
`effective[x]` restricted to `dateTime` and a constraint on `effectiveDateTime` compare equal.

Reference targets that move from a core type to a profile of the same type
(`Practitioner` → `us-core-practitioner`) are reported once as `target-narrowed`.

A package never says that one profile replaces another. When the only profile of a type that
disappeared meets the only one of that type that appeared, the two are compared as a pair and
reported as `profile-replaced`, so the changes between them show. Extensions are never paired.

An invariant that left one element and arrived on another is reported once, as `invariant-moved`
when its expression says the same from the new element, as `invariant-changed` otherwise.

`mustSupport` on a choice covers the types marked with `elementdefinition-type-must-support`, or
all of them if none is marked.

## Rules

| Severity | Rules |
|---|---|
| **breaking**: an instance valid against the old profile may be invalid against the new one, or implementers gain an obligation | `profile-removed`, `profile-replaced`, `min-increased` (including a new required slice), `max-decreased`, `type-removed`, `target-removed`, `must-support-added`, `binding-stronger` (to extensible or required), `binding-added` (required or extensible), `invariant-added` (error), `fixed-added`, `fixed-changed`, `slicing-closed` |
| **warning**: may break, depending on content the diff does not see | `value-set-changed` (under a required or extensible binding), `invariant-changed`, `invariant-warning-added`, `target-narrowed`, `base-changed`, `slice-removed`, `modifier-changed` |
| **info**: relaxations and additions | `profile-added`, `min-decreased`, `max-increased`, `type-added`, `target-added`, `must-support-removed`, `binding-stronger-advisory` (example → preferred), `binding-weaker`, `invariant-moved`, `invariant-removed`, `fixed-removed`, `slicing-opened`, `slice-added` |

## Options

| Option | |
|---|---|
| `--format text\|markdown\|json\|sarif` | Text for a terminal or CI log (the default), Markdown for people, JSON for tools, SARIF 2.1.0 for code scanning |
| `--min-severity info\|warning\|breaking` | Report only changes at least this severe |
| `--fail-on never\|warning\|breaking` | Exit 1 when a change at least this severe is found (exit 2 on errors) |
| `--profile URL\|NAME` | Compare only these profiles (repeatable) |
| `-o FILE` | Write the report to a file |

### GitHub Actions

```yaml
- run: fhir-diff hl7.fhir.us.core@9.0.0 ./output --fail-on breaking --format sarif -o fhir-diff.sarif
- uses: github/codeql-action/upload-sarif@v3
  if: always()
  with:
    sarif_file: fhir-diff.sarif
```

SARIF results point at `package/StructureDefinition-*.json`. Their logical location is
`<profile url>#<element path>`.

## Accuracy

`cargo run --example selfcheck -- <package>` compares each effective tree with the snapshot
the package publishes, on min, max and mustSupport of every element:

| Package | Elements | Agree |
|---|---:|---:|
| `hl7.fhir.us.core@9.0.0` | 2,455 | 99.4% |
| `hl7.fhir.us.core@6.1.0` | 2,133 | 99.3% |

Known gaps:

- Bases are looked for in the package's dependencies, transitively, while one is still missing;
  value set and code system packages (`hl7.terminology.*`, VSAC, PHIN VADS) are never read. When
  no dependency defines a base, it is taken from the other package's dependencies when it has
  one with the same canonical URL, and the profile is compared over the core type otherwise. The
  report says which under *Notes*.
- mustSupport set on a choice through a type slice (`value[x]:valueCodeableConcept`) shows on
  the slice, not on the choice.
- Value set content is not compared. A different value set under a required binding is a
  warning, not an expansion diff.

## Library

```toml
[dependencies]
fhir-diff = { version = "0.1", default-features = false }
```

Without default features the crate reads packages from folders or from definitions you hand it
(`Loaded`), and needs neither a network stack nor a package store. Features:

- `registry`: `load("name@version")` installs from the FHIR package registry into the local
  package store (octofhir-canonical-manager).
- `cli` (default): the `fhir-diff` command; enables `registry`.

```rust
let (old, new) = (fhir_diff::load("./old")?, fhir_diff::load("./new")?);
let (old_pool, new_pool) = fhir_diff::Pool::pair(&old, &new);
let report = fhir_diff::diff("old", &old_pool, "new", &new_pool, &Default::default());
println!("{}", fhir_diff::report::text(&report, fhir_diff::Severity::Info));
```

## Install

```sh
cargo install --git https://github.com/octofhir/fhir-diff
```

## Building

```sh
cargo build --release
cargo test
```

## License

MIT OR Apache-2.0.