@cosyte/dicom 0.0.7 → 0.0.10

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,8 +4,431 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ### Fixed
8
+
9
+ - **🩺 A private value crossed the PS3.5 §7.8.1 reservation boundary and was retained under
10
+ `RetainSafePrivate`, in output stamped `PatientIdentityRemoved = YES`.** The **absorb** direction
11
+ is closed; the **eject** direction and the private-`SQ` carve-out are measured, pinned and **not**
12
+ closed (see below)
13
+ (`DICOM-PRIVATE-CREATOR-RESERVATION-LEAK`). **`PRE-EXISTING` and live on the published `0.0.9`**;
14
+ found by `#51`'s pass-6 `conformance-refuter` and measured **identical on `origin/main`**, so it
15
+ was never introduced by that branch.
16
+
17
+ **The shape.** An `(FFFE,E000)` Item that declares more bytes than its enclosing `SQ` element's
18
+ Value Length allows reads past the end of its sequence and absorbs the element that followed it.
19
+ With a Private Creator in the Item's genuine content and the private data element written at the
20
+ **root** - where no creator reserved its block, so the Basic Profile removes it - the absorbed
21
+ element lands beside that creator and is kept verbatim. Measured on `164eb39`, Explicit VR LE and
22
+ BE, `deidentify(ds, { retain: ["RetainSafePrivate"], profile: profiles.ge })`:
23
+ `report.removedPrivateTags` is `[]`, `SECRET-PRIVATE-PHI` is in the serialized output,
24
+ `(0012,0062)` is `YES`, and **the file parses with no warning on either channel**. A stamp that
25
+ outruns the redaction is the worst shape this class has, because a consumer trusting it has no
26
+ signal at all.
27
+
28
+ **The remedy is at the de-identify boundary and it removes; it does not downgrade the stamp.**
29
+ PS3.5 2026c §7.8.1, read from the vendored pin (`4dfd7b8c…`), one occurrence in the document:
30
+ "Items within a sequence are self contained Data Sets ..., any Item in the sequence that contains
31
+ Private Data Elements shall also have Private Creator Data Element reserving a block of Elements
32
+ for those Private Data Elements. The scope of the reservation is just within the Item. Items do
33
+ not inherit the Private Data Element reservations made by Private Creator Data Elements in the
34
+ Data Set in which the Item is nested." The reservation's scope **is** the Item's boundary, so a
35
+ file that contradicts itself about where the Item ends does not determine which reservation
36
+ covers an element. PS3.15 2026c §E.3.10 "Retain Safe Private Option": "Private Attributes that
37
+ are known by the de-identifier to be safe from identity leakage shall be retained, together with
38
+ the Private Creator IDs that are required to fully define the retained Private Attributes; all
39
+ other Private Attributes shall be removed **or processed in the element-specific manner recommended by Deidentification Action (0008,0307), if present within Private Data Element Characteristics Sequence (0008,0300)**". **The clause licenses two dispositions and the
40
+ first draft of this entry closed the quote at "removed"**, reading a permissive clause as an
41
+ absolute; this library does not implement `(0008,0307)`, so removal is the branch available to it.
42
+ Nothing there is _known_, so the standard's own
43
+ default applies. Every private element **the recursion reaches** in such an Item, and at every
44
+ depth below it, is removed and named in `report.removedPrivateTags` - **with one carve-out, stated
45
+ in the sentence rather than only in a later paragraph**: a private `SQ` the profile vouches for is
46
+ settled before the descent and is kept verbatim (see below).
47
+ **The unit is the SEQUENCE, not the individual Item**: `descendSequence` decides once and
48
+ applies it to every item, so a two-item sequence where only item 1 over-runs also loses a genuine
49
+ block in the honest item 0 (measured and pinned; the grid sweeps single-item fixtures, so that
50
+ extra cost is NOT inside the priced 20).
51
+ **The unit is the SEQUENCE, not the individual Item**: `descendSequence` decides once and
52
+ applies it to every item, so a two-item sequence where only item 1 over-runs also loses a genuine
53
+ block in the honest item 0 (measured and pinned; the grid sweeps single-item fixtures, so that
54
+ extra cost is NOT inside the priced 20).
55
+
56
+ **It is NOT a parser bound, and no reading changes.** An over-declaring Item and an
57
+ under-declaring Sequence are byte-identical - `#51` reached that conclusion three times and its
58
+ refuter could build no predicate separating them. `scripts/measure-sq-bound-grid.ts` over
59
+ **83,037 cells** against `164eb39`: **0 cells differ in any PARSE respect**, **0 cells whose
60
+ READING differs** (a new counter; the existing parse-respect count folds both warning channels
61
+ in), 0 new lenient fatals, 0 new strict fatals, 0 markers lost or gained from the parsed object,
62
+ 0 wrong root `(0010,0020)`, 0 reports that lose an attribute, and **0 Implicit VR LE cells
63
+ changed** - a free control, because that path slices the item stream so no Item can over-run.
64
+
65
+ **The fail-safe-direction argument `#51` made in five artifacts is RETRACTED, not reworded.** The
66
+ direction is not a property of the two readings; it is a property of **where the sender put the
67
+ Private Creator relative to the disputed bytes**. Both **absorb** placements are pinned by tests
68
+ and both are refused now: the creator inside the Item with the data element absorbed in, and the
69
+ data element inside the Item with the creator absorbed in. The **eject** placement is pinned as a
70
+ residual and is not refused.
71
+
72
+ **The price, measured rather than asserted.** The grid gained a `priv|` family - the first
73
+ population in that harness that runs `RetainSafePrivate` at all, which is why three earlier
74
+ refuter passes read "0 PHI regressions" off it while this was live. **58 -> 0** cells keep a
75
+ private value inside an Item on a self-contradicting file, and **20 of those 58 were not leaking
76
+ anything**: the creator and the data element were both genuine Item content and the reservation
77
+ was real. They pay for the guarantee, because nothing on the wire distinguishes them. Retention on
78
+ a file whose length fields agree is **unchanged** - 6 -> 6 inside an Item, **9 -> 9** at the root -
79
+ and 0 -> 0 rows are kept with no creator in scope, on either tree.
80
+
81
+ **🩺 Two routes are NOT closed, found by the pass-1 `conformance-refuter` grade.** Both are
82
+ `PRE-EXISTING`, reproduce identically on `164eb39`, and both produce the same false attestation
83
+ (`removedPrivateTags: []`, the value in the output, `(0012,0062) = YES`).
84
+ **(a) The eject direction.** An Item that _under_-declares pushes its trailing elements **out**
85
+ into the enclosing Data Set, which is not narrowed, so a Private Creator that lands there reserves
86
+ a block for elements the sender never gave it. **It is NOT root-specific and an earlier draft of
87
+ this entry said it was** - pass 2 reproduced it one level down, where an inner sequence ejects a
88
+ creator into the still-usable enclosing Item. The item filed for this must be scoped to every
89
+ still-usable Data Set or it will be built to the wrong bar. Silent on every channel
90
+ and under `{ strict: true }`, Explicit VR LE and BE. **22 grid cells.** The widening was **built
91
+ and measured**: narrowing the flag whenever a Data Set _contains_ an over-running sequence costs
92
+ **24** root retentions and closes **2** of the 22, because the other 20 are Implicit VR LE where
93
+ the sequence records no over-run at all. A different mechanism, and its own item.
94
+ **(b) The private-`SQ` carve-out.** `keepsPrivate` decides **before** the descent, so a private
95
+ `SQ` the profile vouches for is kept **verbatim** and this rule is never consulted inside it. The
96
+ first draft of this entry said "every private element in such an Item ... is removed",
97
+ unconditionally, which is `#54`'s exact refusal repeated; the sentence is corrected rather than the
98
+ guard widened. Both routes are pinned as **residual tests that assert the leaking behaviour**, so
99
+ they go red when fixed.
100
+
101
+ **No new public surface**: no new warning code, no new report field, no snapshot change. The
102
+ action is already visible on `report.removedPrivateTags`, the channel this defect read `[]` on.
103
+ What is _not_ on any channel is the reason - a caller sees which private tags went, not that the
104
+ file contradicted itself. Disclosed rather than closed, because a new Tier-2 code would also have
105
+ to answer to `profiles.strict` escalation and to the per-element amplification bound `#48`
106
+ established.
107
+
108
+ - **An unrecognized Explicit VR was read short-form, contrary to PS3.5 2026c §6.2's normative
109
+ "shall"** (`DICOM-UNRECOGNIZED-VR-SHORT-FORM`). It is now read - and written - long-form. **This
110
+ is a behaviour change on the read path**, and the trade is measured below rather than asserted.
111
+
112
+ **The clause, read from the vendored `vendor/nema/part05` pin (`4dfd7b8c…`), one occurrence in the
113
+ document.** §6.2 "Value Representation (VR)": "All new VRs defined in future versions of DICOM
114
+ **shall** be of the same Data Element Structure as defined in [§7.1.2] with reserved bytes after
115
+ the VR and a 32-bit unsigned integer VL (i.e., following the format for VRs such as OB or UT), and
116
+ may or may not permit Undefined Length." §6.2's **note** about an implementation choosing to
117
+ ignore unrecognized VRs is informative, and is deliberately not what this rests on.
118
+
119
+ **What a conformant future-VR file used to do: no sentence, deliberately.** Four attempts have
120
+ now been made to summarize that in one sentence and all four were wrong, the fourth by the first
121
+ draft of this entry. It is **shape-specific**: the short-form read took the carrier's length from
122
+ the two bytes §7.1.2 reserves (`0x0000`), got a zero-length value, and resumed inside the 32-bit
123
+ VL field - so what happened next depended on the payload's own bytes. Sometimes a whole-object
124
+ `INVALID_FILE_META`; sometimes a **clean parse into a tree the sender did not write**, with a
125
+ zero-length carrier plus an element manufactured out of the payload and, under Explicit VR BE,
126
+ **no warning on either channel**. `scripts/measure-unrecognized-vr.ts` is new here and prints the
127
+ per-shape table for both trees, including that case (`long-payload-tiles`). **Add a shape rather
128
+ than writing a summary.**
129
+
130
+ **What changed, and what deliberately did not.** `readExplicitElementHeader` and the File Meta
131
+ element reader take the 12-byte header for any VR outside the 34 this edition defines;
132
+ `serializeDicom` writes one. Everything after the header read is untouched - the same value read,
133
+ the same "declared length exceeds the remaining buffer" refusal, and the same undefined-length
134
+ refusal that `OB` / `UT` / `UN` already take. **This is one more VR class routed into an existing
135
+ bound, not a new bound**, which is the distinction four refusals in this family were about. No new
136
+ warning code was minted: a §6.2-conformant file is not something to warn about, and a new Tier-2
137
+ code would throw under `{ strict: true }` on exactly such a file.
138
+
139
+ **The reader and the writer had to move in one commit.** The short form's length field is 16 bits.
140
+ With the reader fixed alone, a 70,000-byte unrecognized-VR value would have been re-emitted
141
+ declaring **4,464**, silently. Pinned by a round-trip test at that size.
142
+
143
+ **The trade, on `scripts/measure-sq-bound-grid.ts` against `66f0c95` (76,611 cells).**
144
+ **1,221 recovered** (fatal before, parse now) against **932 newly refused**; 0 PHI regressions;
145
+ 0 cells where the root `(0010,0020)` changes value; 0 reports that lose an attribute on a cell
146
+ that still parses; **0 Implicit VR LE cells changed at all** (a free control - there is no on-wire
147
+ VR there); leaking cells unmoved at 11 (`DICOM-BINARY-CARRIER-OVERDECLARE`, untouched here).
148
+ **All 932** of the newly refused had an unrecognized VR in their `66f0c95` parse tree, so every
149
+ file this refuses is one the old reader only "read" by manufacturing a Data Element header out of
150
+ the middle of somebody's value; the fabricated VRs across that population include `"CT"` - a
151
+ Modality value read as a VR.
152
+ The grid also reads **0 cells that parse on both trees and read differently**. Read that as a
153
+ statement about the grid's fixtures, **not** as "nothing is silently re-read": the
154
+ `long-payload-tiles` shape is exactly such a cell and it is outside anything the grid sweeps.
155
+ **The mirror shape is the honest cost**: a sender that ignores §6.2 and writes an unrecognized VR
156
+ in the 8-byte short form produced a readable object before and is refused now. That is pinned by a
157
+ test rather than left to be discovered.
158
+
159
+ **`deidentify()` is unchanged, and one of its claims was not.** The rule that empties an element
160
+ whose VR is not one of the 34 still fires - reading a header is not the same as knowing what its
161
+ value means, and Table E.1-1 acts per attribute. **Disclosed, not fixed**: it is the same
162
+ over-redaction trade the un-auditable-sequence rule makes, and re-deciding it is a product call.
163
+
164
+ **`#55` published "on a file conformant to PS3.5 2026c the cost is zero". It was never true**, and
165
+ no replacement account of the old reader belongs here: three were written while this entry was
166
+ drafted and the gate refuted all three. The harness prints what each shape does on each tree, and
167
+ the shape that refutes the shortest version of the story is a row in it
168
+ (`long-payload-tiles-future-vr`). **Add a shape rather than a sentence.**
169
+
170
+ **`DICOM_NONZERO_RESERVED_BYTES` no longer names a tag, and that is a PHI fix this slice owed.**
171
+ Reading an unrecognized VR long-form means landing on the two bytes §7.1.2 reserves, so this code
172
+ is newly reachable on a **fabricated** header - one built out of the middle of somebody's value.
173
+ Its message interpolated the four bytes it would call a tag: measured on an `ST` carrier holding
174
+ `"MR BRAIN SMITHSON"` under-declared by 6, it streamed `Element (54495348) …` - the bytes
175
+ `"ITHS"` in wire order, four letters of the surname - where `66f0c95` emitted nothing naming that
176
+ element on the same file (its only warning there is `DICOM_ODD_LENGTH_VALUE_PADDED`, on a genuine
177
+ `(0010,0010)`). (Quote the payload **with** its tag: `"MR BRAIN SMITHSON "` produces `48544F53`
178
+ instead, and an earlier draft of this entry paired the two wrongly.) **The factory now takes no
179
+ tag parameter**, so the bound is the signature rather than a branch, and `position.byteOffset`
180
+ locates the element. This is exactly the remedy `#55` paid a blocker for in
181
+ `report.undefinedVrElements[].tag`, and the reason is the same: where the trigger is "these bytes
182
+ are not what they claim to be", `renderTag` checks shape and cannot refuse them. It also closes a
183
+ `PRE-EXISTING` instance on the same factory - a long-form VR fabricated the same way rendered its
184
+ tag on `66f0c95` too. Pinned with a name-bearing fixture and a non-vacuity assertion that the code
185
+ really fires.
186
+
187
+ **A second `DeidentifyReport` claim corrected rather than widened.** The report was described as
188
+ "value-free apart from `uidMap`". `removedPrivateTags` is a second exception: its entries are four
189
+ source bytes each, and on a malformed file those bytes can be document content. Measured
190
+ identically on **both** trees - an `OB` carrier holding `"SECRET-NOTE-"` followed by a well-formed
191
+ odd-group header reports `["41534342"]`, whose wire-order bytes read `"SABC"`. Reporting the tag
192
+ is the whole audit value of the field on a well-formed file, so it is documented as PHI rather
193
+ than withheld; narrowing it is a product decision that has not been made.
194
+
195
+ - **A test asserted the machine it ran on rather than the code, and the script suites paid a
196
+ process start they did not need** (`PARSER-TESTTIMEOUT-ASSERTS-AN-IDLE-BOX`). This is a trim plus
197
+ two deletions and nothing else. Test and test-configuration only: no source, parser, de-identify,
198
+ serializer or public API change, and no number in this package moves.
199
+
200
+ **The trim.** Three suites spawned this repository's own TypeScript scripts through `tsx`. Node
201
+ strips types natively, so that process start bought nothing and was paid on every invocation.
202
+ They now go through `test/helpers/run-script.ts`, which runs them under `node`, measured with
203
+ byte-identical stdout and the same exit code from both runners. The suites are measurably faster;
204
+ no ratio is quoted here, because independent re-takes on one box do not agree with each other and
205
+ the gap moves with the workload as well as the box.
206
+
207
+ **The one script that cannot make that move, pinned rather than asserted.**
208
+ `scripts/generate-annex-e.ts` imports `"../src/dictionary/repeating-groups.js"`, and Node's ESM
209
+ resolver will not rewrite that `.js` specifier onto the `.ts` file; under `node` it exits 1 with
210
+ `ERR_MODULE_NOT_FOUND` at module resolution, before it opens a byte of DocBook or touches the
211
+ tracked `SHA.txt` this suite otherwise rewrites. It stays on `tsx`, and a new test asserts that
212
+ failure **by name**, so the carve-out goes red the day Node can resolve the specifier rather than
213
+ quietly outliving its reason. The rule and its single exception are stated once, in
214
+ `run-script.ts`, rather than copied into each suite: the sibling generator imports nothing from
215
+ `src/` and was measured green under `node` with byte-identical output, so the carve-out is one
216
+ script and not "the generators".
217
+
218
+ **Deletion one: a stopwatch that was already a false red.** `embedded-attribute.test.ts` carried
219
+ `expect(performance.now() - started).toBeLessThan(2_000)` over a 512 KiB value. It reads a couple
220
+ of hundred milliseconds on a quiet box, but on an ordinary loaded one it was measured at or over
221
+ that literal with the algorithm untouched. It was also the weakest cost claim in this package: its
222
+ fixture is half a megabyte of `0x41`, which yields zero tiling candidates, so every offset
223
+ short-circuits before the repertoire scan where the quadratic it named lived and the number it
224
+ produced was never a measurement of that cost. Deleted, and the test renamed to what it does
225
+ assert. The cost is still bounded, by `vitest.config.ts`'s per-test
226
+ timeout, which is the mechanism built for it.
227
+
228
+ **Deletion two: `hookTimeout: 10_000`, a verbatim no-op.** Measured rather than read: a `beforeAll`
229
+ that sleeps past it reds at "Hook timed out in 10000ms" on this repository's own Vitest with no
230
+ configuration at all, so the line restated the default exactly and said nothing. The one hook that
231
+ needs more (`docs-content.test.ts`, which shells out to `pnpm build`) already passes its own budget
232
+ at the call site.
233
+
234
+ **`testTimeout: 10_000` is UNCHANGED.**
235
+
236
+ **Disclosed residual, not fixed here.** The two `test/property/` fast-check suites can exceed that
237
+ 10,000 ms global on a contended box, which reds them with the parser untouched. A per-test budget
238
+ for them was built and then **withdrawn**: the measurements offered for it did not survive
239
+ re-measurement, and the correct figures do not support the claim they were used to make. Those
240
+ suites therefore remain on the 10,000 ms global - **exactly where `main` is today** - so this
241
+ change neither fixes nor worsens that. It is a live false red and it needs its own slice, measured
242
+ from scratch.
243
+
244
+ - **The `attw` publish gate exited 0 on a tarball carrying no type declarations, so a broken publish
245
+ read as a pass** (`ATTW-FALSE-GREEN-PORT`). `pnpm attw` now runs `scripts/attw.mjs`, ported from
246
+ `@cosyte/terminology` (#28, `bf153cb`), where the defect was diagnosed; `typecheck:exports` runs
247
+ through the same wrapper with its `--profile node16` forwarded. The code ported, the measurements
248
+ were re-taken on this package rather than carried over.
249
+
250
+ **The cause is unconditional CLI semantics, not a lenient invocation and not concurrency.**
251
+ `getExitCode.js` in `@arethetypeswrong/cli@0.18.4` opens with `if (!analysis.types) return 0`,
252
+ returning before the problem list is read, because an untyped package is a legitimate npm package.
253
+ Reproduced here with **zero concurrency**: on a quiet box `rm -f dist/index.d.ts dist/index.d.cts
254
+ && pnpm attw` and `rm -rf dist && pnpm attw` both print "This package does not contain types." and
255
+ exit **0**. Concurrency only supplies the condition. `tsup` writes the JS bundles before the
256
+ declaration files, so every build of this package has a window in which `dist/` holds
257
+ `index.mjs`/`index.cjs` and no `index.d.ts`: measured over three clean `pnpm build` runs on an idle
258
+ box, polling every 25ms, at **1.06s, 1.23s and 1.43s** (JS at +4.83s to +5.36s, declarations at
259
+ +6.06s to +6.49s). That is why the remedy is not a lock, a lease or a build queue - a lock would
260
+ leave the defect intact on an idle box, and the gate is supposed to be able to say its own inputs
261
+ were missing whatever removed them.
262
+
263
+ **Two nets, which catch different things.** A **preflight** that every relative path
264
+ `package.json` promises (`main`, `module`, `types`, `typings`, and every string leaf of `exports`,
265
+ which here is `./dist/index.cjs`, `./dist/index.mjs`, `./dist/index.d.ts` and
266
+ `./dist/index.d.cts`) exists and is non-empty; it catches the build window and names the missing
267
+ file rather than leaving a reader to infer it from a sentence about types. And a **post-check**
268
+ that promotes `attw`'s untyped sentence to a failure, which the preflight structurally cannot see:
269
+ the declarations can be on disk and still be absent from the tarball because `files` or
270
+ `.npmignore` left them out. No instance of that second case is on record in this repo. **Neither
271
+ net covers the rest of `files`** (`README.md`, `LICENSE`, `TRADEMARKS.md`, `CHANGELOG.md`), stated
272
+ plainly rather than left to be discovered: `attw` analyses types and never looks at them.
273
+
274
+ **The post-check reads a string, so the routes that would hide that string are refused**, by option
275
+ and not by value. **Eleven were measured here** on a fixture whose tarball genuinely carries no
276
+ types, each restoring the exact exit-0 with the sentence unreadable: `--quiet`, `-q`,
277
+ `--format json`, `-f json`, `--format=json`, `-fjson`, `-qf json`, `-Pfjson`, a `.attw.json`
278
+ setting `quiet` or `format` (`readConfig()` applies config after argv), and **`--config-path`**
279
+ pointing at a file that sets one - that last is the difference from terminology's copy, which
280
+ refused it by inference and said so.
281
+
282
+ **The three cluster forms are why the predicate is not an exact-token set, and a first draft of
283
+ this change got that wrong.** It matched each whole argument, before any `=`, against a set of
284
+ exact spellings; commander lets a short option's value attach to it and lets shorts combine, so
285
+ `-f` is not visible as a token in `-fjson`. Measured on that draft, on the untyped fixture:
286
+ `-fjson` gave **exit 0 with the gate silent**. A single-dash argument is now refused if any
287
+ character in its cluster is `q` or `f`, which is sound because `-f` is `attw`'s only value-taking
288
+ short option.
289
+
290
+ Measured the other way too, and both directions are stated because the bound matters: `--format
291
+ table-flipped` and `--format ascii` still print the sentence and blind nothing, and are refused
292
+ anyway (value-parsing them would be a third moving part in the guard); while `--form json`,
293
+ `--quiet=true` and `-f=json` each look like a route and are not, because commander rejects them
294
+ outright with exit 1. **The over-strictness is bounded and nothing else is refused**: a forwarded
295
+ `--profile node16` or `-P` still reaches `attw`. A forwarded extra positional does not retarget the
296
+ run either - `--pack .` supplies the first positional and `attw` ignores the second, so the
297
+ analysis stays on this package.
298
+
299
+ `test/scripts/attw-gate.test.ts` pins both nets against the real binary: the upstream exit-0
300
+ itself, so an `attw` upgrade that fixes it or rewords the sentence reds instead of letting the net
301
+ go slack; the `tsup` window in this package's own dual ESM/CJS `exports` shape, asserting both that
302
+ bare `attw` passes it with exit 0 and that the wrapper reds naming `./dist/index.d.ts`; a negative
303
+ control on a well-formed package; that a real `attw` failure still fails with `attw`'s own status;
304
+ and that other arguments are still forwarded. **Proved non-vacuous by putting the bare invocation
305
+ back into the script: 15 of the 22 tests red**, re-measured on the file as it stands. The 7 that
306
+ survive are exactly the ones that must pass on both: the upstream exit-0 pin, the well-formed
307
+ negative control, the real-failure parity case, argument forwarding, and the three controls that
308
+ only ever invoke the bare tool.
309
+ `scripts/verify.sh` in the meta-repo needed no change and was not touched, and no lock, lease,
310
+ semaphore or queue was added (ADR 0015).
311
+
312
+ ### Changed
313
+
314
+ - `format` and `format:check` now cover `scripts/**/*.mjs` as well as `scripts/**/*.ts`, so the new
315
+ gate script is actually format-gated. Exactly one pre-existing file entered that glob,
316
+ `scripts/sync-version.mjs`, and it was verified Prettier-clean before the glob widened, so nothing
317
+ is red-flagged retroactively. (The tree's other `.mjs` file, `test/smoke/esm/index.mjs`, is under
318
+ `test/` and is not covered by this glob or by the `test/**/*.ts` one.)
319
+
320
+ **`lint` was deliberately NOT widened the same way, and the reason is measured rather than
321
+ assumed.** ESLint applies no rules at all to `.mjs` here: the shared `@cosyte/eslint-config` rule
322
+ blocks are scoped to `**/*.ts`, so `eslint --print-config scripts/attw.mjs` returns only the
323
+ Prettier-conflict disables, and a deliberately seeded unused variable plus a missing semicolon in
324
+ `scripts/attw.mjs` produced **zero** findings. Widening the `lint` glob would have added a
325
+ gate-shaped thing that gates nothing, which is the exact failure this change exists to remove.
326
+ Making ESLint genuinely cover `.mjs` means changing the shared config, and that is its own slice.
327
+
7
328
  ### Security
8
329
 
330
+ - **A symbolic link under `test/fixtures/` pointing at a PHI-bearing file scanned CLEAN on BOTH of
331
+ `scripts/phi-scan.ts`'s enumerating routes, so the commit gate passed it twice over**
332
+ (`PHI-SCAN-SYMLINK-BLIND-ON-BOTH-ROUTES`; pre-existing). Reproduced on `0f898be` with a synthetic
333
+ name-bearing payload outside the walk root and a link to it at `test/fixtures/leak.txt`: all-mode
334
+ exited **0** printing `OK - no hits`, `--staged` exited **0** after `git add`, and naming the
335
+ target explicitly exited **1** with three hits (the PN plus both dates). The payload was always
336
+ detectable; the two routes never looked at it.
337
+
338
+ **Two mechanisms, two fixes.** `walk()` enumerates `Dirent.isFile()`, which is an lstat answer, so
339
+ a link is neither a file nor a directory and fell out of the loop silently. `isDirectory()`
340
+ answers false for a **linked directory** too, so a whole subtree vanished the same way (measured:
341
+ a link to a directory holding the payload, exit 0). And `--staged` reads content with
342
+ `git show :<path>`, while git stores a link as its **target path** under mode `120000`, so that
343
+ route was handed the path text and never the target's bytes. `--staged` is this repo's
344
+ `pre-commit` hook.
345
+
346
+ **Neither route is made to follow the link.** Following would read bytes the enumeration does not
347
+ control (outside the repo, a loop, a device, a FIFO that blocks the gate forever), and git does not
348
+ carry those bytes anyway, so a hit on them would be a claim about something no commit contains.
349
+ The enumeration is narrowed instead: an **in-scope entry that is not a regular file refuses the
350
+ scan** (exit 2), naming every offender rather than the first. `--staged` now reads
351
+ `git diff --cached --raw -z` so the destination mode is visible; an unparseable `--raw` record
352
+ refuses too, because a silently shortened list is exactly what this scan must never report clean
353
+ over. **Be precise about which mode was blind:** `120000` was the leak. A staged **gitlink**
354
+ (`160000`) already exited 2 on the base scanner, because `git show :<path>` fails outright on one
355
+ (`fatal: bad object`, exit 128) and the read-error path caught it. Its refusal is now a named one
356
+ with a reason instead of a relayed git error, which is an improvement in the message, not the
357
+ closing of a hole.
358
+
359
+ **`--diff-filter=AMT`, not `AM`, and the one-letter difference is what makes the mode check
360
+ reachable.** Replacing a **tracked** fixture with a link is neither an add nor a modify: git raises
361
+ it as a typechange, `:100644 120000 <sha> <sha> T`. Measured on git 2.39.5, both
362
+ `git diff --cached --raw --diff-filter=AM` and `--name-only --diff-filter=AM` print **nothing** for
363
+ that stage, so the record died before any mode could be read and the hook would have passed a
364
+ mode-`120000` blob green while this entry claimed it refuses one. Typechange carries a single path,
365
+ exactly like `A` and `M`, so admitting it costs the two-field record stride nothing, and the
366
+ reverse typechange (a link replaced by a real file) is now scanned as the file it became.
367
+
368
+ **`--no-renames` as well, and the status filter alone was not enough - the gate's first pass
369
+ claimed otherwise and was refuted by measurement.** Rename detection is on by default, so
370
+ `git mv <link> test/fixtures/<name>` - an ordinary developer action, no crafted input - stages as
371
+ `:120000 120000 <sha> <sha> R100` with **two** paths, which `--diff-filter=AMT` then deletes
372
+ outright: the index held `120000 ... test/fixtures/toplink.txt` and this route printed
373
+ `OK - no hits` and exited **0**. The first pass disclosed that as out of scope on the grounds that
374
+ admitting a rename "needs the two-path record shape handled". It does not. With detection off the
375
+ destination arrives as an ordinary single-path `A` (`:000000 120000 0000000 <sha> A`) and the
376
+ source as a `D` the filter drops, so the two-field stride is untouched and the entry is refused.
377
+ It also makes that stride **structural rather than conditional**: with detection off, no `R` or `C`
378
+ record can be produced whatever the caller's `diff.renames` setting is. The cost is that the
379
+ destination of an ordinary clean rename into `test/fixtures/` is now scanned where it used to be
380
+ dropped, which is more coverage rather than less, and is pinned in both directions.
381
+
382
+ **The fixture root's own path is in scope too, not just what is under it.** A prefix test requiring
383
+ the trailing slash let a staged `test/fixtures` through (measured: mode `120000` at exactly that
384
+ path, exit 0) - the corpus root replaced by a link, so the whole corpus goes unscanned. Git records
385
+ no index entry for a directory, so that path can only mean a blob, a link or a gitlink, and it is
386
+ now refused. Only the "never a directory" half is load-bearing; the other three are all handled.
387
+
388
+ **A refusal names the entry's own repo-relative path and an engine-owned kind token, never the
389
+ link target**, which is working-tree text that can itself carry PHI. That is not hypothetical
390
+ here: on the base scanner, a link whose target filename carried a name and a date made `--staged`
391
+ exit **1** and print a hit whose value was **the date out of the filename** - a report about the
392
+ working tree's own text, not about anything the target contained. For the same reason the shape of
393
+ such a filename is written out in the scanner's docblock rather than exemplified: a diagnostic
394
+ about a PHI leak is itself a PHI surface, and that applies to the prose explaining it.
395
+
396
+ **Almost all of the scope is unchanged, and the two places it moved both admit MORE.** All-mode
397
+ still walks only `test/fixtures/` and still excludes a gitignored entry, by the same rule that
398
+ already excludes a gitignored fixture (`git check-ignore` does not answer for a tracked path, so
399
+ force-adding the link puts it back in scope, and that is pinned). `--staged` still covers only the
400
+ fixture path, and only the staged records git reports as added, modified or typechanged - a
401
+ deletion has no staged blob to scan and an unmerged path has no single one, both pre-existing and
402
+ both stated in the scanner's own banner rather than left to be inferred from the path prefix. The
403
+ two movements are the rename destination and the root path above; neither widens a route to a new
404
+ directory, and a link outside either boundary is still left alone, tested in both directions. The
405
+ `readme.md` exemption deliberately does **not** reach a link: that exemption is a judgement about a
406
+ file whose bytes the walk could have read, and a link's name is no evidence at all about what is on
407
+ the other side.
408
+
409
+ Pinned in `test/scripts/phi-scan.test.ts` with a synthetic name-bearing payload whose target
410
+ filename also carries a name, so the no-echo assertions cannot pass by fixture: **13 of the 31
411
+ cases are red on `0f898be`**, and the four cases covering the three corrections above are red on the
412
+ first pass of this change as well. The file also carries a negative control asserting the scanner
413
+ under test is this package's and not a sibling's.
414
+
415
+ **Not covered, deliberately, each measured rather than assumed.** Explicit-path mode already read
416
+ through a link and is unchanged. The **enumerate-then-read race** is untouched:
417
+ a file that vanishes between `walk()` and `readFileSync` aborts the whole sweep at exit 2, which
418
+ fails closed and is a different defect from this one. **There is still no rule refusing a scan that
419
+ observed nothing, and that has a measured instance worth naming rather than leaving abstract:** if
420
+ `test/fixtures` is itself a DANGLING link, `existsSync` follows it, answers false, and all-mode
421
+ prints `OK - no hits` and exits **0** over a corpus it never opened (identical on the base
422
+ scanner). If it is a link to a regular file, `readdirSync` raises an uncaught `ENOTDIR` - noisy and
423
+ fail-closed, but exit 1, which this contract reserves for "hits found". The `--staged` half of that
424
+ same shape is closed above; the all-mode half needs the observed-nothing rule, which is its own
425
+ slice. Also `--allow-fixture <path>` with no positional path still scans **nothing** and
426
+ prints `OK - no hits` even for a path that does not exist (measured), which makes this suite's
427
+ existing "honors `--allow-fixture` with an override-log entry" case vacuous. All pre-existing,
428
+ disclosed rather than fixed here.
429
+
430
+ No source, parser, de-identify or public API change.
431
+
9
432
  - **An element whose on-wire VR is not one of the 34 PS3.5 §6.2 defines was kept verbatim by
10
433
  `deidentify()`, carrying a source `(0010,0020)` Patient ID into de-identified output next to the
11
434
  `(0012,0062) PatientIdentityRemoved = YES` this library writes** (`DICOM-CARRIER-LEAF-LEAKS`,
package/README.md CHANGED
@@ -47,7 +47,7 @@ That's the pitch: no config, no schema upload, no spec lookup. The parser accept
47
47
  - **Lazy typed value decode**: `element.value` decodes raw bytes into a discriminated `DicomValue` across all 34 VRs (numbers, `bigint`s, person names, dates/times, sequences, raw `binary`), honoring `(0008,0005)` Specific Character Set through nested items.
48
48
  - **Real-world tolerance, Postel's Law**: a lenient reader emits 25 stable warning codes for what it tolerated; only 4 truly-structural conditions are fatal. The serializer always writes spec-clean Part 10.
49
49
  - **Source/vendor profile system**: `defineProfile()` + 5 built-ins (`ge`, `siemens`, `philips`, `strict`, `lenient`) that only ever _tighten or annotate_ a parse, resolving vendor private tags by the file's live Private Creator string, never a wrong decode.
50
- - **Metadata-level de-identification**: `deidentify()` applies the PS3.15 Annex E Basic Profile + the nine metadata Options, returning a fresh dataset and an audit report that is value-free apart from the source UIDs in `report.uidMap`.
50
+ - **Metadata-level de-identification**: `deidentify()` applies the PS3.15 Annex E Basic Profile + the nine metadata Options, returning a fresh dataset and an audit report built from static tables, with two fields that carry source bytes and are named as such on the type: `report.uidMap` (source UIDs) and `report.removedPrivateTags`.
51
51
  - **Spec-clean serializer**: `serializeDicom(ds)` round-trips a dataset back to Part 10 bytes in its source transfer syntax (no transcode), with correct File Meta group length, even-length padding, byte-exact sequence passthrough, and lossless File Meta: non-modeled `(0002,xxxx)` elements are preserved and re-emitted in tag order.
52
52
  - **Strict TypeScript, dual ESM + CJS, Node ≥ 22**: `noUncheckedIndexedAccess`, no `any`, JSDoc + `@example` on every public export feeding your editor's IntelliSense. Zero runtime dependencies today.
53
53
 
@@ -197,7 +197,7 @@ if (img.isEnhancedMultiFrame) {
197
197
 
198
198
  ### De-identify before sharing
199
199
 
200
- `deidentify()` applies the PS3.15 Annex E Basic Application Level Confidentiality Profile (replacing, emptying, or removing every attribute the standard lists as identifying) and returns a fresh dataset plus a report that is value-free apart from the source UIDs in `report.uidMap`.
200
+ `deidentify()` applies the PS3.15 Annex E Basic Application Level Confidentiality Profile (replacing, emptying, or removing every attribute the standard lists as identifying) and returns a fresh dataset plus a report. Two of the report's fields are composed from source bytes rather than from static tables and are documented as such: `report.uidMap`, whose keys are the file's own UIDs, and `report.removedPrivateTags`, whose four-byte entries are the sender's own private tag numbers on a well-formed file and can be value bytes on a malformed one.
201
201
 
202
202
  ```ts
203
203
  import { parseDicom, deidentify, serializeDicom } from "@cosyte/dicom";
@@ -365,7 +365,8 @@ Thrown by `serializeDicom` for `MISSING_TRANSFER_SYNTAX` (the dataset names no t
365
365
  - **Pixel data.** No decode/decompression, no rendering, no measurements: Pixel Data is exposed as raw bytes. And v1 does not read a **compressed object at all**, not even structurally: a transfer syntax outside the four listed below is the fatal `UNSUPPORTED_TRANSFER_SYNTAX`, so JPEG / JPEG-LS / JPEG2000 / RLE / HTJ2K objects do not parse. → `@cosyte/dicom-pixel`.
366
366
  - **Burned-in PHI.** v1 **warns** it cannot remove burned-in annotation; a "de-identified" output is **metadata-de-identified only**.
367
367
  - **A sequence the parser could not open: content is dropped, not passed through.** `deidentify()` recurses only into a sequence whose items the parser materialized. A **standard** `SQ` element with no items is now **emptied**: its bytes are Data Sets by PS3.5 §7.5.1 and PS3.15 §E.1.1 obliges the de-identifier to reach the listed attributes inside them, so a run that cannot enumerate them must not ship them. `report.unauditableSequences` names the carrier and the byte length dropped (capped at 64 entries; the emptying itself is never capped), and `DICOM_DEIDENT_SEQUENCE_NOT_AUDITABLE` is raised. Expect **data loss** on such a file. The parse refusal is usually a sender-side encoding defect, but not always: a **conformant** file nested deeper than this library's own `NESTING_DEPTH_LIMIT` of 64 is refused the same way and loses that sequence too. **Two shapes are exempt and still leak.** A **private** `SQ` kept under `RetainSafePrivate` + a `Profile` is kept verbatim and unaudited, because the profile vouched for it. And an undefined-length **`UN`** the CP-246 descent could not read as a sequence keeps `vr === "UN"`, so the rule does not reach it and cannot be extended there: every ordinary `UN` element also has no items. For either, the reliable test remains `el.items === undefined` on the element itself.
368
- - **An element whose on-wire VR is not a VR: the value is dropped, not passed through.** Under an Explicit VR syntax the VR is two bytes the sender wrote and this parser trusts them. If those two bytes are not one of the 34 PS3.5 §6.2 defines, `deidentify()` **empties** the element: §6.2 requires every undefined VR to be long-form with a 32-bit VL, this parser reads it short-form, so those bytes are not a Value Field the library decoded and PS3.15 §E.1.1's obligation cannot be discharged inside them. `report.undefinedVrElements` names the **byte offset** and the byte length dropped (capped at 64 entries; the emptying itself is never capped) and `DICOM_DEIDENT_UNDEFINED_VR_NOT_AUDITABLE` is raised. It names **no tag**, deliberately and uniquely among the report's findings: this header _may_ have been fabricated out of the middle of some element's value, in which case its tag bytes are document content - and nothing distinguishes that case from an honestly-written unrecognized VR, so the tag is withheld either way. The usual cause is an **under**-declared Value Length earlier in the file, which desynchronizes the reader so that leftover value bytes are read as a Data Element header - and the element that genuinely followed becomes the fabricated element's value, which is how a `(0010,0020)` Patient ID used to reach de-identified output. **A file conformant to PS3.5 2026c never trips this**, and an Implicit VR LE file cannot: there the VR comes from the dictionary. (The edition matters: §6.2 exists precisely to say how a _future_ VR will be encoded.) `UN` is one of the 34 and is not affected. **There is no exemption** - unlike the sequence rule above, `RetainSafePrivate` does not keep such an element.
368
+ - **An unrecognized Explicit VR is read (and written) long-form, and `deidentify()` still empties the element.** PS3.5 2026c §6.2: "All new VRs defined in future versions of DICOM **shall** be of the same Data Element Structure as defined in [§7.1.2] with reserved bytes after the VR and a 32-bit unsigned integer VL." So two on-wire VR bytes outside the 34 this release knows get a **12-byte header**, exactly as `OB` and `UT` do, on both the read and the write path. Before `0.0.9` the reader took the 8-byte form, which read the length out of the two bytes §6.2 reserves. What such a file did then was **shape-specific** - sometimes a whole-object refusal, sometimes a clean parse into a tree the sender did not write - which is why this package ships `scripts/measure-unrecognized-vr.ts` and no one-sentence account of it. The value is otherwise handled like any other: a declared length past the end of the buffer, or an undefined length, is refused exactly as it is for every other non-`SQ` VR. **The trade is stated rather than buried**: a sender that ignores §6.2 and writes an unrecognized VR short-form produced a readable object before and is refused now. `deidentify()` **still empties** such an element, because reading a header is not the same as knowing what its value means - Table E.1-1 acts per attribute, and nothing can say whether a VR from a later edition holds a name, a date or an opaque blob. `report.undefinedVrElements` names the **byte offset** and the byte length dropped (capped at 64 entries; the emptying itself is never capped) and `DICOM_DEIDENT_UNDEFINED_VR_NOT_AUDITABLE` is raised. It names **no tag**, deliberately and uniquely among the report's findings: the header may still have been fabricated out of the middle of some element's value, in which case its tag bytes are document content - and nothing distinguishes that case from an honestly-written unrecognized VR, so the tag is withheld either way. An Implicit VR LE file cannot reach any of this: there the VR comes from the dictionary. `UN` is one of the 34 and is not affected. **There is no exemption** - unlike the sequence rule above, `RetainSafePrivate` does not keep such an element.
369
+ - **`RetainSafePrivate` retains nothing inside ANY Item of a sequence whose item stream over-runs its own declared length.** An Item that declares more bytes than its enclosing Sequence does absorbs whatever follows the sequence, so a private data element the sender wrote at the **root** - with no Private Creator reserving its block there - can land beside a creator that is genuine Item content and be kept verbatim. Through `0.0.9` it was, with `report.removedPrivateTags` reading `[]` and the object stamped `(0012,0062) Patient Identity Removed = YES`: a stamp that outran the redaction. PS3.5 2026c §7.8.1 scopes a private block reservation to the Item ("The scope of the reservation is just within the Item"), so a file that contradicts itself about where the Item ends does not determine which reservation covers an element; PS3.15 2026c §E.3.10 retains only what is **known** safe and requires that "all other Private Attributes shall be removed **or processed in the element-specific manner recommended by Deidentification Action (0008,0307), if present within Private Data Element Characteristics Sequence (0008,0300)**" (a two-branch clause; this library does not implement `(0008,0307)`, so removal is the branch available to it). So every private element the recursion **reaches** inside such a sequence, at every depth below it, is removed and named in `report.removedPrivateTags`. **The unit is the SEQUENCE, not the individual Item**: `descendSequence` decides once and applies it to every item, so a two-item sequence where only item 1 over-runs also loses a genuine block sitting in the honest item 0 (measured; the grid sweeps single-item fixtures, so that extra cost is NOT inside the priced 20). **Two routes are not closed and both still leak, measured and pinned:** an Item that _under_-declares **ejects** its trailing elements out into the enclosing Data Set, which is not narrowed - **at any depth, not only at the root** (the widening was built and measured at 24 retentions lost to close 2 of 22, so it is a separate slice); and a **private `SQ`** the profile vouches for is kept verbatim before the descent runs, which is the carve-out named two bullets above. **The direction is not fail-safe on its own and the opposite claim is retracted**: it depends on where the sender put the Private Creator. Both **absorb** placements are pinned by tests; the **eject** placement is pinned as a residual and still leaks. **The price is measured, not asserted**: on `scripts/measure-sq-bound-grid.ts`, 58 cells lose a private value and **20 of them were not leaking anything** (the creator and the data element were both genuine Item content). Retention on a file that does not contradict itself is unchanged, and **no reading changes** - no parser file is touched. This is not a parser bound: an over-declaring Item and an under-declaring Sequence are byte-identical, so nothing on the wire can separate them.
369
370
  - **An over-declared Value Length into a binary carrier still leaks, and it is a known trade.** The mirror case - an _over_-declared length that swallows the following element into an `OB`/`OW`/`US`/`UN` value - is **not** detected, and a `(0010,0020)` inside it reaches de-identified output with no warning and no report entry. Arbitrary bytes are exactly what those VRs are for, so no content test can decide it; the only candidate remedy was measured and empties conformant binary values (a legal LUT or blob deleted because 8 of its bytes read as a zero-length `(0010,0020)`). String carriers **are** covered, because there the same bytes are provably outside the VR's repertoire. If you accept files from a sender you do not control, treat a binary attribute's length as untrusted.
370
371
  - **Networking & web.** No DIMSE (C-STORE/FIND/MOVE, MWL, MPPS); no DICOMweb (QIDO/WADO/STOW). → `@cosyte/dicom-net`, `@cosyte/dicomweb`.
371
372
  - **Transcoding.** No transfer-syntax conversion. The serializer re-emits in the dataset's source syntax only.