@cosyte/dicom 0.0.1 → 0.0.4

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.
package/CHANGELOG.md CHANGED
@@ -4,9 +4,392 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ### Added
8
+
9
+ - **The element registry is now generated from NEMA's PS3.6 DocBook, the normative publication**
10
+ (`DICOM-UPSTREAM-EDITION-LAG`). `vendor/nema/part06/` pins `part06.xml` (PS3.6 **2026c**) by
11
+ SHA-256, and `scripts/generate-dictionary.ts` applies it as a **per-field overlay** over the
12
+ Innolitics mirror: for a tag both sources carry, PS3.6 wins on name, keyword, VR, VM and
13
+ retirement; a tag PS3.6 carries and the mirror does not is added; a tag the mirror carries and
14
+ PS3.6 does not is **kept**, because PS3.6 retires elements rather than deleting them, so an absence
15
+ is far more likely to be a parse gap here than a withdrawal there and dropping the entry would turn
16
+ a decoded element into an unknown one (that set is empty today, and the generator prints its size).
17
+ The overlay is scoped to the four registry tables (6-1, 7-1, 8-1, 9-1). UIDs stay on the
18
+ `sops.json` + curated path deliberately: `uids.ts` carries the four short forms every DICOM toolkit
19
+ uses instead of PS3.6's "...: Default Transfer Syntax for ..." clauses, and retirement as a
20
+ structured `retired` boolean rather than a trailing " (Retired)" glued into the name, and an
21
+ overlay would undo both. PS3.15 Annex E is a different part and a different generator, unchanged.
22
+ This is the durable fix for the edition lag recorded in `vendor/innolitics/README.md`: the mirror's
23
+ pin is exactly current against its own upstream, but that upstream last refreshed its data on
24
+ 2024-04-18, so the tables sat on PS3.6 2024b against a current 2026c and no re-pinning discipline
25
+ could have moved them. The registry no longer comes from a source whose regeneration cadence is not
26
+ ours.
27
+ **The pin is a precondition, not a comment.** The generator recomputes the SHA-256 of the file it
28
+ reads and refuses to generate on a mismatch, reads the edition from the document's own
29
+ `<subtitle>` rather than asserting it, and fails loudly rather than emitting a thinner dictionary
30
+ on a row that is not six cells, a malformed `(gggg,eeee)` tag cell, a non-identifier keyword, an
31
+ unrecognized VR token, or a total under 5,000 registry rows. Two traps in the DocBook are handled
32
+ explicitly and covered by tests: the keyword column carries **13,470 ZERO WIDTH SPACE** line-break
33
+ hints in 2026c, one of which left in produces a keyword that looks right and never matches; and the
34
+ sixth column carries `DICOS` and `DICONDE` dictionary markers alongside `RET (edition)`, so reading
35
+ it as a boolean would retire **391** live tags. Only a leading `RET` retires. Row accounting is
36
+ the part that had to be got right rather than merely asserted: every `<tr>` in a registry table
37
+ must resolve to a matched body row or a header row, which covers unrecognized row markup, a second
38
+ `<tbody>`, and a row outside any `<tbody>` in one check. Counting row opens inside the already
39
+ truncated first-`<tbody>` slice, as an earlier draft did, structurally cannot see the rows it
40
+ dropped: splitting Table 6-1 in two made that version read 207 rows instead of 5,309. Cell text
41
+ is likewise stripped of markup to a fixpoint and then refused if any `<` or `>` survives, rather
42
+ than stripped in a single pass that can leave a residue which reassembles into another tag.
43
+ Every run prints the overlay it applied (shared / added / mirror-only, and the fields overridden
44
+ broken out by field, **with every VR override listed individually**), so a future re-pin of either
45
+ source shows what moved instead of a 5,000-line diff. There is deliberately **no staleness clock**:
46
+ a date-based gate would fire the day it was written, demand an action nobody can take on demand
47
+ (NEMA publishes when NEMA publishes), red unrelated pull requests, and train people to bump a date
48
+ instead of re-deriving anything. "Has NEMA published a new edition" is one command against
49
+ _content_, documented in `vendor/nema/README.md`. What is gated in CI is unchanged and offline:
50
+ the committed dictionary must be byte-for-byte what the pinned inputs produce.
51
+
52
+ - **Em-dash brand gate in CI (`EMDASH-CONFORMANCE`).** The founder directive of 2026-07-24
53
+ (`knowledgebase/06-brand/voice-and-tone.md`, "No em dashes. Ever.") bans `U+2014` outright across
54
+ every cosyte surface and names commit messages explicitly, and the meta-repo's
55
+ `documentation/conventions.md` has stated for weeks that the rule is CI-gated. It now actually is,
56
+ here: `scripts/check-no-emdash.sh` (`pnpm check:no-emdash`) plus a dedicated
57
+ `.github/workflows/no-emdash.yml` job that scans **both** halves the rule covers, the tracked files
58
+ **and** the PR title, body, and commit messages. The workflow carries the non-default `edited`
59
+ pull-request trigger, which is load-bearing: this repo squash-merges under
60
+ `squash_merge_commit_title: COMMIT_OR_PR_TITLE` and `squash_merge_commit_message: COMMIT_MESSAGES`
61
+ (read off the repo, not assumed), so the subject that lands on `main` is the PR title, or the lone
62
+ commit's subject when the branch has exactly one, and the body is the branch commit messages.
63
+ Without `edited` a title changed after the last push would never be re-checked. The PR body does
64
+ **not** land; the gate scans it anyway, as deliberate over-strictness on a surface that costs
65
+ nothing to cover. It is a separate workflow rather than a job in `ci.yml` because that trigger
66
+ would otherwise re-run the whole Node 22 + 24 matrix on every typo fix, and because the shared
67
+ `cosyte/.github` pipeline runs no arbitrary repo script.
68
+ **This one did change content, unlike the earlier ports.** An ecosystem survey had measured
69
+ markdown only (0 of 25 `.md` files) and read dicom as already clean. Measuring all 178 tracked
70
+ files found six live em dashes in four non-markdown files, all removed here (see Changed, below).
71
+ Measure every tracked file, because that is what the scan covers.
72
+ The script is the **text-only** variant, taken from `ncpdp` (PR #34, `39212bb`) rather than from
73
+ the older `knowledgebase` copy, so it carries `ncpdp`'s two fixes to the shared shape instead of
74
+ inheriting the holes: a tracked file named `-` was read by `grep` as standard input (which `xargs`
75
+ points at `/dev/null`) and so was never opened, and `-d skip` silently passed a tracked symlink to
76
+ a directory. Both are re-proved here rather than taken on faith: with the `./` path prefix removed
77
+ the gate prints OK and exits 0 over a live em dash in a file named `-`, and with `-d skip` restored
78
+ it goes green over an unread symlink.
79
+ It deliberately omits `grep -I`, and in this repo that is the most load-bearing flag choice in the
80
+ file. `src/dataset/vr/charset.ts` holds a functional NUL byte inside `/[\x00 ]+$/u`, the regex that
81
+ strips DICOM's own padding, so grep classifies it binary. It carries no em dash today and scans
82
+ green, which is the whole answer to the belief that this port was blocked: the red would come from
83
+ a match, never from the NUL. **If it ever gains one, the gate reds.** Measured with GNU grep 3.8:
84
+ grep exits 0, writes nothing to stdout, and writes `binary file matches` to stderr, so the hit
85
+ never reaches the hit list and the stderr capture fails the run instead. With `-I` added, the same
86
+ edit goes green, which is why `-I` stays out. The failure message names that case explicitly rather
87
+ than blaming an I/O error that did not happen.
88
+ A gate that prints OK when it did not read its input is worse than no gate, so nine routes by which
89
+ a dead or blind scan could still report green are each checked red, not assumed: a corrupt git
90
+ index, an unreadable tracked file, a tracked file named `-q`, a C-quoted non-ASCII path, a
91
+ mis-encoded text file, an empty tracked-file list, a tracked file named `-`, a tracked symlink to a
92
+ directory, and the NUL-bearing source file gaining an em dash.
93
+ Known limits are documented in the script header and inherited knowingly from the shared shape
94
+ rather than patched in this copy alone: encoded-form matching is literal (so `&#x2014` without the
95
+ semicolon, and lowercase `%e2%80%94`, pass), stderr capture binds to the scanning `grep` rather
96
+ than to the filters ahead of it, an em dash encoded in a non-UTF-8 charset is not matched (a live
97
+ possibility here, given `(0008,0005)` Specific Character Set fixtures), and the scan reads file
98
+ **contents** only, so a tracked path that itself carries an em dash passes. Tooling only: no
99
+ runtime, public-API, or parse-behavior change.
100
+ - **`docs-content/` now covers the full canonical Diátaxis spine** (`DOCS-CONTENT-P6`). Beyond the
101
+ existing Overview (`intro`), the sidebar gains **Installation** and **Quickstart** (tutorials),
102
+ five **Core Concepts** notes (the object model, the tolerance/warning model, the typed value
103
+ layer, the safety-critical views, and the source-profile system), a **Guides** cookbook (four
104
+ recipes: re-serialize, de-identify, read raw pixel data, triage warnings), and a
105
+ **Troubleshooting & known limitations** reference. Every documented capability is grounded in the
106
+ package's actually-shipped surface; the metadata-first boundary is stated explicitly, with the
107
+ permanent non-goals named in Troubleshooting (**no pixel decode, no DIMSE networking, no
108
+ DICOMweb**, no pixel-level de-identification).
109
+ - **Doc/code-agreement gate (`test/docs-content.test.ts`).** Every ` ```ts runnable ` snippet in
110
+ `docs-content/` is extracted, compiled, and executed against the **built** package via
111
+ `docSnippetSuite()` from `@cosyte/vitest-config/snippets`, with its inline `// =>` assertions
112
+ checked, so a documented example can never silently drift from the code. All examples use
113
+ synthetic, base64-encoded Part 10 objects (invented patient, fake UIDs); no real PHI, no `.dcm`
114
+ file on disk.
115
+
7
116
  ### Fixed
8
117
 
9
- - **`private: true` removed — `@cosyte/dicom` can publish.** The flag dated to the very first
118
+ - **`deidentify()` never removed and never reported the repeating-group attributes PS3.15 marks `X`**
119
+ (`DICOM-REPEATING-GROUP-DEID-GAP`). Table E.1-1 states three of its rows as a group mask rather
120
+ than a single tag: `(50xx,xxxx)` Curve Data, `(60xx,3000)` Overlay Data and `(60xx,4000)` Overlay
121
+ Comments, all three marked **X** (remove). The matcher keyed attributes by exact tag, which cannot
122
+ express a mask, so all three rows were unreachable; `deidentify()` reads "not in the table" as
123
+ "not listed, keep", so a file carrying `(6000,4000)` free text came back with a **clean report and
124
+ the text still in it**. Overlay comments are a classic burned-in-identifier carrier, and
125
+ `DICOM_BURNED_IN_ANNOTATION_NOT_REMOVED` gives no coverage here: it keys on `(7FE0,0010)` +
126
+ `(0028,0301)`, which describe the image, not the overlay planes. This shipped at `0.0.3`, and
127
+ `deid`'s `/dicom` adapter delegates here, so it had the same hole.
128
+ The three rows are now generated from the pinned PS3.15 DocBook as pattern rules and consulted on
129
+ an exact-tag miss. A matched element is **removed and reported**: the report entry carries the
130
+ concrete tag, the family's attribute name, and a new `DeidentifiedAttribute.repeatingGroup` field
131
+ naming the mask that matched, so an audit distinguishes a mask hit from a single-tag hit. The
132
+ `CleanGraphics` column is honoured on these rows exactly as the table states it. **The exact-tag
133
+ table wins** over a mask that would also cover the tag, as the more specific statement the standard
134
+ makes about it; 2026c publishes no such overlap and the generator counts and prints it every run.
135
+ **The mask covers the groups PS3.5 bounds it to, not any four hex digits.** PS3.5 §7.6: repeating
136
+ groups "shall only be allowed in the even numbered Groups 6000-601E", and PS3.5-2004 §7.6, which
137
+ the current edition's note delegates to for curves, states "even Groups (5000-501E,eeee)". Sixteen
138
+ groups per mask, not 256, and the same section says of the odd ones that there is "no implication
139
+ of repeating semantics". Both bounds are load-bearing in opposite directions: a hex wildcard would
140
+ remove attributes the standard never marked (data loss on a call asked to be conservative), and an
141
+ exact tag matched nothing. Odd groups in the overlay range are private and still go through the
142
+ private-attribute path.
143
+ **The generator now refuses a masked row on a prefix PS3.5 does not define as a repeating group**,
144
+ rather than printing it and carrying on, which is what let these three rows go missing. Proven by
145
+ mutation rather than asserted: against a Table E.1-1 carrying an injected `(7Fxx,0010)` family row
146
+ the pre-remedy generator exits 0 and drops it, the post-remedy one exits 1 and names the prefix. A
147
+ second mutation moves the Overlay Comments Basic Profile code `X` -> `K` and asserts the emitted
148
+ rule moves with it, so the action codes are read from the document rather than assumed. Every run
149
+ prints the rules emitted, the concrete groups each covers, the private family row it does not
150
+ emit, and the shadowing count.
151
+ - **`RetainLongitudinalTemporal` now documents which E.3.6 sub-option a caller actually gets.**
152
+ Behaviour is unchanged: PS3.15 §E.3.6 is two options and this package exposes one name carrying the
153
+ **full-dates** column, the less protective branch. It is `K` on all **169** rows where
154
+ modified-dates says `C`. The option's JSDoc and `docs-content/troubleshooting.md` now say so, and
155
+ say that date shifting is not performed at this layer. Splitting the option is a public-surface
156
+ change and is deliberately not made here.
157
+ - **`deidentify()` retained 35 patient-identifying attributes and reported nothing**
158
+ (`DICOM-ANNEX-E-DEID-LAG`). The PS3.15 Annex E action table came from a third-party mirror alone,
159
+ pinned at a snapshot whose Table E.1-1 data is 2024b-era. Current PS3.15 publishes 652 concrete
160
+ attributes; that snapshot carried 617. Because `annexE()` returns `undefined` for a tag it does not
161
+ carry and `deidentify()` reads `undefined` as "not listed, keep", every missing attribute came
162
+ through the call verbatim, and the `DeidentifyReport`, whose whole job is to say what was done,
163
+ said nothing about them. The caller's only signal was a clean return. This shipped at `0.0.3`.
164
+ **32 of the 35 are marked X** (remove) by the standard; the other three are `(0040,B020)` Waveform
165
+ Annotation Sequence (`X/D`), `(0070,0006)` Unformatted Text Value (`D`), and `(300A,0054)` Table Top
166
+ Position Alignment UID (`U`). The removals include `(0010,0011)` through `(0010,0016)`,
167
+ the person-names-to-use and pronoun block, of which `(0010,0012)` **Name to Use is a patient's
168
+ preferred name and survived verbatim**; `(0010,0041)` through `(0010,0047)`, the gender-identity
169
+ and sex-parameters-for-clinical-use attributes; `(0010,2161)` `EthnicGroupCodeSequence` and
170
+ `(0010,2162)` `EthnicGroups`, the two attributes that replaced the retired `(0010,2160)`
171
+ `EthnicGroup`; the four `(0008,130x)` diagnosis code sequences; and the waveform, montage,
172
+ acquisition-context and display-URI attributes at `(003A,xxxx)`, `(0040,Axxx)`, `(0040,Bxxx)` and
173
+ `(0040,E012)`. `(0032,1033)` `RequestingService` also gains the `CleanDescriptors: C` column the
174
+ mirror had dropped.
175
+ The fix is the same authority rule the data dictionary already uses, pointed at PS3.15.
176
+ `vendor/nema/part15/` pins `part15.xml` (PS3.15 **2026c**) by SHA-256, and
177
+ `scripts/generate-annex-e.ts` applies Table E.1-1 as a **per-field overlay** over the mirror: for a
178
+ tag both sources carry, PS3.15 wins on attribute name, Basic Profile action code, and all nine
179
+ metadata-affecting option columns; a tag PS3.15 carries and the mirror does not is added; a tag the
180
+ mirror carries and PS3.15 does not is **kept**, because the standard retires rather than deletes,
181
+ so an absence is far more likely to be a parse gap here than a withdrawal there and dropping the
182
+ entry would turn an attribute the de-identifier acts on into one it silently keeps. That set is
183
+ empty today, and its size prints on every run so the assumption stays observable. Nothing is
184
+ hand-corrected: every value is derived from the fetched normative bytes and reproduced by every
185
+ regen. No Basic Profile code changed for a tag both sources carried, which is the reassuring half
186
+ of the finding: the mirror was not wrong about what it had, only silent about what it lacked.
187
+ **The pin is a precondition, not a comment.** The generator recomputes the SHA-256 of the file it
188
+ reads and refuses to generate on a mismatch, reads the edition from the document's own
189
+ `<subtitle>` rather than asserting it, and fails loudly rather than emitting a thinner table on a
190
+ header row whose 15 column labels are not where the generator's indices expect them, a body row that
191
+ is not 15 cells, a tag cell it does not recognize, an action code outside Table E.1-1a in any
192
+ non-empty action column, an empty Basic Profile cell, a `<tr>` that resolves to neither a matched
193
+ body row nor a header row, or a total under 600 concrete rows. The header check is the one that
194
+ catches a column **reorder**, which a cell count cannot see and which would read one option's action
195
+ code as another's. Every run prints the overlay
196
+ it applied, **with every action-code override listed individually**, because a changed action code
197
+ is the one difference that decides whether an identifier survives.
198
+ Two exclusions are deliberate and now **printed on every run** instead of being assumed. Four rows
199
+ of Table E.1-1 state a family rather than a single tag (`(50xx,xxxx)` Curve Data, `(60xx,3000)`
200
+ Overlay Data, `(60xx,4000)` Overlay Comments, and `(gggg,eeee) where gggg is odd` Private
201
+ Attributes); an exact-tag map cannot key them, private attributes are removed through their own
202
+ path, and the three repeating-group rows remain a stated gap. And PS3.15's E.3.6 is two options,
203
+ full dates and modified dates, against this package's single `RetainLongitudinalTemporal`, which
204
+ carries the full-dates column; the two columns diverge on **169** of the 652 rows in 2026c, and
205
+ that count now prints rather than living in a comment that guessed "usually not".
206
+ There is deliberately **no staleness clock**, for the same reasons as PS3.6: a date gate fires the
207
+ day it is written, demands an action nobody can take on demand, and reds unrelated pull requests.
208
+ What CI gates is unchanged and offline: the committed table must be byte-for-byte what the pinned
209
+ inputs produce.
210
+ - **Three defects in the shipped data dictionary, all resolved from normative bytes**
211
+ (`DICOM-UPSTREAM-EDITION-LAG`). None is hand-corrected; each is what PS3.6 2026c prints, and each
212
+ is reproduced by every regen.
213
+ - **`(0010,2160)` `EthnicGroup` was marked current although PS3.6 retired it in 2025a**, and its
214
+ replacements were absent entirely. It is now `retired: true`, and `(0010,2161)`
215
+ `EthnicGroupCodeSequence` (`SQ`, VM 1) and `(0010,2162)` `EthnicGroups` (`UC`, VM 1-n) are
216
+ present. This is a real demographic-attribute change: an application reading only `EthnicGroup`
217
+ silently misses what current instances actually carry. Retired does not mean removed, so the
218
+ keyword still resolves and an older study still reads; the caller is told both what the tag is
219
+ and that it is no longer current.
220
+ - **`(3004,0012)` `DoseValue` was marked retired although PS3.6 defines it.** The `RET (2022d)`
221
+ marker belongs to the preceding row, `(3004,0010)` `RTDoseROISequence`, which remains retired.
222
+ `DoseValue` is now `retired: false`. A dose attribute wrongly flagged retired is the dangerous
223
+ direction of that error: it is the one that talks a caller out of reading a real RT dose.
224
+ - **`(003A,0320)` and `(003A,0325)` carried truncated keywords**, `SummarizedFilterLookupTable` and
225
+ `AnalogFilterType`, so `byKeyword` missed on the spelling PS3.6 publishes. They are now
226
+ `SummarizedFilterLookupTableSequence` and `AnalogFilterTypeCodeSequence`. The truncated forms are
227
+ **gone rather than kept as aliases**: they were never PS3.6 keywords, and carrying them would
228
+ preserve the defect under a nicer name.
229
+ - Alongside these, **180 tags PS3.6 has gained are now known**, so `Dictionary.lookup` names them
230
+ and Implicit VR parsing resolves their VR from the dictionary instead of falling back to `UN`.
231
+ The registry goes from **5,129 to 5,309** tags and `byKeyword` from **5,035 to 5,214** keywords.
232
+ Zero VR, name and VM differences existed on the 5,129 shared tags and zero tags were dropped, so
233
+ **no previously-decoded element decodes differently**; the change is corrections plus additions.
234
+
235
+ - **174 SOP Class UID names in `UIDS` were wrong (`DICOM-DICT-CURRENCY`).** The dictionary generator
236
+ appended `" Storage"` to every name it took from the vendor `sops.json`, but that field is already
237
+ the full PS3.6 Table A-1 UID Name. The result was a doubled suffix on the 164 names that already
238
+ ended in "Storage", so `UIDS["1.2.840.10008.5.1.4.1.1.2"].name` read **"CT Image Storage Storage"**,
239
+ and an equally wrong tail on 10 of the other 11 ("Digital X-Ray Image Storage - For Presentation
240
+ Storage"). Every one shipped in `0.0.1` through `0.0.3`. The suffix is gone and the names now come
241
+ through verbatim.
242
+ The rule was wrong for all 175 entries it touched but landed on the right string for exactly one,
243
+ so removing it needed a compensating correction rather than a second blanket rule:
244
+ `1.2.840.10008.5.1.4.1.1.79.1` is the single UID whose vendor name genuinely lacks the suffix, and
245
+ it is now pinned in the generator's curated table to the "Macular Grid Thickness and Volume Report
246
+ Storage" that PS3.6 2026c Table A-1 gives it.
247
+ One further name is corrected: `1.2.840.10008.1.2.6.1`, the retired RFC 2557 transfer syntax, read
248
+ "RFC 2557 MIME Encapsulation" where PS3.6 prints "RFC 2557 MIME encapsulation".
249
+ Measured against PS3.6 2026c on the branch head: of the **261** UIDs shared with Table A-1,
250
+ **240 match the `UID Name` column byte for byte**; a further **17** differ only in that Table A-1
251
+ writes retirement into the name as a trailing " (Retired)" where this dictionary carries a
252
+ structured `retired` boolean, which gives **257** when those are read as matches and **zero**
253
+ retirement-flag disagreements; the remaining **4** are the deliberate transfer-syntax short forms
254
+ tabulated in `vendor/innolitics/README.md`; **0** are unexplained. All **7** well-known frames of
255
+ reference match Table A-2 byte for byte. `uids.ts` changes on 175 lines in total.
256
+ Name only: no UID value, `type`, or `retired` flag changed. Transfer-syntax dispatch keys off the
257
+ UID **value**, never the name, so no parse or de-identification behavior changes. The one place a
258
+ name reaches a caller at runtime is the human-readable `snippet` on the fatal
259
+ `UNSUPPORTED_TRANSFER_SYNTAX` error, which is improved by the correction rather than altered in
260
+ meaning.
261
+ - **The byte-identical regen gate could go green while generating nothing (`DICOM-DICT-CURRENCY`).**
262
+ Two ways, both fixed by making the gate delete `src/dictionary/generated/` (except the hand-written
263
+ `README.md`) before running `pnpm gen:all`, so it measures what the generators produce instead of
264
+ what happens to be on disk. A stale orphan, an artifact a generator used to emit and no longer
265
+ does, was never rewritten and never diffed, so it passed indefinitely. And gutting `gen:all` to a
266
+ no-op wrote nothing, left every committed artifact untouched, and produced an empty `git diff`:
267
+ permanently green. `package.json` also joins the workflow's `paths` filters, without which that
268
+ second case never triggered the gate at all. Both now red, proved by seeding each defect and
269
+ watching the run fail. The delete is `-mindepth 1` rather than `-type f`, so a stale symlink or
270
+ subdirectory cannot become an orphan the gate is blind to, and the step asserts that only
271
+ `README.md` survives instead of printing an expectation nothing checks. `pnpm gen:clean` exposes
272
+ the same delete for local use but is deliberately **not** chained into `gen:all`: the generators
273
+ throw on malformed or missing vendor input, which is the normal state part-way through a re-pin,
274
+ and a chained clean would leave the working tree empty and the build broken. The gate does its own
275
+ delete rather than trusting the script under test.
276
+
277
+ ### Changed
278
+
279
+ - **The PS3.5 repeating-group bound is now derived from pinned normative documents rather than
280
+ transcribed (`DICOM-PS35-NOT-VENDORED`). No runtime impact: the emitted bound is identical and no
281
+ public API moves.** `deidentify()` expands PS3.15 Table E.1-1's `(50xx,xxxx)`, `(60xx,3000)` and
282
+ `(60xx,4000)` rows over the concrete groups PS3.5 section 7.6 admits, sixteen even groups per mask
283
+ (`5000`-`501E`, `6000`-`601E`), not 256. That bound is a safety limit in **both** directions, and it
284
+ was the last input here that lived as a **quotation in a source file** while PS3.6 and PS3.15 were
285
+ SHA-pinned and re-hashed before use, so the generator's guard could catch a new mask _prefix_ but
286
+ never a changed _bound_.
287
+ `vendor/nema/part05/` pins `part05.xml` (PS3.5 **2026c**) and `vendor/nema/part05-2004/` pins
288
+ `04_05pu.pdf`, both re-hashed as a precondition, and `scripts/generate-repeating-groups.ts` emits
289
+ `src/dictionary/generated/repeating-groups.ts`, which `src/dictionary/repeating-groups.ts`
290
+ re-exports. **Two documents, because the bound is split across two editions:** the current one
291
+ states the overlay bound normatively and excludes the odd `6001`-`601F`, but says nothing about the
292
+ curve bound, having retired curve encoding and delegated it to PS3.5-2004 by URL in section 7.6's
293
+ own Note. So the 2004 PDF is the authority the edition in force names, not a convenience copy. The
294
+ generator **proves that delegation** (section 7.6 must link exactly the vendored URL) instead of
295
+ assuming it, and because both editions state the overlay bound it **requires them to agree**.
296
+ `gen:repeating-groups` runs first in `gen:all`, since the Annex E generator expands family rows
297
+ through this bound.
298
+ **The values did not change; their falsifiability did.** Moving the overlay bound in either
299
+ vendored edition reds the cross-check, moving the curve bound in the 2004 edition moves the emitted
300
+ artifact (caught by the byte-identical regen gate), and removing the delegation link stops the 2004
301
+ document being used at all. Each was confirmed non-vacuous by disabling the guard and watching the
302
+ test fail. Reading a 2004 PDF needs a PDF reader; the one in that generator is deliberately minimal
303
+ (Node `zlib` only, one sentence recovered, matched against a precise expected shape) and is not to
304
+ be grown into a general parser. There is no staleness clock, for the same reasons as PS3.6 and
305
+ PS3.15.
306
+ - **Recorded what the vendor pin is actually current against (`DICOM-DICT-CURRENCY`).**
307
+ `vendor/innolitics/README.md` gains a measured Currency section and drops the "re-pin monthly"
308
+ policy, which nothing ran and which measured the wrong thing. The pin is exactly current against
309
+ upstream: `git ls-remote` resolves `master` to the pinned SHA and all four vendored files are
310
+ byte-identical to upstream at it. Upstream itself is the stale link, having last changed
311
+ `attributes.json` on 2024-04-18 ("Update standard to rev2024b"), so the tag tables are grounded in
312
+ PS3.6 2024b while the current edition is PS3.6 2026c. The drift is now measured rather than
313
+ assumed, against the NEMA DocBook source for 2026c, and every non-additive difference is named in
314
+ that file. Figures on `86ab6c1` (`origin/main` at measurement time; this slice does not touch
315
+ `tags.ts` or `keywords.ts`): all **5,129** committed tags are shared with PS3.6 2026c, and across
316
+ them there are **zero VR differences**, zero name differences and zero VM differences, with 2
317
+ keyword and 2 retirement-status differences. **Every tag this dictionary carries is still in
318
+ PS3.6 2026c**; the drift is otherwise purely additive, 180 tags the standard has gained. VR is
319
+ what turns bytes into a value, so zero VR drift is the result that matters for reading a real
320
+ study. Nothing in the dictionary is hand-edited to close the gap: it is generated, and the
321
+ remaining differences are recorded for the next re-pin. One method note lives with the
322
+ measurement, because getting it wrong is easy and was got wrong on the first run: DICOM tag values
323
+ are hexadecimal and their case is not semantic, so tag keys must be compared case-insensitively.
324
+ PS3.6 prints them uniformly uppercase and `tags.ts` agrees on 1,540 of its 5,129 keys, but
325
+ `tags.ts` lowercases the 8 repeating-group keys whose trailing digits are hex letters and no
326
+ others, so a verbatim comparison mis-classifies exactly those 8.
327
+
328
+ - **Removed the six em dashes the new gate found (`EMDASH-CONFORMANCE`).** Four tracked files, none
329
+ of them markdown: `.github/CODEOWNERS` (2), `.github/workflows/release.yml` (2),
330
+ `vendor/nema/SHA.txt` (1), and the npm `description` in `package.json` (1). The last is the only
331
+ one visible outside the repo: the package description on npm and on the GitHub sidebar now reads
332
+ "Developer-focused DICOM Part 10 parser + utility library for Node.js and TypeScript:
333
+ metadata-first, vendor-quirky-tolerant, dual ESM/CJS." Each rewrite replaces the character with a
334
+ period, colon, or comma, per the rule's own instruction; none re-encodes it, and no wording beyond
335
+ the punctuation changed.
336
+ - **Bumped the `@cosyte/vitest-config` devDependency to `^0.0.2`** to pick up the `./snippets`
337
+ export that ships the doc/code-agreement runner.
338
+ - **Corrected the element-access examples in `intro.md`.** `Dataset.get` / `has` take the
339
+ 8-character `(group,element)` **tag** form only. The prior snippets showed `get("PatientName")`,
340
+ `get("(0010,0010)")`, and `get("StudyDate")`, all of which return `undefined`. They now use the
341
+ tag form (and show resolving a keyword to its tag via `Dictionary.byKeyword`), matching the code.
342
+
343
+ - **All tests now live in a top-level `test/` mirroring `src/`.** `@cosyte/dicom` was the lone parser
344
+ that co-located `*.test.ts` files beside their source; the 32 co-located suites were moved to
345
+ `test/<path>/` preserving the `src/` sub-structure, bringing the repo in line with the
346
+ `hl7`/`mllp`/`x12`/`ccda`/`ncpdp` archetype (`DICOM-TEST-RELOCATE`). Relative imports in the moved
347
+ files were retargeted (`./foo.js` → `../../src/<path>/foo.js`). `vitest.config.ts` drops the
348
+ `src/**/*.test.ts` include glob, so the config now matches the archetype and a stray co-located
349
+ test is no longer silently collected alongside the `test/` suite. Test-only, no public-surface
350
+ change: same 585 passing tests (+1 todo), coverage gates (still pointed at the `src/` source
351
+ directories) unchanged and green.
352
+
353
+ ### Fixed
354
+
355
+ - **`Dictionary Regen` is green again (`DICOM-DICT-REGEN`).** The byte-identical regen gate had been
356
+ red since 2026-06-24: seven consecutive failing runs, four of them pushes to `main`, every one of
357
+ them failing in its first real step. The last green run was 2026-05-04.
358
+ `.github/workflows/dictionary-regen.yml` passed `version: 10` to `pnpm/action-setup@v6` while
359
+ `package.json` already declares `packageManager: pnpm@10.0.0`, and v6 treats two sources of truth as
360
+ a hard error: "Multiple versions of pnpm specified". The job died in about 13 seconds having never
361
+ installed anything, never run `pnpm gen:all`, and never compared a single generated byte. Dropping
362
+ the redundant `version:` input lets `packageManager` win, matching the fix `astm` already applied to
363
+ its fuzz workflow (`44c3d88`).
364
+ Two smaller repairs to the same file, both aimed at the gate actually asserting something rather
365
+ than merely starting. The workflow now lists **itself** in its `paths` filters, so a change to the
366
+ gate re-runs the gate; without that, the PR fixing a broken gate could not demonstrate the gate
367
+ fixed. And the verify step now also fails on **untracked** files under `src/dictionary/generated/`:
368
+ `git diff` reports modified tracked files only, so a generator that began emitting a brand-new
369
+ artifact would have left it untracked and invisible to the check.
370
+ The committed dictionary itself is **not** drifted. Regenerating from the pinned inputs
371
+ (`vendor/innolitics/90571bc`) reproduces all four artifacts byte for byte: 5,129 tags, 5,035
372
+ keywords, 268 UIDs, 617 Annex E entries, zero diff. Verified as a positive control rather than
373
+ assumed: perturbing a VR in the vendor input made the gate red, and restoring it made the gate
374
+ green, so the check reads its input. Nothing in the tag tables needed correcting, and nothing
375
+ regenerated was committed.
376
+
377
+ - **Corrected the `private: true` claim in `.github/workflows/release.yml`.** A comment at the top of
378
+ the release caller asserted that `@cosyte/dicom` is `private: true` and that `changeset publish` is
379
+ therefore "a no-op until that flag is removed". `package.json` carries no `private` field, the repo
380
+ is public, and the package ships on npm at `0.0.1`, so the comment described the release pipeline as
381
+ inert when it is live. Documentation only, no behavior change, but a misleading note on a workflow
382
+ that really does publish is worth more than a stale one elsewhere.
383
+
384
+ - **Corrected stale publish-status language in `docs-content/` (`README-ORG-SWEEP`).**
385
+ `installation.md` claimed the package was "not yet published to npm" and that the install command
386
+ was only "the shape it will take at first publish"; `troubleshooting.md` listed a "Not yet
387
+ published … not on npm; the first provenance publish is gated on the coordinated public launch"
388
+ non-goal. `@cosyte/dicom` is published on npm at `0.0.1` and public. Both now state the truth
389
+ (published, public, still pre-alpha on the `0.0.x`-until-first-alpha ladder), and the troubleshooting
390
+ bullet becomes an honest pin-your-version pre-alpha caveat rather than a stale non-goal.
391
+
392
+ - **`private: true` removed: `@cosyte/dicom` can publish.** The flag dated to the very first
10
393
  scaffold commit and was never explained; `changeset publish` silently skips a private package, so
11
394
  this repo was the one parser that could not reach npm even once its pipeline worked. It also
12
395
  contradicted the `publishConfig: { access: "public" }` in the same file. Removed as part of the
@@ -18,25 +401,25 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
18
401
  reference, retargeted at `src/version.ts`) and the `version` script that runs it after
19
402
  `changeset version`, so the bump and the `VERSION` constant land in the same commit.
20
403
  - **`VERSION` is no longer typed as a string literal.** It was declared `export const VERSION =
21
- "0.0.0"`, giving it the literal type `"0.0.0"` — so the exported type would change on every
404
+ "0.0.0"`, giving it the literal type `"0.0.0"`, so the exported type would change on every
22
405
  release, making each version bump a breaking type change. Now annotated `: string`, matching the
23
406
  `hl7` reference. Type-only; the runtime value is unchanged. Done now because the package is
24
- unpublished — after the first publish this would itself be a breaking change.
407
+ unpublished. After the first publish this would itself be a breaking change.
25
408
 
26
409
  - **The Release workflow can actually start.** `.github/workflows/release.yml` calls the shared
27
410
  `cosyte/.github` pipeline, which requests `contents`/`id-token`/`pull-requests: write`, but declared
28
- no `permissions:` of its own — so it inherited the repo default of `contents: read`. A called
411
+ no `permissions:` of its own, so it inherited the repo default of `contents: read`. A called
29
412
  workflow may only downgrade the caller's `GITHUB_TOKEN`, never escalate it, so GitHub rejected the
30
413
  workflow at startup (~1s, no jobs, no logs). Every Release run from June 2026 until now failed this
31
414
  way, unnoticed, because a `startup_failure` produces no logs to read. The caller job now declares
32
- the three scopes explicitly. CI-only — no runtime or API change.
415
+ the three scopes explicitly. CI-only: no runtime or API change.
33
416
 
34
417
  ### Security
35
418
 
36
- - **Dev-dependency advisory remediation (no runtime impact — the published
419
+ - **Dev-dependency advisory remediation (no runtime impact: the published
37
420
  artifact is unchanged).** Added scoped `pnpm.overrides` pinning two
38
421
  transitive **dev/build-time** packages to their patched releases: `esbuild`
39
- (`>=0.27.3 <0.28.1` → `0.28.1`; GHSA dev-server path-traversal — not
422
+ (`>=0.27.3 <0.28.1` → `0.28.1`; GHSA dev-server path-traversal, not
40
423
  reachable here: the library builds via `tsup`/`vitest` and never runs
41
424
  `esbuild serve`) and the `@changesets/parse` copy of `js-yaml`
42
425
  (`>=4.0.0 <4.2.0` → `4.2.0`; GHSA-h67p-54hq-rp68 merge-key DoS). The
@@ -49,19 +432,25 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
49
432
 
50
433
  ### Added
51
434
 
435
+ - **Trademark notice (`TRADEMARKS.md`).** This package names third-party systems to describe what it
436
+ interoperates with; the notice records that cosyte is not affiliated with, endorsed by, or
437
+ sponsored by any of them, that every reference is descriptive, and that the built-in profiles are
438
+ authored from public sources only. Added to `files` so it ships inside the published tarball, not
439
+ just on GitHub. Documentation only: no runtime or API change.
440
+
52
441
  - **Lossless File Meta round-trip.** The parser now retains non-modeled
53
- `(0002,xxxx)` File Meta elements — e.g. `(0002,0017)`/`(0002,0018)`
442
+ `(0002,xxxx)` File Meta elements (e.g. `(0002,0017)`/`(0002,0018)`
54
443
  Sending/Receiving AE Title, `(0002,0100)` Private Information Creator UID,
55
- `(0002,0102)` Private Information — as raw on-wire bytes on the new
444
+ `(0002,0102)` Private Information) as raw on-wire bytes on the new
56
445
  `FileMeta.extraElements` view (each a `FileMetaRawElement` carrying the tag,
57
446
  its Explicit-VR-LE VR, and a defensively copied even-length value). The
58
447
  serializer merges these with the typed fields and emits the whole group in
59
448
  ascending tag order (PS3.5 §7.4) with a recomputed `(0002,0000)` group length
60
- (PS3.10 §7.1), so an exotic File Meta group now round-trips byte-for-byte —
449
+ (PS3.10 §7.1), so an exotic File Meta group now round-trips byte-for-byte,
61
450
  not just the typed fields. New exported type: `FileMetaRawElement`. This
62
451
  resolves the Phase 5 serializer known-limitation ("only the typed `FileMeta`
63
452
  fields round-trip").
64
- - **Documentation completeness (Phase 8).** Rewrote `README.md` into a full developer guide — quickstart,
453
+ - **Documentation completeness (Phase 8).** Rewrote `README.md` into a full developer guide: quickstart,
65
454
  feature tour, a "DICOM in 90 seconds" primer, the two access patterns, an 80/20 **cookbook** (index a
66
455
  folder, build routing keys, read pixel-interpretation metadata safely, de-identify, bridge to FHIR
67
456
  `ImagingStudy` / HL7 v2, round-trip serialize), the four-tier tolerance model, the warning/fatal code
@@ -69,13 +458,13 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
69
458
  export now carries a JSDoc `@example`. Extended the dual ESM/CJS smoke harnesses to exercise the full
70
459
  Phase 1–7 published surface so the documented entrypoints are guaranteed importable from both module
71
460
  systems. Also corrected an `intro.md` snippet that referenced a nonexistent `ds.pixelData` getter (the
72
- real accessor is `ds.get("PixelData")?.value`). Docs-only — no runtime API change.
461
+ real accessor is `ds.get("PixelData")?.value`). Docs-only: no runtime API change.
73
462
  - **Metadata-level de-identification (Phase 7).** New `deidentify(ds, options?)` applies the PS3.15
74
463
  Annex E **Basic Application Level Confidentiality Profile** plus the nine metadata-affecting Annex E
75
464
  Options (`RetainUIDs`, `RetainLongitudinalTemporal`, `RetainPatientCharacteristics`,
76
465
  `RetainDeviceIdentity`, `RetainInstitutionIdentity`, `RetainSafePrivate`, `CleanDescriptors`,
77
466
  `CleanStructuredContent`, `CleanGraphics`), driven by the generated Table E.1-1 action map. It is a
78
- **pure** function — the input `Dataset` is never mutated; it returns a fresh de-identified `Dataset`
467
+ **pure** function: the input `Dataset` is never mutated; it returns a fresh de-identified `Dataset`
79
468
  and a value-free `DeidentifyReport` (tags, keywords, resolved action codes, the UID map, warnings).
80
469
  Each attribute's action (`D` dummy, `Z` zero-length, `X` remove, `K` keep, `C` clean, `U` consistent
81
470
  UID) is resolved from the Basic Profile, overridden by any active Option; conditional codes (`Z/D`,
@@ -101,31 +490,31 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
101
490
  `escalate` (Tier-2 warning codes promoted to a thrown `DicomParseError`), `suppress` (codes silenced
102
491
  as a documented benign quirk of the source), and `privateTags` (a private-creator-keyed overlay that
103
492
  resolves the Implicit VR of vendor private data elements). Private resolution is keyed on the file's
104
- **live** private-creator string and the canonical `"GGGGxxLL"` key (PS3.5 §7.8.1) — never a
105
- hard-coded block number — so the same vendor schema resolves regardless of which block it landed in.
493
+ **live** private-creator string and the canonical `"GGGGxxLL"` key (PS3.5 §7.8.1), never a
494
+ hard-coded block number, so the same vendor schema resolves regardless of which block it landed in.
106
495
  Profiles compose via `extends` (de-duplicated lineage, union of escalations/suppressions, child-wins
107
496
  dictionary merge) and expose a deterministic `describe()` summary. Five built-ins ship under the
108
497
  frozen `profiles` namespace: three vendor overlays (`ge`, `siemens`, `philips`, grounded in the
109
498
  public GDCM / dcm4che / dcm2niix private dictionaries) and two posture presets (`strict` escalates
110
499
  integrity-relevant warnings; `lenient` suppresses cosmetic, high-volume ones). A creator the active
111
500
  profile does not recognize degrades to generic `UN` plus the new `DICOM_PRIVATE_CREATOR_UNKNOWN`
112
- warning — never a wrong decode. Selecting a profile never changes a correct decode. New public
501
+ warning, never a wrong decode. Selecting a profile never changes a correct decode. New public
113
502
  exports: `defineProfile`, `profiles`, `ProfileDefinitionError`, and the types `Profile`,
114
503
  `PrivateTagDefinition`, `DefineProfileOptions`, `ProfilePrivateTags`; `ParseOptions` gains an
115
504
  optional `profile` field. The reserved `DICOM_PRIVATE_CREATOR_UNKNOWN` code is now actively emitted
116
505
  (no change to the `WARNING_CODES` registry surface).
117
506
  - **Spec-clean Part 10 serializer (Phase 5).** New `serializeDicom(ds)` writes a `Dataset` back to a
118
- DICOM Part 10 `Buffer` — the conservative half of Postel's Law. Emits the 128-byte zero preamble +
507
+ DICOM Part 10 `Buffer`, the conservative half of Postel's Law. Emits the 128-byte zero preamble +
119
508
  `DICM`, a File Meta group (always Explicit VR LE) with a computed `(0002,0000)` group length and
120
509
  conservative Type-1 defaults (File Meta Version `0x0001`, cosyte Implementation Class UID under the
121
- `2.25` UUID arc), then the dataset body in the dataset's own transfer syntax — **no transcode** —
510
+ `2.25` UUID arc), then the dataset body in the dataset's own transfer syntax (**no transcode**)
122
511
  across all four v1 syntaxes (Implicit VR LE, Explicit VR LE/BE, Deflated Explicit VR LE). Scalar
123
512
  values are padded to even length per PS3.5 §6.2 (`0x00` for `UI`/byte-stream VRs, `0x20` for text),
124
513
  short vs long-form headers are chosen by VR per §7.1.2 (`SV`/`UV` long-form), retired `(gggg,0000)`
125
514
  group-length elements are omitted per §7.2, and sequence + encapsulated-pixel-data spans pass
126
- through byte-for-byte per §7.5 / §A.4. Pure function — the input `Dataset` is never mutated.
515
+ through byte-for-byte per §7.5 / §A.4. Pure function: the input `Dataset` is never mutated.
127
516
  - **Serializer error taxonomy.** New `DicomSerializeError` with codes `MISSING_TRANSFER_SYNTAX`
128
- (no File Meta Transfer Syntax UID) and `UNSUPPORTED_TRANSFER_SYNTAX` (a UID outside the v1 set) —
517
+ (no File Meta Transfer Syntax UID) and `UNSUPPORTED_TRANSFER_SYNTAX` (a UID outside the v1 set),
129
518
  separate from the parser's fatal codes and the value layer's `DicomValueError`. The message is
130
519
  built only from the code + the offending Transfer Syntax UID (structural facts), never a decoded
131
520
  value, so it is always safe to log. New public exports: `serializeDicom`, `DicomSerializeError`,
@@ -137,7 +526,7 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
137
526
  fields round-trip; any other `(0002,xxxx)` element a source file carried (e.g. `(0002,0100)` Private
138
527
  Information Creator UID) is dropped at _parse_ time (the Phase 2 `FileMeta` view does not model it)
139
528
  and so cannot be re-emitted. The preamble is normalized to zeros and odd-length values are padded
140
- even — the output stays spec-clean but is not a byte-identical copy of a non-conformant input.
529
+ even. The output stays spec-clean but is not a byte-identical copy of a non-conformant input.
141
530
 
142
531
  ### Tests
143
532
 
@@ -158,7 +547,7 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
158
547
  tuple plus Other Patient IDs (so a caller never matches on a bare, non-unique `(0010,0020)`) and
159
548
  keeps `PN` structured; `study`/`series` surface the cross-system UIDs, accession number, modality
160
549
  and Frame of Reference UID. `image` surfaces the pixel-interpretation + geometry metadata a
161
- renderer needs — with the safety-critical omissions intact: `rescaleSlope` is **absent** (not `1`)
550
+ renderer needs, with the safety-critical omissions intact: `rescaleSlope` is **absent** (not `1`)
162
551
  when the tag is absent, `signed` is absent (never guessed) unless `(0028,0103)` was present,
163
552
  `photometricInterpretation` is never defaulted to `MONOCHROME2`, and the three pixel-spacing tags
164
553
  (`(0028,0030)` / `(0018,1164)` / `(0018,2010)`) are distinct, never aliased.
@@ -166,7 +555,7 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
166
555
  Per-Frame-else-Shared (PS3.3 §C.7.6.16): Pixel Measures, Plane Position, Plane Orientation, Pixel
167
556
  Value Transformation, Frame VOI LUT. `image.isEnhancedMultiFrame` flags such objects.
168
557
  - **Value-layer error taxonomy.** New `DicomValueError` (codes `FRAME_INDEX_OUT_OF_RANGE`,
169
- `MISSING_REQUIRED_FUNCTIONAL_GROUP`) — separate from the parser's four fatal codes. The helpers are
558
+ `MISSING_REQUIRED_FUNCTIONAL_GROUP`), separate from the parser's four fatal codes. The helpers are
170
559
  otherwise fail-safe (typed-absent for missing data) and throw only for a structural contract
171
560
  violation; the error message carries only structural facts (indices, tag/macro names), never a
172
561
  decoded PHI value.
@@ -179,7 +568,7 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
179
568
  `CodedConcept`, `RealWorldValueMap`, `FrameFunctionalGroups`, `ValueErrorCode`, plus
180
569
  `VALUE_ERROR_CODES` and the `readCode` / `codingSchemeOid` / `CODING_SCHEME_OIDS` helpers.
181
570
  - **VR value decode + dataset navigation (Phase 3).** `Element.value` now lazily decodes (and
182
- memoizes) an element's raw bytes into a typed, discriminated `DicomValue` covering all 34 VRs —
571
+ memoizes) an element's raw bytes into a typed, discriminated `DicomValue` covering all 34 VRs:
183
572
  numbers (`US/UL/SS/SL/FL/FD`), 64-bit `bigint`s (`SV/UV`), attribute tags (`AT`), person names
184
573
  (`PN` → 3-group / 5-component), strings, free text, numeric strings (`DS/IS` → `number | null`,
185
574
  never `NaN`→0), temporal values (`DA/TM/DT`), sequences (`SQ` → threaded items), and raw `binary`
@@ -203,7 +592,7 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
203
592
  (`_arbitraries.ts`) plus invariant suites for round-trip fidelity, lenient-mode robustness,
204
593
  parsed-model immutability, warning/fatal-code stability (snapshot), and a byte-parser fuzz sweep
205
594
  that feeds arbitrary buffers + random truncations and asserts the parser only ever throws a
206
- sanctioned Tier-3 `DicomParseError` — never an unexpected error, hang, or OOM. No public API
595
+ sanctioned Tier-3 `DicomParseError`, never an unexpected error, hang, or OOM. No public API
207
596
  change. (devDeps: `@cosyte/test-utils@^0.0.1`, `fast-check@3.23.2`.)
208
597
 
209
598
  ### Changed