@cosyte/astm 0.0.21 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +411 -0
- package/README.md +558 -223
- package/dist/index.cjs +309 -64
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1054 -177
- package/dist/index.d.ts +1054 -177
- package/dist/index.mjs +301 -65
- package/dist/index.mjs.map +1 -1
- package/package.json +58 -31
package/CHANGELOG.md
CHANGED
|
@@ -9,8 +9,419 @@ this file is maintained by hand (Changesets handles the version bump and publish
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
**What 0.1 means for you.** This release is the first whose public API we treat as settled: the
|
|
13
|
+
exported names, options, return shapes and warning codes are the surface we keep stable. What it
|
|
14
|
+
covers is reading ASTM E1394 (CLSI LIS02-A2) records with the delimiters each header declares,
|
|
15
|
+
checksum-verified E1381 (CLSI LIS01-A2) framing with a pure receiver state machine for the
|
|
16
|
+
`ENQ`/`ACK`/`NAK`/`EOT` exchange, spec-clean emit of records and frames that writes only the values
|
|
17
|
+
you supply, LOINC mapping through a LIVD catalog you supply, and date conversions that never assume
|
|
18
|
+
a timezone. While the package is below 1.0, a breaking change bumps the minor version and is called
|
|
19
|
+
out here with its migration; a fix that changes no public value ships as a patch. Upgrading from
|
|
20
|
+
0.0.x is itself breaking in the LIVD mapping and in what `primaryCode()` returns, as the entries
|
|
21
|
+
below set out. Not covered yet: no named per-vendor profile ships (the built-in set is `default` and
|
|
22
|
+
`referenceCorpus`), no LOINC, SNOMED or LIVD data is bundled and no LOINC is validated, the transfer
|
|
23
|
+
protocol's timers are yours to drive, and three behaviors (forward scoping of redeclared delimiters,
|
|
24
|
+
the Latin-1 wire encoding and the reserved-byte set) are reasoned from this package's reader rather
|
|
25
|
+
than cited to the standard's purchase-gated text.
|
|
26
|
+
|
|
27
|
+
**The pending changeset set is classified `minor`, not `patch`, so the next release is a minor
|
|
28
|
+
release.** Eight changesets are pending and five of them arrived carrying `patch`. Three of those
|
|
29
|
+
five were reclassified against their own text: one removes public values and changes what an
|
|
30
|
+
exported function returns, and two add public values. None of the three is a fix that adds nothing
|
|
31
|
+
and removes nothing, which is the only thing a `patch` may claim, and only those three bump lines
|
|
32
|
+
moved. The other two keep `patch`: one changes repository tooling and install configuration, the
|
|
33
|
+
other changes what a release carries beside the package, and neither touches a published value or
|
|
34
|
+
emits a byte differently, so `patch` is what each one's own text supports. Two more arrived
|
|
35
|
+
carrying `minor` already, each written against this same rule, so both are applied as written rather
|
|
36
|
+
than reclassified. The eighth is the release statement summarized above, which carries `minor` as the
|
|
37
|
+
class of the release it describes and changes no public value. A set carrying a `patch` beside a
|
|
38
|
+
`minor` still resolves to the minor channel.
|
|
39
|
+
|
|
40
|
+
The breaking change below therefore ships in the minor channel of the pre-1.0 ladder, which is where
|
|
41
|
+
a break belongs before `1.0.0`, rather than as a patch. Every break a consumer of the last published
|
|
42
|
+
version would see is enumerated with its migration in `documentation/release-readiness.md`, together
|
|
43
|
+
with the public export surface this release certifies as settled. No version number is written in
|
|
44
|
+
this section's entries, because Changesets owns the bump and `scripts/sync-version.mjs` mirrors it.
|
|
45
|
+
|
|
46
|
+
The entries the pending set carries are the date conversion surface entry, the LIVD units entry, the
|
|
47
|
+
LIVD publication metadata entry and the vocabulary-attribution entry under Added, and the LIVD
|
|
48
|
+
catalog entry, the PHI scanner entry and the dependency inventory entry under Changed. This section
|
|
49
|
+
also holds entries written
|
|
50
|
+
before them, which is where they have always been kept: no released entry was moved, reworded or
|
|
51
|
+
deleted by the reclassification, because rewriting a shipped changelog destroys the traceability it
|
|
52
|
+
exists for.
|
|
53
|
+
|
|
54
|
+
### Changed
|
|
55
|
+
|
|
56
|
+
- **Every published release now carries a machine-readable dependency inventory as a downloadable
|
|
57
|
+
asset.** No published value, type, warning code or emitted byte moves: this entry is release
|
|
58
|
+
infrastructure only, and nothing under `src/` is touched. The inventory is a CycloneDX document
|
|
59
|
+
naming this package and the version being built alongside every dependency that build resolved,
|
|
60
|
+
runtime and build-time alike, transitively, each with the exact version it resolved to. It is read
|
|
61
|
+
from the lockfile rather than from the manifest's two lists, because the manifest states ranges
|
|
62
|
+
while a build resolves versions, so the artifact answers the question a reader of a dependency
|
|
63
|
+
claim actually has. A team evaluating this package for a regulated laboratory can download it from
|
|
64
|
+
the release page and feed it to their own tooling without cloning anything or reproducing an
|
|
65
|
+
install. `scripts/check-dependency-inventory.ts` both produces and grades it, from the checkout,
|
|
66
|
+
with no registry credential and no network call, and it is generated into the gitignored
|
|
67
|
+
`dist-artifacts/` rather than committed, so it can never describe a version other than the one it
|
|
68
|
+
ships beside. The grading runs in `prepublishOnly`, which is BEFORE the registry upload: an
|
|
69
|
+
inventory that cannot be produced at all, or that is empty, unparseable, missing this package's
|
|
70
|
+
identity or version, or short of a dependency the build resolved, is refused by name and aborts
|
|
71
|
+
the publish, so a bad inventory costs a failed run rather than a published version nothing
|
|
72
|
+
describes. A refusal also removes any older artifact at that path rather than leaving one to be
|
|
73
|
+
read as this build's. Attaching it to the release is a second job in the release workflow, because
|
|
74
|
+
the shared pipeline attaches a fixed two-name asset list; that job runs after the publish, since
|
|
75
|
+
the release does not exist before one, and it fails its run rather than skipping quietly if the
|
|
76
|
+
asset does not arrive. What the inventory does not claim: it states what was resolved and nothing
|
|
77
|
+
about whether that is safe. It is not a vulnerability scan, a licence assessment, an attestation
|
|
78
|
+
or a signature, it carries component names and versions only, and npm provenance remains the
|
|
79
|
+
separate thing it already was.
|
|
80
|
+
- **The PHI scanner refuses a run that enumerated a target and never read it, and the install
|
|
81
|
+
settings that hold a resolution back are now in force rather than merely written down.** No
|
|
82
|
+
published value, type, warning code or emitted byte moves: this entry is repository tooling and
|
|
83
|
+
supply chain configuration only. `scripts/phi-scan.ts` reconciles the targets a run ENUMERATED
|
|
84
|
+
against the ones it actually READ, on every route rather than on the whole-tree sweep alone. The
|
|
85
|
+
whole-file bypass is subtracted from each route's finished target list, so before this a run
|
|
86
|
+
naming two paths and withdrawing one opened one file and answered for both, at the same exit code
|
|
87
|
+
the same arguments produce over a corpus whose only violator is the withdrawn one, and a caller
|
|
88
|
+
could not tell those two runs apart. The run now exits with the scanner's invocation-error code,
|
|
89
|
+
names every path nobody opened, and still prints any hit it had already found, because a refusal
|
|
90
|
+
must not swallow a real finding. Detection is untouched: the loci it reads, the format-agnostic
|
|
91
|
+
floor and the allow list are exactly as they were, and a run passing no bypass is byte-identical
|
|
92
|
+
on all three routes, asserted against a copy of the scanner without the reconciliation rather
|
|
93
|
+
than assumed. Alongside it, `pnpm-workspace.yaml` declares a 1440 minute minimum release age and
|
|
94
|
+
a no-downgrade trust policy and the pinned package manager moves to a release that enforces both
|
|
95
|
+
(an older one ignores the keys entirely, which is a settings file that decorates rather than
|
|
96
|
+
defends), and the `js-yaml` resolution override is superseded rather than joined, because the
|
|
97
|
+
stale selector pinned a lower version over an overlapping range. The 3.x resolution reached
|
|
98
|
+
through the release tooling is a known residual and is deliberately left where it is.
|
|
99
|
+
- **The unrecognized-flag and unrecognized-status warning messages name the same attribution the
|
|
100
|
+
interpreted value reports.** `ASTM_RECORD_UNDEFINED_ABNORMAL_FLAG` now names the code system
|
|
101
|
+
identifier and version the flag was graded against, and `ASTM_RECORD_UNDEFINED_RESULT_STATUS` now
|
|
102
|
+
states that no citable published source binds the status letter set. Both read the same constants
|
|
103
|
+
the interpreted values read, so message and value cannot drift apart. Both warning **codes** are
|
|
104
|
+
unchanged, and both messages remain value-free: position and fixed prose only, never the field
|
|
105
|
+
text, the result value, or any other field content.
|
|
106
|
+
- **BREAKING: a populated first component no longer bypasses the consumer's LIVD catalog, and no
|
|
107
|
+
public value reports one as a LOINC** (defect 9). The Universal Test ID's first component is a
|
|
108
|
+
LOINC slot, and the governing mapping guide puts transmitting LOINC directly from IVD instruments
|
|
109
|
+
explicitly out of scope: the analyte arrives as a vendor-defined code and a LOINC is what a
|
|
110
|
+
consumer maps to afterwards. This package answered the analyte-identity question from that slot
|
|
111
|
+
anyway. `recognizeUniversalTestId` tagged **any** non-empty first component
|
|
112
|
+
`inline-loinc-candidate` with no evidence whatsoever, `primaryCode()` preferred it over the vendor
|
|
113
|
+
local code, and `mapTestId` returned `{ status: "inline-loinc" }` on it and **never reached
|
|
114
|
+
`catalog.lookup`**, so the catalog a consumer supplied was bypassed for every record with a
|
|
115
|
+
populated first component and `R|1|Glucose|28.6|U/L||N||F` reported a LOINC candidate of
|
|
116
|
+
`Glucose`. A right value under the wrong analyte is a wrong clinical result, so the direction of
|
|
117
|
+
that default was the defect.
|
|
118
|
+
|
|
119
|
+
**No LOINC shape test was added, and none is coming.** This package performs no LOINC validation
|
|
120
|
+
of any kind and never decides what a first-component value "looks like": `2345-7` and `Glucose`
|
|
121
|
+
there are treated identically and every route below is positional, decided by which components are
|
|
122
|
+
populated. Believing component 1 only when it is "LOINC-shaped" would need a definition of that
|
|
123
|
+
shape, which is exactly the unverified claim this change is written to avoid.
|
|
124
|
+
|
|
125
|
+
Public values **removed or renamed**, each with its replacement:
|
|
126
|
+
- `UniversalTestId.loincCandidate` is **removed**; `UniversalTestId.unvalidatedWireValue` replaces
|
|
127
|
+
it, carrying the same verbatim component 1 value under a name that vouches for nothing.
|
|
128
|
+
- The `inline-loinc-candidate` token is **removed** from `UniversalTestIdProvenance`. A populated
|
|
129
|
+
component 1 **with** a vendor local code is now `local-code`, since the local code is the
|
|
130
|
+
identifier; **without** one it is the new token `unvalidated-wire-value-only`, which reports
|
|
131
|
+
that the value was seen without claiming it is a code, so such a record stays distinguishable
|
|
132
|
+
from one whose Universal Test ID was empty.
|
|
133
|
+
- The `LivdMapping` variant `{ status: "inline-loinc", loinc, source: "wire" }` is **removed**:
|
|
134
|
+
no disposition reports a wire value as a LOINC. Its positional replacement is the new
|
|
135
|
+
`{ status: "no-vendor-code" }`, a populated component 1 with no vendor local code to look up.
|
|
136
|
+
|
|
137
|
+
**The four retained dispositions keep their documented meanings exactly.** `mapped` still means
|
|
138
|
+
the consumer's catalog vouched for this LOINC and nothing else, never that the library adjudicated
|
|
139
|
+
the wire against it; `ambiguous` still means more than one distinct candidate with none chosen;
|
|
140
|
+
`unmapped` is still a miss; `no-code` is still nothing to map. Only which inputs reach them
|
|
141
|
+
changed. No pre-existing assertion covering those four outcomes for a record with an **empty**
|
|
142
|
+
first component was edited.
|
|
143
|
+
|
|
144
|
+
**`primaryCode()` keeps its name and changes its behavior**, which is a break with no compile
|
|
145
|
+
error behind it: audit every call site. It now returns the vendor local code and nothing else, so
|
|
146
|
+
where it used to answer with a first-component value it returns `undefined`, the same absence it
|
|
147
|
+
already returned for a record carrying nothing usable and which its callers already had to handle.
|
|
148
|
+
It reports no key rather than a key this library cannot vouch for.
|
|
149
|
+
|
|
150
|
+
New on `LivdAnnotation`, alongside the disposition rather than inside it, so both are inspectable
|
|
151
|
+
without reading prose:
|
|
152
|
+
- `unvalidatedWireValue`, the verbatim first-component value, carried on **every** disposition
|
|
153
|
+
(a miss, an ambiguity and a disagreement all keep it) and absent only when component 1 is empty.
|
|
154
|
+
- `wireValueDisagreesWithCatalog`, `true` if and only if the catalog vouched for exactly one LOINC,
|
|
155
|
+
component 1 is populated, and the two are not byte-identical. **`false` in every other case and
|
|
156
|
+
never absent**, including on the ordinary `R|1|^^^687|...` record, so no standing false
|
|
157
|
+
disagreement ships. It is deliberately not computed against an ambiguous candidate list, which
|
|
158
|
+
would corroborate exactly one candidate. It reports the difference and nothing else: both values
|
|
159
|
+
stay surfaced, neither is marked correct, neither is rewritten, and no field claims the
|
|
160
|
+
difference was settled.
|
|
161
|
+
- `reportedCode` is now always the code the catalog was consulted **with**, verbatim, and is
|
|
162
|
+
absent when nothing was looked up.
|
|
163
|
+
|
|
164
|
+
Two fail-safe corners are now stated and tested rather than left to be found. A consumer-supplied
|
|
165
|
+
catalog whose `lookup` throws propagates to the caller unchanged: a consumer's own crash must not
|
|
166
|
+
be indistinguishable from "your code is not in the catalog", so it is never reported as a miss and
|
|
167
|
+
no partially annotated result is returned. And a hit whose LOINC is a zero-length string is
|
|
168
|
+
reported as a miss, exactly as if the catalog held no entry, never as a vouched-for empty LOINC.
|
|
169
|
+
|
|
170
|
+
A record with no vendor local code emits **no** warning of its own, and emits neither
|
|
171
|
+
`ASTM_LIVD_UNMAPPED_CODE` nor `ASTM_LIVD_AMBIGUOUS_MAPPING`: no lookup happened, and a warning
|
|
172
|
+
asserting one did would claim more than happened. Neither warning's code, message or payload
|
|
173
|
+
changed.
|
|
174
|
+
|
|
12
175
|
### Added
|
|
13
176
|
|
|
177
|
+
- **A LIVD catalog says which publication it came from and which LOINC version the mapping was made
|
|
178
|
+
against, and that version rides on every annotation.** A consumer could record the vendor rows and
|
|
179
|
+
nothing about the publication behind them, so a mapping this library annotated was not
|
|
180
|
+
reproducible and the attribution statement the LOINC license requires had nowhere to live beside
|
|
181
|
+
the mappings it applies to.
|
|
182
|
+
- `LivdPublication`, an **optional second argument** to `defineLivdCatalog` and readable back as
|
|
183
|
+
`LivdCatalog.publication`: `publisher`, `publicationVersion`, `loincVersion` and
|
|
184
|
+
`loincCopyright`, all optional. Each declared value is preserved **verbatim**: nothing trimmed,
|
|
185
|
+
case folded, reordered, reformatted or defaulted. Absent, empty and whitespace only are one
|
|
186
|
+
case, no value declared, stored as absent rather than as a blank.
|
|
187
|
+
- `LivdAnnotation.catalogLoincVersion` carries that LOINC version on **every** annotation the
|
|
188
|
+
catalog produced, whatever the lookup answered (`mapped`, `unmapped`, `ambiguous`,
|
|
189
|
+
`no-vendor-code`, `no-code`), so a mapping records what it was made against and can be
|
|
190
|
+
reproduced. It is provenance about the CATALOG: no field says a LOINC was checked against it,
|
|
191
|
+
because none was.
|
|
192
|
+
- `ASTM_LIVD_CATALOG_NO_LOINC_VERSION` joins `LIVD_WARNING_CODES`, with `AstmLivdCatalogWarning`,
|
|
193
|
+
`LivdCatalogIdentity`, `LIVD_CATALOG_IDENTITY_UNDECLARED` and the builder
|
|
194
|
+
`livdCatalogMissingLoincVersion`. A catalog defined with no LOINC version surfaces one on
|
|
195
|
+
`LivdCatalog.warnings` and is **still built**: it is indexed and answers every lookup exactly as
|
|
196
|
+
it would have. The warning is raised once, where the catalog is defined, never per record, so
|
|
197
|
+
the per-record stream `applyLivd` returns keeps the same codes, in the same order, in the same
|
|
198
|
+
number.
|
|
199
|
+
- The warning's message is a **constant** and carries no consumer-supplied text: the metadata is
|
|
200
|
+
text this library cannot vouch for, and interpolating it would put whatever a consumer stored
|
|
201
|
+
into every log that prints the message. `AstmLivdCatalogWarning.catalog` carries the publisher
|
|
202
|
+
and publication version the catalog declared instead, and a catalog that declared neither is
|
|
203
|
+
reported as having declared no identity, positively, with no name, ordinal or borrowed row value
|
|
204
|
+
invented to stand in for one.
|
|
205
|
+
- **None of it is validated and none of it is a claim by this package.** No version format check,
|
|
206
|
+
no copyright-text check, no publisher check: this package performs **no LOINC validation of any
|
|
207
|
+
kind**, so each value is surfaced exactly as supplied and `catalogLoincVersion` never means a
|
|
208
|
+
LOINC was checked, found or conformed against that version. **Carrying an attribution statement
|
|
209
|
+
is not discharging it**: the license obligation stays the consumer's.
|
|
210
|
+
- Additive and source compatible. The second argument is optional, so the existing
|
|
211
|
+
single-argument call is unchanged; `publication` and `warnings` are optional members, so a
|
|
212
|
+
catalog a consumer implemented by hand still satisfies the interface and still annotates without
|
|
213
|
+
raising; and `catalogLoincVersion` is conditional, so an annotation from a catalog declaring no
|
|
214
|
+
LOINC version keeps the exact key set it had. The one visible widening is `LivdWarningCode`,
|
|
215
|
+
which gains the new registry member, so an exhaustive `switch` over it needs one more arm.
|
|
216
|
+
- **`toObject`, `toISO` and `toDate`: the shared `@cosyte` date conversion surface, with the
|
|
217
|
+
timezone honesty ASTM forces.** A parsed `AstmDate` could be rendered (`astmDateToLocalISO`) but
|
|
218
|
+
not read as components and not converted to an instant, and the name it was rendered under was
|
|
219
|
+
this package's alone, so a consumer holding dates from two `@cosyte` parsers wrote two different
|
|
220
|
+
conversions. The three names, their return shapes and their timezone rule are now identical across
|
|
221
|
+
the sibling parsers.
|
|
222
|
+
- `toObject(value)` returns a **frozen** object carrying only the components the value stated
|
|
223
|
+
(`{ year, month, day }` for `"20240315"`), with a spec-native 1-to-12 month and singular
|
|
224
|
+
`hour`/`minute`/`second` keys. Nothing is zero-filled, no key holds `undefined`, and the parse
|
|
225
|
+
bookkeeping (`raw`, `precision`, `truncated`) stays on `AstmDate` rather than crossing over: a
|
|
226
|
+
digit run that cut a component in half reports only its complete components, and the dangling
|
|
227
|
+
digit reaches no field. `Object.keys()` is therefore the value's precision. Deleting
|
|
228
|
+
`offsetMinutes`, which an ASTM value never carries, leaves an object
|
|
229
|
+
`Temporal.PlainDateTime.from` and luxon's `DateTime.fromObject` accept unchanged; neither
|
|
230
|
+
library is a dependency, the shape is the interoperability.
|
|
231
|
+
- `toISO(value)` renders ISO-8601 truncated to the stated precision and appends no `Z` and no
|
|
232
|
+
offset, because ASTM states none. It returns the same string `astmDateToLocalISO` returns for
|
|
233
|
+
every value `parseAstmDate` produces whose components are a real calendar date; that function is
|
|
234
|
+
unchanged, still exported, and still the repo-native name. On a value whose components are not,
|
|
235
|
+
it still renders the digits it was handed (`"1988-13-01"`) and `toISO` answers `undefined`
|
|
236
|
+
instead, which is the only place the two differ.
|
|
237
|
+
- `toDate(value, options?)` returns a `Date` **only** when the caller supplies
|
|
238
|
+
`options.assumeOffsetMinutes`, signed minutes east of UTC, with an explicit `0` meaning "read
|
|
239
|
+
this value as UTC". An ASTM timestamp is local to the instrument that wrote it, so without that
|
|
240
|
+
assumption there is no instant to give and the answer is `undefined`: the host machine's zone is
|
|
241
|
+
never read and UTC is never assumed, both of which would silently shift a date by a day in every
|
|
242
|
+
negative-offset zone. Components below the stated precision fill to their lowest legal value for
|
|
243
|
+
the instant only, leaving the value's own precision untouched, and a four-digit year below 100
|
|
244
|
+
stays that year (`"00500101"` is the year 50, never 1950). An offset that names no usable zone
|
|
245
|
+
is refused rather than applied, with `undefined` and never an `Invalid Date`: one that is not a
|
|
246
|
+
finite number, and one large enough that applying it lands outside the range a JS `Date`
|
|
247
|
+
represents. An `Invalid Date` satisfies the declared return type and defeats it, since it is
|
|
248
|
+
indistinguishable from a real instant without testing `getTime()` for `NaN`.
|
|
249
|
+
- The `DateParts` and `ToDateOptions` types, and a README section covering all three together with
|
|
250
|
+
the aliasing pattern the shared names force on a consumer importing two `@cosyte` parsers in one
|
|
251
|
+
file.
|
|
252
|
+
- A component that is not on the calendar is refused by all three rather than normalised.
|
|
253
|
+
`parseAstmDate` is lenient by design and reads `"19881301"` into month 13 rather than dropping
|
|
254
|
+
the field, and that is unchanged, so the conversions are where it is answered: a month outside 1
|
|
255
|
+
to 12, a day past the end of its own month (29 February in a common year included), an hour past
|
|
256
|
+
23 and a minute or second past 59 each yield `undefined`. A JS `Date` would instead roll month 13
|
|
257
|
+
into January of the following year and return a date of birth a year out with nothing to say so.
|
|
258
|
+
The refusal is of the whole value, never of the offending component alone.
|
|
259
|
+
- None of the three ever throws: `undefined`, `null`, a value this parser refused, a value stating
|
|
260
|
+
no component, and a value whose components are not a real calendar date all answer `undefined`.
|
|
261
|
+
Nothing else moved. No existing export was removed, renamed or changed in behaviour, no
|
|
262
|
+
dependency of any kind was added, and the engine floor is unchanged.
|
|
263
|
+
|
|
264
|
+
- **A LIVD catalog can now say what tells two candidate LOINCs apart, and the reported units choose
|
|
265
|
+
between them.** One vendor analyte code mapping to several LOINCs is the ordinary case, not an
|
|
266
|
+
edge: the governing mapping guide gives the commonest chemistry analytes as its own worked
|
|
267
|
+
examples (a serum glucose reported as a mass concentration versus a substance concentration; a
|
|
268
|
+
urine analyte reported as a spot concentration versus a 24 hour excretion rate) and its remedy is
|
|
269
|
+
a mapping per unit. Until now `LivdEntry` had nowhere to record any of that, so a catalog covering
|
|
270
|
+
more than one reporting unit returned `ambiguous` for a large share of ordinary analytes.
|
|
271
|
+
- `LivdEntry` gains three **optional** attributes, each carried **verbatim** with no trimming,
|
|
272
|
+
case folding or normalization: `vendorSpecimenDescription`, `vendorResultDescription` and
|
|
273
|
+
`representativeUnit`.
|
|
274
|
+
- `LivdCatalog.lookup` gains an **optional** second parameter, the units the record reported.
|
|
275
|
+
`applyLivd` and `lookupLivdForRecord` pass field 5 of an `R` record, verbatim; an `O` record has
|
|
276
|
+
no units field, so nothing is passed for one. A hand-written catalog whose `lookup` declares
|
|
277
|
+
only the vendor code still satisfies the interface and answers exactly as before.
|
|
278
|
+
- Where a vendor code carries several distinct candidate LOINCs, the candidate whose
|
|
279
|
+
`representativeUnit` is **exactly equal** to the reported units is answered `mapped`. **The
|
|
280
|
+
comparison is verbatim and case sensitive and it is not UCUM**: nothing is normalized, case
|
|
281
|
+
folded, scaled or converted on either side, so `MG/DL` does not match `mg/dL` and `mg/L` never
|
|
282
|
+
matches `ug/dL`. UCUM requires a program declaring full conformance to compare unit expressions
|
|
283
|
+
by their semantics, so every unit-selected answer carries the new `unitComparison`
|
|
284
|
+
(`comparison: "verbatim-case-sensitive"`, `ucumSemantic: false`, both units verbatim, and a
|
|
285
|
+
note) rather than letting a matched unit read as a conformance claim this package does not make.
|
|
286
|
+
- Refusing still beats guessing, and nothing about `ambiguous` was narrowed. The units matching no
|
|
287
|
+
candidate, matching more than one, or not being readable at all (absent, empty, whitespace only,
|
|
288
|
+
or lost to a truncated or malformed record) are all `ambiguous` with every candidate surfaced
|
|
289
|
+
and no LOINC chosen. An unreadable units field reads as **no units reported**: a typed
|
|
290
|
+
annotation is still produced, nothing raises and no record is omitted. An ambiguous answer now
|
|
291
|
+
also carries `candidateDetails` (each candidate row's LOINC and LIVD attributes, one per catalog
|
|
292
|
+
row, including rows that are not unit qualified) and a `reason` naming which refusal it was.
|
|
293
|
+
- A candidate whose `representativeUnit` is absent, empty or whitespace only is **not unit
|
|
294
|
+
qualified**: never selected by a unit comparison, and still surfaced among the candidates. A
|
|
295
|
+
vendor code carrying exactly **one** candidate LOINC is answered whether or not the units agree,
|
|
296
|
+
with no `unitComparison`, because no unit chose anything there. An answer that read `mapped`
|
|
297
|
+
before this change can never become `ambiguous`.
|
|
298
|
+
- A `mapped` answer's `representativeUnit` is provenance about the **catalog row**, not a
|
|
299
|
+
restatement of what the record reported. Where a vendor code carries a single candidate LOINC
|
|
300
|
+
across several rows spelling the unit differently, the answer takes the first row's attributes
|
|
301
|
+
(exactly as it has always taken the first row's `loincLongName`), so that field can name a unit
|
|
302
|
+
the record did not report. Only `unitComparison` asserts the two were equal, and it is present
|
|
303
|
+
only where a unit actually chose between candidates: branch on that field, not on this one.
|
|
304
|
+
- `vendorSpecimenDescription` and `vendorResultDescription` are **never matched on**. Both are
|
|
305
|
+
free text, and the guide states directly that this information is not intended to be parsed by
|
|
306
|
+
software that automates the mapping, so they are stored and surfaced for a human and nothing
|
|
307
|
+
else. Only the representative unit ever selects.
|
|
308
|
+
- **Additive throughout.** All three attributes are optional and every new answer field is
|
|
309
|
+
conditional, so a catalog carrying none of them answers byte-identically to how it answered
|
|
310
|
+
before, for `mapped`, `unmapped` and `ambiguous` alike. No LOINC is validated or shape-tested,
|
|
311
|
+
no unit is converted, no data is migrated and no consumer catalog is rewritten. New exported
|
|
312
|
+
types: `LivdCandidate`, `LivdUnitComparison`, `LivdAmbiguityReason`.
|
|
313
|
+
|
|
314
|
+
- **Every interpreted abnormal flag and result status reports the vocabulary it was graded against,
|
|
315
|
+
and `HU`/`LU` are recognized.** The `R` record's flag (field 7) and status (field 9) letters come
|
|
316
|
+
from vocabularies maintained on other bodies' schedules, while the record grammar around them is
|
|
317
|
+
archived and frozen, so this package's exposure to a moving vocabulary was real and invisible from
|
|
318
|
+
the outside: a consumer holding `{ recognized: false, meaning: "undefined" }` could not tell a code
|
|
319
|
+
**no published vocabulary defines** from one **this library had not caught up to**. That
|
|
320
|
+
distinction is now carried on the value itself.
|
|
321
|
+
- `AbnormalFlag.vocabulary` names the code system by `system` and `version`, present whether or not
|
|
322
|
+
the letter was recognized. It is the HL7 v3 ObservationInterpretation code system, which carries
|
|
323
|
+
the concepts kept aligned with the HL7 v2 Table 0078 interpretation codes previously named in
|
|
324
|
+
prose only. **The version records what this library compared against**, not what the sender
|
|
325
|
+
meant, and it is not a conformance claim about any instrument.
|
|
326
|
+
- `ResultStatus.vocabulary` is always the explicit **no citable published source**
|
|
327
|
+
(`attributed: false`, with fixed prose in `reason`). The normative text that would bind the
|
|
328
|
+
status letter set is purchase-gated and was not read; a neighbouring HL7 table whose letters
|
|
329
|
+
partly agree is deliberately **not** adopted in its place, because asserting that binding is not
|
|
330
|
+
something the public record supports. Saying the set is unattributed is the honest answer and is
|
|
331
|
+
distinguishable from an attribution that is simply absent; it never carries an identifier.
|
|
332
|
+
- `HU` (`"significantly-high"`) and `LU` (`"significantly-low"`) are recognized. Both are active
|
|
333
|
+
concepts in the graded vocabulary and were absent from the sixteen letters this package
|
|
334
|
+
recognized, so a feed sending either got a warning and `"undefined"`. They are distinct from
|
|
335
|
+
`H`/`L`, from `HH`/`LL` and from the directional `U`/`D`, all six of which keep their readings
|
|
336
|
+
unchanged. The recognized set is eighteen: `L`, `H`, `LL`, `HH`, `<`, `>`, `N`, `A`, `AA`, `U`,
|
|
337
|
+
`D`, `B`, `W`, `S`, `R`, `I`, `HU`, `LU`.
|
|
338
|
+
- New public values: `ABNORMAL_FLAG_CODES` (the recognized set as a list),
|
|
339
|
+
`ABNORMAL_FLAG_VOCABULARY`, `RESULT_STATUS_VOCABULARY`, `describeVocabulary`, and the
|
|
340
|
+
`NamedVocabulary` / `UnattributedVocabulary` / `VocabularyAttribution` types.
|
|
341
|
+
- **No tolerance was widened.** Recognition stays exact match after the existing trim: no case
|
|
342
|
+
folding, so `hu` and `f` are reported unrecognized with their raw text intact. No other concept
|
|
343
|
+
from that code system is adopted, including the deprecated `H>` and `L<` whose replacements
|
|
344
|
+
`HU`/`LU` are; those stay unrecognized, which is the fail-safe direction. An unrecognized flag is
|
|
345
|
+
still never `normal` and an absent status is still never `final`.
|
|
346
|
+
- **Nothing reaches the wire.** The attribution is interpretation output; a parsed and
|
|
347
|
+
re-serialized record emits byte-identical output, pinned by literal expected strings in
|
|
348
|
+
`test/records/golden-round-trip.test.ts`. No new dependency.
|
|
349
|
+
|
|
350
|
+
- **The PHI sweep now reads the bytes git carries, as a union with the working-tree walk.** All mode
|
|
351
|
+
reconciled the paths it walked against the paths git tracks, and a comparison of path _sets_ is
|
|
352
|
+
satisfied by a working tree that carries different content at those paths. Four states were
|
|
353
|
+
reproduced on the previous release, each printing `[phi-scan] OK: no hits` and exiting `0` over a
|
|
354
|
+
synthetic stream carrying a patient name, a mother's maiden name, a birthdate and a dashed SSN:
|
|
355
|
+
decoy content at a tracked path; a tracked path outside every walk root; a tracked symbolic link
|
|
356
|
+
or nested repository outside every walk root; and an empty index, against which every check passes
|
|
357
|
+
vacuously. All four now report or refuse. The mechanism is written down in exactly one place,
|
|
358
|
+
`buildTargetsForIndex` in `scripts/phi-scan.ts`, and every other surface states only what a green
|
|
359
|
+
means.
|
|
360
|
+
- **It is a union, never a replacement.** No scan root was narrowed and no clause dropped; a file
|
|
361
|
+
the walk reads is still read from disk with exactly the views it had, and a blob whose bytes the
|
|
362
|
+
walk has provably already scanned is skipped, so nothing is reported twice. The skip is a byte
|
|
363
|
+
comparison rather than a timestamp, a size or a hash, because those are exactly what a decoy
|
|
364
|
+
defeats.
|
|
365
|
+
- **The scan reported eighteen tracked files outside every walk root that no route had opened.**
|
|
366
|
+
One carried this package's own published contact address, which is already public in every
|
|
367
|
+
release's registry metadata and is not patient data; it is declared in
|
|
368
|
+
`scripts/phi-allow-list.txt` with the cost of that declaration written beside it, because an
|
|
369
|
+
allowed email domain is global and applies on every route.
|
|
370
|
+
- **A positive control proves the sweep opens the corpus it clears.** This package's own manifest
|
|
371
|
+
is placed at the same out-of-root path in a throwaway tree and the declaration is then struck:
|
|
372
|
+
the same corpus reports the address at exit `1`. A green over an unopened corpus is
|
|
373
|
+
indistinguishable from a green over a corpus that was read, so a case showing the scanner
|
|
374
|
+
passing proves nothing on its own.
|
|
375
|
+
- **What it deliberately does not do**, so a green is not read as wider than it is: it does not
|
|
376
|
+
credit a scan root (a root emptied on disk still refuses, though the index carries its files);
|
|
377
|
+
it does not reach `--staged`, whose scope decides what a _commit_ is blocked on; and markdown
|
|
378
|
+
stays excluded, which is the walk's own rule rather than a new one. Working-tree bytes at a path
|
|
379
|
+
outside every scan root are still read by neither route, unchanged from before and now stated.
|
|
380
|
+
- **Line endings are deliberately not normalized before the byte comparison.** Doing so would
|
|
381
|
+
compare a derived form of the two byte strings, and content differing only in what the
|
|
382
|
+
normalizer erases would then be skipped.
|
|
383
|
+
|
|
384
|
+
- **The two-file contract between `CLAUDE.md` and `documentation/agent-notes.md` is now gated**
|
|
385
|
+
(`pnpm check:agent-notes`, `scripts/check-agent-notes.ts`), with the teeth in
|
|
386
|
+
`test/scripts/agent-notes.test.ts` so it rides the required `ci / verify` matrix rather than
|
|
387
|
+
adding a workflow that would have to be made a required context separately. It asserts that every
|
|
388
|
+
pointer resolves, that no section a pointer reaches is empty, that the record stays reachable from
|
|
389
|
+
the always-read file, and that the always-read file still links the record by path. It asserts
|
|
390
|
+
**no** ecosystem-wide contract: several cosyte repos carry no such record at all, and a gate
|
|
391
|
+
phrased as though every repo did would be an overclaim.
|
|
392
|
+
- **The matcher was re-derived against this tree rather than ported, and that was the whole job.**
|
|
393
|
+
Both sibling spellings score **zero** here: the path-qualified form used by `ccda` and `mllp`,
|
|
394
|
+
and the basename-qualified form used by `terminology`. Every pointer in this repository is a
|
|
395
|
+
third spelling neither sibling gate matches, a backtick-quoted anchor with no filename at all. A
|
|
396
|
+
verbatim port would have reported "all resolving" over a corpus it never opened. Both sibling
|
|
397
|
+
forms are matched anyway so a pasted pointer is never invisible, and the live form falling to
|
|
398
|
+
zero is a refusal rather than a pass.
|
|
399
|
+
- **The anchor space is this record's explicit `<a id>` tags, not GitHub heading slugs.** Not one
|
|
400
|
+
pointer here resolves to a heading slug, because the headings carry long titles with dates and
|
|
401
|
+
status in them. A gate checking only slugs, which is what the siblings check, would have
|
|
402
|
+
reported every pointer in the repository as dangling.
|
|
403
|
+
- **An early draft was vacuous rather than wrong, and only a positive control found it.** Treating
|
|
404
|
+
an explicit anchor and the heading it precedes as separate sections made the empty-section
|
|
405
|
+
assertion cover nothing: the anchor looked empty and the heading looked unreferenced, so both
|
|
406
|
+
were skipped and a deliberately emptied section still printed `OK`. Anchors are now bound to the
|
|
407
|
+
heading they name.
|
|
408
|
+
- **The one declared non-pointer is pinned to the single sentence it describes**, and refuses both
|
|
409
|
+
when it matches nothing (a phantom skip) and when it matches more than once. The second case
|
|
410
|
+
fired during this change's own build.
|
|
411
|
+
- The corpus is `git ls-files` reconciled as sets, with **no exclusion list, no binary skip and no
|
|
412
|
+
NUL skip**; this repository tracks NUL-bearing TypeScript sources, most of them tests, that a NUL
|
|
413
|
+
partition would drop in silence. A pointer is matched if and only if the file spells it in ASCII
|
|
414
|
+
bytes, pinned in both directions by test. Every tracked file is scanned, so a backticked anchor
|
|
415
|
+
is a live pointer wherever it is written, **including `README.md` and `docs-content/`, which
|
|
416
|
+
ships in an immutable release asset**: reference a section by title on any surface that ships.
|
|
417
|
+
- **CI caught a defect the local run structurally could not, and the gate was right.** Because the
|
|
418
|
+
corpus is the index, a new file is invisible until staged: `verify.sh` ran green while both new
|
|
419
|
+
files were untracked, and CI went red once they were tracked, because the gate's own test file
|
|
420
|
+
spelled pointers in its fixtures that were then scanned against the real record. Both the checker
|
|
421
|
+
and its test now **build** pointer strings at run time rather than spelling them, pinned by a
|
|
422
|
+
test; exempting those paths would have been the exclusion list the gate refuses to have. **Re-run
|
|
423
|
+
a corpus-scanning gate after staging, never only before.**
|
|
424
|
+
|
|
14
425
|
- **The PHI commit-gate now reads a stream written as a source string literal, and refuses a sweep
|
|
15
426
|
that did not observe its corpus.** Two separate holes, closed together because closing one alone
|
|
16
427
|
buys only the cross-cutting SSN/email floor.
|