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 });
+ });
+});