openehr-rails 0.5.0 → 0.6.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 49c5536ccdf6abee6fb03aea4a4ed6002fc6402e6551d6f234b0e6a1a7b9200c
4
- data.tar.gz: c36b2b1b58f60d22c2cbbbcb5754108bb3235f12737bd573103d2a3463a70618
3
+ metadata.gz: 69d9f917d3eb32a92e7d47aeb3f43bf8992a6a44f8ceb49320a1e5a6130ecebf
4
+ data.tar.gz: 1f39015dbd51241ea4dca624bd89e988a82bf62ef3a02964e54a343b6e8ccbf1
5
5
  SHA512:
6
- metadata.gz: 0651aa4386933ccff02d230ab06d1eda996ca102da9b99ba2ebf09d3fde233ce7f08cea00b26644b744b92b647f917fe0e80dd6169fa158d9af19759c7db7f1b
7
- data.tar.gz: 4358f499a80546004d5ca80b1ec542f2fe2d977a674461be36ee1afb288322717b71507978a50b1f7560edf1248835783f29e60b0bc874f164d8c4141bcc06a8
6
+ metadata.gz: 5acec88b6d3dee1ab32167080639a81052911eee90043ae09474e083fa0c92e081da69dade909848da04743cffc96603e887765a8efaff29d6172f873e93a074
7
+ data.tar.gz: 1c134c5a1147f54ee8628b62efdcfcb867f257f634a5094a4f0aacb2af460ac28cfcaa011457017610262686437041ec85045186f9c05acb034ed4080e6ec793
@@ -16,6 +16,9 @@ assignees: ''
16
16
  - `openehr` version: <!-- e.g. ~> 2.3 -->
17
17
  - Rails version: <!-- e.g. 8.1 -->
18
18
  - Ruby version: <!-- e.g. 3.3, 3.4, 4.0 -->
19
+ - If the report involves an OPT/ADL file: the generating tool and its
20
+ version (e.g. Better Archetype Designer / Ocean Template Designer 2.6 /
21
+ ADL Workbench / LinkEHR / HMC).
19
22
 
20
23
  ## Reproduction
21
24
 
data/CHANGELOG.md CHANGED
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2026-08-26
11
+
12
+ ### Added
13
+ - `OpenehrRails::Fhir::FshGenerator` renders one FHIR Shorthand profile per
14
+ openEHR entry, including metadata, archetype codes, value constraints,
15
+ component slicing, quantity units, fixed terminology codes, and required
16
+ external value-set bindings. Observation-mapped output is Sushi-verified;
17
+ multi-leaf non-Observation entries have a known structural gap tracked as #33.
18
+
19
+ ### Changed
20
+ - `FshGenerator` no longer depends on `active_support/core_ext/string`; the
21
+ three String helpers it used are now small plain-Ruby private methods,
22
+ verified byte-identical output. Restores the Rails-independence this class
23
+ was designed for (see `anlage`'s `docs/design/fsh-plan.md`), so it can move
24
+ wholesale to a future satellite gem without a Rails dependency riding along.
25
+
10
26
  ## [0.5.0] - 2026-08-25
11
27
 
12
28
  ### Added
data/CLAUDE.md CHANGED
@@ -102,6 +102,25 @@ lasting effect, but the near-miss is what prompted this generalization. See
102
102
  logging the underlying structural fix under consideration - one
103
103
  worktree/session per repo instead of per-command vigilance.)
104
104
 
105
+ ## Cross-repository implementation work needs its own authorization gate
106
+
107
+ The moment it becomes clear that the actual implementation target for a piece of
108
+ work is a repository other than this one, stop before starting that work and insert
109
+ a gate report confirming the implementation target, rather than sliding from
110
+ read-only cross-repo reference (already permitted for design/evidence-gathering)
111
+ into cross-repo write work on the strength of that same permission. Correctly
112
+ targeting a command once you've decided to cross the boundary (the previous
113
+ section's `cd`/`-C`/`-R` discipline) is a different concern from being authorized to
114
+ cross it at all - following the operational convention precisely does not retroactively
115
+ authorize the crossing.
116
+
117
+ (Added 2026-08-26, alongside the `#32`/`#33` FSH-generator work continuation, to
118
+ name explicitly a distinction that the existing repository-context rule left
119
+ implicit: it governs *how* to target a cross-repo command correctly, not *whether*
120
+ crossing into implementation work on another repository has actually been
121
+ authorized for the task at hand. A matching line is planned for `anlage`'s own
122
+ `CLAUDE.md` in a later batch - not yet added there as of this entry.)
123
+
105
124
  ## Project Overview
106
125
 
107
126
  This is `openehr-rails`, a Rails engine gem that turns an openEHR Operational Template (`.opt`, ADL2/XML) into a working Rails resource in one command: `rails generate openehr:scaffold path/to/template.opt --fhir` emits a model, migration, controller, views, i18n locale, and (with `--fhir`) HL7 FHIR R5 `StructureDefinition` profiles. Generated models persist both as typed columns (for forms/search) and as full openEHR RM data (canonical JSON + a typed node graph with immutable-append versioning), and are queryable via a growing AQL surface. A mountable admin engine (`/openehr`) provides template upload/management, runtime scaffolding, and a FHIR R5 facade. Legacy ADL-archetype-only generators (model/controller/migration/helper/assets/i18n/template/template_model, based on `Openehr::Generators::ArchetypedBase`) have been removed — OPT is the only supported input format for scaffolding.
data/docs/backlog.md CHANGED
@@ -34,6 +34,23 @@ actually running, not just present:
34
34
  push will reproduce the same red run. See "Release automation" below.
35
35
  - Most recent `master` push at the time of this record (`4055ec3`): CI run
36
36
  `32569774700`, success.
