Skip to content

feat: dcmjs core — the event-stream reading engine and the green regression tests - #542

Open
awatson1978 wants to merge 150 commits into
1.0-betafrom
release/1.0-beta-core
Open

awatson1978 wants to merge 150 commits into
1.0-betafrom
release/1.0-beta-core

Conversation

@awatson1978

@awatson1978 awatson1978 commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

This is the assembly PR for the core package: it brings release/1.0-beta-core into 1.0-beta, carrying everything that landed on the staging branch as PRs #521 through #596, each of which was individually reviewed and has its own plain-English description with code samples. It's open as a draft on purpose — the core staging branch still has work coming (more on that at the bottom), and this PR will grow as those pieces land. Please don't merge it yet; it's here so the whole can be reviewed as it assembles.

What this is in plain terms. The 1.0 rewrite replaces dcmjs's read path with an event-stream engine. Instead of one function that buffers a whole DICOM file and returns a finished object, the new engine parses bytes as they arrive and emits small events — "element started," "value decoded," "sequence item ended" — that any consumer can listen to. A buffered read is now just the degenerate case: feed the stream one big chunk. That's what makes bounded-memory reading of large files possible, and it's the foundation the FHIR mapping and the rest of the rewrite will build on in later PRs. This assembly delivers both halves of that engine — the complete reading stack and the writer sinks including the streaming Part 10 writer — plus the DicomEventStream facade over them, the fixes for all sixteen core-scope findings from the #512 review, a regression bank of tests pinning today's behavior, the fixtures package they run against, and the process documents that govern how all of this lands.

A worked example. Streaming a file from disk into a naturalized dataset — the memory high-water mark stays bounded no matter how large the file is:

import fs from "fs";
import {
    fromPart10Stream,
    NaturalizedListener,
} from "./src/eventStream/index.js";

const listener = new NaturalizedListener();
await fromPart10Stream(fs.createReadStream("large-ct-series.dcm"), listener);

// listener.result is the familiar naturalized dataset:
listener.result.PatientID; // "12345"
listener.result.ReferencedSeriesSequence[0].SeriesInstanceUID;

The buffered entry points still work exactly as before — DicomMessage.readFile(buffer) — and gained one explicit opt-in from issue #93, for data that has no file header at all (bare DIMSE-style datasets):

const dataset = DicomMessage.readFile(bareBuffer, { allowMissingHeader: true });

What's in it, wave by wave. Six groups of changes, in the order they landed:

Process and tooling (from the docs PRs this branch was built on): RELEASE_PLAN.md, the writing style guide, the PR template, DATACITATION.md, lockstep prerelease publishing config (.releaserc.json) so merges into 1.0-beta can publish 1.0.0-beta.N under the npm beta tag, a pipeline smoke test, and a fixture-reading helper that keeps the suites green on Node 24.

