diff --git a/.changeset/first-minor-release.md b/.changeset/first-minor-release.md new file mode 100644 index 0000000..eebd9fc --- /dev/null +++ b/.changeset/first-minor-release.md @@ -0,0 +1,32 @@ +--- +"@cosyte/astm": 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 ASTM E1394 (CLSI LIS02-A2) records: `H`, `P`, `O`, `R`, `C`, `Q`, `M`, `S` and `L`, with + the delimiters each header declares, escape sequences decoded before a value is split, values and + units surfaced exactly as sent, abnormal flags and result status interpreted without guessing, and + every tolerated deviation reported as a stable warning code instead of a thrown error. +- Framing and transport per ASTM E1381 (CLSI LIS01-A2): checksum-verified frame decoding and + composing, the 240-byte split, framed versus raw detection, and a pure receiver state machine for + the `ENQ`/`ACK`/`NAK`/`EOT` exchange that never acknowledges a frame it could not verify. +- Emitting spec-clean records and frames (`buildAstmMessage`, `serializeAstmRecords`, + `serializeFramedAstm`), which write only the values you supply and never a default clinical value. +- Mapping analyzer local codes to LOINC through a LIVD catalog you supply, and date conversions + (`toObject`, `toISO`, `toDate`) that never assume a timezone. + +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. Upgrading from 0.0.x is itself breaking in the LIVD mapping and in what +`primaryCode()` returns: read the entries below before you upgrade. + +What is not covered yet. No named per-vendor profile ships: the built-in set is `default` and +`referenceCorpus`. No LOINC, SNOMED or LIVD data is bundled, and no LOINC is validated. The +transfer protocol's timers (contention, timeouts, retransmit) are yours to drive. The wire is read as +Latin-1. Three behaviors (forward scoping of redeclared delimiters, the Latin-1 encoding and the +reserved-byte set) are reasoned from this package's reader rather than cited to the standard's +purchase-gated text. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f96a296..ba14395 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,3 +25,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/astm` 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 walk (src/, test/, scripts/, docs-content/) + # does not reach examples/, so the example files are named to it here. + examples: + runs-on: ubuntu-latest + steps: + # Actions are pinned to a commit rather than to a tag that can move. + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + 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/CHANGELOG.md b/CHANGELOG.md index e5e586e..f4c76fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,25 +9,39 @@ this file is maintained by hand (Changesets handles the version bump and publish ## [Unreleased] +**What 0.1 means for you.** This release is the first whose public API we treat as settled: the +exported names, options, return shapes and warning codes are the surface we keep stable. What it +covers is reading ASTM E1394 (CLSI LIS02-A2) records with the delimiters each header declares, +checksum-verified E1381 (CLSI LIS01-A2) framing with a pure receiver state machine for the +`ENQ`/`ACK`/`NAK`/`EOT` exchange, spec-clean emit of records and frames that writes only the values +you supply, LOINC mapping through a LIVD catalog you supply, and date conversions that never assume +a timezone. While the package is below 1.0, a breaking change bumps the minor version and is called +out here with its migration; a fix that changes no public value ships as a patch. Upgrading from +0.0.x is itself breaking in the LIVD mapping and in what `primaryCode()` returns, as the entries +below set out. Not covered yet: no named per-vendor profile ships (the built-in set is `default` and +`referenceCorpus`), no LOINC, SNOMED or LIVD data is bundled and no LOINC is validated, the transfer +protocol's timers are yours to drive, and three behaviors (forward scoping of redeclared delimiters, +the Latin-1 wire encoding and the reserved-byte set) are reasoned from this package's reader rather +than cited to the standard's purchase-gated text. + **The pending changeset set is classified `minor`, not `patch`, so the next release is a minor -release.** Seven changesets are pending and five of them arrived carrying `patch`. Three of those +release.** Eight changesets are pending and five of them arrived carrying `patch`. Three of those five were reclassified against their own text: one removes public values and changes what an exported function returns, and two add public values. None of the three is a fix that adds nothing and removes nothing, which is the only thing a `patch` may claim, and only those three bump lines moved. The other two keep `patch`: one changes repository tooling and install configuration, the other changes what a release carries beside the package, and neither touches a published value or -emits a byte differently, so `patch` is what each one's own text supports. The last two arrived +emits a byte differently, so `patch` is what each one's own text supports. Two more arrived carrying `minor` already, each written against this same rule, so both are applied as written rather -than reclassified. A set carrying a `patch` beside a `minor` still resolves to the -minor channel. +than reclassified. The eighth is the release statement summarized above, which carries `minor` as the +class of the release it describes and changes no public value. A set carrying a `patch` beside a +`minor` still resolves to the minor channel. The breaking change below therefore ships in the minor channel of the pre-1.0 ladder, which is where a break belongs before `1.0.0`, rather than as a patch. Every break a consumer of the last published version would see is enumerated with its migration in `documentation/release-readiness.md`, together -with the public export surface this release would certify as settled. **Each break awaits a decision -before any release, and nothing publishes yet**: the release environment gate stays as it is, and no -version number is written here, because Changesets owns the bump and `scripts/sync-version.mjs` -mirrors it. +with the public export surface this release certifies as settled. No version number is written in +this section's entries, because Changesets owns the bump and `scripts/sync-version.mjs` mirrors it. The entries the pending set carries are the date conversion surface entry, the LIVD units entry, the LIVD publication metadata entry and the vocabulary-attribution entry under Added, and the LIVD diff --git a/documentation/release-readiness.md b/documentation/release-readiness.md index 5690b3e..36250a4 100644 --- a/documentation/release-readiness.md +++ b/documentation/release-readiness.md @@ -26,14 +26,15 @@ The published version is the `version` field of `package.json`, `0.0.22`, on the ladder. The pending set below is every file matching `.changeset/*.md` other than `README.md` (`config.json` is the tool's configuration and carries no bump). -Five of the seven pending changesets arrived classified `patch`, and two arrived carrying `minor`. +Five of the eight pending changesets arrived classified `patch`, and three arrived carrying `minor`. Three of the five are reclassified to `minor` here, each for a reason taken from its own text; their bump lines are the only lines that were rewritten, and no changeset's prose was edited. The other two stay `patch`, because neither one's text removes a public value, adds one, or changes any exported behaviour: reclassification runs in both directions or it is not a reading, and a tooling change written up as a feature would overstate the release exactly as a removal written up as a fix -understates one. The two that arrived carrying `minor` were each written against this same rule, so -both are applied as written rather than reclassified. +understates one. Two that arrived carrying `minor` were each written against this same rule, so +both are applied as written rather than reclassified. The third, the release statement, is read last +below. @@ -46,6 +47,7 @@ both are applied as written rather than reclassified. | `publish-a-dependency-inventory-with-each-release.md` | `patch` | `patch` | | `say-which-vocabulary-a-letter-was-graded-against.md` | `patch` | `minor` | | `shared-date-conversion-surface.md` | `minor` | `minor` | +| `first-minor-release.md` | `minor` | `minor` | @@ -193,6 +195,16 @@ is not a break either. A feature that adds public values is `minor` by the same rule that keeps a real fix at `patch`, so this one carries `minor` as written and needs no reclassification. +### `first-minor-release.md`: `minor` + +This is the release statement rather than a change to the surface: its own text adds no public +value, removes none, and changes no exported behaviour. It tells a consumer what the resolved version +means: the surface section 3 certifies, the promise that below 1.0 a break bumps the minor and is +called out with its migration, the breaks an upgrade from `0.0.x` carries (section 4), and what is +not covered yet. It carries `minor` as the class of the release it describes. Applying it moves +nothing, because the four changesets above that add or remove public values already resolve the set +to `0.1.0`. + ## 2. Why no changeset in this set is classified `major` No changeset in the set is `major`, and this is a decision rather than an omission. @@ -641,9 +653,9 @@ The certification is scoped, deliberately, and the scope is the whole of its hon ## 4. Break candidates, awaiting the operator Every entry below is a change a consumer of `0.0.22` will see. **None of them is decided here.** -The operator decides, per repo, before any release; this file surfaces them so that decision has -something to read. Nothing in this list may be read as approval, and nothing publishes until -section 5's precondition is met. +They were held for the operator, and this file surfaces them so that decision had something to +read. Nothing in this list is itself an approval: the operator's direction to publish `0.1.x`, which +ships them, is recorded in section 6. Entries 1 to 4 are the public values the LIVD changeset removes or redefines, and `primaryCode()` is the one that produces **no compile error at all**. @@ -855,22 +867,25 @@ modules behind it. -## 6. Publication is blocked, and the bump is prepared rather than published - -**Nothing here publishes anything, and the bump prepared on this branch is not a release.** - -- **The precondition that is not met.** Publication is blocked until the release-frequency policy - work lands (`S0161-release-frequency-policy` in the meta-repo). Until it does, nothing publishes, - by the operator's own decision of 2026-08-28, which also declined the offered route of - hand-approving the release environment to relieve deadline pressure. -- **What merging this branch does do.** `.github/workflows/release.yml` fires on a push to `main` - and calls the shared release pipeline, which parks on the `release` environment gate. That is the - current state of every merge into this repo and this work does not change it. This work must not - approve, drain, retrigger or otherwise relieve that gate, and does not. -- **So the honest reading of this branch** is: the pending set now resolves to `0.1.0` instead of - `0.0.23`, the surface that number would certify is on record, and the breaks a consumer would see - are enumerated and waiting on a decision. The version in `package.json` is still `0.0.22` and will - stay `0.0.22` until Changesets writes the next one. +## 6. Publication + +**Nothing in this file publishes anything, and the bump prepared here is not a release until the +release pipeline cuts it.** + +- **The earlier block, and the direction that replaced it.** Publication was blocked by the + operator's decision of 2026-08-28 until the release-frequency policy work landed + (`S0161-release-frequency-policy` in the meta-repo). On 2026-09-25 the operator directed that + every package be put on `0.1.x` and published, in these words: "I need every single package on + v0.1.x and published." For this package the only `0.1.x` is the pending set above, so the breaks + in section 4 ship in `0.1.0` as enumerated there, each with its migration in `CHANGELOG.md`. +- **What merging a change here does.** `.github/workflows/release.yml` fires on a push to `main` + and calls the shared release pipeline, which opens or updates the "Version Packages" pull request + and parks the publish on the `release` environment gate. Merging that pull request and approving + that gate belong to whoever runs the release, never to a change that edits this file. +- **So the honest reading** is: the pending set resolves to `0.1.0` instead of `0.0.23`, the + surface that number certifies is on record, and the breaks a consumer will see are enumerated. The + version in `package.json` is still `0.0.22` and stays `0.0.22` until Changesets writes the next + one. ### The follow-on this bump creates, recorded so it is not lost diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..ce32732 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,26 @@ +# Examples + +Small runnable programs, one per job the package does. Each one imports `@cosyte/astm` by its +published name, so it runs against the built package exactly as a consumer installs it, prints what +it read, and checks its own output: a mismatch exits non-zero. Every record in them is synthetic. + +Build once, then run them all: + +```bash +pnpm install +pnpm build +pnpm examples +``` + +| File | What it shows | Run | +| ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| [`read-results.ts`](read-results.ts) | Parse a de-framed record stream and read each result's value, units, flag and status, plus the two patient IDs kept distinct. | `pnpm tsx examples/read-results.ts` | +| [`build-and-frame.ts`](build-and-frame.ts) | Build spec-clean records from typed input, frame them with checksums, read them back, and see a corrupted frame refused. | `pnpm tsx examples/build-and-frame.ts` | +| [`receive-over-ltp.ts`](receive-over-ltp.ts) | Drive the receiver state machine: `ACK` good frames, `NAK` a damaged one, accept the retransmission, deliver the records. | `pnpm tsx examples/receive-over-ltp.ts` | +| [`map-local-codes-to-loinc.ts`](map-local-codes-to-loinc.ts) | Map analyzer local codes to LOINC with your own LIVD catalog: one candidate, two candidates settled by units, and an unmapped code. | `pnpm tsx examples/map-local-codes-to-loinc.ts` | + +`data/result-stream.astm` is the input `read-results.ts` reads: one synthetic patient, one order and +two results, copied from the repository's test fixtures. + +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. diff --git a/examples/build-and-frame.ts b/examples/build-and-frame.ts new file mode 100644 index 0000000..cf8da9e --- /dev/null +++ b/examples/build-and-frame.ts @@ -0,0 +1,96 @@ +/** + * Build spec-clean records, frame them for the wire, and read them back. + * + * `buildAstmMessage` emits only the values you supply: an omitted result status stays empty and + * reads back as `unspecified`, never as a final result. `serializeFramedAstm` wraps each record in + * a numbered frame with a computed checksum, and `parseFramedAstm` decodes the frames and parses + * the records they carry. A frame whose checksum fails is reported and its record never reaches + * the parser. Every value here is synthetic. + * + * Run from the repository root after `pnpm build`: + * + * pnpm tsx examples/build-and-frame.ts + */ + +import assert from "node:assert/strict"; + +import { + buildAstmMessage, + parseAstmRecords, + parseFramedAstm, + results, + serializeFramedAstm, +} from "@cosyte/astm"; + +const text = buildAstmMessage({ + header: { fields: ["", "", "analyzer^1"] }, + records: [ + { type: "P", practiceAssignedId: "PRAC-0001", laboratoryAssignedId: "LAB-0009" }, + { type: "O", specimenId: "SPEC-7", universalTestId: ["", "", "", "687"] }, + { + type: "R", + universalTestId: ["", "", "", "687"], + value: "28.6", + units: "U/L", + referenceRange: "10-40", + abnormalFlags: "N", + resultStatus: "F", + }, + // No status supplied, so none is written. + { type: "R", universalTestId: ["", "", "", "688"], value: "5.1", units: "mmol/L" }, + ], + terminationCode: "N", +}); + +console.log("Records as built (one per line):"); +console.log(text.trimEnd().split("\r").join("\n")); + +// Frame the records: STX, frame number, text, ETX, checksum, CR LF. +const framed = serializeFramedAstm(parseAstmRecords(text)); +const decoded = parseFramedAstm(framed); +console.log("Frames:", decoded.frames.length); +console.log( + "Every checksum valid:", + decoded.frames.every((f) => f.checksum.valid), +); +console.log( + "Results read back:", + results(decoded.message).map( + (r) => `${String(r.value)} ${String(r.units)} (${r.status.meaning})`, + ), +); + +// Corrupt one byte of the first result's value in transit: that frame is refused, not trusted. +const damaged = framed.slice(); +damaged[Buffer.from(damaged).indexOf("28.6")] = "3".charCodeAt(0); +const fromDamaged = parseFramedAstm(damaged); +console.log( + "After corrupting one byte:", + fromDamaged.frameWarnings.map((w) => w.code), + "results left:", + results(fromDamaged.message).map((r) => r.value), +); + +// The checks that make this file a test: `pnpm examples` fails if any of them does not hold. +assert.equal( + text, + "H|\\^&|||analyzer^1\rP|1|PRAC-0001|LAB-0009\rO|1|SPEC-7||^^^687\r" + + "R|1|^^^687|28.6|U/L|10-40|N||F\rR|2|^^^688|5.1|mmol/L\rL|1|N\r", +); +assert.equal(decoded.frames.length, 6); +assert.ok(decoded.frames.every((f) => f.checksum.valid && f.trusted)); +assert.deepEqual( + results(decoded.message).map((r) => [r.value, r.status.meaning]), + [ + ["28.6", "final"], + ["5.1", "unspecified"], + ], +); +assert.deepEqual( + fromDamaged.frameWarnings.map((w) => w.code), + ["ASTM_FRAME_BAD_CHECKSUM"], +); +assert.deepEqual( + results(fromDamaged.message).map((r) => r.value), + ["5.1"], +); diff --git a/examples/data/result-stream.astm b/examples/data/result-stream.astm new file mode 100644 index 0000000..54fdd6d --- /dev/null +++ b/examples/data/result-stream.astm @@ -0,0 +1 @@ +H|\^&|||analyzer^cobas^1|||||host||P|1|20240315093000 P|1|PRAC-0001|LAB-0009||DOE^JANE^Q||20200101|F O|1|ACC-42|SPEC-7|^^^687\^^^688|R R|1|^^^687|28.6|U/L|10-40|N||F||tech01|20240315091500|20240315093000|COBAS-01 R|2|^^^688|5.1|mmol/L|3.5-5.1|H||F||tech01|20240315091500|20240315093000|COBAS-01 L|1|N \ No newline at end of file diff --git a/examples/eslint.config.js b/examples/eslint.config.js new file mode 100644 index 0000000..01a28b6 --- /dev/null +++ b/examples/eslint.config.js @@ -0,0 +1,5 @@ +import cosyte from "@cosyte/eslint-config"; + +// The examples are programs that print, not library code with a public API, so they are linted as +// an application: every type-safety rule applies, and `no-console` and the JSDoc gate do not. +export default cosyte(import.meta.dirname, { files: ["*.ts"], library: false }); diff --git a/examples/map-local-codes-to-loinc.ts b/examples/map-local-codes-to-loinc.ts new file mode 100644 index 0000000..e7e78b2 --- /dev/null +++ b/examples/map-local-codes-to-loinc.ts @@ -0,0 +1,74 @@ +/** + * Map an analyzer's local test codes to LOINC with your own LIVD catalog. + * + * `@cosyte/astm` bundles no LOINC data and never guesses a LOINC. You supply the catalog (here, three + * synthetic rows in the LIVD shape), and `applyLivd` annotates each order and result record. A code + * with one candidate is `mapped`; a code with several candidates is settled only by the units the + * record reported, compared verbatim; a code the catalog does not hold is `unmapped` and warned + * about. The raw record is never changed. + * + * Run from the repository root after `pnpm build`: + * + * pnpm tsx examples/map-local-codes-to-loinc.ts + */ + +import assert from "node:assert/strict"; + +import { applyLivd, defineLivdCatalog, parseAstmRecords } from "@cosyte/astm"; + +const catalog = defineLivdCatalog( + [ + { vendorCode: "687", loinc: "1920-8", loincLongName: "AST", representativeUnit: "U/L" }, + // One vendor glucose code, two LOINCs: the reported units decide between them. + { + vendorCode: "GLU", + loinc: "2345-7", + loincLongName: "Glucose (mass)", + representativeUnit: "mg/dL", + }, + { + vendorCode: "GLU", + loinc: "14749-6", + loincLongName: "Glucose (moles)", + representativeUnit: "mmol/L", + }, + ], + { publisher: "Example Diagnostics", loincVersion: "2.78" }, +); + +const msg = parseAstmRecords( + "H|\\^&\r" + + "R|1|^^^687|28.6|U/L|10-40|N||F\r" + + "R|2|^^^GLU|5.1|mmol/L|3.9-5.5|N||F\r" + + "R|3|^^^999|12|s||N||F\r" + + "L|1|N\r", +); + +const { annotations, warnings } = applyLivd(msg, catalog); +for (const a of annotations) { + const loinc = a.mapping.status === "mapped" ? a.mapping.loinc : "(none)"; + console.log(`Local code ${String(a.reportedCode)}: ${a.mapping.status}, LOINC ${loinc}`); +} +console.log( + "Warnings:", + warnings.map((w) => w.code), +); + +// The checks that make this file a test: `pnpm examples` fails if any of them does not hold. +assert.deepEqual( + annotations.map((a) => [ + a.reportedCode, + a.mapping.status, + a.mapping.status === "mapped" ? a.mapping.loinc : undefined, + ]), + [ + ["687", "mapped", "1920-8"], + ["GLU", "mapped", "14749-6"], + ["999", "unmapped", undefined], + ], +); +assert.ok(annotations.every((a) => a.catalogLoincVersion === "2.78")); +assert.deepEqual( + warnings.map((w) => w.code), + ["ASTM_LIVD_UNMAPPED_CODE"], +); diff --git a/examples/read-results.ts b/examples/read-results.ts new file mode 100644 index 0000000..996c0b1 --- /dev/null +++ b/examples/read-results.ts @@ -0,0 +1,47 @@ +/** + * Read results off a de-framed ASTM record stream. + * + * `examples/data/result-stream.astm` is synthetic: one patient, one order and two results, the + * records separated by CR as they arrive off an analyzer. The example parses it, prints each result + * with its units, flag and status, and checks the values it printed. + * + * Run from the repository root after `pnpm build`: + * + * pnpm tsx examples/read-results.ts + */ + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +import { parseAstmRecords, patient, results } from "@cosyte/astm"; + +const stream = readFileSync(new URL("data/result-stream.astm", import.meta.url), "utf8"); +const msg = parseAstmRecords(stream); + +// The practice- and laboratory-assigned IDs stay distinct: collapsing them misfiles results. +const who = patient(msg); +console.log("Practice-assigned patient ID:", who?.practiceAssignedId); +console.log("Laboratory-assigned patient ID:", who?.laboratoryAssignedId); + +// Values and units come back as sent: never converted, never defaulted. +for (const r of results(msg)) { + console.log( + `Test ${String(r.universalTestId?.localCode)}: ${String(r.value)} ${String(r.units)}`, + `(range ${String(r.referenceRange)}), flag ${String(r.flag?.meaning)},`, + `status ${r.status.meaning}, active final: ${String(r.status.isActiveFinal)}`, + ); +} +console.log("Warnings:", msg.warnings.length); + +// The checks that make this file a test: `pnpm examples` fails if any of them does not hold. +assert.equal(who?.practiceAssignedId, "PRAC-0001"); +assert.equal(who?.laboratoryAssignedId, "LAB-0009"); +assert.deepEqual( + results(msg).map((r) => [r.universalTestId?.localCode, r.value, r.units, r.flag?.meaning]), + [ + ["687", "28.6", "U/L", "normal"], + ["688", "5.1", "mmol/L", "above-normal"], + ], +); +assert.ok(results(msg).every((r) => r.status.isActiveFinal)); +assert.equal(msg.warnings.length, 0); diff --git a/examples/receive-over-ltp.ts b/examples/receive-over-ltp.ts new file mode 100644 index 0000000..5d9a612 --- /dev/null +++ b/examples/receive-over-ltp.ts @@ -0,0 +1,80 @@ +/** + * Drive the receiver side of the ASTM transfer protocol with the pure `ltpReduce` state machine. + * + * The reducer owns no socket and no clock: you feed it what arrived (`enq`, a decoded frame, `eot`) + * and it returns the state to keep and the actions to take (`sendAck`, `sendNak`, + * `deliverRecord`). A frame the codec did not vouch for is answered with a NAK, never an ACK, and + * its bytes are never appended to a record, so the sender retransmits. Here the result frame arrives + * damaged once and is then resent intact. Every value is synthetic. + * + * Run from the repository root after `pnpm build`: + * + * pnpm tsx examples/receive-over-ltp.ts + */ + +import assert from "node:assert/strict"; + +import { + composeAstmFrames, + decodeAstmFrames, + detectFraming, + ltpInitialState, + ltpReduce, + parseAstmRecords, + results, + type AstmFrame, + type LtpEvent, +} from "@cosyte/astm"; + +// What the analyzer sends: three records, one frame each. +const wire = composeAstmFrames([ + "H|\\^&|||analyzer^1\r", + "R|1|^^^687|28.6|U/L|10-40|N||F\r", + "L|1|N\r", +]); +console.log("Framing detected:", detectFraming(wire).framing); + +const good = decodeAstmFrames(wire).frames; +const [header, result, terminator] = good; +assert.ok(header !== undefined && result !== undefined && terminator !== undefined); + +// The result frame as it arrives the first time, with one character damaged on the line. +const damagedBytes = wire.slice(); +damagedBytes[Buffer.from(damagedBytes).indexOf("28.6")] = "3".charCodeAt(0); +const damaged: AstmFrame | undefined = decodeAstmFrames(damagedBytes).frames[1]; +assert.ok(damaged !== undefined); + +const events: LtpEvent[] = [ + { type: "enq" }, + { type: "frame", frame: header }, + { type: "frame", frame: damaged }, + { type: "frame", frame: result }, // the sender's retransmission after our NAK + { type: "frame", frame: terminator }, + { type: "eot" }, +]; + +let state = ltpInitialState(); +const replies: string[] = []; +const delivered: Uint8Array[] = []; +for (const event of events) { + const step = ltpReduce(state, event); + state = step.state; + for (const action of step.actions) { + if (action.type === "deliverRecord") delivered.push(action.record); + else replies.push(action.type); + } + const shown = step.actions.map((a) => a.type).join(", ") || "(nothing)"; + const warned = step.warnings.map((w) => ` [${w.code}]`).join(""); + console.log(`${event.type.padEnd(5)} -> ${shown}${warned}`); +} + +const msg = parseAstmRecords(Buffer.concat(delivered)); +console.log("Delivered records:", delivered.length); +console.log("Result:", results(msg)[0]?.value, results(msg)[0]?.units); + +// The checks that make this file a test: `pnpm examples` fails if any of them does not hold. +assert.equal(detectFraming(wire).framing, "framed"); +assert.deepEqual(replies, ["sendAck", "sendAck", "sendNak", "sendAck", "sendAck"]); +assert.equal(delivered.length, 3); +assert.equal(results(msg)[0]?.value, "28.6"); +assert.equal(state.phase, "neutral"); diff --git a/examples/tsconfig.json b/examples/tsconfig.json new file mode 100644 index 0000000..3110042 --- /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 912f174..957b855 100644 --- a/package.json +++ b/package.json @@ -4,13 +4,25 @@ "description": "ASTM parser, serializer, and builder for Node.js and TypeScript: lenient on parse, spec-clean on emit.", "keywords": [ "astm", - "parser", + "astm-e1394", + "astm-e1381", + "lis02-a2", + "lis01-a2", + "clsi", + "lis", + "laboratory", + "lab-instrument", + "analyzer", + "loinc", + "livd", "healthcare", "interoperability", - "typescript", - "serializer" + "parser", + "serializer", + "builder", + "typescript" ], - "homepage": "https://github.com/cosyte/astm#readme", + "homepage": "https://docs.cosyte.com/astm", "bugs": { "url": "https://github.com/cosyte/astm/issues" }, @@ -72,12 +84,16 @@ "check:no-internal-refs": "bash scripts/check-no-internal-refs.sh", "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,mjs}\" \"docs-content/**/*.{md,json}\" \"*.{json,md,yml}\"", - "format:check": "prettier --check \"src/**/*.{ts,md}\" \"test/**/*.ts\" \"scripts/**/*.{ts,mjs}\" \"docs-content/**/*.{md,json}\" \"*.{json,md,yml}\"", + "format": "prettier --write \"src/**/*.{ts,md}\" \"test/**/*.ts\" \"scripts/**/*.{ts,mjs}\" \"docs-content/**/*.{md,json}\" \"examples/**/*.{ts,js,json,md}\" \"*.{json,md,yml}\"", + "format:check": "prettier --check \"src/**/*.{ts,md}\" \"test/**/*.ts\" \"scripts/**/*.{ts,mjs}\" \"docs-content/**/*.{md,json}\" \"examples/**/*.{ts,js,json,md}\" \"*.{json,md,yml}\"", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "test:fuzz": "vitest run test/property/frames-fuzz.property.test.ts test/property/records-fuzz.property.test.ts", + "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/*", "clean": "rm -rf dist coverage", "pack:docs": "bash scripts/build-docs-artifacts.sh", "attw": "node scripts/attw.mjs", diff --git a/scripts/run-examples.ts b/scripts/run-examples.ts new file mode 100644 index 0000000..5d15ed1 --- /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/astm`, 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);