37
+ - **Tag `v0.5.0` (2026-08-25, #30): `release.yml` run
38
+ [32831263811](https://github.com/skoba/openehr-rails/actions/runs/32831263811) --
39
+ overall conclusion **success**, all 12 jobs green (9 reused `ci.yml` spec-matrix
40
+ jobs, `demo smoke`, `application template smoke test`, `Build gem artifact`).**
41
+ First fully-green tag-push run on record -- confirms the "Release automation fix
42
+ landed (2026-08-23, PR #28)" note below actually fixed the structural red
43
+ described above, not just that the workflow file changed (`v0.4.1`/`v0.4.0`/`v0.3.0`
44
+ were all red at the now-removed RubyGems-publish step; `v0.5.0` has no such step to
45
+ fail at). Downloaded the run's `gem` artifact and independently built
46
+ `pkg/openehr-rails-0.5.0.gem` locally at the same tagged commit (`69db63f`): both
47
+ 197632 bytes, identical sha256
48
+ `e07815bd1c86736403cbb558fec869fbe04666f695e6cc12a41dad9be77230e6`. RubyGems
49
+ publish (human `gem push`, per "Release automation" below) confirmed complete via
50
+ `https://rubygems.org/api/v1/versions/openehr-rails.json`: version `0.5.0`,
51
+ `created_at: 2026-08-25T09:26:15.342Z`, published `sha` field matches the same
52
+ sha256 above exactly -- local build, CI artifact, and the published gem are all
53
+ byte-identical.
37
54
 
38
55
  Recorded here after this file previously claimed "this repo currently has no CI
39
56
  workflow gating pull requests" — wrong on both existence and execution. This file is
@@ -0,0 +1,113 @@
1
+ # Enhancement: FshGenerator (FSH output alongside ProfileGenerator)
2
+
3
+ - Status: draft, approved-by-citation (design decisions made upstream in
4
+ `skoba/anlage#17`, not re-litigated here)
5
+ - Target: `openehr-rails` (this repo)
6
+ - Issue: [#32](https://github.com/skoba/openehr-rails/issues/32)
7
+ - Log: `docs/reports/fsh-generator-log.md`
8
+ - Design authority: `skoba/anlage`'s `docs/design/fsh-plan.md` (2026-08-26
9
+ ruling, all 4 judgments approved) and `docs/reports/fsh-log.md` R1-R4.
10
+ This doc translates that already-approved plan into this repo's
11
+ file:line reality; it does not re-decide source/scope/output-form.
12
+
13
+ ## 1. Summary
14
+
15
+ Add `OpenehrRails::Fhir::FshGenerator`, a sibling to
16
+ `OpenehrRails::Fhir::ProfileGenerator` that renders FSH (FHIR Shorthand)
17
+ text instead of a JSON StructureDefinition Hash, from the exact same
18
+ input: `OpenehrRails::Opt::FieldExtractor#entries`. No independent OPT
19
+ walk — this is the "share the intermediate representation" design
20
+ anlage's plan settled on (rejecting a pathcards-based or
21
+ independently-walking alternative as violating the no-double-derivation
22
+ constraint).
23
+
24
+ ## 2. Input contract (already shipped, `skoba/openehr-rails#30` / 0.5.0)
25
+
26
+ `FieldExtractor#entries[].fields[]` now always carries (verified live
27
+ in `field_extractor.rb:160-177`, doc comment `field_extractor.rb:1-23`):
28
+
29
+ - `value_set_uri`: `C_CODE_REFERENCE#reference_set_uri`, else `nil`
30
+ - `code_bindings`: `[{system_uri:, code:}]`, else `[]` — not limited to
31
+ `DV_CODED_TEXT` (BMI's LOINC binding sits on a `DV_QUANTITY` element,
32
+ `CHANGELOG.md` [0.5.0] Added note)
33
+
34
+ No further gem work is a prerequisite for this Issue.
35
+
36
+ ## 3. v1 scope (per the approved anlage plan, §"v1サブセットの範囲確定")
37
+
38
+ Mirror exactly what `ProfileGenerator` already emits, in FSH syntax,
39
+ plus binding writes (now unblocked):
40
+
41
+ | JSON (`profile_generator.rb`) | FSH form |
42
+ |---|---|
43
+ | `build_profile` metadata (`:33-50`) | `Profile:`/`Parent:`/`Id:`/`Title:` header lines |
44
+ | `code_element` (`:62-69`) | `* code.coding.system = ...` / `* code.coding.code = ...` (or a `patternCodeableConcept`-equivalent assignment — pick whichever Sushi compiles identically to the JSON shape; verify by comparing compiled output, not by assumption) |
45
+ | `value_elements` (`:71-80`) | `* value[x] <min>..<max>` + `* value[x] only <Type>` |
46
+ | `component_elements`/`component_slice` (`:82-113`) | slicing declaration (`^slicing.discriminator...`) + `* component contains <slice> <card>` + per-slice `code`/`value[x]` assignment (see anlage's `docs/reports/fsh-log.md` R1 for a working Sushi-verified example of this exact shape) |
47
+ | `DV_QUANTITY` unit (`apply_value_constraints`, `:117-124`) | `* value[x].unit = "..."` (or equivalent fixed-value rule) |
48
+ | `DV_CODED_TEXT` binding (`:125-131`, now includes `valueSet`) | `field[:value_set_uri]` present → `* value[x] from <uri> (required)`. `field[:code_bindings]` present (any rm_type) → fixed `patternCodeableConcept`-equivalent assignment per binding entry |
49
+
50
+ Explicitly out of scope for v1 (matches the JSON facade's own gaps, not
51
+ new ones introduced here): `magnitude_range` (extracted but never
52
+ emitted by `ProfileGenerator` either — a separate, pre-existing gap).
53
+ `mml_referral`-scale templates are out of v1's verification scope per
54
+ the approved plan (size cap); this Issue's fixtures stay to the 3 small
55
+ ones listed below.
56
+
57
+ ## 4. Fixtures (no new SNOMED literal budget — reuse what's already here)
58
+
59
+ - `spec/generators/templates/bmi_calculation.opt` — single-leaf,
60
+ `DV_QUANTITY`, real SNOMED-CT (`60621009`) + LOINC `code_binding` on
61
+ `at0004` (`spec/templates/bmi_calculation_without_uid.opt:1683-1697`
62
+ for the byte-identical sibling with visible XML)
63
+ - `spec/templates/problem_list.opt` — `C_CODE_REFERENCE`/ICD-11
64
+ `value_set_binding` on `at0002`, local `code_list` contrast on
65
+ `at0073` (byte-identical copy of anlage's `ProblemList.opt`,
66
+ provenance comment already in the file per `0bbbc47`)
67
+
68
+ Both are already indexed by existing specs
69
+ (`spec/openehr_rails/fhir/profile_generator_spec.rb`,
70
+ `spec/openehr_rails/opt/field_extractor_binding_spec.rb`) — reuse the
71
+ same `template` `let` pattern.
72
+
73
+ ## 5. TDD (t-wada, per this repo's CLAUDE.md)
74
+
75
+ 1. Red/Green: `FshGenerator` non-binding v1 subset against
76
+ `bmi_calculation.opt` — metadata, single-leaf `value[x]`, unit. Fix
77
+ the exact FSH text with a golden spec (string equality or a small
78
+ set of `include` assertions on stable substrings — match whichever
79
+ style `profile_generator_spec.rb` already uses for the JSON side)
80
+ 2. Red/Green: `code_binding` write against `bmi_calculation.opt`'s
81
+ `at0004` (SNOMED-CT `60621009` and/or the LOINC binding — pick
82
+ whichever demonstrates the fixed-value FSH form most clearly)
83
+ 3. Red/Green: `value_set_binding` write against `problem_list.opt`'s
84
+ `at0002` (ICD-11) — fix the `from <uri> (required)` line
85
+ 4. Red/Green: multi-leaf `component` slicing — `bmi_calculation.opt`'s
86
+ `openEHR-EHR-OBSERVATION.body_mass_index.v2` entry already has 2 leaf
87
+ fields (verified: `bundle exec ruby` with `FieldExtractor#entries`,
88
+ 2026-08-26), and `at0004`'s SNOMED/LOINC `code_binding` sits inside
89
+ this same multi-leaf entry — one fixture covers both slicing and
90
+ binding for this case. No new fixture needed (`problem_list.opt` also
91
+ has 5 leaf fields, `lab_result_report_reduced.opt` has 3, for
92
+ reference)
93
+ 5. Manual verification (not part of this Issue's automated suite,
94
+ done once locally before closing): pipe generated FSH through
95
+ `fsh-sushi` and confirm 0 Errors, matching the shapes anlage already
96
+ verified by hand (`docs/reports/fsh-log.md` R1 in `skoba/anlage`)
97
+
98
+ ## 6. Structural note
99
+
100
+ `FshGenerator` takes only `FieldExtractor#entries` (or a `template` it
101
+ extracts from, same as `ProfileGenerator.new(template)`) — no
102
+ `ActiveRecord::Base`/`Rails` dependency. This keeps it relocatable if a
103
+ future `openehr-fhirbridge` satellite gem is split out (anlage
104
+ `docs/backlog.md` item 5, not a decision made in this Issue).
105
+
106
+ ## 7. Out of scope here
107
+
108
+ - CI wiring for Sushi verification (anlage-side decision, ruled out for
109
+ v1 — `docs/design/fsh-plan.md`'s 判断3 in `skoba/anlage`)
110
+ - The `rake fsh:export` output path (anlage-side, `skoba/anlage`'s own
111
+ Issue #17 scope)
112
+ - Any RubyGems release/publish step (human-gated per this repo's
113
+ `docs/backlog.md` "Release automation" / `skoba/openehr-rails#27`)
@@ -0,0 +1,180 @@
1
+ # Fix: multi-leaf non-Observation entries constrain a nonexistent `component`
2
+
3
+ - Status: draft, awaiting approval
4
+ - Target: `openehr-rails` (this repo). No cross-repo work.
5
+ - Issue: [#33](https://github.com/skoba/openehr-rails/issues/33)
6
+ - Log: `docs/reports/fsh-generator-log.md` (continuing R1-R5)
7
+
8
+ ## 1. Bug summary (from the issue, re-confirmed directly)
9
+
10
+ `TypeMap::ENTRY_RESOURCES` (`type_map.rb:12-18`) maps openEHR ENTRY types to five
11
+ different FHIR R5 resources: `OBSERVATION`→`Observation`, `EVALUATION`→`Condition`,
12
+ `INSTRUCTION`→`ServiceRequest`, `ACTION`→`Procedure`, `ADMIN_ENTRY`→`Encounter`.
13
+ Only `Observation` has a `component` element in FHIR R5 — confirmed against the
14
+ [FHIR R5 Observation resource definition](http://hl7.org/fhir/R5/observation.html);
15
+ `Condition`/`ServiceRequest`/`Procedure`/`Encounter` have no equivalent repeatable
16
+ slot for arbitrary additional codings/values. `ProfileGenerator#differential_elements`
17
+ (`profile_generator.rb:52-60`) and `FshGenerator#build_profile`
18
+ (`fsh_generator.rb:26-42`) both choose between a single-leaf `value[x]` path and a
19
+ multi-leaf `component` path purely on `entry[:fields].size`, with no check for
20
+ whether `resource_type` actually supports `component`. Confirmed via `sushi`:
21
+ `problem_list.opt`'s 5-leaf `EVALUATION`→`Condition` entry produces 29
22
+ `"No element found at path component..."` errors (matches the issue's own
23
+ citation).
24
+
25
+ This is **not EVALUATION-specific** — it affects any multi-leaf entry mapped to any
26
+ of the four non-`Observation` resources. No existing fixture currently has a
27
+ multi-leaf `INSTRUCTION`/`ACTION`/`ADMIN_ENTRY` entry, so those paths are untested
28
+ today, but the same defect applies to them by construction.
29
+
30
+ ### Related latent dead code, found during this investigation
31
+
32
+ `TypeMap.value_element(resource_type)` (`type_map.rb:53-58`):
33
+ ```ruby
34
+ def value_element(resource_type)
35
+ resource_type == 'Observation' ? 'value[x]' : 'value[x]'
36
+ end
37
+ ```
38
+ Its own comment says "Single-leaf Observations use `value[x]`; everything else hangs
39
+ off a component" — but the ternary returns the identical string on both branches, so
40
+ it implements none of that. Confirmed via `grep`: **called nowhere in `lib/`** (the
41
+ only other hits are unrelated local-variable names in `profile_generator_spec.rb`).
42
+ This is exactly the resource-type branch this issue needs, stubbed and never wired
43
+ up. Recommend removing it as part of this fix rather than leaving dead, misleading
44
+ code that describes behavior it doesn't implement — see §3.
45
+
46
+ ## 2. Options considered
47
+
48
+ **(a) FHIR Extension-based encoding for non-Observation multi-leaf entries.**
49
+ Define a custom complex extension (its own `StructureDefinition`) to carry
50
+ additional leaf values on `Condition`/`ServiceRequest`/`Procedure`/`Encounter`,
51
+ sliced under `resource_type.extension`. This is the FHIR-canonical way to add
52
+ structured data beyond a base resource's element set, and is the only option that
53
+ actually *represents* the extra leaves rather than dropping or refusing them.
54
+ **Rejected for this issue**: it requires designing and shipping a second
55
+ `StructureDefinition` (the extension itself) with its own cardinality/binding/typing
56
+ decisions that no prior art in this repo or `anlage`'s design docs addresses (checked
57
+ directly: `anlage/docs/design/fsh-plan.md` only says "multi-leaf `component`
58
+ slicing" as v1 scope, never anticipates the non-Observation case). Getting an
59
+ extension's shape right is a real FHIR-modeling decision with downstream
60
+ consequences for anyone consuming these profiles — not something to decide
61
+ unilaterally inside a bug-fix issue. Worth a future issue of its own if a real
62
+ `EVALUATION`/`INSTRUCTION`/`ACTION`/`ADMIN_ENTRY` multi-leaf template is actually
63
+ needed by a consumer (none is, today — see §5).
64
+
65
+ **(b) Split into multiple linked FHIR resources** (e.g. a primary `Condition` plus
66
+ separate `Observation` resources per extra leaf, linked via `Observation.focus`).
67
+ **Rejected**: changes the "one profile per ENTRY" architecture
68
+ (`profile_generator.rb:8-11`'s own doc comment) into "one-to-many," a much larger
69
+ structural change than this issue's scope, and duplicates concerns `#33`'s own
70
+ "needs its own explore/plan" framing already flags as too large for this pass.
71
+
72
+ **(c) Restrict multi-leaf profile generation to `Observation`-mapped entries; raise
73
+ a clear, documented error for multi-leaf entries mapped elsewhere.** No data is
74
+ silently dropped (an error beats silent corruption) and no unvetted FHIR-modeling
75
+ decision is made. The limitation is explicit and testable end-to-end today.
76
+ **Recommended** — see §3.
77
+
78
+ ## 3. Recommended fix: (c), restrict + explicit error
79
+
80
+ - `TypeMap` gains a way to answer "does this resource type support `component`?" —
81
+ replace the dead `value_element` method (§1) with what it should have been:
82
+ ```ruby
83
+ COMPONENT_CAPABLE_RESOURCES = %w[Observation].freeze
84
+
85
+ def component_capable?(resource_type)
86
+ COMPONENT_CAPABLE_RESOURCES.include?(resource_type)
87
+ end
88
+ ```
89
+ (A one-element array reads oddly today, but names the actual FHIR fact this repo
90
+ has verified — `Observation` is the only R5 resource among the five in
91
+ `ENTRY_RESOURCES` with a `component` element — and gives future resource-type
92
+ additions one place to declare the same fact, rather than re-deriving it.)
93
+ - `ProfileGenerator#differential_elements` (`profile_generator.rb:52-60`): when
94
+ `entry[:fields].size > 1` **and** `!TypeMap.component_capable?(resource_type)`,
95
+ raise a new `OpenehrRails::Fhir::UnsupportedProfileError` (or similar; exact class
96
+ name/placement to be finalized in implementation, not a design-doc-blocking
97
+ detail) with a message naming the entry's archetype id, resource type, and leaf
98
+ count — not a silent skip, not a partial/wrong profile.
99
+ - `FshGenerator#build_profile` (`fsh_generator.rb:26-42`): same branch, same error
100
+ class — one shared decision point, not two independently-drifting ones. Consider
101
+ whether the check belongs in a shared location both classes call (e.g. a small
102
+ module method) rather than duplicated inline logic, to avoid the exact kind of
103
+ two-copies drift `#33` itself was born from (`ProfileGenerator` and
104
+ `FshGenerator` independently duplicating the same `size == 1` branch that neither
105
+ originally checked `resource_type` for).
106
+ - **Where the error surfaces to a caller**: `ProfileGenerator#profiles` and
107
+ `FshGenerator#to_fsh_files` currently map over `@entries` unconditionally
108
+ (`profile_generator.rb:23-24`, `fsh_generator.rb:17-22`) — a raised error on one
109
+ entry would currently abort the whole batch, silently dropping profiles for
110
+ *other*, unaffected entries in the same template. Decide during implementation
111
+ whether that's acceptable (single-entry templates are today's only real fixtures)
112
+ or whether `profiles`/`to_fsh_files` should skip-and-report per-entry instead of
113
+ raising through the whole batch — flagging this as a design question for
114
+ approval, not deciding it here, since it changes both public methods' contracts
115
+ either way.
116
+
117
+ ## 4. Spec plan (t-wada: red before green)
118
+
119
+ - New spec (both `ProfileGenerator` and `FshGenerator`, or a shared example group if
120
+ the check ends up in one shared place per §3): parsing `problem_list.opt`
121
+ (5-leaf `EVALUATION`→`Condition`) and calling `.profiles`/`.to_fsh_files` raises
122
+ the new error, with a message that names the archetype id. **Red** today: no
123
+ error is raised; instead a differential/FSH referencing a nonexistent `component`
124
+ path is silently produced. **Enhancement** resolution kind (new documented
125
+ behavior, not a pre-existing property being pinned).
126
+ - Regression pin: `bmi_calculation.opt`'s multi-leaf `Observation`-mapped entry
127
+ (`body_mass_index.v2`, 2 leaves) is unaffected — existing
128
+ `profile_generator_spec.rb`/`fsh_generator_spec.rb` coverage of this fixture
129
+ already exercises it; confirm those specs still pass unchanged (they should, this
130
+ fix only adds a new branch for the non-capable-resource case).
131
+ - FSH-side confirmation per the issue's own acceptance criteria: after the fix,
132
+ regenerate FSH for `bmi_calculation.opt` and pipe through `sushi` again — expect
133
+ the same 0 Errors/0 Warnings as R2/R3 already established, unchanged.
134
+ `problem_list.opt` no longer produces any FSH to compile (it raises instead), so
135
+ there's nothing left to feed `sushi` for that fixture — satisfies the acceptance
136
+ criterion's spirit (no invalid FSH exists for a case that can't be represented
137
+ correctly yet) without a false claim of validity.
138
+
139
+ ## 5. Compatibility, scope, and semver
140
+
141
+ - **No existing fixture regresses**: no repo fixture has a multi-leaf
142
+ `INSTRUCTION`/`ACTION`/`ADMIN_ENTRY` entry today, and `problem_list.opt`'s
143
+ `EVALUATION` entry going from "silently wrong" to "clear error" is a bug fix, not
144
+ a behavior anyone could have been relying on (the prior output was invalid FHIR).
145
+ - **Host-app impact**: any host app that scaffolded `--fhir` output from a
146
+ multi-leaf `EVALUATION`/`INSTRUCTION`/`ACTION`/`ADMIN_ENTRY` template today has a
147
+ silently-broken generated profile already (confirmed for `EVALUATION` via
148
+ `sushi`; the other three are the same code path). This fix surfaces that as a
149
+ loud error on regeneration rather than continuing to ship invalid output.
150
+ `CHANGELOG.md` should say this plainly.
151
+ - **Semver**: raising a new, previously-unraised exception for input that already
152
+ produced semantically-invalid output is a bug fix — **patch**, not minor (no new
153
+ public API surface is added for callers to use; `UnsupportedProfileError`, if
154
+ it becomes part of the public interface at all, is something callers only see
155
+ when hitting the previously-broken case, not a new capability to opt into).
156
+ - **`docs/reports/fsh-generator-log.md`** continues as this fix's progress log
157
+ (already tracking `#32`/`#33` as one continuing thread).
158
+
159
+ ## 6. Open questions for approval
160
+
161
+ 1. Confirm option (c) (restrict + explicit error) over (a)/(b) — recommended, but
162
+ this is the actual FHIR-modeling judgment call this design doc exists to get
163
+ signed off on.
164
+ 2. Per-entry error handling in `profiles`/`to_fsh_files` (§3's last bullet): raise
165
+ through the whole batch, or skip-and-report per entry? No existing fixture has
166
+ more than one entry per template that would surface this distinction today, so
167
+ either choice is currently unobservable in this repo's own fixtures — pick the
168
+ simpler one (raise-through) unless there's a reason to prefer graceful
169
+ degradation now.
170
+ 3. Exact error class name/namespace (`OpenehrRails::Fhir::UnsupportedProfileError`
171
+ suggested, not fixed).
172
+
173
+ ## 7. Stop point
174
+
175
+ Explore + design only, per this repo's ticket-driven workflow. Do not implement
176
+ until this document is approved. Next steps after approval: implement (Codex per
177
+ this repo's division of labor, or directly if the change is judged small enough to
178
+ skip that split — decide at approval time), Claude Code review, full
179
+ `bundle exec rspec` + full-repo `rubocop` + `sushi` re-verification for
180
+ `bmi_calculation.opt`, commit(s), `docs/reports/fsh-generator-log.md` entry.
@@ -343,3 +343,69 @@ section). So tagging `v0.5.0` and pushing the tag is lower-risk than it might so
343
343
  publishing to RubyGems is a distinct, later, human-executed step. This would also be
344
344
  the **first real tag to exercise PR #28's fixed release path end-to-end** -- its own
345
345
  body deferred that verification to "the next real release (>= 0.5.0)," which this is.
346
+
347
+ ## R8 -- v0.5.0 tagged, release.yml verified green, artifact reproducibility confirmed
348
+
349
+ User approved tagging. Executed:
350
+
351
+ 1. `lib/openehr_rails/version.rb` bumped `0.4.1` -> `0.5.0`; `CHANGELOG.md`'s
352
+ `[Unreleased]` retitled to `## [0.5.0] - 2026-08-25` (fresh empty `[Unreleased]`
353
+ left above it), matching the exact pattern the `0.4.1` release-bump commit used
354
+ (`2cddb0d`, verified by reading its diff directly rather than guessing the
355
+ convention). Full suite re-run after the bump: 275 examples, 0 failures.
356
+ `bundle exec rake release:check` failed first (dirty tree, expected -- the check
357
+ requires a clean tree), then passed clean after committing (`69db63f`, pushed).
358
+ `git tag -a v0.5.0` (annotated, full CHANGELOG-derived message) and
359
+ `git push origin v0.5.0`.
360
+
361
+ 2. **`release.yml` run verified green end-to-end** -- GitHub Actions run
362
+ [32831263811](https://github.com/skoba/openehr-rails/actions/runs/32831263811),
363
+ triggered by the `v0.5.0` tag push. Overall conclusion: **success** (confirmed via
364
+ the run's own `conclusion` field, not inferred from individual step icons). All 12
365
+ jobs green: the 9 reused `ci.yml` spec-matrix jobs, `demo smoke`, `application
366
+ template smoke test`, and `Build gem artifact` (its `release:check`, `Build gem`,
367
+ and `Upload gem artifact` steps all succeeded). This is the **first tag-push run
368
+ with a fully green overall conclusion** -- `v0.4.1`
369
+ (`32550344344`) and `v0.4.0` (`31660884406`) were both structurally red at the
370
+ now-removed RubyGems-publish step (`docs/backlog.md` "CI status" section); that
371
+ step no longer exists in `release.yml` (PR #28), and this run empirically confirms
372
+ its removal actually fixed the red -- not just that the workflow file changed.
373
+
374
+ 3. **Artifact/local-build reproducibility confirmed** -- downloaded the run's `gem`
375
+ artifact (`gh run download 32831263811 -n gem`):
376
+ `openehr-rails-0.5.0.gem`, 197632 bytes, sha256
377
+ `e07815bd1c86736403cbb558fec869fbe04666f695e6cc12a41dad9be77230e6`. Ran
378
+ `bundle exec rake build` locally at the same tagged commit (`69db63f`, clean
379
+ tree): `pkg/openehr-rails-0.5.0.gem`, same size, **identical sha256**. CI's
380
+ built artifact and a local build from the same commit are byte-identical --
381
+ confirms build reproducibility, not just "both builds succeeded."
382
+
383
+ RubyGems publish is next: per condition, that remains a deliberate human `gem push`
384
+ step (established operating model, `docs/backlog.md` "Release automation"), not
385
+ something this session executes -- the sha256 above is the value to check the
386
+ locally-published gem against before/after `gem push`, and after publish confirms.
387
+ Awaiting that confirmation before the final backlog/CHANGELOG follow-up (condition 5)
388
+ and returning to dormancy.
389
+
390
+ ## R9 -- RubyGems publish confirmed, backlog updated, dormant
391
+
392
+ User reported publish complete. Verified directly rather than trusting the report at
393
+ face value: queried `https://rubygems.org/api/v1/versions/openehr-rails.json`.
394
+ `openehr-rails` `0.5.0` is listed, `created_at: 2026-08-25T09:26:15.342Z`, and its
395
+ published `sha` field (`e07815bd1c86736403cbb558fec869fbe04666f695e6cc12a41dad9be77230e6`)
396
+ matches R8's CI-artifact and local-build sha256 exactly -- full chain confirmed
397
+ byte-identical: local build == CI artifact == published gem.
398
+
399
+ Added a bullet to `docs/backlog.md`'s "CI status" section recording the `v0.5.0`
400
+ tag run (first fully-green tag-push run, confirming PR #28's release-path fix)
401
+ and the three-way sha256 match including the now-confirmed RubyGems listing.
402
+ Verified `CHANGELOG.md`'s `[0.5.0] - 2026-08-25` section is correctly finalized
403
+ with a fresh empty `[Unreleased]` above it -- no further edit needed. Full suite
404
+ re-confirmed green (275 examples, 0 failures) before this commit.
405
+
406
+ **Issue #30 fully closed out**: filed, designed, implemented (Codex + independent
407
+ review), merged, released as `0.5.0`, published to RubyGems, and the upstream
408
+ knowledge shared back to `skoba/openehr-ruby#31`. `anlage`'s FSH export
409
+ (`skoba/anlage#17`) can now pick up `field[:value_set_uri]`/`field[:code_bindings]`
410
+ via a normal `bundle update` -- the consumption prerequisite flagged in R7 is
411
+ satisfied. Returning to dormancy.
@@ -0,0 +1,317 @@
1
+ # FshGenerator progress log
2
+
3
+ R1〜.
4
+
5
+ ---
6
+
7
+ ## R1 -- Filing and design doc (Step 0-1)
8
+
9
+ Filed `skoba/openehr-rails#32` (goal Issue). Design authority is
10
+ `skoba/anlage`'s already-approved `docs/design/fsh-plan.md`
11
+ (2026-08-26 ruling) -- this Issue implements that plan's gem-level
12
+ commit split, not a fresh design.
13
+
14
+ Confirmed the input contract this Issue depends on is already live
15
+ (`skoba/openehr-rails#30`, shipped 0.5.0): `FieldExtractor#entries[].
16
+ fields[]` always carries `value_set_uri` and `code_bindings`
17
+ (`field_extractor.rb:160-177`).
18
+
19
+ Checked fixture leaf-field counts directly (`bundle exec ruby` with
20
+ `require 'active_record'; require 'openehr_rails'` -- `openehr_rails`
21
+ needs ActiveRecord loaded first, this repo has no `bin/rails`):
22
+
23
+ - `bmi_calculation.opt` / `bmi_calculation_without_uid.opt`: `height.v2`
24
+ (1 leaf), `body_weight.v2` (1 leaf), `body_mass_index.v2` (**2
25
+ leaves** -- and `at0004` already carries real SNOMED-CT (`60621009`)
26
+ + LOINC `code_binding`, so this one entry covers both multi-leaf
27
+ `component` slicing and code_binding in a single fixture)
28
+ - `problem_list.opt`: `problem_diagnosis.v1`, 5 leaves;
29
+ `value_set_binding` (ICD-11) on `at0002`
30
+ - `lab_result_report_reduced.opt`: `laboratory_test_result.v1`, 3 leaves
31
+ - `sample_blood_pressure.opt`: crashes on parse (`ArgumentError:
32
+ invalid archetype id form`, pre-existing and unrelated to this
33
+ Issue -- not investigated further, not needed for this fixture set)
34
+
35
+ No new SNOMED literal budget needed -- all binding demonstrations reuse
36
+ codes already checked into this repo's fixtures.
37
+
38
+ Design doc written: `docs/design/fsh-generator-plan.md`. Proceeding to
39
+ implementation (Codex, reviewed and committed by Claude Code per this
40
+ repo's division of labor).
41
+
42
+ ---
43
+
44
+ ## R2 -- Implementation, two correction rounds, commit `5f669af` (2026-08-26)
45
+
46
+ ### Round 0: Codex's first delivery
47
+
48
+ Codex implemented `FshGenerator` covering the full v1 scope table.
49
+ Self-reported: "SUSHI 3.16.0 was present, but could not finish loading
50
+ its R5 package under restricted network access" -- Codex's own sandbox
51
+ could not actually verify the generated FSH compiles. Claude Code has
52
+ unrestricted network access in this session and re-verified directly.
53
+
54
+ ### Round 1 finding: conflicting CodeableConcept assignments
55
+
56
+ Regenerated FSH from Codex's actual committed code (not a
57
+ paraphrase) and piped it through `sushi` (v3.16.0, real local install)
58
+ myself. `bmi_calculation.opt`'s `body_mass_index.v2` (2 code_bindings
59
+ on `at0004`: SNOMED-CT + LOINC) produced:
60
+
61
+ ```
62
+ error Cannot assign http://snomed.info/sct to this element; a different
63
+ uri is already assigned: "http://openehr.org/ckm/archetypes".
64
+ error Cannot assign http://loinc.org to this element; a different uri
65
+ is already assigned: "http://openehr.org/ckm/archetypes".
66
+ ```
67
+
68
+ Root cause: the original `binding_rules` wrote one `* path =
69
+ SYSTEM#code` fixed-value assignment per `code_bindings` entry, stacked
70
+ after the archetype_id anchor's `.coding.system`/`.coding.code` pair.
71
+ `* path = SYSTEM#code` is FSH shorthand for fixing an *entire*
72
+ CodeableConcept to one coding -- writing it twice (or after an
73
+ existing `.coding.system`/`.coding.code` pair) is a conflicting
74
+ reassignment of the same slot, not an append.
75
+
76
+ Explored the fix space empirically (all runs actually compiled with
77
+ `sushi`, not assumed):
78
+ - `code.coding[0]`/`code.coding[1]` explicit indexing without a prior
79
+ slicing declaration: **fails** ("No element found at path
80
+ code.coding[0]") -- FHIR profile differentials don't support
81
+ indexing into an un-sliced repeating element this way.
82
+ - `code.coding[+]`/`[=]` append syntax, same issue: **fails**,
83
+ identical error.
84
+ - Unindexed `code.coding.system = X` / `code.coding.code = Y` alone
85
+ (no second coding attempted): **compiles** (0 Errors) -- but this
86
+ form sets a *pattern* on the coding sub-elements
87
+ (`patternUri`/`patternCode` in the resulting differential), not an
88
+ indexed instance value, which is why indexing on top of it doesn't
89
+ work.
90
+ - **Slicing `code.coding` by the `system` value discriminator, one
91
+ named slice per source** (`ckm` for the archetype_id anchor, one per
92
+ `code_bindings` entry, using the terminology alias downcased as the
93
+ slice name): **compiles, 0 Errors**, both standalone (`code.coding`)
94
+ and nested inside a `component[slice].code.coding` path. This is the
95
+ fix that shipped.
96
+
97
+ Sent Codex the exact verified-working FSH pattern (both the standalone
98
+ and nested-in-component forms, copy-pasteable) plus the failing
99
+ pattern and its error, and asked for a rewrite -- not a vague "fix the
100
+ conflict" instruction. Codex's fix matches: `code_rules` now branches
101
+ on whether `bindings` is empty (`simple_code_rules`, unchanged
102
+ unindexed form) vs non-empty (new sliced form via `binding_slices`).
103
+
104
+ ### Round 2 finding: `component` doesn't exist on `Condition`
105
+
106
+ Re-verified again after the round-1 fix -- `bmi_calculation.opt`
107
+ (all-`Observation` entries) now compiles with **0 Errors**, confirmed
108
+ independently (regenerated from the committed code, not from Codex's
109
+ pasted example). `problem_list.opt` (single `EVALUATION` entry, 5
110
+ leaves) does not: **29 errors**, all `"No element found at path
111
+ component..."` for `Condition`.
112
+
113
+ Root cause: `TypeMap::ENTRY_RESOURCES` maps `EVALUATION` → `Condition`
114
+ (`type_map.rb:14`). `Condition` has no `component` element -- that's
115
+ `Observation`-specific in FHIR R5. `FshGenerator`'s multi-leaf branch
116
+ (mirroring `ProfileGenerator#component_elements`) always emits
117
+ `component`-path rules whenever `entry[:fields].size > 1`, with no
118
+ check for whether the target `resource_type` actually has a
119
+ `component` slot. This is a **pre-existing gap**, not something this
120
+ Issue introduced -- `ProfileGenerator`'s JSON output for the same
121
+ fixture almost certainly has the identical semantic defect, just never
122
+ caught (JSON isn't schema-validated the way FSH is by Sushi, and
123
+ `profile_generator_spec.rb` only exercises all-`Observation` fixtures).
124
+
125
+ Filed separately: `skoba/openehr-rails#33`. Out of scope for `#32` --
126
+ needs its own explore/plan (candidate directions: an extension,
127
+ restricting multi-leaf support to `Observation`-mapped entries with an
128
+ explicit documented limitation, or something else not yet decided).
129
+
130
+ Narrowed `#32`'s scope to match reality rather than over-claim: added
131
+ a 2-line code comment on the multi-leaf branch citing `#33`, and a
132
+ CHANGELOG note distinguishing "Observation-mapped output is
133
+ Sushi-verified" from "multi-leaf non-Observation entries have a known
134
+ gap tracked as #33". The `value_set_binding` spec assertion for
135
+ `problem_list.opt` stays as a text-level substring check (the `from
136
+ <uri> (required)` write itself is correct FSH, verified in isolation
137
+ in `docs/reports/fsh-log.md` R1 in `skoba/anlage`) -- it does not claim
138
+ whole-document Sushi compilation, which would be false for this
139
+ fixture.
140
+
141
+ ### Final verification (Claude Code, independent of Codex's own reports)
142
+
143
+ - Regenerated FSH from the final committed `fsh_generator.rb` for both
144
+ `bmi_calculation.opt` and `problem_list.opt`, piped each through
145
+ `sushi` directly: **0 Errors** for `bmi_calculation.opt` (3
146
+ profiles), **29 errors** for `problem_list.opt` (expected, tracked
147
+ as `#33`, not a regression from anything this Issue claims)
148
+ - `bundle exec rspec spec/openehr_rails/fhir/`: 28 examples, 0 failures
149
+ - `bundle exec rspec` (full suite, this session's own environment, not
150
+ Codex's more restricted sandbox): **281 examples, 0 failures** --
151
+ the 18 failures Codex reported (`Errno::EPERM`/`getifaddrs` in
152
+ remote-fetch specs) did not reproduce here, consistent with this
153
+ project's prior observation that this class of sandbox network
154
+ restriction doesn't reproduce on GitHub Actions runners either
155
+ - `bundle exec rubocop`: 108 files, no offenses
156
+ - Committed `5f669af`, pushed, CI run verified green (see bundle
157
+ report for run ID)
158
+
159
+ ### Closure
160
+
161
+ `#32`'s acceptance criteria are met for the scope that's actually
162
+ Sushi-clean (`Observation`-mapped entries). `#33` tracks the
163
+ `Condition`/`component` gap as separate follow-up work, not a blocker
164
+ for closing `#32`.
165
+
166
+ ---
167
+
168
+ ## R3 -- Remove active_support from FshGenerator (0.6.0 task, condition 1)
169
+
170
+ Codex's `#32` delivery required `active_support/core_ext/string` for three
171
+ `String` methods (`camelize`, `humanize`, `parameterize.dasherize`),
172
+ violating the structural clause `anlage`'s `docs/design/fsh-plan.md`
173
+ already committed to (追記1, 2026-08-26): FshGenerator must depend on
174
+ `openehr` only (Rails-independent), so it can move wholesale to the
175
+ future `openehr-fhirbridge` satellite gem -- explicitly named review
176
+ criterion: no dependency on `ActiveRecord::Base` or the `Rails`
177
+ namespace. `require 'active_support/core_ext/string'` doesn't touch
178
+ either directly, but ActiveSupport is Rails-family tooling, not
179
+ `openehr`-only; the clause's spirit is what's being restored here, not a
180
+ literal `ActiveRecord::Base`/`Rails` grep hit.
181
+
182
+ Measured exact behavior before replacing anything (not guessed): ran the
183
+ three ActiveSupport methods against representative inputs directly, then
184
+ cross-checked plain-Ruby candidate replacements against every entry from
185
+ every fixture this generator is exercised against (`bmi_calculation.opt`,
186
+ `bmi_calculation_without_uid.opt`, `problem_list.opt`,
187
+ `lab_result_report_reduced.opt`) -- all matched exactly, including the
188
+ one non-obvious case: `.parameterize` alone leaves internal underscores
189
+ intact (`"OBSERVATION.body_mass_index.v2".parameterize` =>
190
+ `"observation-body_mass_index-v2"`); `.dasherize` afterward is doing real
191
+ work (not redundant), converting those to `"observation-body-mass-index-v2"`.
192
+
193
+ Replaced with three small private helpers (`camelize`, `humanize`,
194
+ `parameterize`) matching the measured behavior. Re-verified with `sushi`
195
+ after the change, not just `rspec`: `bmi_calculation.opt` still 0
196
+ Errors/0 Warnings (3 profiles), `problem_list.opt` still exactly 29
197
+ errors (unchanged `#33` gap). Full suite: 281 examples, 0 failures.
198
+ RuboCop clean. Committed directly to `master` (`0181f7f`, tiny/self-
199
+ contained per the task's own "極小コミット" framing -- no separate
200
+ issue/branch/PR, matching this repo's docs-and-tooling-adjacent-fix
201
+ precedent for changes that don't touch public API or behavior).
202
+
203
+ `ProfileGenerator` still requires `active_support/core_ext/string` --
204
+ explicitly out of scope: it isn't planned for extraction to
205
+ `openehr-fhirbridge` on its own, so the structural clause doesn't bind it.
206
+
207
+ ---
208
+
209
+ ## R4 -- Release inventory (v0.5.0..master) and 0.6.0 proposal (condition 3)
210
+
211
+ Classified every non-merge commit since `v0.5.0` (8 commits; per-commit
212
+ file lists pulled via `git log --name-only`, not asserted from memory):
213
+
214
+ - **Neutral (6 commits)**: `19c905f`, `0d2a8ce` (docs, #30 closeout),
215
+ `ec019b4` (`.github/ISSUE_TEMPLATE/bug_report.md` only -- dev-tooling
216
+ config), `f7cc0eb` (design doc + report log), `a996ef4` (report log),
217
+ `7e8014f` (`CLAUDE.md` + report log).
218
+ - **`5f669af`** (adds `FshGenerator`): new file, new public class
219
+ (`OpenehrRails::Fhir::FshGenerator`, public `to_fsh_files`), touches
220
+ `lib/openehr_rails.rb` to require it. New backward-compatible public
221
+ API surface -- **minor**.
222
+ - **`0181f7f`** (drops `active_support` from `FshGenerator`): touches
223
+ `lib/` directly but verified byte-identical output (R3 above) -- no
224
+ new capability, no behavior change, no public API change, no gemspec
225
+ dependency change. Per this repo's own release convention ("a commit
226
+ touching shipped runtime code is at minimum patch, even when its
227
+ observable behavior is unchanged"), classified **patch**.
228
+
229
+ Overall: the range's highest-level commit dominates -- **minor**, i.e.
230
+ `v0.5.0 -> v0.6.0`. No breaking changes; `FshGenerator` is net-new so
231
+ there's no existing consumer to break. `CHANGELOG.md`'s `[Unreleased]`
232
+ content matches (Added: FshGenerator; Changed: the active_support
233
+ removal -- the latter's entry was missing and added in `4b595ca` while
234
+ preparing this inventory, since this repo's convention writes CHANGELOG
235
+ entries at merge/commit time and it had been skipped when `0181f7f`
236
+ landed).
237
+
238
+ No downstream-consumption blocker analogous to R7's anlage-FSH note is
239
+ outstanding for this release -- `anlage`'s FSH work is what's consuming
240
+ `FshGenerator` itself, and per condition 4 of this task, the immediate
241
+ next step after this release is `#33`'s own explore/plan, not a
242
+ release-gated anlage dependency.
243
+
244
+ Proceeding to version bump, CHANGELOG finalization, and tag per the
245
+ task's condition 3 -- same mechanical process as R8/R9 for `v0.5.0`,
246
+ already reused rather than re-derived.
247
+
248
+ ## R5 -- v0.6.0 tagged, release.yml verified green, artifact reproducibility confirmed
249
+
250
+ Caught and fixed a missing `CHANGELOG.md` entry for `0181f7f` (the
251
+ active_support removal) while preparing the version bump -- added a
252
+ `### Changed` note (`4b595ca`), full suite re-confirmed green first.
253
+
254
+ `lib/openehr_rails/version.rb` bumped `0.5.0` -> `0.6.0`;
255
+ `CHANGELOG.md`'s `[Unreleased]` retitled `## [0.6.0] - 2026-08-26` (fresh
256
+ empty `[Unreleased]` left above). Full suite: 281 examples, 0 failures.
257
+ Full-repo RuboCop (not just the touched files): 108 files, no offenses.
258
+ `bundle exec rake release:check`: OK on the first try (tree was already
259
+ clean from the prior commit). Committed (`4080053`), pushed, annotated
260
+ tag `v0.6.0` pushed.
261
+
262
+ `release.yml` run [32915373663](https://github.com/skoba/openehr-rails/actions/runs/32915373663):
263
+ overall conclusion **success** (confirmed via the run's own `conclusion`
264
+ field), all 12 jobs green -- second consecutive fully-green tag-push run,
265
+ same as `v0.5.0`'s (R8), confirming the release-path fix (PR #28) holds
266
+ across releases, not just as a one-off.
267
+
268
+ Artifact/local-build reproducibility, same method as R8: downloaded the
269
+ run's `gem` artifact (`gh run download 32915373663 -n gem`):
270
+ `openehr-rails-0.6.0.gem`, sha256
271
+ `08cdd14ab1f3890b0c6b5f0ae0d5ca55615f0b4874ed5efb0cd4d7bda9e573ca`. Built
272
+ `pkg/openehr-rails-0.6.0.gem` locally at the same tagged commit
273
+ (`4080053`, clean tree): **identical sha256**. CI artifact and local
274
+ build byte-identical, second release in a row.
275
+
276
+ RubyGems publish is the human's step next (established operating model,
277
+ unchanged from `v0.5.0`) -- the sha256 above is what to check the
278
+ published gem against. Awaiting that confirmation, then per condition 4
279
+ of the task moving on to `#33`'s explore/plan (already directed, not a
280
+ new decision to make here) and returning to dormancy in the meantime.
281
+
282
+ ## R6 -- #33 explore + design doc (condition 4)
283
+
284
+ Read the issue directly (`gh issue view 33 --json ...`, not summarized
285
+ from memory) and the current `type_map.rb`/`profile_generator.rb`/
286
+ `fsh_generator.rb` source. Confirmed independently against the FHIR R5
287
+ spec: only `Observation` has a `component` element among the five
288
+ resources `TypeMap::ENTRY_RESOURCES` maps to -- the bug is not
289
+ `EVALUATION`-specific, it affects any multi-leaf entry mapped to
290
+ `Condition`/`ServiceRequest`/`Procedure`/`Encounter` (no existing
291
+ fixture exercises the latter three multi-leaf, so only `EVALUATION` is
292
+ empirically confirmed today, but the defect applies by construction).
293
+
294
+ Found a related dead-code smell while reading `type_map.rb`:
295
+ `TypeMap.value_element(resource_type)` has a comment describing exactly
296
+ the resource-type branch `#33` needs, but its ternary returns the same
297
+ string on both branches and the method is called nowhere in `lib/`
298
+ (confirmed via `grep`) -- a stub for this exact check that was never
299
+ wired up. Recommending its removal/replacement as part of this fix
300
+ rather than leaving misleading dead code.
301
+
302
+ Checked `anlage`'s `docs/design/fsh-plan.md` directly for prior art on
303
+ this exact problem before designing from scratch -- none exists; the v1
304
+ scope only anticipated `component` slicing, never the non-Observation
305
+ resource-type mismatch.
306
+
307
+ Design doc written: `docs/design/multi-leaf-non-observation-plan.md`.
308
+ Three options considered (FHIR extension encoding / split into linked
309
+ resources / restrict-to-Observation with an explicit error);
310
+ **recommending the third** -- no unvetted FHIR-modeling decision made
311
+ unilaterally, no silent data loss, verifiable end-to-end today with the
312
+ tools already in hand (`sushi`). Two genuine judgment calls flagged for
313
+ approval rather than decided here: the option choice itself, and whether
314
+ `profiles`/`to_fsh_files` should raise through the whole batch or
315
+ skip-and-report per entry when one entry hits the restriction.
316
+
317
+ Gate: reporting to the user for approval before implementation.
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenehrRails
4
+ module Fhir
5
+ # Generates one FHIR Shorthand profile per openEHR ENTRY.
6
+ class FshGenerator
7
+ ARCHETYPE_SYSTEM = 'http://openehr.org/ckm/archetypes'
8
+ TERMINOLOGY_ALIASES = {
9
+ 'SNOMED-CT' => ['SNOMEDCT', 'http://snomed.info/sct'],
10
+ 'LOINC' => ['LOINC', 'http://loinc.org']
11
+ }.freeze
12
+
13
+ def initialize(template)
14
+ @entries = OpenehrRails::Opt::FieldExtractor.new(template).entries
15
+ end
16
+
17
+ def to_fsh_files
18
+ @entries.to_h do |entry|
19
+ id = profile_id(entry[:archetype_id])
20
+ [id, build_profile(entry, id)]
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def build_profile(entry, id)
27
+ resource_type = TypeMap.resource_for_entry(entry[:rm_type])
28
+ lines = alias_rules(entry[:fields])
29
+ lines << '' unless lines.empty?
30
+ lines.concat(metadata(entry, id, resource_type))
31
+ if entry[:fields].one?
32
+ field = entry[:fields].first
33
+ lines.concat(code_rules(entry[:archetype_id], 'code', field[:code_bindings]))
34
+ lines.concat(value_rules('value[x]', field))
35
+ else
36
+ # Multi-leaf non-Observation resources may not expose component;
37
+ # this pre-existing ProfileGenerator structural gap is tracked in #33.
38
+ lines.concat(code_rules(entry[:archetype_id]))
39
+ lines.concat(component_rules(entry[:fields]))
40
+ end
41
+ "#{lines.join("\n")}\n"
42
+ end
43
+
44
+ def alias_rules(fields)
45
+ bindings = fields.flat_map { |field| field[:code_bindings] }
46
+ return [] if bindings.empty?
47
+
48
+ aliases = [['CKM', ARCHETYPE_SYSTEM]]
49
+ aliases.concat(bindings.filter_map { |binding| TERMINOLOGY_ALIASES[binding[:system_uri]] })
50
+ aliases.uniq.map { |name, uri| "Alias: #{name} = #{uri}" }
51
+ end
52
+
53
+ def metadata(entry, id, resource_type)
54
+ [
55
+ "Profile: #{camelize(id.tr('-', '_'))}",
56
+ "Parent: #{resource_type}",
57
+ "Id: #{id}",
58
+ "Title: \"openEHR #{humanize(entry[:concept])} (#{entry[:archetype_id]})\"",
59
+ ''
60
+ ]
61
+ end
62
+
63
+ # Plain-Ruby equivalent of ActiveSupport's String#camelize on an
64
+ # underscore-separated input: "foo_bar" -> "FooBar".
65
+ def camelize(str)
66
+ str.split('_').map { |word| word[0].upcase + word[1..].to_s }.join
67
+ end
68
+
69
+ # Plain-Ruby equivalent of ActiveSupport's String#humanize on an
70
+ # already-lowercase, underscore-separated input: "foo_bar" -> "Foo bar".
71
+ def humanize(str)
72
+ spaced = str.tr('_', ' ')
73
+ spaced[0] ? spaced[0].upcase + spaced[1..].to_s.downcase : spaced
74
+ end
75
+
76
+ def code_rules(archetype_id, path = 'code', bindings = [])
77
+ return simple_code_rules(archetype_id, path) if bindings.empty?
78
+
79
+ coding_path = "#{path}.coding"
80
+ slices = ['ckm 1..1', *binding_slices(bindings).map { |slice, _| "#{slice} 0..1" }]
81
+ [
82
+ "* #{coding_path} ^slicing.discriminator.type = #value",
83
+ "* #{coding_path} ^slicing.discriminator.path = \"system\"",
84
+ "* #{coding_path} ^slicing.rules = #open",
85
+ "* #{coding_path} contains #{slices.join(' and ')}",
86
+ "* #{coding_path}[ckm] = CKM##{archetype_id}",
87
+ *binding_slices(bindings).map do |slice, binding|
88
+ alias_name = TERMINOLOGY_ALIASES.fetch(binding[:system_uri]).first
89
+ "* #{coding_path}[#{slice}] = #{alias_name}##{bare_code(binding[:code])}"
90
+ end
91
+ ]
92
+ end
93
+
94
+ def simple_code_rules(archetype_id, path)
95
+ [
96
+ "* #{path}.coding.system = \"#{ARCHETYPE_SYSTEM}\"",
97
+ "* #{path}.coding.code = ##{archetype_id}"
98
+ ]
99
+ end
100
+
101
+ def component_rules(fields)
102
+ rules = [
103
+ '* component ^slicing.discriminator.type = #pattern',
104
+ '* component ^slicing.discriminator.path = "code"',
105
+ '* component ^slicing.rules = #open'
106
+ ]
107
+ fields.each do |field|
108
+ slice_name = field[:name].downcase.gsub(/[^a-z0-9]/, '')
109
+ path = "component[#{slice_name}]"
110
+ rules << "* component contains #{slice_name} #{field[:required] ? 1 : 0}..1"
111
+ rules.concat(
112
+ code_rules(
113
+ "#{field[:archetype_id]}##{field[:node_id]}",
114
+ "#{path}.code",
115
+ field[:code_bindings]
116
+ )
117
+ )
118
+ rules.concat(value_rules("#{path}.value[x]", field))
119
+ end
120
+ rules
121
+ end
122
+
123
+ def binding_slices(bindings)
124
+ bindings.filter_map do |binding|
125
+ terminology = TERMINOLOGY_ALIASES[binding[:system_uri]]
126
+ next unless terminology
127
+
128
+ [terminology.first.downcase, binding]
129
+ end
130
+ end
131
+
132
+ # OPT term bindings wrap codes as `[TERMINOLOGY(version)::code]` (the
133
+ # version is optional). FSH needs only the code after the final `::`.
134
+ def bare_code(raw_code)
135
+ raw_code.to_s.sub(/\A\[.*::/, '').delete_suffix(']')
136
+ end
137
+
138
+ def value_rules(path, field)
139
+ rules = [
140
+ "* #{path} only #{TypeMap.datatype_for(field[:rm_type])}"
141
+ ]
142
+ rules.unshift("* #{path} #{field[:required] ? 1 : 0}..1") if path == 'value[x]'
143
+ rules << "* #{path}.unit = \"#{field[:units]}\"" if field[:rm_type] == 'DV_QUANTITY' && field[:units]
144
+ rules << "* #{path} from #{value_set_uri(field[:value_set_uri])} (required)" if field[:value_set_uri]
145
+ rules
146
+ end
147
+
148
+ # C_CODE_REFERENCE uses `terminology:<canonical>` in OPT; FSH binding
149
+ # targets use the canonical itself.
150
+ def value_set_uri(uri)
151
+ uri.delete_prefix('terminology:')
152
+ end
153
+
154
+ def profile_id(archetype_id)
155
+ "openehr-#{parameterize(archetype_id.delete_prefix('openEHR-EHR-'))}"
156
+ end
157
+
158
+ # Plain-Ruby equivalent of ActiveSupport's String#parameterize.dasherize:
159
+ # downcase, collapse every run of non-alphanumeric characters to a
160
+ # single "-", and strip any leading/trailing "-".
161
+ def parameterize(str)
162
+ str.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/\A-+|-+\z/, '')
163
+ end
164
+ end
165
+ end
166
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpenehrRails
4
- VERSION = '0.5.0'
4
+ VERSION = '0.6.0'
5
5
  end
data/lib/openehr_rails.rb CHANGED
@@ -32,6 +32,7 @@ require 'openehr_rails/template_importer'
32
32
  require 'openehr_rails/runtime_scaffolder'
33
33
  require 'openehr_rails/fhir/type_map'
34
34
  require 'openehr_rails/fhir/profile_generator'
35
+ require 'openehr_rails/fhir/fsh_generator'
35
36
  require 'openehr_rails/fhir/resource_registry'
36
37
  require 'openehr_rails/fhir/serializer'
37
38
  require 'openehr_rails/fhir/deserializer'
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'openehr_rails'
5
+
6
+ describe OpenehrRails::Fhir::FshGenerator do
7
+ let(:opt_file) do
8
+ File.expand_path('../../generators/templates/bmi_calculation.opt', __dir__)
9
+ end
10
+ let(:template) { OpenehrRails::Opt.parse(opt_file) }
11
+ let(:generator) { described_class.new(template) }
12
+
13
+ describe '#to_fsh_files' do
14
+ subject(:files) { generator.to_fsh_files }
15
+
16
+ it 'builds one FSH profile per OBSERVATION entry' do
17
+ expect(files.keys).to contain_exactly(
18
+ 'openehr-observation-height-v2',
19
+ 'openehr-observation-body-weight-v2',
20
+ 'openehr-observation-body-mass-index-v2'
21
+ )
22
+ end
23
+
24
+ describe 'the height profile' do
25
+ subject(:fsh) { files.fetch('openehr-observation-height-v2') }
26
+
27
+ it 'emits metadata and slices codings by system' do
28
+ expect(fsh).to include(
29
+ "Alias: CKM = http://openehr.org/ckm/archetypes\n",
30
+ "Alias: LOINC = http://loinc.org\n",
31
+ "Profile: OpenehrObservationHeightV2\n",
32
+ "Parent: Observation\n",
33
+ "Id: openehr-observation-height-v2\n",
34
+ "Title: \"openEHR Height (openEHR-EHR-OBSERVATION.height.v2)\"\n",
35
+ "* code.coding ^slicing.discriminator.type = #value\n",
36
+ "* code.coding ^slicing.discriminator.path = \"system\"\n",
37
+ "* code.coding ^slicing.rules = #open\n",
38
+ "* code.coding contains ckm 1..1 and loinc 0..1\n",
39
+ "* code.coding[ckm] = CKM#openEHR-EHR-OBSERVATION.height.v2\n",
40
+ "* code.coding[loinc] = LOINC#8302-2\n"
41
+ )
42
+ end
43
+
44
+ it 'constrains value[x] to an optional Quantity with a fixed unit' do
45
+ expect(fsh).to include(
46
+ "* value[x] 0..1\n",
47
+ "* value[x] only Quantity\n",
48
+ '* value[x].unit = "cm"'
49
+ )
50
+ end
51
+ end
52
+
53
+ describe 'a multi-element entry (body_mass_index)' do
54
+ subject(:fsh) { files.fetch('openehr-observation-body-mass-index-v2') }
55
+
56
+ it 'slices component by code and constrains each slice value' do
57
+ expect(fsh).to include(
58
+ "* component ^slicing.discriminator.type = #pattern\n",
59
+ "* component ^slicing.discriminator.path = \"code\"\n",
60
+ "* component ^slicing.rules = #open\n",
61
+ "* component contains bodymassindex 0..1\n",
62
+ '* component[bodymassindex].code.coding ' \
63
+ "^slicing.discriminator.type = #value\n",
64
+ '* component[bodymassindex].code.coding ' \
65
+ "^slicing.discriminator.path = \"system\"\n",
66
+ "* component[bodymassindex].code.coding ^slicing.rules = #open\n",
67
+ '* component[bodymassindex].code.coding contains ' \
68
+ "ckm 1..1 and snomedct 0..1 and loinc 0..1\n",
69
+ '* component[bodymassindex].code.coding[ckm] = ' \
70
+ "CKM#openEHR-EHR-OBSERVATION.body_mass_index.v2#at0004\n",
71
+ "* component[bodymassindex].value[x] only Quantity\n",
72
+ '* component[bodymassindex].value[x].unit = "kg/m2"'
73
+ )
74
+ end
75
+
76
+ it 'writes each ontology code binding into its coding slice' do
77
+ expect(fsh).to include(
78
+ "Alias: CKM = http://openehr.org/ckm/archetypes\n",
79
+ "Alias: SNOMEDCT = http://snomed.info/sct\n",
80
+ "Alias: LOINC = http://loinc.org\n",
81
+ '* component[bodymassindex].code.coding[snomedct] = ' \
82
+ "SNOMEDCT#60621009\n",
83
+ '* component[bodymassindex].code.coding[loinc] = ' \
84
+ "LOINC#39156-5\n"
85
+ )
86
+ end
87
+ end
88
+ end
89
+
90
+ describe 'value-set bindings' do
91
+ let(:opt_file) { File.expand_path('../../templates/problem_list.opt', __dir__) }
92
+
93
+ it 'emits a required binding for a C_CODE_REFERENCE constraint' do
94
+ fsh = generator.to_fsh_files.fetch('openehr-evaluation-problem-diagnosis-v1')
95
+
96
+ expect(fsh).to include(
97
+ '* component[problemdiagnosisat0002].value[x] from ' \
98
+ 'http://id.who.int/icd/release/11/mms (required)'
99
+ )
100
+ end
101
+ end
102
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openehr-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shinji KOBAYASHI
@@ -258,7 +258,10 @@ files:
258
258
  - docs/backlog.md
259
259
  - docs/design/binding-extraction-plan.md
260
260
  - docs/design/fix-terminology-scope-plan.md
261
+ - docs/design/fsh-generator-plan.md
262
+ - docs/design/multi-leaf-non-observation-plan.md
261
263
  - docs/reports/binding-extraction-log.md
264
+ - docs/reports/fsh-generator-log.md
262
265
  - docs/reports/institutionalization-log.md
263
266
  - gemfiles/rails_7_2.gemfile
264
267
  - gemfiles/rails_8_0.gemfile
@@ -292,6 +295,7 @@ files:
292
295
  - lib/openehr_rails/engine.rb
293
296
  - lib/openehr_rails/fhir/capability_statement.rb
294
297
  - lib/openehr_rails/fhir/deserializer.rb
298
+ - lib/openehr_rails/fhir/fsh_generator.rb
295
299
  - lib/openehr_rails/fhir/profile_generator.rb
296
300
  - lib/openehr_rails/fhir/profile_repository.rb
297
301
  - lib/openehr_rails/fhir/resource_registry.rb
@@ -342,6 +346,7 @@ files:
342
346
  - spec/openehr_rails/aql/model_api_spec.rb
343
347
  - spec/openehr_rails/aql/query_validator_spec.rb
344
348
  - spec/openehr_rails/authentication_spec.rb
349
+ - spec/openehr_rails/fhir/fsh_generator_spec.rb
345
350
  - spec/openehr_rails/fhir/profile_generator_spec.rb
346
351
  - spec/openehr_rails/fhir/serializer_spec.rb
347
352
  - spec/openehr_rails/naming_spec.rb