The fixtures package (#521, #534): the six committed test DICOM files moved by pure git mv into packages/fixtures/dicom, every test routed through a fixturePath() helper, and a vendored corpus of 20 test images in packages/fixtures/testImages covering all three endianness/explicitness permutations, deflate, and single- and multi-frame encapsulated pixel data. Every image's origin and license is recorded in DATACITATION.md (the dicomParser MIT set and David Clunie's deflate set). This PR also carries the fix that moved dependency pins into pnpm-workspace.yaml overrides, resolving 15 pre-existing high-severity audit advisories in dev tooling.

The green regression bank (#524–#529): twenty-plus suites under test/issues/, each named for the GitHub issue it pins, covering round-trip integrity, UUID-derived UIDs, UN padding, encapsulated write, latin1 fallback, CS multi-value lengths, private tags, undefined-value writes, element ordering, async reader behavior, pooled buffer reads, lenient-read/strict-write, anonymizer behavior, and prototype safety. The idea: everything today's code gets right is pinned green before the engine changes anything, and everything still broken is checked in as an it.skip naming the issue it waits on — 20 of them, which is exactly the skip count in the test summary. The later fix PRs flip skips to green; nothing can regress silently.

The event-stream engine (#530, #532, #533, #535–#540): the contract and its reference CollectorListener; the fromDicomWebJson and fromDataSet sources for input that's already parsed; createEventAsyncIterable for pull-style consumption with cancellation on early exit (review finding 13); the element-decode primitives and ISO 2022 character-set decoders; the allowMissingHeader fix for issue #93; the xs meta-VR fix (signed-vs-unsigned now resolved via PixelRepresentation) plus a much quieter unknown-VR fallback; the streaming Part 10 reader itself (#538, under the granted budget waiver — a single 1,468-line state machine handling incremental File Meta decode, deflate via incremental pako inflation, encapsulated fragments with Basic Offset Table windowing, and a backpressure relay for review finding 12); the NaturalizedListener that turns any event stream into the familiar naturalized model; and the dual-path suites proving the buffered and streaming paths agree.

One structural decision deserves its own sentence: the vendored dicom-parser tokenizer is gone. The buffered fromPart10 is now a thin wrapper feeding the streaming reader one chunk, so the library carries exactly one tokenizer — the inversion the #512 review recommended.

The engine-fix wave (#544–#551): the eight core-scope code findings from the #512 review map, each fixed in its own small PR with the regression test that pins it — Buffer views parsed by their exact byte range (finding 1), UV null writes (3), private elements preserved through denaturalize in a writable shape (4), a structural fallback when the meta group length is overstated (24), the async reader brought into the xs contract (25), honest memory accounting in SplitDataView (27), the concat write-position fix (28), and registerTag symmetry without mutating the shared nameMap (32). Each PR body records whether the defect also exists on 0.x master; FINDINGS-CHECKLIST.md on this PR's base branch tracks all 33 findings with their fixing PRs.

The writers wave (#552–#557): the writer sinks ported from the rewrite with their review findings fixed in the port — Part10Writer and DicomWebJsonWriter (#553); the streaming StreamingPart10Writer with body-syntax-aware endianness, fragment-span slicing, and the test-helper fix (findings 5, 11, 22 — #555); the deflate-relay deadlock and dropped-source-error fixes in fromPart10Stream (findings 6, 7 — #554); the 1,400-line stream-equivalence corpus bank with its chunk-gate fix (finding 23 — #556); and the DicomEventStream facade wiring the whole engine into a dcmjs.eventStream namespace (#557, retiring finding 14 by trimming the video factories to their media-wave home). Plus a small test-infrastructure fix making fixture downloads fail fast instead of poisoning later runs (#552).

The package split and review response (#563–#567): the engine now lives in its package folders — @dcmjs-org/core (the shared building blocks: value representations, tags, buffer machinery, character sets, constants, the runtime dictionary, and DicomMetaDictionary), @dcmjs-org/legacy (the eager and async engines users run today, re-exporting the shared pieces so its surface is unchanged), @dcmjs-org/parser (the event-stream reading side), and @dcmjs-org/generator (the writer sinks). Every move is a pure git mv in its own commit with shims left at the old paths, so nothing outside the packages changed behavior — the full suite and the bundle surface are identical before and after. Two placement decisions are declared in the split PRs for reviewer eyes: DicomMetaDictionary went to core (its imports are all core, its consumers span every package, and it and ValueRepresentation reference each other — anywhere else creates a package cycle), and BufferStream/SplitDataView went to core rather than the plan table's legacy (the streaming reader and writers use them; legacy placement would force the new engines to depend on legacy, which we want to stay deprecable). One known residue, declared in #567: Part10Writer keeps a temporary dependency on legacy's DicomDict.write. #563 also renamed the intentional-changes document to docs/BREAKING_CHANGES.md per review.

The finish-out wave (#570, #590–#593): the engine packages dropped their scaffold-era private flags so the gated publish can ship them, with the BREAKING CHANGE: footer §9 requires (#570); the anonymizer's default tagNamesToEmpty list had 103 entries that matched no dictionary keyword and were silently skipped — they now resolve and cleanTags actually empties them, completing review finding 2 (#590, contract documented in docs/BREAKING_CHANGES.md); a corrected migration guide landed as docs/MIGRATION.md, fixing the three documentation findings 29–31 (#592) — which closes out all 33 findings from the #512 review: fixed, retired with the lazy core, documented as intentional, or routed to their package waves and fixed there; the step-8 @dcmjs-org/tests and @dcmjs-org/validator scaffolds (#591); a scoped, documented audit ignore for the unpatchable braces advisory published 2026-10-02 (#593); and the removal of the generator package's last declared legacy edge — the Part 10 envelope relocated from DicomDict.write into core's writeCore, byte-exactness pinned on plain and deflated fixtures, so all three new-engine packages are now fully independent of legacy (#596).

Test evidence. 863 tests across 73 suites: 852 passing, 11 skipped, 0 failing. The skip count has fallen from twenty to eleven as fixes landed. CI is green at the branch tip a954dda — tests, lint/format, and audit — and I reproduced the same 847/11/858 locally. The corpus gate runs every vendored image through raw-bytes-to-events and compares element-for-element against the eager reader (it's the test that caught the xs defect fixed in #537), and the stream-equivalence bank additionally drives the corpus through the streaming path at multiple chunk sizes, down to one byte at a time.

The GitHub issues this addresses. Between the regression bank and the engine-fix wave, this branch touches 53 GitHub issues, in four distinct ways — fixed outright, verified already-correct and pinned against regression, documented as a known gap with a skipped failing test, or blocked on a fixture nobody can obtain:

Fixed by this PR (9):

(One mapping caveat for reviewers: #338's fixing PR #549 cites review finding 24 from the #512 review map rather than the issue number, and its regression test lives in test/data.test.js rather than an issue-named suite.)

Behavior pinned green (30) — verified correct today and locked by regression tests; no code fix was needed:

Known gaps documented as skipped tests (10) — still failing, each tracked by an it.skip KNOWN GAP entry naming the issue it waits on:

Blocked awaiting fixtures (4) — it.skip BLOCKED inventory markers in issue78-unobtainable-fixtures.test.js; these cannot be verified pass or fail until someone produces a file:

Test usage and examples. The branch now carries an EXAMPLES.md at the repo root (#558) — a library-oriented worked tour of every API surface in this PR, adapted from the CLI examples document in awatson1978/dcmjs-commands. Every snippet in it was run against real files from the dcmjs-org/data releases before being written down, and the test-drive section at the bottom fetches everything it needs into /tmp in about two minutes. Three of its recipes, re-run for this description from a fresh checkout (pnpm install && pnpm run build, snippets saved as .mjs files in the repo root):

Bounded memory, concretely. Take the 4 MB ultrasound cine from the fixtures release, inflate it to 211 MB by replaying its event stream through a filter that re-emits every pixel-data fragment 48 times, then read it back sampling live ArrayBuffer memory as it streams:

mkdir -p /tmp/dcmjs-examples-data && cd /tmp/dcmjs-examples-data
curl -sLO https://github.com/dcmjs-org/data/releases/download/us-multiframe-cine/us-cine.zip
unzip -o -q us-cine.zip -d us-cine
// make-big.mjs
import fs from "node:fs";
import dcmjs from "./build/dcmjs.es.js";
const { DicomEventStream, StreamingPart10Writer } = dcmjs.eventStream;

const out = fs.createWriteStream("/tmp/dcmjs-examples-data/big-cine.dcm");
let pending = Promise.resolve();
const writer = new StreamingPart10Writer(
  { onChunk(c) { if (!out.write(c)) pending = new Promise(r => out.once("drain", r)); } },
  { binaryFragment(next, chunk) { for (let i = 0; i < 48; i++) next(chunk); } }
);
writer.setDrain(() => pending);

await DicomEventStream.fromPart10Stream(
  fs.createReadStream("/tmp/dcmjs-examples-data/us-cine/us-cine.dcm")
).process(writer);
out.end();
console.log("wrote big-cine.dcm:", (writer.bytesWritten / 1e6).toFixed(0), "MB");
// wrote big-cine.dcm: 211 MB
// bounded-read.mjs — run with: node --expose-gc bounded-read.mjs
import fs from "node:fs";
import dcmjs from "./build/dcmjs.es.js";
const { DicomEventStream, EventStreamListener } = dcmjs.eventStream;

const file = "/tmp/dcmjs-examples-data/big-cine.dcm";
let peak = 0, fragments = 0, payload = 0;
const sample = () => { global.gc(); peak = Math.max(peak, process.memoryUsage().arrayBuffers); };

const listener = new EventStreamListener({
  binaryFragment(next, chunk) {
    payload += chunk.byteLength;
    if (++fragments % 100 === 0) sample();
    return next(chunk);
  }
});

await DicomEventStream.fromPart10Stream(
  fs.createReadStream(file, { highWaterMark: 1024 * 1024 })
).process(listener);
sample();

console.log(`file: ${(fs.statSync(file).size / 1e6).toFixed(0)} MB,`,
  `pixel payload seen: ${(payload / 1e6).toFixed(0)} MB in ${fragments} fragments,`,
  `peak live ArrayBuffer memory: ${(peak / 1e6).toFixed(1)} MB`);
// file: 211 MB, pixel payload seen: 211 MB in 5617 fragments, peak live ArrayBuffer memory: 4.3 MB

4.3 MB of peak live buffer memory against a 211 MB file, on this run — the eager readFileSync + DicomMessage.readFile path sits near 379 MB on the same file, and the streaming number is bounded by the chunk size and largest element, never the file. (Exact peaks vary a few MB run to run with GC timing; EXAMPLES.md records 15.6 MB for the same recipe.)

Whole-slide imaging over live DICOMweb. fromDicomWebJson pointed at a real digital-pathology study on the public OHIF demo server — the base pyramid level is 21,710 frames tiling a 40,001 × 31,019 matrix, and the metadata naturalizes in under a second because pixel data stays behind BulkDataURI references:

// wsi.mjs
import dcmjs from "./build/dcmjs.es.js";
const { DicomEventStream } = dcmjs.eventStream;

const base = "https://d14fa38qiwhyfd.cloudfront.net/dicomweb";
const study = "2.25.103659964951665749659160840573802789777";
const series = "1.3.6.1.4.1.5962.99.1.1131469279.179373488.1637514041823.2.0";

const res = await fetch(`${base}/studies/${study}/series/${series}/metadata`);
const instances = await res.json();
for (const instance of instances) {
  const ds = await DicomEventStream.fromDicomWebJson(instance)
    .toNaturalized({ cardinalityViolationPolicy: "preserve" });
  console.log(ds.Modality, "frames:", ds.NumberOfFrames,
    "matrix:", ds.TotalPixelMatrixColumns + "x" + ds.TotalPixelMatrixRows);
}
// SM frames: 21710 matrix: 40001x31019
// SM frames: 1 matrix: 990x768
// SM frames: 99 matrix: 2500x1938
// SM frames: 1386 matrix: 10000x7754

Pull events and stop early. asyncIterable() gives for await-able events with real backpressure, and breaking out of the loop cancels the parse — "what modality is this file?" costs a few kilobytes of reading, not a full pass:

// early-exit.mjs (multiframe-ultrasound.dcm is in the EXAMPLES.md test-drive download)
import fs from "node:fs";
import dcmjs from "./build/dcmjs.es.js";
const { DicomEventStream } = dcmjs.eventStream;

const events = DicomEventStream.fromPart10Stream(
  fs.createReadStream("/tmp/dcmjs-examples-data/multiframe-ultrasound.dcm")
);
let currentTag = null;
for await (const ev of events.asyncIterable()) {
  if (ev.type === "startElement") currentTag = ev.args[0];
  if (ev.type === "value" && currentTag === "00080060") {
    console.log("Modality:", ev.args[0]);
    break; // early exit cancels the underlying parse
  }
}
// Modality: US

Benchmarks. The branch now also carries a BENCHMARKS.md at the repo root, with the bench/ harness that produced it (#561), measured on both CI-tested Node versions — v22.20.0 and v24.9.0, which agree within run-to-run noise (Apple M4, 16 GB RAM, macOS). The headline comparison on the 211 MB stress file from the recipe above — median wall time plus peak live ArrayBuffer memory sampled after forced GC:

Workload (211 MB multiframe) eager / buffered streaming
read → naturalized dataset 38–41 ms · 422 MB 58–59 ms · 214–239 MB
read → scan, retaining nothing — ~47 ms · 4–16 MB
write → Part 10 bytes 616–813 ms · ~840 MB ~60 ms · ~9 MB

On the write side that's the one-line summary: StreamingPart10Writer is ~10–13× faster than the buffered writer at roughly 1/90th of the memory. On small committed fixtures the eager path remains the fastest way to a dataset (sub-millisecond; streaming adds ~1–2 ms of per-chunk overhead there). Full tables for both Node versions, the chunk-size/throughput trade-off, the method, and exact reproduction commands are in BENCHMARKS.md (#561).

The numbers. 206 files changed, +28,123 / −6,708 lines against the base — the deletions are almost entirely the package-split moves (git records them as renames; the PR view shows R100 for the pure moves). 75 conventional commits across 38 reviewed PRs, merged (not squashed) so the release tooling sees each one.

Breaking changes. None intended to the public API — everything here is additive, and the full existing test suite passes unchanged. Behavioral differences to know about, all in defect or edge cases: non-DICOM input now fails with the streaming reader's own error message (the old vendored-tokenizer error text is gone); a non-conformant undefined-length UT element now throws on both entry points instead of silently returning garbage on the buffered path; the xs meta-VR resolves correctly for signed pixel data on both the sync and async readers (previously wrong — a bug fix, but output-visible); the unknown-VR fallback logs once instead of per element; UV elements with null values now write instead of throwing; private elements survive denaturalize→write instead of being dropped or emitted unwritable; and an overstated File Meta group length now recovers with a warning instead of quietly mis-framing the body. One more landed with the finish-out wave and deserves emphasis: cleanTags now actually empties the ~103 attributes its default list always claimed to cover — including six sequences that can carry diagnostic content — per PS3.15; the opt-out recipe is in docs/BREAKING_CHANGES.md.

Still to come on this branch before it's merge-ready: nothing structural — the package split above was the last planned wave; what remains is review and the end-of-landing cleanup (the writing-style doc and the issue-triage catalog leave the tree last, per the review threads). Earlier cleanup items all landed — #559 restored the PS3.5 7.8.1 private-creator range (finding 26), #560 added deflate-on-write so the corpus gate now covers the deflate trio, and #562 ported the RFC 4122 uid() and started docs/BREAKING_CHANGES.md, which documents the deliberate standards-conformance behavior changes. Separately queued but not blockers for this branch: the master hotfix PRs for the master-applicable findings (1, 3 partial, 24, 25, 28 latent — verdicts in FINDINGS-CHECKLIST.md on the base branch; deferred for now), and the schemas assembly in #543. Merging this PR will also auto-resolve #518 and shrink #519, since this branch's history contains their commits.

🤖 Generated with Claude Code

awatson1978 and others added 30 commits September 16, 2026 18:12
Supersedes the 2026-08-07 plan. Describes the package split, the
1.0-beta release branch, per-package staging branches, the test-first
sequence for core, routing of the 33 review findings, and lockstep
publishing with semantic-release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two trivial assertions on the committed sample-sr.dcm fixture. The
purpose is to exercise the CI checks on the 1.0-beta branch and prove
they run and report on pull requests. See RELEASE_PLAN.md section 6,
step 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e 24

fs.readFileSync can return a small file inside a larger shared memory
pool. Taking .buffer on that result hands the parser the whole pool,
with the file sitting at a nonzero offset, and the DICM marker check
then fails. Node 24 pools more aggressively, which is why
anonymizer.test.js and data.test.js fail there today (7 tests) while
Node 22 passes by luck of alignment.

This adds readFileAsArrayBuffer() to testUtils and routes all 22
file-read sites through it. Typed-array .buffer uses are untouched.
Same class of problem as issue #311.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ware helper

The first pass converted single-line fs.readFileSync(...).buffer sites.
CI on Node 24 then failed in a second population: reads assigned to a
variable first, with .buffer taken on a later line. This converts those
sites in data.test.js, data-options.test.js, lossless-read-write.test.js
and normalizers.test.js, and drops the imports that became unused.
Sites that already handle byteOffset correctly are untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ensureTestDataDir checked for the directory and then created it. Two
parallel jest workers can both pass the check, and the slower mkdir then
fails with EEXIST, which failed test_multiframe_1 on one CI leg.
mkdirSync with recursive:true succeeds whether or not the directory
exists, so the check goes away.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three process documents the release plan calls for. The style guide
defines the audience (healthcare-literate, no assumed programming or
imaging jargon) and bans machine-flavored prose. The PR template
requires plain-language descriptions, runnable examples, test evidence,
and fixture citations. DATACITATION.md records the origin and license
of every committed DICOM test file, with the legacy files honestly
marked as provenance-pending.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
semantic-release computes one version from conventional commits;
stamp-workspace-versions.mjs writes it into every publishable workspace
package.json, and pnpm -r publish releases them together under the npm
beta tag. The publish workflow now also fires on 1.0-beta and
release/0.5x, and passes NODE_AUTH_TOKEN for pnpm publish. Nothing
publishes until the repository owner adds 1.0-beta to the publish
environment allowlist. See RELEASE_PLAN.md sections 9 and 12.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Brings in the Node 24 buffer fix and the temp-directory race fix so
this branch's CI can pass before #518 merges. Once #518 lands, the
diff of #519 collapses back to just the process files.
Adapts the rules from Cursor's unslop skill: the fuller AI-vocabulary
list, filler phrases, fancy synonyms for is, vague attributions,
synonym cycling, false ranges, punctuation and formatting rules
(em dashes, colons, bold, sentence-case headings, straight quotes),
abstract metaphor nouns, and a plain-speech section that includes the
rule against over-compressed arrow-speak. Also states the boundary
this guide draws that unslop does not: metaphors that teach a concept
to a non-technical reader stay, metaphors that decorate go. The
guide's own prose now follows its own em-dash rule.

Source: https://github.com/cursor/plugins/blob/main/pstack/skills/unslop/SKILL.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adopts wayfarer3130's review comments: the engines split from the data
types (core = building blocks only; legacy = today's readers and
writers; parser = the new streaming reader; generator = the new
writers). The vendored dicom-parser tokenizer is dropped from the 1.0
stream entirely, and about 300 tests retire with it. @dcmjs/media is
added as a bundle over dicomdir, pdfs, and video, which all remain
individually installable. The wrapper publishes as @dcmjs/dcmjs during
the beta so the real dcmjs name stays untouched until the team is
ready. FHIR stays in the plan with its landing order marked as an open
question. Section 7 is rewritten to the simpler tests-travel-with-code
approach discussed since the first draft.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s sequencing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e git mv)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hrough fixturePath()

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
refactor: move committed test DICOM fixtures into packages/fixtures
…regression bank

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
test: add the issue triage catalog and part10 walker for the regression bank
…avior

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…gth behavior

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rdering

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d strict-write

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… value-handling behaviors

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
test: pin round-trip integrity, UUID-derived UIDs, and UN padding behavior
test: pin encapsulated write, latin1 fallback, and CS multi-value length behavior
test: pin private tag handling, undefined value writes, and element ordering
awatson1978 and others added 2 commits October 2, 2026 11:09
@dcmjs-org/core, @dcmjs-org/legacy, @dcmjs-org/parser, and
@dcmjs-org/generator drop their scaffold-era private flags so the
lockstep publish can ship them. @dcmjs-org/fixtures stays private per
RELEASE_PLAN section 11, and the root package stays private until the
wrapper PR. Publishing remains gated behind the publish GitHub
environment — merging this changes nothing on npm.

BREAKING CHANGE: dcmjs 1.0 replaces the single monolithic package with
the @dcmjs-org scoped package set (core, legacy, parser, generator,
schemas). The legacy package preserves today's reader and writer
surfaces; the event-stream engines are new. See RELEASE_PLAN.md and
docs/BREAKING_CHANGES.md for the behavior changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat: mark the engine packages publishable
awatson1978 and others added 8 commits October 2, 2026 21:02
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The default tagNamesToEmpty list carried 103 entries that matched no
dictionary keyword (misspellings, singular/plural mistakes, and ad-hoc
abbreviations like RefStudySeq), so cleanTags silently skipped the
attributes they were meant to scrub and the PHI survived anonymization
(issue #345). Port the corrected list from the rewrite line: every
entry now maps 1:1 to its real dictionary keyword, with retired
attributes using the dictionary's RETIRED_ prefix. The most visible
newcomers are the six diagnostic sequences named in
docs/BREAKING_CHANGES.md (ContentSequence, SourceImageSequence,
ReferencedImageSequence, AcquisitionContextSequence,
RequestAttributesSequence, IconImageSequence), which PS3.15 Table
E.1-1 assigns removal actions to.

Unskips the KNOWN GAP #345 structural pin (every name resolves via
DicomMetaDictionary.nameMap), adds a behavioral test that a
previously-skipped ContentSequence is actually emptied, flips the
catalog entry to green, and updates the pre-landed BREAKING_CHANGES
entry's status note.

Closes review finding 2 of the PR #512 review map.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fix(anonymizer): correct the 103 non-resolving names in tagNamesToEmpty
feat: scaffold the tests and validator packages (step 8)
…ings 29-31)

The v2 guide (packages/docs/docs/migration/from-0x.md on
v2.0-development) described the lazy read core as the default, promised
a byte-identity rewrite guarantee, and listed naturalization under
"no migration needed". None of the three is true on this branch: the
lazy core was removed per review, writes re-encode with round-trip
integrity pinned by tests, and naturalization changed in three
documented ways. docs/MIGRATION.md states the branch truth, with each
behavioral claim tied to the suite that pins it, and cross-links
BREAKING_CHANGES.md and EXAMPLES.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
docs: correct the 0.x migration guide for what actually shipped (findings 29-31)
…ented

GHSA-vfj7-8cjw-p6xm was published 2026-10-02 with a claimed fix range of
>=3.0.4, but no such braces release exists on the registry, so there is
nothing to override to. Every path is dev tooling; braces never reaches
the published packages. Same entry the fhir staging branch already
carries (#589) — remove it the moment a patched braces ships.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ci: ignore the unpatchable braces advisory, scoped and documented
awatson1978 and others added 3 commits October 3, 2026 00:15
…Part10

The full envelope from legacy DicomDict.write() - 128-byte preamble +
DICM, meta group as explicit little endian with computed
FileMetaInformationGroupLength, the deflate-on-write branch for
1.2.840.10008.1.2.1.99, and the default-transfer-syntax fallback - moves
verbatim into core/writeCore.js as writePart10({ meta, dict }, options).
DicomDict.write() becomes a delegating call with unchanged options and
semantics (including the in-place TransferSyntaxUID default on meta).

SequenceOfItems.writeBytes used to write items through the late-bound
DicomMessage slot, which only legacy populates; it now goes through a new
setWriteDataSet slot that writeCore wires at its own module evaluation -
behavior-identical (the legacy static delegates to the same writeDataSet)
but populated whenever core loads, so the whole write path works with no
@dcmjs-org/legacy in the module graph. The slot stays late-bound per the
module-eval-cycle discipline around Tag.js, and its text matches PR #580
(the dicomdir wave landed the identical seam on its staging branch) so
the branches converge cleanly.

DicomDict keeps its setDicomMessageClass slot for API compatibility
(the wrapper index, legacy's DicomMessage module, and existing tests
still call it), but nothing reads it anymore - the same readerless-slot
pattern Tag.js already uses.

test/core/writePart10.test.js pins byte-exactness: for the same input,
the delegating DicomDict.write() and direct writePart10 produce
byte-identical output on an ELE fixture, a deflated fixture, and the
missing-TransferSyntaxUID defaulting path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Part10Writer now serializes through core's writePart10 instead of
constructing a legacy DicomDict, and @dcmjs-org/legacy leaves the
package's dependencies (it was never in devDependencies). The README's
declared-edge paragraph is rewritten to record that the edge closed in
October 2026 with the envelope's relocation.

packages/generator/test/standalone.test.js is the regression: it imports
only core + parser + generator by package name, arms a virtual jest.mock
tripwire that throws if @dcmjs-org/legacy ever loads, and exercises
Part10Writer end to end (events -> bytes -> fromPart10Stream readback)
for both explicit little endian and the DEFLATED transfer syntax, so the
relocated deflate branch is proven legacy-free. Red-first: before the
relocation the tripwire fired at Part10Writer.js:5 and failed the whole
suite at module load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
refactor: remove the generator → legacy edge by relocating the Part 10 envelope into core
awatson1978 added a commit that referenced this pull request Oct 3, 2026
Per review on #542: the guide existed to hold the staged-landing PR
descriptions to one standard, and every description is now written.
Removed rather than kept stale; git history preserves it if the team
wants it back later. The PR template keeps the one-line spirit of it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per review on #542: the guide existed to hold the staged-landing PR
descriptions to one standard, and every description is now written.
Removed rather than kept stale; git history preserves it if the team
wants it back later. The PR template keeps the one-line spirit of it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 2a5935e)
awatson1978 added a commit that referenced this pull request Oct 3, 2026
Per review on #542: the guide existed to hold the staged-landing PR
descriptions to one standard, and every description is now written.
Removed rather than kept stale; git history preserves it if the team
wants it back later. The PR template keeps the one-line spirit of it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 2a5935e)
awatson1978 added a commit that referenced this pull request Oct 3, 2026
Per review on #542: the guide existed to hold the staged-landing PR
descriptions to one standard, and every description is now written.
Removed rather than kept stale; git history preserves it if the team
wants it back later. The PR template keeps the one-line spirit of it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 2a5935e)
awatson1978 added a commit that referenced this pull request Oct 3, 2026
Per review on #542: the guide existed to hold the staged-landing PR
descriptions to one standard, and every description is now written.
Removed rather than kept stale; git history preserves it if the team
wants it back later. The PR template keeps the one-line spirit of it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 2a5935e)
awatson1978 added a commit that referenced this pull request Oct 3, 2026
Per review on #542: the guide existed to hold the staged-landing PR
descriptions to one standard, and every description is now written.
Removed rather than kept stale; git history preserves it if the team
wants it back later. The PR template keeps the one-line spirit of it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 2a5935e)
awatson1978 added a commit that referenced this pull request Oct 3, 2026
Per review on #542: the guide existed to hold the staged-landing PR
descriptions to one standard, and every description is now written.
Removed rather than kept stale; git history preserves it if the team
wants it back later. The PR template keeps the one-line spirit of it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 2a5935e)
awatson1978 added a commit that referenced this pull request Oct 3, 2026
Per review on #542: the guide existed to hold the staged-landing PR
descriptions to one standard, and every description is now written.
Removed rather than kept stale; git history preserves it if the team
wants it back later. The PR template keeps the one-line spirit of it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 2a5935e)
awatson1978 added a commit that referenced this pull request Oct 3, 2026
Per review on #542: the guide existed to hold the staged-landing PR
descriptions to one standard, and every description is now written.
Removed rather than kept stale; git history preserves it if the team
wants it back later. The PR template keeps the one-line spirit of it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 2a5935e)
@awatson1978 awatson1978 changed the title feat: dcmjs core, first assembly — the event-stream reading engine and the green regression bank feat: dcmjs core — the event-stream reading engine and the green regression tests Oct 6, 2026
wayfarer3130 added a commit that referenced this pull request Oct 7, 2026
…519)

* docs: release plan for landing 1.0 as staged package PRs

Supersedes the 2026-08-07 plan. Describes the package split, the
1.0-beta release branch, per-package staging branches, the test-first
sequence for core, routing of the 33 review findings, and lockstep
publishing with semantic-release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: add pipeline smoke test

Two trivial assertions on the committed sample-sr.dcm fixture. The
purpose is to exercise the CI checks on the 1.0-beta branch and prove
they run and report on pull requests. See RELEASE_PLAN.md section 6,
step 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: read fixtures with an offset-aware helper so suites pass on Node 24

fs.readFileSync can return a small file inside a larger shared memory
pool. Taking .buffer on that result hands the parser the whole pool,
with the file sitting at a nonzero offset, and the DICM marker check
then fails. Node 24 pools more aggressively, which is why
anonymizer.test.js and data.test.js fail there today (7 tests) while
Node 22 passes by luck of alignment.

This adds readFileAsArrayBuffer() to testUtils and routes all 22
file-read sites through it. Typed-array .buffer uses are untouched.
Same class of problem as issue #311.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: route the remaining indirect fixture reads through the offset-aware helper

The first pass converted single-line fs.readFileSync(...).buffer sites.
CI on Node 24 then failed in a second population: reads assigned to a
variable first, with .buffer taken on a later line. This converts those
sites in data.test.js, data-options.test.js, lossless-read-write.test.js
and normalizers.test.js, and drops the imports that became unused.
Sites that already handle byteOffset correctly are untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: make the shared temp directory creation race-free

ensureTestDataDir checked for the directory and then created it. Two
parallel jest workers can both pass the check, and the slower mkdir then
fails with EEXIST, which failed test_multiframe_1 on one CI leg.
mkdirSync with recursive:true succeeds whether or not the directory
exists, so the check goes away.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: writing style guide, PR template, and data citation file

Three process documents the release plan calls for. The style guide
defines the audience (healthcare-literate, no assumed programming or
imaging jargon) and bans machine-flavored prose. The PR template
requires plain-language descriptions, runnable examples, test evidence,
and fixture citations. DATACITATION.md records the origin and license
of every committed DICOM test file, with the legacy files honestly
marked as provenance-pending.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: configure lockstep prerelease publishing from the 1.0-beta branch

semantic-release computes one version from conventional commits;
stamp-workspace-versions.mjs writes it into every publishable workspace
package.json, and pnpm -r publish releases them together under the npm
beta tag. The publish workflow now also fires on 1.0-beta and
release/0.5x, and passes NODE_AUTH_TOKEN for pnpm publish. Nothing
publishes until the repository owner adds 1.0-beta to the publish
environment allowlist. See RELEASE_PLAN.md sections 9 and 12.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: fold the unslop pattern catalog into the writing style guide

Adapts the rules from Cursor's unslop skill: the fuller AI-vocabulary
list, filler phrases, fancy synonyms for is, vague attributions,
synonym cycling, false ranges, punctuation and formatting rules
(em dashes, colons, bold, sentence-case headings, straight quotes),
abstract metaphor nouns, and a plain-speech section that includes the
rule against over-compressed arrow-speak. Also states the boundary
this guide draws that unslop does not: metaphors that teach a concept
to a non-technical reader stay, metaphors that decorate go. The
guide's own prose now follows its own em-dash rule.

Source: https://github.com/cursor/plugins/blob/main/pstack/skills/unslop/SKILL.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: restructure the package map per first review round

Adopts wayfarer3130's review comments: the engines split from the data
types (core = building blocks only; legacy = today's readers and
writers; parser = the new streaming reader; generator = the new
writers). The vendored dicom-parser tokenizer is dropped from the 1.0
stream entirely, and about 300 tests retire with it. @dcmjs/media is
added as a bundle over dicomdir, pdfs, and video, which all remain
individually installable. The wrapper publishes as @dcmjs/dcmjs during
the beta so the real dcmjs name stays untouched until the team is
ready. FHIR stays in the plan with its landing order marked as an open
question. Section 7 is rewritten to the simpler tests-travel-with-code
approach discussed since the first draft.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: drop the FHIR sequencing aside from the package table

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: describe FHIR's role in the landing order instead of hedging its sequencing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: add @dcmjs/mcp and @dcmjs/web to the package map as stretch goals

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Update RELEASE_PLAN.md

* docs: plain-language pass on the plan intro, plus three publishing clarifications from review

Rewrites the section-1 metaphor in direct terms and retitles section 6.
Adds the breaking-change-commit requirement for the first 1.0.0-beta.1
version calculation, documents the private root package during the beta,
records dist-tag promotion as a deliberate manual step, and fixes the
appendix publishCmd to tag by channel instead of hardcoding beta.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: tag publishes by channel and keep the unscoped root private during the beta

Review found the publish command hardcoded --tag beta, which would have
followed 1.0-beta into master at GA, and that a recursive publish would
ship the unscoped dcmjs name during the beta against the plan's intent.
The tag now derives from the release channel, and the root package is
private until the @dcmjs/dcmjs wrapper PR renames it. Dist-tag promotion
stays deliberately manual, now documented in RELEASE_PLAN section 10.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: publish under the @dcmjs-org scope

The @dcmjs npm organization is not ours; per review discussion the
packages publish under @dcmjs-org (matching the GitHub organization),
and the team can reconsider the shorter scope if its owner responds.
Every package name in the plan now reads @dcmjs-org/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: follow the @dcmjs-org scope in the data citation file

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: retire the writing style guide now that authoring is done

Per review on #542: the guide existed to hold the staged-landing PR
descriptions to one standard, and every description is now written.
Removed rather than kept stale; git history preserves it if the team
wants it back later. The PR template keeps the one-line spirit of it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: name the @dcmjs-org scope in the owner actions of the release plan

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build(release): OIDC trusted publishing and a hardened, fully pinned publish job

Review feedback from #519 (trusted publishing becomes required in 2027,
so adopt it now):

