Validate the instance¶
DHIS2 lets you put almost anything in a code and almost anything in a name.
Most of it never bites - until the metadata leaves DHIS2 for a system that
reads codes as identifiers and names as page headings, at which point a <
in an option-set code stops a publish dead and a code with a space in it
changes shape. This page runs the check that reads every code and name your
instance holds, grades each finding by what it costs the publish, and writes
the list your metadata owner can act on. It needs no project directory and it
writes nothing to DHIS2.
Who this is for: the operator gating a build, and the implementer sizing up an instance's codes before generating anything.
Before you start: a d2w with a profile for your instance. A project is optional - see scope for what having one changes.
You will be able to:
- run the FHIR-safety check and read its verdict off the terminal
- say what an error, a warning, and an info each cost your build
- read a grade against the posture your project generates under
- use the code-source dial to preview a code migration
- hand the written report to whoever owns the metadata
Run it¶
$ d2w fhir validate
running 5 step(s)
[1/5] connecting: http://localhost:8080
[2/5] selection: 2 data sets, 6 programs, 5 stages, 70 data elements, 13 option
sets, 5 categories, 1,332 organisation units
[3/5] instance sweep: 40 collections, 1,634 objects
[4/5] option sets: 13 read
[5/5] findings: 41 finding(s)
wrote /home/you/demo-ig/reports/fhir-validate-report.md
wrote /home/you/demo-ig/reports/fhir-validate-report.csv
wrote /home/you/demo-ig/reports/fhir-validate-report.pdf
fhir validate
┌───────────────────┬──────────────────────────────────────────────────────────┐
│profile │ local_basic (fhir.toml) │
│resource types │ 40 │
│objects swept │ 1634 │
│option sets │ 13 │
│options │ 52 │
│attributes │ 4 │
│errors │ 4 │
│warnings │ 4 │
│infos │ 33 │
│selection findings │ 4 errors, 4 warnings, 20 infos │
│code coverage │ 1/1433 (selection objects whose code can serve as an │
│ │ identity stem) │
│code source │ id │
│hostile names │ refuse - every name is published exactly as DHIS2 states │
│ │ it │
└───────────────────┴──────────────────────────────────────────────────────────┘
findings by category (7)
┏━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━┓
┃Severity ┃ Scope ┃ Category ┃ Count┃
┡━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━┩
│error │ selection │ template-hostile-name │ 4 │
│warning │ selection │ template-hostile-name │ 4 │
│info │ selection │ spaced-code │ 20 │
│info │ instance │ invalid-code │ 1 │
│info │ instance │ missing-code │ 1 │
│info │ instance │ template-hostile-name │ 11 │
│info │ instance │ unmapped-tracked-en...│ 1 │
└─────────┴───────────┴───────────────────────┴──────┘
findings (4)
[one row per error - the object, its code, and the character it carries]
error: 4 error(s) found; exiting 1 (--no-fail to suppress)
That run is a 2.43.1 instance carrying the Sierra Leone demo database plus a
few added programs, and it is the instance every transcript in this series
was taken against. Its project states hostile_names = "refuse", which is
why four names are errors; the scaffold writes "substitute", under which the
same four are informational - see
Grade under your hostile-names posture. The three wrote lines are echoed before the tables so a
redirected log still names the files. The PDF writer can add a line of its
own naming a glyph the bundled font has no drawing for - Font
MPDFAA+NotoSans is missing the following glyphs - which is a note about the
font, not a finding about your instance.
The default output is a status view, not the finding firehose: the summary
table, a rollup with one row per (severity, scope, category), every error
individually - because an error is what gates the build and you need the
object without opening a file - and one closing line. instance rows render
dimmed so the build path carries the visual weight. A national instance
raises hundreds of warnings; reading them one row at a time is what the
written report is for. --details puts every finding on the terminal too.
Read severity as build impact¶
Every finding is graded by what it does to your build, not by abstract hygiene:
| Severity | Meaning |
|---|---|
| error | Your build will abort. An in-scope <, in a code on an identifier surface or in a name the guide publishes byte-true - both land in HTML the publisher writes unescaped and then strict-parses. The only findings that gate exit 1. Whether a name is published byte-true is hostile_names. |
| warning | An in-scope degradation the build survives - a code falling back to the UID, a > or & malforming a page. |
| info | Instance hygiene: the same defects on objects the build never reads - a code-migration watchlist, not build noise. |
The summary's code coverage line counts how many in-scope objects carry
a code usable as an identity stem - the R4 id bar, stricter than the R4
code datatype. It is the number to watch grow before switching
[generate.naming] source from "id" toward "code". It counts the seven
surfaces whose objects take an identity from their code - the six naming
surfaces plus dataElements, whose codes become dictionary concept codes. The
scope carries more surfaces than that, because a name is graded on every kind
of object the selection publishes, but a fraction over a category option or a
tracked entity attribute would answer a question nobody asks: those read their
identity from the object that publishes them.
Read the scope column¶
Before anything is graded, the run resolves the configured selection into an
emission scope - the same selection semantics generate uses, so validate
and generate can never disagree about what is on the build path. Every
finding carries the verdict as its scope: selection for objects the
configured IG emits, instance for the rest.
Outside a project every selection table is empty, which selects everything of its kind - so the whole instance grades as being on the build path. Inside a project, findings grade against that project's own selection.
Grade under your hostile-names posture¶
A severity says what an object costs this project, and half of that is
hostile_names - the [generate] key that
decides what a generate run does with a DHIS2 name the published guide cannot
carry as it stands. Validate reads the same key, and the summary's hostile
names row says which reading produced the counts above it.
| Posture | What a name carrying < is graded |
|---|---|
"substitute" |
info. The guide publishes the name rewritten, the build survives, and the finding states both spellings: published as 'Vitamin A given to under 5y' - the name is rewritten for publication (hostile_names = "substitute"); DHIS2 keeps 'Vitamin A given to < 5y'. |
"refuse" |
error. The guide publishes the name byte-true, so the build aborts and the remedy is to change the name in DHIS2. |
| unset | error. An unanswered run refuses, so the grade is the refusing one. |
Only what the rewrite settles moves. A name carrying > or & stays a
warning under either posture, because the rewrite reads < and nothing
else, so the page is malformed either way; when one name carries both, the
finding states the published spelling and keeps the warning the leftover
character earns. Under "substitute" a spaced-code finding names the
hyphenated code the guide publishes - published as 'Pre-eclampsia' - each
space becomes a hyphen for publication - instead of describing the quoted FSH
form, which that posture never emits.
template-hostile-code is an error under either posture. A code is an
identifier a consumer joins on, so the substitution rewrites a space in a code
and never a <: the code reaches the guide exactly as DHIS2 states it, and
d2w fhir generate refuses the run whichever posture is set. The finding says
so, and the remedy stays the same - change the code in DHIS2.
--hostile-names substitute and --hostile-names refuse read the instance
under the other posture without touching fhir.toml, which answers "what
would this project cost if we changed our mind?":
The flag beats the config; the config beats unset. Exit 1 follows the graded
severities, so a "substitute" project carrying nothing but rewritten names
exits 0 - which is what lets make refresh run validate mid-chain and keep
going.
Know what the five passes cover¶
| Pass | What it covers |
|---|---|
| Instance-wide sweep | Every object's code in every collection /api/metadata returns, checked against the R4 code datatype; per-type duplicates; organisation units with no code at all. |
| Deep option-set pass | Exactly what code-mode generation would do with each option set's options, over the same projections the emitter consumes. |
| Code-stem pass | Exactly what a code-sourced [generate.naming] source does with each in-scope object of the naming surfaces (option sets, categories, organisation units, data sets, programs, program stages). |
| Deep attribute pass | Every DHIS2 attribute the instance left uncoded - info, a coverage signal about how legible the D2AttributeValue extension is to a consumer without the instance. |
| Subject-typing pass | Every tracked entity type the instance holds that [generate.tracked_entity_types] does not name. |
The subject-typing checklist¶
A tracked entity type this project's mapping table never mentions is published
as a Patient. That default is what keeps a person-tracking project's config
empty, and it is applied silently - so on an instance that also tracks
households, fridges, vehicles, water points, and lab samples, the way to find
out which types are people is to read the list.
This pass writes the list out, one finding per untyped type, under the category
unmapped-tracked-entity-type:
warning selection unmapped-tracked-entity-type trackedEntityTypes oWMH7vxiPpZ Fridge
tracked entity type is absent from [generate.tracked_entity_types], so its registrations
are published as Patient; write '"oWMH7vxiPpZ" = "<resource>"' to publish it as something
else
The severity is build impact like every other finding here. A type this build publishes a form for is a warning - something in this guide is about to call it a person. A type outside the selection is info: a fact about the instance, which this build never reads. Neither is ever an error, because "this really is a person" is a perfectly good reason to leave the line unwritten, and validate cannot tell which types those are. Working down the list once turns the silence into a decision.
The mapping itself is Custom subject types; what a served register does with several types is The register.
Every pass also checks the object's name for one thing that has nothing
to do with codes: template-hostile-name, raised on any name carrying <,
>, or &, which the IG publisher's template injects into HTML unescaped
and then strict-parses. A name containing < is an error wherever the
guide publishes it byte-true: the publisher cannot read back the page it just
wrote, and the build fails. Under hostile_names = "substitute" the guide
publishes that name rewritten and the finding is informational -
Grade under your hostile-names posture.
> and & cost a malformed page the build survives, so they are warnings
under either posture.
A second name check reads the other thing a real instance carries and a page
cannot show: control-character-name, raised on any name holding a C0 control
character (U+0000 through U+001F). SUSHI carries one byte-true from the FSH it
compiles into the resource JSON, so the character reaches the published guide
whatever the toolchain does with it. Tab, newline, and carriage return are
the whole of what XML 1.0 admits below U+0020 - and the R4 string value regex
[ \r\n\t\S]+ names the same three - so those reach the guide and cost only
what they render as: the pages collapse them to a space, the FSH Title: the
guide writes has already flattened them, and one object ends up stating its
name two ways. Those are warnings. Every other C0 control character has
no XML form at all, not even a numeric character reference, and the IG
publisher writes an XML rendering of every resource beside the JSON one - so
the guide has no readable rendering of that name and the build ends. Those are
errors. The message names the character in words - a tab character, a
null character, the control character \x01 - and the reports print it as
an escape rather than letting an invisible byte reach the page as nothing.
Under hostile_names = "substitute" every control character is rewritten away:
each one reads as the space it stood in and the whitespace around it collapses,
which is what the FSH path already does to a name, so the resource element and
the page furniture state one spelling instead of two. The finding is then
informational and names both spellings. control-character-name is a category
of its own rather than a template-hostile-name because it is a different
defect on different evidence - no HTML template is what breaks - and because
< alone is what the generate refusal gates on. A control-character-name
error does not refuse a generate run.
template-hostile-name's sibling template-hostile-code grades the same three characters the same
way, on the collections whose codes become identifier values (optionSets,
categories, organisationUnits, dataSets, programs,
programStages). The publisher writes identifier values into a table cell
unescaped and fails in its last pass - so on a large IG one hostile code or
name costs the entire build before it says so. The same finding here takes
seconds. Generation does not escape its way around either finding: names and
identifier values are what a consumer matches on, and an IG that disagrees
with its instance is worse than a malformed page. The fix is in DHIS2, which
is what the finding is for.
The name grade and the generate refusal are the same statement¶
Under hostile_names = "refuse" - and unset, which refuses - a
template-hostile-name error on the build path and a d2w fhir generate
refusal name the same objects, and they do so in both directions:
- every name graded
errorin theselectionscope here refuses a generate run, and - every name a generate run refuses is graded
errorin theselectionscope here.
That holds over every kind of object a selection can publish: option sets and
their options, categories and their category options, organisation units, data
sets, event programs, tracker programs and their stages, tracked entity types,
and the questions all of those ask - data elements and tracked entity
attributes. Whichever command you run first, the other one has nothing new to
tell you about names. Under "substitute" the parity holds the other way:
neither command stops, because the guide publishes wording the build survives.
Codes are the asymmetry that remains, and deliberately:
a data element's code becomes a concept property the publisher escapes rather
than an identifier value it does not, so template-hostile-code is raised only
on the six collections above and generate gates codes only there.
Run both anyway, because they answer different questions. Validate answers "what does this instance cost this guide?" and grades every object it finds. Generate answers "may I write this guide?" and stops at the first object it cannot write - so on an instance with several offenders, only validate lists them all.
Preview a code migration with --code-source¶
The option-pass findings are gated on the effective code source - the
--code-source flag when given, otherwise concept_code_source from
fhir.toml. In id mode (the default) invalid-code, missing-code, and
duplicate-code are downgraded to info and their message says so, because
generation is not reading those codes yet. Run with code to see what
switching would cost:
The code-stem pass works the same dial for naming: under
source = "code-or-id" a missing, unusable, or colliding code is a
code-stem-fallback warning (that object's ids fall back to the UID); under
source = "code" the same object is a code-stem-refusal error -
d2w fhir generate refuses the run through the same defect predicate, so a
validate error is also a generate refusal.
spaced-code is the info-grade
neighbour: a code with spaces is FHIR-valid but emits in the quoted
#"..." FSH form - unless hostile_names = "substitute", under which the
finding names the hyphenated code the guide publishes instead.
Hand over the report¶
--output-dir names a directory, created if needed; the files inside are
always fhir-validate-report.md / .csv / .pdf. The default is
reports/ under the project root, or under the working directory when there
is no project. --format takes a comma list of md, csv, pdf; all
three are written by default, and each written path is echoed.
| File | Shape |
|---|---|
.md |
Findings grouped under one section per resource type, a Scope column on every row. |
.csv |
One row per finding: severity,scope,category,resource_type,uid,name,code,message. For spreadsheets and for diffing two runs. |
.pdf |
Cover page with summary counts, clickable table of contents, one bookmarked section per resource type with severity-tinted rows. Noto Sans with a Noto Sans Lao fallback, so Lao-script names render. |
The human-facing renderings print a code with its control characters escaped
(BLUE\nBLUE reads on one line) and wrap a code with leading or trailing
spaces in double quotes (" M "), so an invisible character is visible; the
CSV and JSON carry the raw code. The scaffolded .gitignore covers
reports/ - the reports are regenerable snapshots of instance state; pin
one deliberately (git add -f) when handing it over.
Wire it into CI¶
Exit 1 when there are errors - --fail is the default - which makes the
command a CI gate. --no-fail exits 0 regardless, and drops the red
line with it. d2w --json fhir validate emits the full findings list as
JSON on stdout in place of the tables; progress narration stays on stderr
(one plain [k/N] label: summary line per step when stderr is redirected,
which is the form a CI log wants; --no-progress silences it). The exit-1
gate still applies, so CI reads the findings off stdout and the job still
fails on errors.
Next: Generate the IG source