From f2074b99cca78858a723fffd0240431f3d43fcf1 Mon Sep 17 00:00:00 2001 From: Noah Schatz Date: Fri, 25 Sep 2026 20:57:52 +0000 Subject: [PATCH] Prepare the 0.1.0 release: runnable examples, a printing 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 prints its results when copied into a clean project, package metadata that points at the docs site, a changelog entry that says what 0.1 means, and a version commit that passes its own tests. - examples/: four programs covering the jobs the README presents (read a CCD, build one and round-trip it, lenient versus strict parsing of a quirky document, edit a section into a CDA R2 revision). Each imports @cosyte/ccda by name, so it runs against the built package through its own exports, and asserts its own output. The one input document is a copy of the quickstart fixture, kept in a .ts module because this repository never tracks an XML document as a file. - 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. The PHI scan already walks the whole tree. ci.yml gains an examples job with the same action pins as the conformance job; format and format:check cover examples/. - README Usage: the block now prints its key results with console.log, followed by a text block of the output, and test/docs-content.test.ts runs the block as a program and requires its stdout to equal that text block, with a negative control. The `// =>` claims and the MRN input the first-use tests mutate are unchanged. - README Status states what 0.1.0 promises, and the version commit now keeps it true: scripts/sync-readme-version.ts rewrites the one bold backticked version in `## Status` to package.json's, run by the `version` script after sync-version.mjs. With the Status as it was, the Version Packages commit failed "states the version package.json declares" in the release Verify step, before anything publishes; the sync also keeps the published README from declaring 0.0.15 at 0.1.0. - package.json: keywords for C-CDA and its document types; homepage https://docs.cosyte.com/ccda. - .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 | 33 +++++ .github/workflows/ci.yml | 22 ++++ README.md | 67 +++++----- examples/README.md | 28 +++++ examples/build-and-round-trip.ts | 65 ++++++++++ examples/data/ccd.ts | 148 +++++++++++++++++++++++ examples/edit-a-section.ts | 77 ++++++++++++ examples/eslint.config.js | 15 +++ examples/lenient-and-strict.ts | 50 ++++++++ examples/read-a-ccd.ts | 64 ++++++++++ examples/tsconfig.json | 7 ++ package.json | 27 +++-- scripts/run-examples.ts | 61 ++++++++++ scripts/sync-readme-version.ts | 83 +++++++++++++ test/docs-content.test.ts | 63 +++++++++- test/scripts/sync-readme-version.test.ts | 78 ++++++++++++ 16 files changed, 848 insertions(+), 40 deletions(-) create mode 100644 .changeset/first-minor-release.md create mode 100644 examples/README.md create mode 100644 examples/build-and-round-trip.ts create mode 100644 examples/data/ccd.ts create mode 100644 examples/edit-a-section.ts create mode 100644 examples/eslint.config.js create mode 100644 examples/lenient-and-strict.ts create mode 100644 examples/read-a-ccd.ts create mode 100644 examples/tsconfig.json create mode 100644 scripts/run-examples.ts create mode 100644 scripts/sync-readme-version.ts create mode 100644 test/scripts/sync-readme-version.test.ts diff --git a/.changeset/first-minor-release.md b/.changeset/first-minor-release.md new file mode 100644 index 0000000..1b1df64 --- /dev/null +++ b/.changeset/first-minor-release.md @@ -0,0 +1,33 @@ +--- +"@cosyte/ccda": 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 C-CDA R2.1 documents: all twelve US Realm document types are recognized, the header and + the patient are typed, and fourteen entry families are extracted (problems, medications, + allergies, results, vital signs, immunizations, procedures, encounters, smoking status, plan of + treatment, functional status, mental status, family history and past medical history), each coded + value keeping the code system it was sent with. +- Lenient parsing with stable warning codes and a `{ strict: true }` mode that refuses the first + tolerated deviation; hostile XML (a DTD, entity expansion, an oversized or over-deep document) is + always refused. +- Building a spec-clean CCD, Referral Note or inpatient Discharge Summary with `buildCcda`, editing + a parsed document section by section with `editCcda` (which stamps a CDA R2 revision), and + re-serializing a parsed document, where parse and serialize are a fixed point. +- Required-section validation that reports, for every document type, how much of its obligation it + asserts, and UCUM checks on the quantities in results and vital signs. + +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. `buildCcda` emits three of the twelve document types and throws for the +other nine. Editing replaces or adds whole sections only: no entry-level append and no section +removal. Required-section validation under-warns for most document types, so a quiet parse is not a +conformance result. A terminology adapter is consulted at five coded slots only, and this package +ships value set identifiers, never their member codes. UCUM checking is grammatical over a curated +set of atoms. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9e8154..b55e615 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,3 +74,25 @@ jobs: # bytes differ from what this run produced, so a behaviour that moved without its # published statement moving with it reds here. - run: pnpm conformance + + # The runnable examples under examples/, checked the way a consumer meets them: each imports + # `@cosyte/ccda` 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 in the shared caller above already walks examples/. + examples: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.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 diff --git a/README.md b/README.md index 3b05264..038f877 100644 --- a/README.md +++ b/README.md @@ -53,10 +53,12 @@ silence. ## Status -`package.json` declares **`0.0.15`**, on the pre-alpha `0.0.x` version ladder. What that version -claims is narrow and worth reading literally: the behaviour documented on this page is implemented -and covered by tests, but the public API is **not settled**, and any release may change it, so pin an -exact version rather than a range. These surfaces are named rather than implied: +`package.json` declares **`0.0.15`**. From `0.1.0` the public API is settled and safe to depend on: +the behaviour documented on this page is implemented and covered by tests, the exported names, +options, return shapes and warning codes are the surface we keep stable, and below 1.0 a breaking +change bumps the minor version and is called out in the changelog with its migration. On a `0.0.x` +version, pin an exact version rather than a range. Some surfaces are still partial, and they are +named rather than implied: - **Building is partial.** `buildCcda` emits three of the twelve US Realm document types (CCD, Referral Note and inpatient Discharge Summary); the other nine throw rather than emitting @@ -90,16 +92,16 @@ substrate for C-CDA's XML. ## Usage -One example, end to end: build a complete, spec-clean CCD, read it back, and check what came out. The -`// =>` comments are the values this code really produces. A test in this repository executes this -block against the published entry point and fails if either side drifts, so a reader (or an agent) -lifting it verbatim gets code that runs. +One example, end to end: build a complete, spec-clean CCD, read it back, and print what came out. +The `// =>` comments are the values this code really produces, and the output below is what it +prints. Tests in this repository execute this block against the published entry point and fail if +either side drifts, so a reader (or an agent) lifting it verbatim gets code that runs. ```ts runnable import { buildCcda, parseCcda } from "@cosyte/ccda"; -// A complete synthetic CCD, built here so the example stands on its own. In your -// integration this is the document a sending system handed you. +// A synthetic CCD, built here so the example stands on its own. In your integration this is the +// document a sending system handed you. const xml = buildCcda({ patient: { mrn: "MRN001", given: ["Jane"], family: "Doe", gender: "F", birthTime: "19800101" }, problems: [{ problem: { code: "59621000", displayName: "Essential hypertension" } }], @@ -116,37 +118,40 @@ const xml = buildCcda({ reaction: { code: "247472004", displayName: "Hives" }, // SNOMED CT }, ], - vitalSigns: [ - { - vitals: [ - { - code: { code: "8480-6", displayName: "Systolic blood pressure" }, // LOINC - quantity: { value: 120, unit: "mm[Hg]" }, // UCUM - }, - ], - }, - ], }).toString(); const doc = parseCcda(xml); +const problem = doc.getProblems()[0]; +const allergen = doc.getAllergies()[0]?.allergies[0]?.allergen; + +console.log(doc.documentType, doc.getMrn(), doc.getPatient()?.name?.family); +console.log("problem", problem?.problems[0]?.value?.code, problem?.status); +console.log("medication", doc.getMedications()[0]?.drug?.code); +console.log("allergy", allergen?.code, allergen?.displayName); +console.log("warnings", doc.warnings.length); +// The emit half is a fixed point: re-parsing and re-serializing changes nothing. +console.log("round trip unchanged", parseCcda(doc.toString()).toString() === xml); +// The values printed above, which the test suite asserts on every run: doc.documentType; // => "ccd" -doc.getPatient()?.name?.family; // => "Doe" doc.getMrn(); // => "MRN001" -doc.getProblems()[0]?.problems[0]?.value?.code; // => "59621000" -doc.getProblems()[0]?.status; // => "active" +problem?.problems[0]?.value?.code; // => "59621000" +problem?.status; // => "active" doc.getMedications()[0]?.drug?.code; // => "314076" -doc.getAllergies()[0]?.allergies[0]?.allergen?.code; // => "7980" +allergen?.code; // => "7980" doc.warnings; // => [] +parseCcda(doc.toString()).toString() === xml; // => true +``` -// An observation value is a discriminated union; switch on `kind` to reach the -// UCUM-checked quantity, with the document's own unit intact. -const systolic = doc.getVitals()[0]?.vitals[0]?.value; -systolic?.kind; // => "physicalQuantity" -systolic?.kind === "physicalQuantity" ? systolic.quantity?.value : undefined; // => 120 +It prints: -// The emit half is a fixed point: re-parsing and re-serializing changes nothing. -parseCcda(doc.toString()).toString() === xml; // => true +```text +ccd MRN001 Doe +problem 59621000 active +medication 314076 +allergy 7980 Penicillin G +warnings 0 +round trip unchanged true ``` The parser is **lenient by default**: recoverable vendor quirks become stable-coded `CcdaWarning`s on diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..c7aff8b --- /dev/null +++ b/examples/README.md @@ -0,0 +1,28 @@ +# Examples + +Small runnable programs, one per job the package does. Each one imports `@cosyte/ccda` by its +published name, so it runs against the built package exactly as a consumer installs it, prints what +it read or built, and checks its own output: a mismatch exits non-zero. Every document in them is +synthetic, with an invented patient and fake OIDs. + +Build once, then run them all: + +```bash +pnpm install +pnpm build +pnpm examples +``` + +| File | What it shows | Run | +| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | +| [`read-a-ccd.ts`](read-a-ccd.ts) | Read a CCD: the document type, the patient and MRN, and the problems, medications and allergies with their code systems. | `pnpm tsx examples/read-a-ccd.ts` | +| [`build-and-round-trip.ts`](build-and-round-trip.ts) | Build a spec-clean CCD from typed input, read back a UCUM-checked vital sign, and see the re-serialized output match byte for byte. | `pnpm tsx examples/build-and-round-trip.ts` | +| [`lenient-and-strict.ts`](lenient-and-strict.ts) | Read a document with a missing medication route: lenient parsing keeps the data and reports a stable warning code, and strict mode refuses it. | `pnpm tsx examples/lenient-and-strict.ts` | +| [`edit-a-section.ts`](edit-a-section.ts) | Replace one section and add another with `editCcda`, getting back a CDA R2 revision of the source. | `pnpm tsx examples/edit-a-section.ts` | + +`data/ccd.ts` exports the synthetic CCD three of the examples read. It is a copy of the fixture the +quickstart parses, kept in a module because this repository never tracks an XML document as a file. + +CI runs `pnpm typecheck:examples`, `pnpm lint:examples` and `pnpm examples` after `pnpm build` on +every pull request, so an example that drifts from the package fails the build. The repository's +PHI scan walks the whole tree, `examples/` included. diff --git a/examples/build-and-round-trip.ts b/examples/build-and-round-trip.ts new file mode 100644 index 0000000..e4198da --- /dev/null +++ b/examples/build-and-round-trip.ts @@ -0,0 +1,65 @@ +/** + * Build a spec-clean CCD from typed input, read it back, and show that the emit half is a fixed + * point: re-parsing and re-serializing the output changes nothing. + * + * `buildCcda` emits the six sections a CCD requires, each with the template the specification asks + * for, so the result reads back with no warning. Every value here is synthetic. + * + * Run from the repository root after `pnpm build`: + * + * pnpm tsx examples/build-and-round-trip.ts + */ + +import assert from "node:assert/strict"; + +import { buildCcda, parseCcda, requiredSectionKeys } from "@cosyte/ccda"; + +const xml = buildCcda({ + patient: { mrn: "MRN001", given: ["Jane"], family: "Doe", gender: "F", birthTime: "19800101" }, + problems: [{ problem: { code: "59621000", displayName: "Essential hypertension" } }], + medications: [ + { + drug: { code: "314076", displayName: "Lisinopril 10 MG Oral Tablet" }, // RxNorm + dose: { value: 1, unit: "{tablet}" }, + route: { code: "C38288", displayName: "Oral" }, // NCI Thesaurus + }, + ], + allergies: [ + { + allergen: { code: "7980", displayName: "Penicillin G" }, // RxNorm + reaction: { code: "247472004", displayName: "Hives" }, // SNOMED CT + }, + ], + vitalSigns: [ + { + vitals: [ + { + code: { code: "8480-6", displayName: "Systolic blood pressure" }, // LOINC + quantity: { value: 120, unit: "mm[Hg]" }, // UCUM + }, + ], + }, + ], +}).toString(); + +const doc = parseCcda(xml); +console.log(`Built ${String(doc.documentType)}: ${String(xml.length)} characters of XML`); +console.log("Sections:", doc.sections.map((s) => s.key ?? "(unrecognized)").join(", ")); + +// An observation value is a discriminated union: switch on `kind` to reach the UCUM quantity. +const systolic = doc.getVitals()[0]?.vitals[0]?.value; +if (systolic?.kind === "physicalQuantity") { + console.log(`Systolic: ${String(systolic.quantity?.value)} ${String(systolic.quantity?.unit)}`); +} + +const fixedPoint = parseCcda(doc.toString()).toString() === xml; +console.log("Re-serialized unchanged:", fixedPoint); + +// The checks that make this file a test: `pnpm examples` fails if any of them does not hold. +assert.equal(doc.documentType, "ccd"); +assert.equal(doc.getMrn(), "MRN001"); +assert.equal(systolic?.kind, "physicalQuantity"); +assert.ok(fixedPoint); +// Every section a CCD requires is present, whatever order the builder writes them in. +assert.deepEqual(doc.sections.map((s) => s.key).sort(), [...requiredSectionKeys("ccd")].sort()); +assert.equal(doc.warnings.length, 0); diff --git a/examples/data/ccd.ts b/examples/data/ccd.ts new file mode 100644 index 0000000..123ec12 --- /dev/null +++ b/examples/data/ccd.ts @@ -0,0 +1,148 @@ +/** + * A synthetic Continuity of Care Document: an invented patient, fake OIDs and identifiers. It is a + * byte-for-byte copy of the fixture the quickstart parses (`test/__fixtures__/first-use.ts`), + * embedded in a module because this repository never tracks an XML document as a file. + */ +export const SYNTHETIC_CCD = ` + + + + + + + Synthetic Continuity of Care Document + + + + + + JaneQDoe + + + + + +
+ + + Problems + Essential hypertension + + + + + + + + + + +
+
+ + + Medications + Lisinopril 10 MG Oral Tablet + + + + + + + + + + + + + +
+
+ + + Allergies + No known allergies. + + + + + + + + + +
+
+ + + Results + Hemoglobin 13.5 g/dL + + + + + + + + + + + + +
+
+ + + + Vital Signs + Heart rate: 72 /min + + + + + + + + + + + + + + +
+
+ + + Social History + Never smoker + + + + + + + + +
+
+ + + Immunizations + Influenza, split virus, trivalent, injectable, preservative free + + + + + + + + + + + + + +
+
+
`; diff --git a/examples/edit-a-section.ts b/examples/edit-a-section.ts new file mode 100644 index 0000000..d322998 --- /dev/null +++ b/examples/edit-a-section.ts @@ -0,0 +1,77 @@ +/** + * Edit a document: replace one section and add another, and get back a CDA R2 revision of the + * source. + * + * `editCcda` rebuilds only the sections you name and carries every other section through byte for + * byte. By default the result is a new version of the source document: a new id, the same + * version-series `setId`, `versionNumber` incremented, and a `relatedDocument` of type `RPLC` + * naming the version it replaces. The input is the synthetic CCD from `examples/data/ccd.ts`. + * + * Run from the repository root after `pnpm build`: + * + * pnpm tsx examples/edit-a-section.ts + */ + +import assert from "node:assert/strict"; + +import { editCcda, parseCcda } from "@cosyte/ccda"; + +import { SYNTHETIC_CCD } from "./data/ccd.js"; + +const source = parseCcda(SYNTHETIC_CCD); + +const revised = editCcda(source, { + sections: [ + // Replace the whole Medications section. + { + kind: "medications", + mode: "replace", + content: [ + { + drug: { code: "197361", displayName: "Amlodipine 5 MG Oral Tablet" }, // RxNorm + dose: { value: 1, unit: "{tablet}" }, + route: { code: "C38288", displayName: "Oral" }, // NCI Thesaurus + }, + ], + }, + // Add a section the source did not carry. + { + kind: "familyHistory", + content: [ + { + relative: { relationship: { code: "72705000", displayName: "Mother" } }, + observations: [{ condition: { code: "73211009", displayName: "Diabetes mellitus" } }], + }, + ], + }, + ], +}); + +console.log( + `Version ${String(revised.header.versionNumber)}, replaces the source:`, + revised.header.relatedDocuments[0]?.typeCode, +); +console.log( + "Medications now:", + revised.getMedications().map((m) => m.drug?.displayName), +); +console.log( + "Problems carried through:", + revised.getProblems().flatMap((c) => c.problems.map((p) => p.value?.code)), +); +console.log("Family history entries:", revised.getFamilyHistory().length); +console.log("Warnings:", revised.warnings.length); + +// The checks that make this file a test: `pnpm examples` fails if any of them does not hold. +assert.equal(revised.header.versionNumber, 2); +assert.equal(revised.header.relatedDocuments[0]?.typeCode, "RPLC"); +assert.deepEqual( + revised.getMedications().map((m) => m.drug?.code), + ["197361"], +); +assert.deepEqual( + revised.getProblems().flatMap((c) => c.problems.map((p) => p.value?.code)), + ["59621000"], +); +assert.equal(revised.getFamilyHistory().length, 1); +assert.equal(revised.warnings.length, 0); 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/lenient-and-strict.ts b/examples/lenient-and-strict.ts new file mode 100644 index 0000000..403734f --- /dev/null +++ b/examples/lenient-and-strict.ts @@ -0,0 +1,50 @@ +/** + * Read a vendor-quirky document leniently, then refuse the same document in strict mode. + * + * The input is the synthetic CCD from `examples/data/ccd.ts` with the medication's `routeCode` + * removed, which is how some sending systems emit it. The lenient default still reads the + * medication, leaves the route absent rather than defaulting it, and reports the deviation as a + * stable warning code with its position. `{ strict: true }` turns the same deviation into a thrown + * `CcdaParseError` carrying the same code. + * + * Run from the repository root after `pnpm build`: + * + * pnpm tsx examples/lenient-and-strict.ts + */ + +import assert from "node:assert/strict"; + +import { CcdaParseError, parseCcda } from "@cosyte/ccda"; + +import { SYNTHETIC_CCD } from "./data/ccd.js"; + +// The same document as a sender that leaves out the medication route transmits it. +const withoutRoute = SYNTHETIC_CCD.replace(/\s*]*\/>/, ""); +assert.notEqual(withoutRoute, SYNTHETIC_CCD); + +const lenient = parseCcda(withoutRoute); +const medication = lenient.getMedications()[0]; +const route = medication?.route === undefined ? "absent, never defaulted" : "present"; +console.log(`Medication still read: ${String(medication?.drug?.code)}, route ${route}`); +for (const w of lenient.warnings) { + console.log(`Warning ${w.code} at line ${String(w.position?.line)}: ${w.message}`); +} + +let refusedWith = ""; +try { + parseCcda(withoutRoute, { strict: true }); +} catch (error) { + if (!(error instanceof CcdaParseError)) throw error; + refusedWith = error.code; +} +console.log(`Strict mode refused it with ${refusedWith}`); + +// The checks that make this file a test: `pnpm examples` fails if any of them does not hold. +assert.equal(medication?.drug?.code, "314076"); +assert.equal(medication?.route, undefined); +assert.deepEqual( + lenient.warnings.map((w) => w.code), + ["MISSING_ROUTE_CODE"], +); +assert.equal(refusedWith, "MISSING_ROUTE_CODE"); +assert.equal(parseCcda(SYNTHETIC_CCD).warnings.length, 0); diff --git a/examples/read-a-ccd.ts b/examples/read-a-ccd.ts new file mode 100644 index 0000000..c3764da --- /dev/null +++ b/examples/read-a-ccd.ts @@ -0,0 +1,64 @@ +/** + * Read a C-CDA a sending system handed you: the document type, the patient, and the reconciliation + * triad (problems, medications, allergies) as typed values. + * + * `examples/data/ccd.ts` holds a synthetic Continuity of Care Document (an invented patient, fake + * OIDs), copied from the repository's test fixtures. Every coded value keeps the code system it was + * sent with, and nothing absent is defaulted. + * + * Run from the repository root after `pnpm build`: + * + * pnpm tsx examples/read-a-ccd.ts + */ + +import assert from "node:assert/strict"; + +import { parseCcda } from "@cosyte/ccda"; + +import { SYNTHETIC_CCD } from "./data/ccd.js"; + +const doc = parseCcda(SYNTHETIC_CCD); +const patient = doc.getPatient(); + +console.log(`Document type: ${String(doc.documentType)}`); +console.log(`Patient: ${patient?.name?.given?.join(" ") ?? ""} ${String(patient?.name?.family)}`); +console.log(`MRN: ${String(doc.getMrn())}`); + +for (const concern of doc.getProblems()) { + for (const problem of concern.problems) { + console.log( + `Problem: ${String(problem.value?.displayName)} (${String(problem.value?.code)}), concern ${String(concern.status)}`, + ); + } +} +for (const med of doc.getMedications()) { + console.log( + `Medication: ${String(med.drug?.displayName)} (RxNorm ${String(med.drug?.code)}),`, + `${String(med.dose?.raw)} ${String(med.dose?.unit)} ${String(med.route?.displayName)}`, + ); +} +for (const concern of doc.getAllergies()) { + for (const allergy of concern.allergies) { + console.log( + allergy.noKnownAllergy === true + ? "Allergies: none known (a negated entry, not an empty list)" + : `Allergy: ${String(allergy.allergen?.displayName)}`, + ); + } +} +console.log("Warnings:", doc.warnings.length); + +// The checks that make this file a test: `pnpm examples` fails if any of them does not hold. +assert.equal(doc.documentType, "ccd"); +assert.equal(doc.getMrn(), "MRN-00042"); +assert.equal(patient?.name?.family, "Doe"); +assert.deepEqual( + doc.getProblems().flatMap((c) => c.problems.map((p) => p.value?.code)), + ["59621000"], +); +assert.deepEqual( + doc.getMedications().map((m) => [m.drug?.code, m.route?.code]), + [["314076", "C38288"]], +); +assert.equal(doc.getAllergies()[0]?.allergies[0]?.noKnownAllergy, true); +assert.equal(doc.warnings.length, 0); 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/package.json b/package.json index 2b94cd7..6f43ca7 100644 --- a/package.json +++ b/package.json @@ -4,13 +4,23 @@ "description": "C-CDA parser, serializer, and builder for Node.js and TypeScript: lenient on parse, spec-clean on emit.", "keywords": [ "ccda", - "parser", + "c-cda", + "cda", + "hl7-cda", + "ccd", + "clinical-document", + "continuity-of-care-document", + "discharge-summary", + "referral-note", + "ehr", "healthcare", "interoperability", - "typescript", - "serializer" + "parser", + "serializer", + "builder", + "typescript" ], - "homepage": "https://github.com/cosyte/ccda#readme", + "homepage": "https://docs.cosyte.com/ccda", "bugs": { "url": "https://github.com/cosyte/ccda/issues" }, @@ -73,16 +83,19 @@ "phi-scan": "tsx scripts/phi-scan.ts", "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}\" \"test/**/*.ts\" \"scripts/**/*.ts\" \"docs-content/**/*.md\" \"*.{json,md,yml}\"", - "format:check": "prettier --check \"src/**/*.{ts,md}\" \"test/**/*.ts\" \"scripts/**/*.ts\" \"docs-content/**/*.md\" \"*.{json,md,yml}\"", + "format": "prettier --write \"src/**/*.{ts,md}\" \"test/**/*.ts\" \"scripts/**/*.ts\" \"docs-content/**/*.md\" \"examples/**/*.{ts,js,json,md}\" \"*.{json,md,yml}\"", + "format:check": "prettier --check \"src/**/*.{ts,md}\" \"test/**/*.ts\" \"scripts/**/*.ts\" \"docs-content/**/*.md\" \"examples/**/*.{ts,js,json,md}\" \"*.{json,md,yml}\"", "conformance": "tsx --max-old-space-size=4096 scripts/conformance/run.ts", "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", "clean": "rm -rf dist coverage", "pack:docs": "bash scripts/build-docs-artifacts.sh", "attw": "node scripts/attw.mjs", - "version": "changeset version && node scripts/sync-version.mjs && prettier --write package.json src/index.ts", + "version": "changeset version && node scripts/sync-version.mjs && tsx scripts/sync-readme-version.ts && prettier --write package.json src/index.ts README.md", "release": "changeset publish", "prepublishOnly": "pnpm clean && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm attw", "prepare": "command -v simple-git-hooks >/dev/null 2>&1 && simple-git-hooks || true" diff --git a/scripts/run-examples.ts b/scripts/run-examples.ts new file mode 100644 index 0000000..2622f58 --- /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/ccda`, 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/scripts/sync-readme-version.ts b/scripts/sync-readme-version.ts new file mode 100644 index 0000000..a77073d --- /dev/null +++ b/scripts/sync-readme-version.ts @@ -0,0 +1,83 @@ +/** + * Sync the version README.md's `## Status` section declares with `package.json`'s `version`. + * + * Why this exists: `test/docs-content.test.ts` requires `## Status` to state the version + * `package.json` declares, and the release pipeline runs `pnpm test` on the "Version Packages" + * commit before it publishes. Changesets rewrites `package.json` only, so without this step that + * commit fails its own test and the publish stops. The `version` script runs this after + * `changeset version` and `sync-version.mjs`, so the bump and the README line land in the same + * commit, the same way the `VERSION` constant does. + * + * It rewrites exactly one thing: the bold, backticked version (`**`X.Y.Z`**`) in the `## Status` + * section. It is idempotent, and it refuses (exit 1) when the section or the marker is missing or + * appears more than once, so a reworded status cannot silently stop being synced. + * + * tsx scripts/sync-readme-version.ts + */ + +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +/** The status marker: a bold, backticked semantic version. */ +const MARKER = /\*\*`(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)`\*\*/g; + +/** + * The README with the `## Status` marker naming `version`, or an error message. + * + * @param readme - The whole README.md text. + * @param version - `package.json` `version`. + * @returns `{ text }` with the synced README, or `{ error }` naming why it could not be synced. + */ +export function syncReadmeVersion( + readme: string, + version: string, +): { readonly text: string } | { readonly error: string } { + if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) { + return { error: `package.json version ${JSON.stringify(version)} is not a semantic version` }; + } + const start = readme.indexOf("\n## Status\n"); + if (start === -1) return { error: "README.md has no `## Status` section" }; + const next = readme.indexOf("\n## ", start + 1); + const end = next === -1 ? readme.length : next; + const section = readme.slice(start, end); + const markers = [...section.matchAll(MARKER)]; + if (markers.length !== 1) { + return { + error: + `README.md \`## Status\` carries ${String(markers.length)} bold backticked versions; ` + + "expected exactly one, so there is no single line to sync", + }; + } + // A replacer function, so a version string is inserted literally and never read as a pattern. + const synced = section.replace(MARKER, () => `**\`${version}\`**`); + return { text: readme.slice(0, start) + synced + readme.slice(end) }; +} + +function main(): void { + const readmePath = join(REPO_ROOT, "README.md"); + const manifest: unknown = JSON.parse(readFileSync(join(REPO_ROOT, "package.json"), "utf8")); + const version = + typeof manifest === "object" && manifest !== null && "version" in manifest + ? manifest.version + : undefined; + const readme = readFileSync(readmePath, "utf8"); + const result = syncReadmeVersion(readme, typeof version === "string" ? version : ""); + if ("error" in result) { + console.error(`sync-readme-version: ${result.error}`); + process.exit(1); + } + if (result.text === readme) { + console.log(`sync-readme-version: README.md already declares ${String(version)}`); + return; + } + writeFileSync(readmePath, result.text); + console.log(`sync-readme-version: README.md -> ${String(version)}`); +} + +// Run only as a CLI, never on import (the test imports the function above). +if (import.meta.url === `file://${process.argv[1] ?? ""}`) { + main(); +} diff --git a/test/docs-content.test.ts b/test/docs-content.test.ts index 8194623..245ea63 100644 --- a/test/docs-content.test.ts +++ b/test/docs-content.test.ts @@ -1,6 +1,7 @@ -import { execFileSync } from "node:child_process"; -import { readFileSync, readdirSync, rmSync } from "node:fs"; +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { join, relative } from "node:path"; +import { pathToFileURL } from "node:url"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; @@ -255,3 +256,61 @@ describe("a runnable tag is an execution that happens", () => { expect(unexecutedRunnableFences("planted.md", planted)).toEqual(["planted.md:3"]); }); }); + +/** + * The README's `## Usage` block PRINTS its key results, and the page shows what it prints in a + * `text` block right after it. That output block is a claim too, so it is checked the way a reader + * would check it: the block is run as a program, as-is apart from the one specifier rewritten to + * the built entry point, and its stdout must be the output block byte for byte. A negative control + * changes the input MRN and the printed output has to change with it, so a runner that executed + * nothing could not pass. + */ +describe("the README usage example prints exactly what the page says it prints", () => { + const TSX = join(root, "node_modules", ".bin", "tsx"); + const SPECIFIER = '"@cosyte/ccda"'; + const USAGE_TMP = join(root, ".cosyte-usage-stdout"); + + afterAll(() => { + rmSync(USAGE_TMP, { recursive: true, force: true }); + }); + + function runAsProgram( + code: string, + name: string, + ): { status: number | null; stdout: string; stderr: string } { + expect(code.split(SPECIFIER).length - 1, "the block imports @cosyte/ccda exactly once").toBe(1); + mkdirSync(USAGE_TMP, { recursive: true }); + const file = join(USAGE_TMP, name); + writeFileSync(file, code.replace(SPECIFIER, JSON.stringify(pathToFileURL(ENTRY).href)), "utf8"); + const run = spawnSync(TSX, [file], { + cwd: root, + encoding: "utf8", + shell: false, + timeout: 60_000, + }); + return { status: run.status, stdout: run.stdout, stderr: run.stderr }; + } + + test("is followed by the text block holding its output", () => { + const blocks = fences(section(README_TEXT, "## Usage")); + expect(blocks.map((block) => block.lang).slice(0, 2)).toEqual(["ts", "text"]); + }); + + test("runs as a program and prints the text block byte for byte", () => { + const [code, shown] = fences(section(README_TEXT, "## Usage")); + const run = runAsProgram(code?.body ?? "", "usage-stdout.mts"); + expect(run.stderr).toBe(""); + expect(run.status).toBe(0); + expect(run.stdout).toBe(`${shown?.body ?? ""}\n`); + }, 60_000); + + test("CONTROL: a changed input value changes what it prints", () => { + const [code, shown] = fences(section(README_TEXT, "## Usage")); + const body = code?.body ?? ""; + expect(body.split('mrn: "MRN001"').length - 1).toBe(1); + const run = runAsProgram(body.replace('mrn: "MRN001"', 'mrn: "MRN002"'), "usage-control.mts"); + expect(run.status).toBe(0); + expect(run.stdout).not.toBe(`${shown?.body ?? ""}\n`); + expect(run.stdout).toContain("ccd MRN002 Doe"); + }, 60_000); +}); diff --git a/test/scripts/sync-readme-version.test.ts b/test/scripts/sync-readme-version.test.ts new file mode 100644 index 0000000..21865f9 --- /dev/null +++ b/test/scripts/sync-readme-version.test.ts @@ -0,0 +1,78 @@ +/** + * `scripts/sync-readme-version.ts` keeps the version README.md's `## Status` declares equal to + * `package.json` across a Changesets version commit, which is what lets that commit pass the README + * status check in `test/docs-content.test.ts` before the release publishes it. Every case is driven + * on README text built here, except the last, which reads this repository's own README. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { syncReadmeVersion } from "../../scripts/sync-readme-version.js"; + +const README = [ + "# @cosyte/ccda", + "", + "## Status", + "", + "`package.json` declares **`0.0.15`**. From `0.1.0` the public API is settled.", + "", + "## Install", + "", + "**`9.9.9`** outside the section is never touched.", + "", +].join("\n"); + +describe("syncing the README status version", () => { + it("rewrites the one marker in ## Status to the manifest version", () => { + const result = syncReadmeVersion(README, "0.1.0"); + expect("text" in result && result.text).toBe(README.replace("**`0.0.15`**", "**`0.1.0`**")); + }); + + it("leaves everything outside the marker byte for byte", () => { + const result = syncReadmeVersion(README, "0.1.0"); + const text = "text" in result ? result.text : ""; + expect(text).toContain("From `0.1.0` the public API is settled."); + expect(text).toContain("**`9.9.9`** outside the section is never touched."); + }); + + it("is idempotent", () => { + expect(syncReadmeVersion(README, "0.0.15")).toEqual({ text: README }); + }); + + it("refuses a README with no ## Status section", () => { + expect(syncReadmeVersion("# x\n\n## Install\n", "0.1.0")).toHaveProperty("error"); + }); + + it("refuses a status with no marker, so a reworded line cannot stop being synced in silence", () => { + const reworded = README.replace("**`0.0.15`**", "`0.0.15`"); + expect(syncReadmeVersion(reworded, "0.1.0")).toHaveProperty("error"); + }); + + it("refuses a status with two markers", () => { + const doubled = README.replace( + "declares **`0.0.15`**", + "declares **`0.0.15`**, then **`0.0.14`**", + ); + expect(syncReadmeVersion(doubled, "0.1.0")).toHaveProperty("error"); + }); + + it("refuses a manifest version that is not a semantic version", () => { + expect(syncReadmeVersion(README, "")).toHaveProperty("error"); + expect(syncReadmeVersion(README, "$&")).toHaveProperty("error"); + }); + + it("finds exactly one marker in this repository's README, already in sync", () => { + const readme = readFileSync(join(import.meta.dirname, "..", "..", "README.md"), "utf8"); + const pkg: unknown = JSON.parse( + readFileSync(join(import.meta.dirname, "..", "..", "package.json"), "utf8"), + ); + const version = + typeof pkg === "object" && pkg !== null && "version" in pkg && typeof pkg.version === "string" + ? pkg.version + : ""; + expect(syncReadmeVersion(readme, version)).toEqual({ text: readme }); + }); +});