- minimumReleaseAge 1440 -> 2880 (two days)
- semantic-release and its five plugins are exact-pinned devDependencies
  installed from the lockfile; the publish step runs pnpm exec
  semantic-release instead of cycjimmy/semantic-release-action with
  runtime-fetched extra_plugins
- every action in the publish workflow is pinned by commit SHA
- npm auth is OIDC trusted publishing: no NPM_TOKEN/NODE_AUTH_TOKEN, no
  registry-url .npmrc; RELEASE_PLAN documents the per-package trusted
  publisher setup and the first-publish bootstrap
- cache: pnpm removed from the publish job

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* build: move override pins to pnpm-workspace.yaml where pnpm 11 reads them

pnpm 11 stopped reading package.json pnpm.overrides (it warned on every
install), so the security pins silently stopped applying and the audit
gate failed once the lockfile was regenerated: form-data@3 and ws@7
resolved to advisory-vulnerable versions through jest 27's jsdom chain,
with more paths added by the new semantic-release devDependencies.

Ports the fix already on release/1.0-beta-core: the overrides block
(old pins plus audit minimums for adm-zip, js-yaml, brace-expansion,
browserslist, ws@7, form-data@3) and the braces GHSA ignore (advisory
names a patched >=3.0.4 that still does not exist on the registry; all
paths are dev tooling) live in pnpm-workspace.yaml, and the dead
package.json pnpm field is removed.

pnpm audit --audit-level=high exits 0; full suite 320/320.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Bill Wallace <wayfarer3130@gmail.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants