From 421033af2cfa0500d469cb593c90ba4ea823d4be Mon Sep 17 00:00:00 2001 From: Noah Schatz Date: Fri, 25 Sep 2026 21:08:58 +0000 Subject: [PATCH] Prepare the 0.1.0 release: runnable examples, a self-contained quickstart, metadata and the 0.1 changeset The 0.1.0 release needs examples a new user can run, CI that keeps them honest, a README quickstart that runs when copied into an empty project, package metadata that points at the docs site, and a changelog entry that says what 0.1 means for someone depending on the package. - examples/: four programs covering the jobs the README presents (read the four views, walk every element with its typed value, tolerate a missing preamble and re-serialize spec-clean, de-identify with an audit report). Each imports @cosyte/dicom by name, so it runs against the built package through its own exports, and asserts its own output. The one object is the first-use fixture's bytes as base64 in examples/data/ct-object.ts, since this repository builds fixtures in modules rather than committing binary files. - pnpm examples runs them all (scripts/run-examples.ts, refusing to pass when it finds nothing to run); typecheck:examples and lint:examples check them against the built types, and phi-scan:examples names them to the PHI scanner, whose roots are test/, README.md and docs-content/. The scanner decodes the base64 object and scans it. - ci.yml gains an examples job, with actions pinned by commit to what the tags dictionary-regen.yml uses resolve to today; format and format:check cover examples/. - README Usage: the block read study.dcm from disk, so it could not run from a clean install. It now parses the same synthetic object inline (the fixture's preamble-less variant, as base64, so the tolerated DICOM_MISSING_PREAMBLE it shows is real) and prints its key results, followed by a text block of the output. test/docs-content.test.ts runs it unmodified, asserts the inline object is byte-identical to the fixture, and requires its stdout to equal the text block, with a negative control; the claim-mutation and compile checks stay. - package.json: keywords for imaging and de-identification; homepage https://docs.cosyte.com/dicom. - .changeset/first-minor-release.md: the minor that states what 0.1 covers, what the version promises below 1.0, and what is not covered yet. It is the changelog entry the release generates. Claude-Session: https://claude.ai/code/session_01U24bnHKNxQWMxmfN7ny7w9 --- .changeset/first-minor-release.md | 30 +++++++++ .github/workflows/ci.yml | 22 +++++++ README.md | 27 +++++++- examples/README.md | 29 +++++++++ examples/data/ct-object.ts | 16 +++++ examples/deidentify.ts | 44 +++++++++++++ examples/eslint.config.js | 15 +++++ examples/read-metadata.ts | 47 ++++++++++++++ examples/tolerate-and-reserialize.ts | 53 ++++++++++++++++ examples/tsconfig.json | 7 +++ examples/walk-elements.ts | 63 +++++++++++++++++++ package.json | 25 +++++--- scripts/run-examples.ts | 61 ++++++++++++++++++ test/docs-content.test.ts | 92 ++++++++++++++++++++-------- 14 files changed, 494 insertions(+), 37 deletions(-) create mode 100644 .changeset/first-minor-release.md create mode 100644 examples/README.md create mode 100644 examples/data/ct-object.ts create mode 100644 examples/deidentify.ts create mode 100644 examples/eslint.config.js create mode 100644 examples/read-metadata.ts create mode 100644 examples/tolerate-and-reserialize.ts create mode 100644 examples/tsconfig.json create mode 100644 examples/walk-elements.ts create mode 100644 scripts/run-examples.ts diff --git a/.changeset/first-minor-release.md b/.changeset/first-minor-release.md new file mode 100644 index 0000000..30ac79d --- /dev/null +++ b/.changeset/first-minor-release.md @@ -0,0 +1,30 @@ +--- +"@cosyte/dicom": minor +--- + +**This is 0.1.0, the first release whose public API we treat as settled.** + +What is covered, and what you can build against: + +- Reading DICOM Part 10 metadata: the four typed views (`patient`, `study`, `series`, `image`), + access to any element by tag with the Part 6 data dictionary compiled in, and lazy typed decode of + all 34 value representations, honoring Specific Character Set. +- The four native transfer syntaxes and every PS3.5 2026c section A.4 encapsulation syntax are read; + encapsulated pixel data comes back as raw fragments, and a DICOMDIR reads as a record tree. +- Lenient parsing: only four structural conditions are fatal, and every tolerated deviation is a + stable warning code with a byte offset. Vendor profiles (`ge`, `siemens`, `philips`, `strict`, + `lenient`, or your own through `defineProfile`) only ever tighten or annotate a parse. +- A spec-clean serializer that writes the source transfer syntax back, and metadata + de-identification with the PS3.15 Annex E Basic Profile and the metadata-affecting options, + returning a fresh dataset and an audit report. + +What the version promises. The exported names, options, return shapes and warning codes are the +surface we keep stable. While the package is below 1.0, a breaking change bumps the minor version +(0.1 to 0.2) and is called out in this changelog with its migration; a fix that changes no public +value ships as a patch. + +What is not covered yet. Pixel data is never decoded, in any transfer syntax. A de-identified output +is metadata-de-identified only: burned-in annotation is warned about, not removed, and the +residuals the README lists under known limitations are disclosed rather than closed. There is no +transcoding between transfer syntaxes, no DIMSE networking and no DICOMweb, and coded values are +surfaced but not validated against a terminology. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4facc3..0c6e053 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,3 +21,25 @@ jobs: uses: cosyte/.github/.github/workflows/ci.yml@main with: run-phi-scan: true + + # The runnable examples under examples/, checked the way a consumer meets them: each imports + # `@cosyte/dicom` by name, which resolves through package.json `exports` to the BUILT dist/, so + # the build runs first. Each example asserts its own output, so `pnpm examples` goes red when one + # drifts from the package. The PHI scan's default roots (test/, README.md, docs-content/) do not + # reach examples/, so the example files are named to it here. Actions are pinned to the commits + # the tags dictionary-regen.yml uses resolve to. + examples: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: "22" + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm build + - run: pnpm typecheck:examples + - run: pnpm lint:examples + - run: pnpm examples + - run: pnpm phi-scan:examples diff --git a/README.md b/README.md index 48894ec..3a2cb32 100644 --- a/README.md +++ b/README.md @@ -58,12 +58,23 @@ pnpm add @cosyte/dicom Useful output after install and parse. No DICOM spec knowledge required. ```ts runnable -import { readFile } from "node:fs/promises"; import { parseDicom } from "@cosyte/dicom"; -// Here study.dcm is a synthetic CT object saved without its 128-byte preamble. -const ds = parseDicom(await readFile("study.dcm")); +// A synthetic CT object, saved without its 128-byte preamble: an invented patient and example-root +// UIDs, no real PHI. In your integration these are the bytes of the file you read. +const buf = Buffer.from( + "AgAAAFVMBAAcAAAAAgAQAFVJFAAxLjIuODQwLjEwMDA4LjEuMi4xAAgAFgBVSRoAMS4yLjg0MC4xMDAwOC41LjEuNC4xLjEuMgAIABgAVUkeADEuMi44MjYuMC4xLjM2ODAwNDMuOC40OTguMTExAAgAIABEQQgAMTkwMDAxMDEIAGAAQ1MCAENUEAAQAFBOCABEb2VeSmFuZRAAIABMTwYATVJOLTQyEAAhAExPDABTQU1QTEUtSE9TUCAgAA0AVUkeADEuMi44MjYuMC4xLjM2ODAwNDMuOC40OTguMS4xACAADgBVSR4AMS4yLjgyNi4wLjEuMzY4MDA0My44LjQ5OC4xLjIAIAARAElTAgAyICgAEABVUwIAAAIoABEAVVMCAAACKAAAAVVTAgAQACgAAwFVUwIAAQAoAFIQRFMGAC0xMDI0ICgAUxBEUwIAMSAoADAARFMIADAuNVwwLjUg", + "base64", +); +const ds = parseDicom(buf); + +console.log("patient", ds.patient.id, "issuer", ds.patient.issuerOfId); +console.log("study", ds.study.instanceUid); +console.log("series", ds.series.modality, "image", ds.image.rows, "x", ds.image.columns); +console.log("rescale slope", ds.image.rescaleSlope, "intercept", ds.image.rescaleIntercept); +console.log("tolerated", ds.warnings.length, ds.warnings[0]?.code); +// The values printed above, which the test suite asserts on every run. // NOT globally unique on its own: pair it with ds.patient.issuerOfId. ds.patient.id; // => "MRN-42" // The global study anchor. @@ -76,6 +87,16 @@ ds.image.rescaleSlope; // => 1 ds.warnings.map((w) => w.code); // => ["DICOM_MISSING_PREAMBLE"] ``` +It prints: + +```text +patient MRN-42 issuer SAMPLE-HOSP +study 1.2.826.0.1.3680043.8.498.1.1 +series CT image 512 x 512 +rescale slope 1 intercept -1024 +tolerated 1 DICOM_MISSING_PREAMBLE +``` + The structural route, and emitting bytes back out: ```ts diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..e46a7a5 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,29 @@ +# Examples + +Small runnable programs, one per job the package does. Each one imports `@cosyte/dicom` by its +published name, so it runs against the built package exactly as a consumer installs it, prints what +it read or wrote, and checks its own output: a mismatch exits non-zero. The one object they read is +synthetic: an invented patient, example-root UIDs, header attributes only. + +Build once, then run them all: + +```bash +pnpm install +pnpm build +pnpm examples +``` + +| File | What it shows | Run | +| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------- | +| [`read-metadata.ts`](read-metadata.ts) | Read the patient, study, series and image views, name the SOP class from the dictionary, and convert the study date. | `pnpm tsx examples/read-metadata.ts` | +| [`walk-elements.ts`](walk-elements.ts) | Walk every element with its dictionary name and its typed, decoded value. | `pnpm tsx examples/walk-elements.ts` | +| [`tolerate-and-reserialize.ts`](tolerate-and-reserialize.ts) | Read an object saved without its preamble, see the stable warning code, and write it back as spec-clean Part 10 that reads back with no warning. | `pnpm tsx examples/tolerate-and-reserialize.ts` | +| [`deidentify.ts`](deidentify.ts) | De-identify the header with the PS3.15 Annex E Basic Profile, print the audit report, and write the result out. | `pnpm tsx examples/deidentify.ts` | + +`data/ct-object.ts` holds the synthetic CT object as base64, a copy of the first-use fixture the +README and the quickstart parse, kept in a module because this repository builds every fixture in +one rather than committing binary files. + +CI runs `pnpm typecheck:examples`, `pnpm lint:examples`, `pnpm examples` and +`pnpm phi-scan:examples` after `pnpm build` on every pull request, so an example that drifts from +the package fails the build. The PHI scan decodes the base64 object and scans it as DICOM. diff --git a/examples/data/ct-object.ts b/examples/data/ct-object.ts new file mode 100644 index 0000000..c17e4c2 --- /dev/null +++ b/examples/data/ct-object.ts @@ -0,0 +1,16 @@ +/** + * A synthetic CT object in DICOM Part 10 form: the declared-synthetic patient "Doe^Jane", an + * invented identifier and issuer, the placeholder study date 1900-01-01, and every UID under the + * `1.2.826.0.1.3680043.8.498` example root. It carries header attributes only, no pixel data. + * + * The bytes are a copy of the first-use fixture (`test/fixtures/first-use/ct-object.ts`), the same + * object the README and the quickstart parse, stored here as base64 because this repository builds + * every fixture in a module rather than committing binary files. + */ +import { Buffer } from "node:buffer"; + +/** The object with its 128-byte preamble and `DICM` magic, as a conformant writer saves it. */ +export const SYNTHETIC_CT = Buffer.from( + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABESUNNAgAAAFVMBAAcAAAAAgAQAFVJFAAxLjIuODQwLjEwMDA4LjEuMi4xAAgAFgBVSRoAMS4yLjg0MC4xMDAwOC41LjEuNC4xLjEuMgAIABgAVUkeADEuMi44MjYuMC4xLjM2ODAwNDMuOC40OTguMTExAAgAIABEQQgAMTkwMDAxMDEIAGAAQ1MCAENUEAAQAFBOCABEb2VeSmFuZRAAIABMTwYATVJOLTQyEAAhAExPDABTQU1QTEUtSE9TUCAgAA0AVUkeADEuMi44MjYuMC4xLjM2ODAwNDMuOC40OTguMS4xACAADgBVSR4AMS4yLjgyNi4wLjEuMzY4MDA0My44LjQ5OC4xLjIAIAARAElTAgAyICgAEABVUwIAAAIoABEAVVMCAAACKAAAAVVTAgAQACgAAwFVUwIAAQAoAFIQRFMGAC0xMDI0ICgAUxBEUwIAMSAoADAARFMIADAuNVwwLjUg", + "base64", +); diff --git a/examples/deidentify.ts b/examples/deidentify.ts new file mode 100644 index 0000000..85438e9 --- /dev/null +++ b/examples/deidentify.ts @@ -0,0 +1,44 @@ +/** + * De-identify an object's metadata, audit what was done, and write the result out. + * + * `deidentify` applies the PS3.15 Annex E Basic Profile to the header and returns a fresh dataset + * plus a report of every attribute it acted on (tag, keyword, action; never a value). The input + * dataset is not mutated. It does not touch pixels. The input is the synthetic CT object in + * `examples/data/ct-object.ts`. + * + * Run from the repository root after `pnpm build`: + * + * pnpm tsx examples/deidentify.ts + */ + +import assert from "node:assert/strict"; + +import { deidentify, parseDicom, serializeDicom } from "@cosyte/dicom"; + +import { SYNTHETIC_CT } from "./data/ct-object.js"; + +const source = parseDicom(SYNTHETIC_CT); +const { dataset, report } = deidentify(source); + +for (const a of report.attributes) { + console.log(`(${a.tag.slice(0, 4)},${a.tag.slice(4)}) ${a.keyword}: ${a.action}, ${a.applied}`); +} + +const safe = parseDicom(serializeDicom(dataset)); +const method = safe.get("00120063")?.value; +console.log("Patient ID after:", JSON.stringify(safe.patient.id)); +console.log("Study UID after:", safe.study.instanceUid); +console.log("Method recorded:", method?.kind === "strings" ? method.values[0] : undefined); +console.log("Source untouched:", source.patient.id); + +// The checks that make this file a test: `pnpm examples` fails if any of them does not hold. +const applied = new Map(report.attributes.map((a) => [a.keyword, a.applied])); +assert.equal(applied.get("Patient's Name"), "emptied"); +assert.equal(applied.get("Patient ID"), "emptied"); +assert.equal(applied.get("Issuer of Patient ID"), "removed"); +assert.equal(applied.get("Study Instance UID"), "uid-remapped"); +assert.equal(safe.patient.id, undefined); +assert.equal(safe.patient.name, undefined); +assert.notEqual(safe.study.instanceUid, source.study.instanceUid); +assert.equal(method?.kind, "strings"); +assert.equal(source.patient.id, "MRN-42"); diff --git a/examples/eslint.config.js b/examples/eslint.config.js new file mode 100644 index 0000000..2c7a66b --- /dev/null +++ b/examples/eslint.config.js @@ -0,0 +1,15 @@ +import cosyte from "@cosyte/eslint-config"; + +// The examples are programs that print, not library code with a public API, so every type-safety +// rule applies to them and the library-only `no-console` and JSDoc gates do not. +export default [ + ...cosyte(import.meta.dirname, { files: ["**/*.ts"] }), + { + files: ["**/*.ts"], + rules: { + "no-console": "off", + "jsdoc/require-jsdoc": "off", + "jsdoc/require-example": "off", + }, + }, +]; diff --git a/examples/read-metadata.ts b/examples/read-metadata.ts new file mode 100644 index 0000000..24fa1b3 --- /dev/null +++ b/examples/read-metadata.ts @@ -0,0 +1,47 @@ +/** + * Read who, what and how out of a DICOM Part 10 object through the four typed views. + * + * `examples/data/ct-object.ts` holds a synthetic CT object (an invented patient, example-root + * UIDs). The views answer the safety-critical questions without a tag table: whose object, which + * study and series, and the image geometry you need before interpreting a pixel. A missing value + * stays `undefined`, never a substituted default. + * + * Run from the repository root after `pnpm build`: + * + * pnpm tsx examples/read-metadata.ts + */ + +import assert from "node:assert/strict"; + +import { Dictionary, parseDicom, toISO } from "@cosyte/dicom"; + +import { SYNTHETIC_CT } from "./data/ct-object.js"; + +const ds = parseDicom(SYNTHETIC_CT); +const name = ds.patient.name?.alphabetic; +const sopClass = ds.get("00080016")?.value; +const sopClassUid = sopClass?.kind === "strings" ? sopClass.values[0] : undefined; + +console.log(`Patient: ${String(name?.givenName)} ${String(name?.familyName)}`); +console.log(`Patient ID: ${String(ds.patient.id)} (issuer ${String(ds.patient.issuerOfId)})`); +console.log(`SOP class: ${String(Dictionary.uid(sopClassUid ?? "")?.name)}`); +console.log(`Study: ${String(ds.study.instanceUid)} on ${String(toISO(ds.study.date))}`); +console.log(`Series ${String(ds.series.number)}: ${String(ds.series.modality)}`); +console.log( + `Image: ${String(ds.image.rows)} x ${String(ds.image.columns)},`, + `signed ${String(ds.image.signed)}, rescale ${String(ds.image.rescaleSlope)} / ${String(ds.image.rescaleIntercept)},`, + `spacing ${JSON.stringify(ds.image.pixelSpacing)}`, +); +console.log("Warnings:", ds.warnings.length); + +// The checks that make this file a test: `pnpm examples` fails if any of them does not hold. +assert.equal(name?.familyName, "Doe"); +assert.equal(ds.patient.id, "MRN-42"); +assert.equal(ds.patient.issuerOfId, "SAMPLE-HOSP"); +assert.equal(Dictionary.uid(sopClassUid ?? "")?.name, "CT Image Storage"); +assert.equal(toISO(ds.study.date), "1900-01-01"); +assert.equal(ds.series.modality, "CT"); +assert.deepEqual([ds.image.rows, ds.image.columns], [512, 512]); +assert.equal(ds.image.signed, true); +assert.deepEqual([ds.image.rescaleSlope, ds.image.rescaleIntercept], [1, -1024]); +assert.equal(ds.warnings.length, 0); diff --git a/examples/tolerate-and-reserialize.ts b/examples/tolerate-and-reserialize.ts new file mode 100644 index 0000000..9fcc088 --- /dev/null +++ b/examples/tolerate-and-reserialize.ts @@ -0,0 +1,53 @@ +/** + * Read a vendor-quirky object leniently, then write it back out as spec-clean Part 10. + * + * The input is the synthetic CT object from `examples/data/ct-object.ts` with its 128-byte preamble + * and `DICM` magic cut off, as some writers save it. The parser reads it anyway and records the + * deviation as the stable code `DICOM_MISSING_PREAMBLE`. `serializeDicom` writes a conformant file + * in the same transfer syntax, with the preamble, File Meta rebuilt and elements in ascending tag + * order, and no value changed; reading that back raises no warning. + * + * Run from the repository root after `pnpm build`: + * + * pnpm tsx examples/tolerate-and-reserialize.ts + */ + +import assert from "node:assert/strict"; + +import { parseDicom, serializeDicom } from "@cosyte/dicom"; + +import { SYNTHETIC_CT } from "./data/ct-object.js"; + +// Preamble (128 bytes) plus the "DICM" magic (4 bytes), removed. +const quirky = SYNTHETIC_CT.subarray(132); +const read = parseDicom(quirky); +console.log( + `Read ${String(quirky.length)} bytes, tolerated:`, + read.warnings.map((w) => w.code), +); + +const written = serializeDicom(read); +const reread = parseDicom(written); +console.log( + `Wrote ${String(written.length)} bytes, magic "${written.toString("ascii", 128, 132)}"`, +); +console.log( + "Reading it back tolerated:", + reread.warnings.map((w) => w.code), +); +console.log("Transfer syntax kept:", reread.fileMeta?.transferSyntaxUID); + +const tags = [...reread.elements()].map((e) => e.tag); +const values = (ds: typeof read): string[] => + [...ds.elements()].map((e) => `${e.tag}=${JSON.stringify(e.value)}`).sort(); + +// The checks that make this file a test: `pnpm examples` fails if any of them does not hold. +assert.deepEqual( + read.warnings.map((w) => w.code), + ["DICOM_MISSING_PREAMBLE"], +); +assert.equal(written.toString("ascii", 128, 132), "DICM"); +assert.equal(reread.warnings.length, 0); +assert.equal(reread.fileMeta?.transferSyntaxUID, "1.2.840.10008.1.2.1"); +assert.deepEqual(tags, [...tags].sort()); +assert.deepEqual(values(reread), values(read)); diff --git a/examples/tsconfig.json b/examples/tsconfig.json new file mode 100644 index 0000000..f470ad9 --- /dev/null +++ b/examples/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cosyte/tsconfig/base.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["./**/*.ts"] +} diff --git a/examples/walk-elements.ts b/examples/walk-elements.ts new file mode 100644 index 0000000..1c1aaa9 --- /dev/null +++ b/examples/walk-elements.ts @@ -0,0 +1,63 @@ +/** + * Walk every element of an object with its dictionary name and its typed value. + * + * `ds.elements()` visits every data element; `Dictionary.lookup` names a tag from the Part 6 data + * dictionary compiled into the package; `element.value` decodes the raw bytes into a discriminated + * `DicomValue` on first read. The input is the synthetic CT object in `examples/data/ct-object.ts`. + * + * Run from the repository root after `pnpm build`: + * + * pnpm tsx examples/walk-elements.ts + */ + +import assert from "node:assert/strict"; + +import { Dictionary, parseDicom, type DicomValue } from "@cosyte/dicom"; + +import { SYNTHETIC_CT } from "./data/ct-object.js"; + +/** One readable line for a decoded value; every kind this object carries is spelled out. */ +function show(value: DicomValue): string { + switch (value.kind) { + case "strings": + return value.values.join(" | "); + case "numbers": + case "integerString": + case "decimalString": + return value.values.join(" \\ "); + case "personName": + return value.values + .map((pn) => `${String(pn.alphabetic?.familyName)}^${String(pn.alphabetic?.givenName)}`) + .join(" | "); + case "dates": + return value.values.map((d) => d.raw).join(" | "); + // Kinds this object does not carry: named rather than decoded here. + case "text": + case "bigints": + case "attributeTags": + case "times": + case "dateTimes": + case "binary": + case "sequence": + case "empty": + return `(${value.kind})`; + } +} + +const ds = parseDicom(SYNTHETIC_CT); +const seen: string[] = []; +for (const element of ds.elements()) { + const entry = Dictionary.lookup(element.tag); + seen.push(entry?.keyword ?? element.tag); + console.log( + `(${element.tag.slice(0, 4)},${element.tag.slice(4)}) ${element.vr}`, + `${String(entry?.name).padEnd(28)} ${show(element.value)}`, + ); +} + +// The checks that make this file a test: `pnpm examples` fails if any of them does not hold. +assert.equal(seen.length, 17); +assert.ok(seen.includes("PatientName") && seen.includes("RescaleIntercept")); +const spacing = ds.get("00280030")?.value; +assert.ok(spacing?.kind === "decimalString"); +assert.deepEqual(spacing.values, [0.5, 0.5]); diff --git a/package.json b/package.json index f120069..77df121 100644 --- a/package.json +++ b/package.json @@ -4,15 +4,22 @@ "description": "DICOM Part 10 parser + utility library for Node.js and TypeScript: metadata-first, vendor-quirky-tolerant, dual ESM/CJS.", "keywords": [ "dicom", - "parser", + "dicom-part-10", "medical-imaging", + "radiology", + "pacs", + "ct", + "mri", + "metadata", + "de-identification", + "anonymization", "healthcare", "interoperability", - "typescript", - "part-10", - "metadata" + "parser", + "serializer", + "typescript" ], - "homepage": "https://github.com/cosyte/dicom#readme", + "homepage": "https://docs.cosyte.com/dicom", "bugs": { "url": "https://github.com/cosyte/dicom/issues" }, @@ -74,11 +81,15 @@ "typecheck:exports": "node scripts/attw.mjs --profile node16", "lint": "eslint \"src/**/*.ts\" \"scripts/**/*.ts\" \"test/**/*.ts\" --max-warnings=0 --no-error-on-unmatched-pattern", "lint:fix": "eslint \"src/**/*.ts\" \"scripts/**/*.ts\" \"test/**/*.ts\" --fix --no-error-on-unmatched-pattern", - "format": "prettier --write \"src/**/*.{ts,md}\" \"scripts/**/*.{ts,mjs}\" \"test/**/*.ts\" \"*.{json,md,yml,yaml}\" --no-error-on-unmatched-pattern", - "format:check": "prettier --check \"src/**/*.{ts,md}\" \"scripts/**/*.{ts,mjs}\" \"test/**/*.ts\" \"*.{json,md,yml,yaml}\" --no-error-on-unmatched-pattern", + "format": "prettier --write \"src/**/*.{ts,md}\" \"scripts/**/*.{ts,mjs}\" \"test/**/*.ts\" \"examples/**/*.{ts,js,json,md}\" \"*.{json,md,yml,yaml}\" --no-error-on-unmatched-pattern", + "format:check": "prettier --check \"src/**/*.{ts,md}\" \"scripts/**/*.{ts,mjs}\" \"test/**/*.ts\" \"examples/**/*.{ts,js,json,md}\" \"*.{json,md,yml,yaml}\" --no-error-on-unmatched-pattern", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", + "examples": "tsx scripts/run-examples.ts", + "typecheck:examples": "tsc -p examples/tsconfig.json", + "lint:examples": "eslint examples --max-warnings=0", + "phi-scan:examples": "tsx scripts/phi-scan.ts examples/*.ts examples/data/*.ts", "gen:dictionary": "tsx scripts/generate-dictionary.ts", "gen:annex-e": "tsx scripts/generate-annex-e.ts", "gen:repeating-groups": "tsx scripts/generate-repeating-groups.ts", diff --git a/scripts/run-examples.ts b/scripts/run-examples.ts new file mode 100644 index 0000000..0946d99 --- /dev/null +++ b/scripts/run-examples.ts @@ -0,0 +1,61 @@ +/** + * Runs every example under `examples/` and exits non-zero if any of them fails. + * + * Each `examples/*.ts` file (depth 1: `examples/data/` holds their inputs, and a name starting with + * `_` is skipped) imports the package by its published name, `@cosyte/dicom`, which Node resolves + * through this package's own `exports` to the BUILT files in `dist/`. So the examples exercise what + * a consumer installs, and `pnpm build` has to run first. + * + * Each example asserts its own key output and exits non-zero on a mismatch. This runner only + * aggregates: it reports every example, prints the output of any that failed, and refuses to pass + * when it found nothing to run, because a runner that ran nothing proves nothing. + * + * File names are passed to `spawnSync` as argv, never through a shell. + * + * pnpm build && pnpm examples + */ + +import { spawnSync } from "node:child_process"; +import { existsSync, readdirSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const EXAMPLES_DIR = join(REPO_ROOT, "examples"); +/** What `exports["."].import` in package.json points at. */ +const BUILT_ENTRY = join(REPO_ROOT, "dist", "index.mjs"); + +if (!existsSync(BUILT_ENTRY)) { + console.error("dist/index.mjs is missing: run `pnpm build` before `pnpm examples`."); + process.exit(1); +} + +const examples = readdirSync(EXAMPLES_DIR, { withFileTypes: true }) + .filter((d) => d.isFile() && d.name.endsWith(".ts") && !d.name.startsWith("_")) + .map((d) => d.name) + .sort(); + +if (examples.length === 0) { + console.error("No examples found under examples/: refusing to report a pass."); + process.exit(1); +} + +let failed = 0; +for (const file of examples) { + const run = spawnSync(process.execPath, ["--import", "tsx", join("examples", file)], { + cwd: REPO_ROOT, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (run.status === 0) { + console.log(`ok ${file}`); + continue; + } + failed += 1; + console.error(`FAIL ${file} (exit ${String(run.status ?? run.signal)})`); + console.error(run.stdout); + console.error(run.stderr); +} + +console.log(`${String(examples.length - failed)} of ${String(examples.length)} examples passed`); +process.exit(failed === 0 ? 0 : 1); diff --git a/test/docs-content.test.ts b/test/docs-content.test.ts index 226dfe7..d2ad6e3 100644 --- a/test/docs-content.test.ts +++ b/test/docs-content.test.ts @@ -1,8 +1,8 @@ import { Buffer } from "node:buffer"; -import { execFileSync } from "node:child_process"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { pathToFileURL } from "node:url"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; @@ -53,17 +53,19 @@ docSnippetSuite({ * sweep above. The quickstart's first block must be the one the sweep executes, and the Part 10 * object it prints as base64 must be byte-identical to the fixture `test/fixtures/first-use` builds, * which `pnpm phi-scan` reads with the rest of `test/`. The README's first `## Usage` block is read - * out of README.md and run here against the built package; the one edit made to it is pointing its - * `readFile("study.dcm")` at a temporary copy of that fixture's preamble-less variant, and that - * rewrite is counted. A changed value in either example turns this file red. Temp modules live in - * their own directory inside the root, as the harness requires, and every temp path is removed. + * out of README.md and run here against the built package as it stands: the object it parses is + * inline base64 too, and it must be byte-identical to that fixture's preamble-less variant, so the + * tolerated deviation the README shows is one the object really carries. The block also prints its + * key results, and its stdout must equal the `text` block the README shows beside it. A changed + * value in either example turns this file red. Temp modules live in their own directory inside the + * root, as the harness requires, and every temp path is removed. */ const FIRST_USE_TMP = join(root, ".cosyte-first-use-snippets"); const QUICKSTART = readFileSync(join(root, "docs-content", "quickstart.md"), "utf8"); const QUICKSTART_FIRST = fences(QUICKSTART)[0]; const QUICKSTART_FIRST_RUNNABLE = extractRunnableSnippets(QUICKSTART)[0]; -const README_FIRST = fences(section(readFileSync(join(root, "README.md"), "utf8"), "## Usage"))[0]; -const STUDY_FILE = '"study.dcm"'; +const README_USAGE = fences(section(readFileSync(join(root, "README.md"), "utf8"), "## Usage")); +const README_FIRST = README_USAGE[0]; /** * The snippet harness strips types without checking them, so compiling is checked separately, the * way a reader's new TypeScript project compiles the block, against the source entry point the @@ -73,22 +75,31 @@ const STUDY_FILE = '"study.dcm"'; const SOURCE_PATHS = { "@cosyte/dicom": join(root, "src", "index.ts") }; const COMPILE_TIMEOUT = 60_000; -let studyDir = ""; - -beforeAll(() => { - studyDir = mkdtempSync(join(tmpdir(), "dicom-first-use-")); - writeFileSync(join(studyDir, "study.dcm"), FIRST_USE_CT_NO_PREAMBLE); -}); - afterAll(() => { rmSync(FIRST_USE_TMP, { recursive: true, force: true }); - rmSync(studyDir, { recursive: true, force: true }); }); -/** The README block with its one input file pointed at the fixture copy; the rewrite is counted. */ -function readmeRunnable(code: string): string { - expect(code.split(STUDY_FILE).length - 1, "the example reads study.dcm exactly once").toBe(1); - return code.replace(STUDY_FILE, JSON.stringify(join(studyDir, "study.dcm"))); +/** + * Run a block as a program, the way a reader runs it: the one `@cosyte/dicom` specifier is + * rewritten to the built entry point, and that rewrite is counted. + */ +function runAsProgram( + code: string, + name: string, +): { status: number | null; stdout: string; stderr: string } { + const specifier = '"@cosyte/dicom"'; + expect(code.split(specifier).length - 1, "the block imports @cosyte/dicom exactly once").toBe(1); + mkdirSync(FIRST_USE_TMP, { recursive: true }); + const file = join(FIRST_USE_TMP, name); + writeFileSync(file, code.replace(specifier, JSON.stringify(pathToFileURL(ENTRY).href)), "utf8"); + const tsx = join(root, "node_modules", ".bin", "tsx"); + const run = spawnSync(tsx, [file], { + cwd: root, + encoding: "utf8", + shell: false, + timeout: 60_000, + }); + return { status: run.status, stdout: run.stdout, stderr: run.stderr }; } describe("the quickstart's first example", () => { @@ -185,13 +196,40 @@ describe("the README ## Usage example", () => { COMPILE_TIMEOUT, ); - it("AC-DI2: runs against the built package reading the fixture, and every claimed value holds", async () => { - await runSnippet(readmeRunnable(README_FIRST?.body ?? ""), { - resolve: resolveEntry, - tmpDir: FIRST_USE_TMP, - }); + it("AC-DI2: runs against the built package, and every claimed value holds", async () => { + await runSnippet(README_FIRST?.body ?? "", { resolve: resolveEntry, tmpDir: FIRST_USE_TMP }); + }); + + it("AC-DI4: the Part 10 object it parses is byte-identical to the preamble-less fixture", () => { + expect(base64Object(README_FIRST?.body ?? "")?.equals(FIRST_USE_CT_NO_PREAMBLE)).toBe(true); + }); + + it("is followed by the text block holding its output", () => { + expect(README_USAGE.map((block) => block.lang).slice(0, 2)).toEqual(["ts", "text"]); }); + it("runs as a program and prints the text block byte for byte", () => { + const run = runAsProgram(README_FIRST?.body ?? "", "readme-usage-stdout.mts"); + expect(run.stderr).toBe(""); + expect(run.status).toBe(0); + expect(run.stdout).toBe(`${README_USAGE[1]?.body ?? ""}\n`); + }, 60_000); + + it("CONTROL: a changed input value changes what it prints and is no longer the fixture", () => { + const code = README_FIRST?.body ?? ""; + const bytes = base64Object(code) ?? Buffer.alloc(0); + const at = bytes.indexOf("MRN-42"); + expect(at).toBeGreaterThan(-1); + const changed = Buffer.from(bytes); + changed.write("MRN-43", at, "ascii"); + const mutated = code.replace(bytes.toString("base64"), changed.toString("base64")); + expect(base64Object(mutated)?.equals(FIRST_USE_CT_NO_PREAMBLE)).toBe(false); + const run = runAsProgram(mutated, "readme-usage-control.mts"); + expect(run.status).toBe(0); + expect(run.stdout).not.toBe(`${README_USAGE[1]?.body ?? ""}\n`); + expect(run.stdout).toContain("patient MRN-43"); + }, 60_000); + it("AC-DI3: a changed claimed value turns the run red", async () => { const code = README_FIRST?.body ?? ""; expect(code.split('ds.series.modality; // => "CT"').length - 1).toBe(1); @@ -200,7 +238,7 @@ describe("the README ## Usage example", () => { 'ds.series.modality; // => "MR"', ); await expect( - runSnippet(readmeRunnable(mutated), { resolve: resolveEntry, tmpDir: FIRST_USE_TMP }), + runSnippet(mutated, { resolve: resolveEntry, tmpDir: FIRST_USE_TMP }), ).rejects.toThrow(); }); });