@cosyte/astm 0.0.8 → 0.0.9

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 CHANGED
@@ -11,6 +11,59 @@ this file is maintained by hand (Changesets handles the version bump and publish
11
11
 
12
12
  ### Added
13
13
 
14
+ - **`ASTM_RECORD_FIELDS_UNSEPARATED`: a record that lost its fields no longer loses them in
15
+ silence** (`ASTM-TYPE-LETTER-SECOND-READER`, finding 2, the one that outranked the other).
16
+ A record carries its type letter and then its fields, separated by the field delimiter the header
17
+ declared, so a record that carries content beyond its type letter and still yields exactly **one**
18
+ field contains no field separator at all: the delimiters in force are not the set that record was
19
+ written with. Every modeled field of it is then absent. On an `R` that is the **value, the units
20
+ and the status at once**, and the result reads back as though it simply never carried them.
21
+
22
+ The reachable route is a header whose type letter the reader could not recognize. Delimiters are
23
+ re-read at every `H`, keyed on that same letter, so an unrecognized header does not re-scope them
24
+ and the whole following message is tokenized with the previous header's set. Measured: a
25
+ `99.9 mmol/L` final result read back with no value, no units and status `unspecified`, filed under
26
+ the previous message's patient, with the unrecognized-type warning as the **only** report on the
27
+ stream. That warning says a letter was unreadable; it never said a value had gone.
28
+
29
+ The detector is keyed on the **observed collapse**, not on that one cause of it, because
30
+ identifying the mangled header would itself require guessing which byte the sender meant. It
31
+ therefore also fires on the same silent collapse reached without any mangled header at all, which
32
+ reproduced on the previous release with **zero** warnings: a lone record written in another set
33
+ (`H|\^&` then `R*1*:::688*99.9*mmol/L**H**F`) parsed clean and answered `undefined` for the value.
34
+
35
+ **Reported, never repaired.** The fields are not re-split on a set no header declared, because
36
+ that would invent data; the raw line is surfaced intact and nothing is dropped. One warning per
37
+ affected record, each at its own position and carrying no field data. The code is **safety-critical
38
+ by construction** (the forbidden set is computed as every known code minus the tolerable
39
+ allow-list), so no profile can quiet it and `{ strict: true }` refuses. A header is exempt by
40
+ construction rather than by exception: it is always read with the set it declares itself. Content
41
+ after the type letter that is entirely whitespace is excluded, since no field is at stake there.
42
+
43
+ **Deliberately partial, and its absence certifies nothing.** This tests one of the four delimiter
44
+ roles, the **field** separator, and only in its total form, where that separator occurs nowhere in
45
+ the line. Two classes of the same loss stay silent and are documented as such on the code, in
46
+ `README.md` and in the quickstart rather than left to be inferred. A foreign set whose **field**
47
+ separator happens to occur anywhere in the line still splits, on the wrong boundaries: one stray
48
+ `|` inside an otherwise `*`-separated result loses the value, the units and the status with no
49
+ warning, and this can happen to one record **inside** a run of these warnings, so a run is not a
50
+ sweep of the records it spans. A set differing in the **repeat, component or escape** role usually
51
+ splits into fields normally, with the damage varying: a mis-split component can cost a test
52
+ identity while the value survives, but an **escape** character occurring literally in a record
53
+ merges every field after it and costs the value, the units and the status together (a lone `&`
54
+ under the canonical set leaves a nine-field `R` reading as four, in silence). All of them
55
+ reproduce identically on the previous release, and the last is recorded as a defect in its own
56
+ right. Widening the check would mean deciding which set a record ought to have had, which is the
57
+ same guess declined above, so the boundary is written down instead of chased.
58
+
59
+ The exported factory `fieldsUnseparated` joins the record registry, and `WARNING_CODES` goes from
60
+ 15 members to 16. **That a record's fields are separated by the declared field delimiter is read
61
+ off this package's own emit contract and off every fixture in this repository, not off a normative
62
+ sentence, and no clause is claimed for it**: the relevant CLSI text is withheld from the free
63
+ sample and the surrounding standards are paywalled, and the redistributable reference corpus
64
+ hardcodes the canonical delimiters and never reads the declaration, so it cannot ground a
65
+ delimiter question either.
66
+
14
67
  - **`messages()`: read a stream as the sequence of messages it actually is**
15
68
  (`ASTM-PATIENT-RESULT-MISATTRIBUTION`). A parsed model has always been a whole record
16
69
  **stream**, and a stream may carry several messages back to back: a message runs from an `H`
@@ -129,6 +182,39 @@ phase 8` passes while `Phase 8` reds). An arm keyed on a following digit was wri
129
182
 
130
183
  ### Changed
131
184
 
185
+ - **BREAKING: a record type letter the reader could not recognize no longer lets a host-query
186
+ request read as a result set** (`ASTM-TYPE-LETTER-SECOND-READER`, finding 1). `classifyMessage` counts `Q` / `R` /
187
+ `O` records **by letter**, and the `Q`-dominates guarantee (a message carrying a query is never
188
+ read as a result set) was stated on that count, so it only ever held while every letter was
189
+ legible. Measured: `H|\^&` plus a `Q` carrying one stray leading byte plus an `R` classified
190
+ `kind: "results"` with `isHostQueryRequest: false`, and the `ASTM_RECORD_AMBIGUOUS_MESSAGE_KIND`
191
+ warning that flags a `Q` + `R` contradiction vanished along with the `Q` it was counting. A stray
192
+ byte turned the fail-safe off.
193
+
194
+ The intended letter is still **never inferred**: that is the guess this package declines to make,
195
+ and inferring it would split a stream a different way just as silently. What the classifier does
196
+ instead is decline the positive answer. An unsupported record with no `Q` read alongside it now
197
+ yields `kind: "indeterminate"` rather than `"results"` or `"orders"`, and the new
198
+ `AstmMessageClassification.hasUnrecognized` reports why. A `Q` that **was** read still dominates,
199
+ because an unreadable letter can only ever add a kind, never remove a query already on the wire.
200
+
201
+ `hasQuery` / `hasResults` / `hasOrders` stay truthful, so a caller wanting the raw tally still has
202
+ it, and `results()` and the rest are untouched. Breaking for a consumer branching on `kind`: on a
203
+ stream carrying an unrecognized record letter, a previously positive `kind` now reads
204
+ `indeterminate`. That is the fix, and it moves in the fail-safe direction. A conformant stream,
205
+ where every letter is legible, is unchanged. Kept on the `0.0.x` pre-alpha ladder as a patch, per
206
+ the repo's version policy.
207
+
208
+ Two limits, stated rather than implied. `msg.classification` is still folded over the **whole
209
+ stream** (a known, separately-recorded defect), so one unrecognized letter anywhere now withholds
210
+ `kind` for the entire stream **unless a `Q` was read**, in which case the `Q` still dominates as
211
+ before. That widens the existing over-trigger; the per-message answer,
212
+ `classifyMessage(m.records)` on a `messages()` entry, is unaffected and is the reading to prefer.
213
+ And the fix lands on `kind`, not on `isHostQueryRequest`, which `README.md` calls the safety
214
+ surface: a mangled `Q` still reports `false` there, because the parser genuinely did not read a
215
+ query. `false` therefore means "no query was read", never "this is a result set", and both the
216
+ type doc and the quickstart now say so and point at `kind`.
217
+
132
218
  - **BREAKING: a profile may no longer tolerate `ASTM_RECORD_UNKNOWN_TYPE`**
133
219
  (`ASTM-UNKNOWN-RECORD-REMERGE`). The code moves off the profile safety gate's tolerable
134
220
  allow-list and onto the safety-critical set, so `defineAstmProfile({ tolerate: [...] })` naming
@@ -292,6 +378,68 @@ phase 8` passes while `Phase 8` reds). An arm keyed on a following digit was wri
292
378
 
293
379
  ### Fixed
294
380
 
381
+ - **One unescaped `&` in a value no longer swallows every field after it**
382
+ (`ASTM-UNESCAPED-ESCAPE-SWALLOWS-TAIL`; `PRE-EXISTING`, shipped `0.0.1` through `0.0.8`, not a
383
+ regression). The escape codec read an escape character with no closing escape character as the
384
+ opening of a sequence and copied from it to the end of the record, so every field after it merged
385
+ into the field it sat in, **and there was no warning code for the condition at all**. Measured on
386
+ `064c078`: `R|1|^^^687|28.6&|U/L||N||F` read back `value = "28.6&|U/L||N||F"` with `units`,
387
+ `abnormalFlags` and `flag` all `undefined` and `status` `unspecified` rather than `final`, on a
388
+ stream declaring the canonical `H|\^&`, with `warnings: []`. On the patient side
389
+ `P|1||LAB-0001||O&BRIEN^JANE||19800101|F` read the surname as the whole rest of the record and
390
+ came back with **no birth date and no sex**, again silently. A result that reads `28.6` with its
391
+ units gone is the harm this package exists to prevent, and an ampersand in a surname is not exotic.
392
+
393
+ **The round trip made it worse rather than surfacing it.** Emit re-escaped the merged text into
394
+ `R|1|^^^687|28.6&E&&F&U/L&F&&F&N&F&&F&F`: a spec-clean-looking line that re-parsed to the same
395
+ wrong value, with zero warnings, byte-stable across further trips. Silent re-read divergence, with
396
+ no malformed delimiter set anywhere in it.
397
+
398
+ **The fix is a bound, and it infers nothing.** An escape sequence is now exactly three characters
399
+ (the escape character, **one** body character, the escape character), which is all the four
400
+ mnemonics `&F&` `&S&` `&R&` `&E&` ever need, and both `splitEscapeAware` and `decodeEscapes` share
401
+ that one definition. An escape character that heads no such sequence is read as the **literal
402
+ character it is**: the value keeps the byte that arrived, it opens no atom, and nothing is
403
+ invented to close a sequence the sender did not open. The same fixture now
404
+ reads `value = "28.6&"` with `units: "U/L"` and `status.meaning: "final"`, and the patient keeps
405
+ their birth date and sex. Emit writes the literal character as `&E&`, so the emitted line is
406
+ spec-clean **and** structurally faithful, and re-parses to the same tree.
407
+
408
+ The bound also removes a non-local behavior: under the unbounded scan, whether a field kept its
409
+ value depended on whether some **later** field in the record happened to carry an escape character
410
+ too, which paired across the field separator between them. Two bare ampersands in one record now
411
+ cost nothing.
412
+
413
+ New warning code `ASTM_UNPAIRED_ESCAPE_CHARACTER` and exported factory `unpairedEscapeCharacter`
414
+ report each such character, one per occurrence, positioned on the record and field, carrying no
415
+ field text (PHI discipline). The code is **tolerable**: a profile may expect it, because reading
416
+ the character as a literal is unconditional and the parsed value is byte-identical with the profile
417
+ and without it. Untolerated, `{ strict: true }` refuses.
418
+
419
+ **The scope is the bare character, and only that.** The atom rule is unchanged, so an `&X&`
420
+ sequence whose body **is** a delimiter still swallows that delimiter and still costs the value, the
421
+ units and the status together: `R|1|^^^687|28.6&|&U/L||||F` reads `28.6&|&U/L` with no units and
422
+ status `unspecified`. It is never silent, but its only report is `ASTM_UNKNOWN_ESCAPE_SEQUENCE`,
423
+ which is tolerable, so a profile expecting that code lets `{ strict: true }` accept it. That case
424
+ is recorded as a known defect and deliberately not closed here: narrowing the atom would break the
425
+ guarantee it exists for, which is that `&F&` stays one token under a set naming `F` as a delimiter.
426
+ Both halves are pinned, so neither can drift.
427
+
428
+ `ASTM_UNKNOWN_ESCAPE_SEQUENCE` is unchanged for a single unrecognized body (`&Z&`). A multi-
429
+ character body is no longer treated as one atom: its bytes are all preserved, but a **delimiter
430
+ inside** such a body now splits, which moves every field after it, and the report becomes one
431
+ unpaired-character warning per loose escape character instead of one unknown-sequence warning. Over
432
+ an exhaustive corpus of bodies up to four characters in a result-value slot, that is 1,085 records
433
+ read correctly where the previous release read them wrongly, against 27 read differently in the
434
+ other direction.
435
+
436
+ Measured red on base: 10 of the 18 new tests fail against `064c078` extracted into a clean tree.
437
+ Of the 8 that pass, 6 measure behavior this change does not touch and 2 are the boundary pins on
438
+ what it deliberately does **not** fix.
439
+ `test/records/unseparated-fields.test.ts` had pinned this loss as a documented limit of
440
+ `ASTM_RECORD_FIELDS_UNSEPARATED`; that pin is inverted rather than deleted, and the prose stating
441
+ the limit moved with it in `README.md`, the quickstart and the warning code's own docs.
442
+
295
443
  - **Emit no longer writes a stream it cannot read back** (`ASTM-EMIT-RESIDUALS`; both gaps shipped
296
444
  `0.0.1` through `0.0.3`, `PRE-EXISTING`, neither a regression). Two residual emit-side holes, both
297
445
  recorded and deliberately deferred by `ASTM-MIXED-DELIMITER-EMIT` (#21) and again by
package/README.md CHANGED
@@ -143,6 +143,30 @@ produce a confident wrong value: an embedded escaped delimiter reads as one comp
143
143
  record type is surfaced (never dropped), and a missing unit is flagged (never defaulted). A
144
144
  `{ strict: true }` mode escalates every tolerated deviation to a thrown error.
145
145
 
146
+ ### An unescaped ampersand does not cost you the rest of the record
147
+
148
+ An escape sequence is the escape character, **one** body character, and the escape character again
149
+ (`&F&` `&S&` `&R&` `&E&`). An escape character that heads no such sequence is not an escape: it is
150
+ read as the literal character it is, it opens no atom, and `ASTM_UNPAIRED_ESCAPE_CHARACTER` reports
151
+ it. So `R|1|^^^687|28.6&|U/L||N||F` reads a value of `28.6&` with units `U/L` and status `final`,
152
+ and `O&Brien` in a surname keeps the patient's birth date and sex.
153
+
154
+ The parser does not decide what the sender meant by the character: it keeps the byte that arrived
155
+ and says so. The spec-clean way to send a literal escape character is `&E&`, which is what this
156
+ package's serializer emits, so a stream it produced never trips the code. The code is **tolerable**,
157
+ so a vendor profile can expect it on a feed that sends bare ampersands and still parse
158
+ `{ strict: true }`.
159
+
160
+ **One escape shape still costs a field boundary, and it is a different code.** A real three-character
161
+ sequence is opaque by design, which is what keeps `&F&` one token under a set that names `F` as a
162
+ delimiter. So where the body is itself a delimiter (`&|&` under the canonical set) that delimiter
163
+ does not split, and every field after it shifts: `R|1|^^^687|28.6&|&U/L||||F` reads a value of
164
+ `28.6&|&U/L` with no units and status `unspecified`. It is never silent, but the only report is
165
+ `ASTM_UNKNOWN_ESCAPE_SEQUENCE`, which is **tolerable**, so a profile expecting that code (including
166
+ the shipped `referenceCorpus`) will let a `{ strict: true }` parse accept it. Narrowing the atom to
167
+ close this would break the guarantee the atom exists for, so it is written down rather than papered
168
+ over.
169
+
146
170
  ### Several messages in one stream
147
171
 
148
172
  A message runs from its `H` header to its `L` terminator, so a stream can carry several. `messages()`
@@ -176,15 +200,55 @@ already names the message.
176
200
  Splitting reads each record's type letter, so check for an `ASTM_RECORD_UNKNOWN_TYPE` warning before
177
201
  you trust the split. A header the reader does not recognize as a header, one carrying a stray leading
178
202
  byte for instance, opens no message, and the messages either side of it merge back into one, so a
179
- patient can end up holding results that arrived under a different header. Delimiters are re-read at
180
- each header too, so if the unrecognized one declared a different set, the records after it are read
181
- with the previous set and their fields can be lost rather than merely misfiled. The
203
+ patient can end up holding results that arrived under a different header. The
182
204
  parser warns on that record and a `{ strict: true }` parse refuses the stream. That warning is the
183
205
  only report the merge produces, so a profile is not allowed to tolerate it: the code is refused when
184
206
  a profile is defined, and a warning carrying it is not downgraded whatever profile is in force. Do
185
207
  not gate on the warning count, though, because the records that merged in can raise warnings of
186
208
  their own.
187
209
 
210
+ Delimiters are re-read at each header too, so if the unrecognized one declared a different set, the
211
+ records after it are read with the previous set and their fields can be lost rather than merely
212
+ misfiled. `ASTM_RECORD_FIELDS_UNSEPARATED` reports a record that suffered the total form of that:
213
+ the delimiters in force found no field separator in it at all, so the whole line read back as one
214
+ field and none of its modeled fields survived. On a result record that is the value, the units and
215
+ the status at once, so treat it as a lost result, not a formatting nit. The fields are never
216
+ reconstructed, because the set the sender used is unknown and guessing at it would invent data. The
217
+ code is safety-critical, and it does not need a mangled header to fire: a lone record written in
218
+ another set trips it too.
219
+
220
+ **Its absence does not certify that a record was read in its own set**, and this is the important
221
+ half. The check tests one of the four delimiter roles, the **field** separator, and only in its
222
+ total form, where no unescaped separator occurs in the line. Two classes of the same loss sit
223
+ outside it:
224
+
225
+ - A foreign set whose **field** separator happens to occur somewhere in the line still splits, on
226
+ the wrong boundaries and in silence. A single stray `|` in an otherwise `*`-separated result loses
227
+ the value, the units and the status with no warning at all, while the identical record without
228
+ that one byte is reported. This also happens **inside** a run of these warnings, so even a run
229
+ does not mean every record in it was checked.
230
+ - A set differing in the **repeat, component or escape** role usually splits into fields normally,
231
+ and the damage then varies. A mis-split component can cost a test identity while the value and
232
+ units survive. The escape role's worst case has **narrowed, not gone**: a bare escape character no
233
+ longer merges the rest of the record (it reads as a literal and raises
234
+ `ASTM_UNPAIRED_ESCAPE_CHARACTER`), but an `&X&` sequence whose body is a delimiter is an opaque
235
+ atom, so that delimiter does not split and the value, the units and the status can still go
236
+ together. That one is reported only by the tolerable `ASTM_UNKNOWN_ESCAPE_SEQUENCE`.
237
+
238
+ All are accepted limits, for two different reasons: widening the field-separator check would mean
239
+ deciding which set a record ought to have had, which is the same guess the parser declines to make
240
+ elsewhere, and narrowing the escape atom would break the guarantee it exists for. So they are
241
+ written down rather than papered over. Read the warning as "this record definitely lost its
242
+ fields", never as "no other record did". If
243
+ delimiter drift is a real risk on your feed, parse with `{ strict: true }`, which refuses both an
244
+ outright collapse and an unrecognized type letter, and treat `ASTM_RECORD_UNKNOWN_TYPE` as
245
+ invalidating what follows it rather than expecting this warning to enumerate the damage.
246
+
247
+ An unrecognized type letter also makes the message **kind** unknowable, because the letter that
248
+ could not be read may have been the very `Q` that decides it. `classification.kind` is
249
+ `indeterminate` in that case rather than `results` or `orders`, and `classification.hasUnrecognized`
250
+ says why. A `Q` that was read still wins outright.
251
+
188
252
  ## Map local codes to LOINC (LIVD, bring-your-own)
189
253
 
190
254
  An analyzer sends a proprietary local test code in the Universal Test ID; a standard LOINC is mapped
package/dist/index.cjs CHANGED
@@ -127,10 +127,59 @@ var WARNING_CODES = {
127
127
  * not recognize as one does not open a new message.
128
128
  */
129
129
  ASTM_RECORD_UNKNOWN_TYPE: "ASTM_RECORD_UNKNOWN_TYPE",
130
+ /**
131
+ * The delimiters in force found **no field separator at all** in a record that carries content
132
+ * beyond its type letter, so the whole record read back as a single field and none of its modeled
133
+ * fields could be recovered. The raw line is surfaced intact and nothing is dropped, but on a
134
+ * result record it means the value, the units and the status are all absent from the parsed model.
135
+ *
136
+ * This is one signature of a record being read with a delimiter set that does not belong to it,
137
+ * which is how a delimiter-scoping mistake turns into lost values. It is reported rather than
138
+ * repaired, because recovering the fields would mean guessing which set the sender meant.
139
+ *
140
+ * **Its absence is not evidence that a record was read in its own set.** This tests one of the
141
+ * four delimiter roles, the field separator, and only in its total form, where that separator
142
+ * occurs in the line (unescaped). Two whole classes of the same loss are outside it: a foreign set
143
+ * whose field separator happens to occur somewhere in the line still splits (on the wrong
144
+ * boundaries, silently, and this can happen to one record inside a run of these warnings); and a
145
+ * set differing in the repeat, component or escape role usually splits into fields normally,
146
+ * where a mis-split component can cost a test identity while the value survives, and where an
147
+ * `&X&` sequence whose body is a delimiter is an opaque atom, so that delimiter does not split
148
+ * and every field after it shifts. The escape role's worst case has narrowed and not
149
+ * disappeared: an escape character heading no sequence is now read as a literal and reported
150
+ * under {@link WARNING_CODES.ASTM_UNPAIRED_ESCAPE_CHARACTER} rather than merging the rest of the
151
+ * record, but a delimiter swallowed inside an `&X&` body is reported only by
152
+ * {@link WARNING_CODES.ASTM_UNKNOWN_ESCAPE_SEQUENCE}, which is tolerable. Treat this code as a
153
+ * report that one record definitely lost its fields, never as a sweep that would have fired if
154
+ * any had.
155
+ */
156
+ ASTM_RECORD_FIELDS_UNSEPARATED: "ASTM_RECORD_FIELDS_UNSEPARATED",
130
157
  /** The header declared delimiters other than the canonical `H|\^&`: tolerated, noted. */
131
158
  ASTM_NONSTANDARD_DELIMITERS: "ASTM_NONSTANDARD_DELIMITERS",
132
159
  /** An escape sequence body was not one of `&F&`/`&S&`/`&R&`/`&E&`: preserved verbatim. */
133
160
  ASTM_UNKNOWN_ESCAPE_SEQUENCE: "ASTM_UNKNOWN_ESCAPE_SEQUENCE",
161
+ /**
162
+ * An escape character appeared where no escape sequence starts (an escape sequence is the escape
163
+ * character, one body character, and the escape character again). It is read as the **literal
164
+ * character it is** and kept byte-for-byte in the decoded value. Nothing is dropped and no byte is
165
+ * invented: this flags that the sender did not write the character the spec-clean way, which
166
+ * is `&E&`.
167
+ *
168
+ * **What it replaced is the reason it exists.** The codec used to read such a character as the
169
+ * opening of a sequence that never closed and merge the whole remainder of the record into the
170
+ * field it sat in, reporting nothing at all. One `&` in a result value cost the units, the
171
+ * abnormal flag and the status together and left the status reading `unspecified` rather than
172
+ * `final`; one in a surname cost the patient's birth date and sex. Emit then re-escaped the merged
173
+ * text into a spec-clean-looking line that read back as the same wrong value, so the mis-read
174
+ * survived a round trip without ever surfacing.
175
+ *
176
+ * **This code is not a statement about the rest of the record.** It reports one character. A
177
+ * *different* escape character in the same record may still head a real three-character sequence,
178
+ * and if that sequence's body happens to be a delimiter (`&|&` under the canonical set) the atom
179
+ * rule means that delimiter does not split. That case is reported separately, under
180
+ * {@link WARNING_CODES.ASTM_UNKNOWN_ESCAPE_SEQUENCE}, and it can still cost a field boundary.
181
+ */
182
+ ASTM_UNPAIRED_ESCAPE_CHARACTER: "ASTM_UNPAIRED_ESCAPE_CHARACTER",
134
183
  /**
135
184
  * A result value field carried an *unescaped* component delimiter, so it split into more than one
136
185
  * component. Both the full raw value and the split are surfaced and this warning fires: the
@@ -222,6 +271,13 @@ function unknownRecordType(position) {
222
271
  position
223
272
  };
224
273
  }
274
+ function fieldsUnseparated(position) {
275
+ return {
276
+ code: WARNING_CODES.ASTM_RECORD_FIELDS_UNSEPARATED,
277
+ message: "The delimiters in force found no field separator in this record, so its whole content read back as one field and none of its modeled fields could be recovered. The raw line is surfaced intact, and no field is reconstructed, because the set the sender used is unknown.",
278
+ position
279
+ };
280
+ }
225
281
  function nonStandardDelimiters(position) {
226
282
  return {
227
283
  code: WARNING_CODES.ASTM_NONSTANDARD_DELIMITERS,
@@ -250,6 +306,13 @@ function unknownEscapeSequence(position) {
250
306
  position
251
307
  };
252
308
  }
309
+ function unpairedEscapeCharacter(position) {
310
+ return {
311
+ code: WARNING_CODES.ASTM_UNPAIRED_ESCAPE_CHARACTER,
312
+ message: "An escape character headed no escape sequence, read as the literal character it is rather than opening a sequence that never closes. The spec-clean form is the escaped one.",
313
+ position
314
+ };
315
+ }
253
316
  function ambiguousValueSplit(position) {
254
317
  return {
255
318
  code: WARNING_CODES.ASTM_RECORD_AMBIGUOUS_VALUE_SPLIT,
@@ -418,17 +481,27 @@ function classifyMessage(records) {
418
481
  let hasQuery = false;
419
482
  let hasResults = false;
420
483
  let hasOrders = false;
484
+ let hasUnrecognized = false;
421
485
  for (const r of records) {
422
486
  if (r.type === "Q") hasQuery = true;
423
487
  else if (r.type === "R") hasResults = true;
424
488
  else if (r.type === "O") hasOrders = true;
489
+ else if (r.type === "unsupported") hasUnrecognized = true;
425
490
  }
426
491
  let kind;
427
492
  if (hasQuery) kind = "host-query";
493
+ else if (hasUnrecognized) kind = "indeterminate";
428
494
  else if (hasResults) kind = "results";
429
495
  else if (hasOrders) kind = "orders";
430
496
  else kind = "indeterminate";
431
- return { kind, hasQuery, hasResults, hasOrders, isHostQueryRequest: kind === "host-query" };
497
+ return {
498
+ kind,
499
+ hasQuery,
500
+ hasResults,
501
+ hasOrders,
502
+ hasUnrecognized,
503
+ isHostQueryRequest: kind === "host-query"
504
+ };
432
505
  }
433
506
 
434
507
  // src/frames/warnings.ts
@@ -542,6 +615,7 @@ var TOLERABLE_CODES = Object.freeze(
542
615
  /* @__PURE__ */ new Set([
543
616
  WARNING_CODES.ASTM_NONSTANDARD_DELIMITERS,
544
617
  WARNING_CODES.ASTM_UNKNOWN_ESCAPE_SEQUENCE,
618
+ WARNING_CODES.ASTM_UNPAIRED_ESCAPE_CHARACTER,
545
619
  WARNING_CODES.ASTM_RECORD_UNINTERPRETED_QUERY_STATUS
546
620
  ])
547
621
  );
@@ -826,7 +900,7 @@ var defaultProfile = defineAstmProfile({
826
900
  // src/profiles/reference-corpus.ts
827
901
  var referenceCorpus = defineAstmProfile({
828
902
  name: "referenceCorpus",
829
- description: "Non-standard-escape tolerance grounded firsthand in the escape-agnostic OSS reference corpus (python-astm / senaite). Syntactic encoding noise only: the value is preserved verbatim, never a safety-critical value.",
903
+ description: "Non-standard-escape tolerance grounded firsthand in the escape-agnostic OSS reference corpus (python-astm / senaite). Mostly syntactic encoding noise, with one measured exception: an escape sequence whose body is itself a delimiter is an opaque atom, so that delimiter does not split and a result can lose its units and its status. Read the values; do not rely on this profile leaving only cosmetic deviations behind.",
830
904
  provenance: {
831
905
  source: "kxepal/python-astm codec.py (BSD) + senaite.astm / senaite.lis2a",
832
906
  reference: "https://github.com/kxepal/python-astm/blob/master/astm/codec.py ; https://github.com/senaite/senaite.astm",
@@ -836,7 +910,7 @@ var referenceCorpus = defineAstmProfile({
836
910
  tolerate: [
837
911
  {
838
912
  code: "ASTM_UNKNOWN_ESCAPE_SEQUENCE",
839
- rationale: "The OSS reference stack (python-astm codec.py, firsthand-verified) treats '&' as literal data and never decodes escape sequences, so streams from that ecosystem carry non-standard '&\u2026&' bodies our escape-aware tokenizer flags. The body is preserved byte-for-byte in the value; the deviation is recognizable, syntactic, and non-clinical."
913
+ rationale: "The OSS reference stack (python-astm codec.py, firsthand-verified) treats '&' as literal data and never decodes escape sequences, so streams from that ecosystem carry non-standard '&\u2026&' bodies our escape-aware tokenizer flags. The body is preserved byte-for-byte in the value. NOT always non-clinical, and this is measured rather than assumed: where the body is a delimiter, that delimiter never became a field boundary, so `28.6&|&U/L` reads as one value with no units and no final status. Tolerating this code hides that too."
840
914
  }
841
915
  ]
842
916
  });
@@ -861,7 +935,10 @@ function getDefaultAstmProfile() {
861
935
  }
862
936
 
863
937
  // src/common/escapes.ts
864
- function decodeEscapes(leaf, d, onUnknown) {
938
+ function isEscapeSequenceAt(text, i, escape) {
939
+ return text.charAt(i) === escape && text.charAt(i + 2) === escape;
940
+ }
941
+ function decodeEscapes(leaf, d, onUnknown, onUnpaired) {
865
942
  const esc = d.escape;
866
943
  if (!leaf.includes(esc)) return leaf;
867
944
  let out = "";
@@ -873,12 +950,13 @@ function decodeEscapes(leaf, d, onUnknown) {
873
950
  i += 1;
874
951
  continue;
875
952
  }
876
- const close = leaf.indexOf(esc, i + 1);
877
- if (close === -1) {
878
- out += leaf.slice(i);
879
- break;
953
+ if (!isEscapeSequenceAt(leaf, i, esc)) {
954
+ out += ch;
955
+ i += 1;
956
+ onUnpaired?.();
957
+ continue;
880
958
  }
881
- const body = leaf.slice(i + 1, close);
959
+ const body = leaf.charAt(i + 1);
882
960
  const replacement = escapeBody(body, d);
883
961
  if (replacement === void 0) {
884
962
  out += esc + body + esc;
@@ -886,7 +964,7 @@ function decodeEscapes(leaf, d, onUnknown) {
886
964
  } else {
887
965
  out += replacement;
888
966
  }
889
- i = close + 1;
967
+ i += 3;
890
968
  }
891
969
  return out;
892
970
  }
@@ -911,14 +989,9 @@ function splitEscapeAware(text, delimiter, escape) {
911
989
  let i = 0;
912
990
  while (i < text.length) {
913
991
  const ch = text.charAt(i);
914
- if (ch === escape) {
915
- const close = text.indexOf(escape, i + 1);
916
- if (close === -1) {
917
- current += text.slice(i);
918
- break;
919
- }
920
- current += text.slice(i, close + 1);
921
- i = close + 1;
992
+ if (ch === escape && isEscapeSequenceAt(text, i, escape)) {
993
+ current += text.slice(i, i + 3);
994
+ i += 3;
922
995
  continue;
923
996
  }
924
997
  if (ch === delimiter) {
@@ -935,26 +1008,38 @@ function splitEscapeAware(text, delimiter, escape) {
935
1008
  }
936
1009
 
937
1010
  // src/records/tokenize.ts
938
- function tokenizeRecord(record, d, onUnknownEscape) {
1011
+ function tokenizeRecord(record, d, onUnknownEscape, onUnpairedEscape) {
939
1012
  const rawFields = splitEscapeAware(record, d.field, d.escape);
940
- return rawFields.map((raw, fieldIndex) => toField(raw, d, () => onUnknownEscape?.(fieldIndex)));
1013
+ return rawFields.map(
1014
+ (raw, fieldIndex) => toField(
1015
+ raw,
1016
+ d,
1017
+ () => onUnknownEscape?.(fieldIndex),
1018
+ () => onUnpairedEscape?.(fieldIndex)
1019
+ )
1020
+ );
941
1021
  }
942
- function tokenizeHeader(record, d, onUnknownEscape) {
1022
+ function tokenizeHeader(record, d, onUnknownEscape, onUnpairedEscape) {
943
1023
  const defEnd = record.indexOf(d.field, 2);
944
1024
  const definition = defEnd === -1 ? record.slice(2) : record.slice(2, defEnd);
945
1025
  const head = [opaqueField(record.slice(0, Math.min(1, record.length))), opaqueField(definition)];
946
1026
  if (defEnd === -1) return head;
947
- const data = tokenizeRecord(record.slice(defEnd + 1), d, (i) => onUnknownEscape?.(i + 2));
1027
+ const data = tokenizeRecord(
1028
+ record.slice(defEnd + 1),
1029
+ d,
1030
+ (i) => onUnknownEscape?.(i + 2),
1031
+ (i) => onUnpairedEscape?.(i + 2)
1032
+ );
948
1033
  return [...head, ...data];
949
1034
  }
950
1035
  function opaqueField(raw) {
951
1036
  return { raw, components: [raw], repeats: [[raw]] };
952
1037
  }
953
- function toField(raw, d, onUnknownEscape) {
1038
+ function toField(raw, d, onUnknownEscape, onUnpairedEscape) {
954
1039
  const rawRepeats = splitEscapeAware(raw, d.repeat, d.escape);
955
1040
  const repeats = rawRepeats.map(
956
1041
  (rep) => splitEscapeAware(rep, d.component, d.escape).map(
957
- (comp) => decodeEscapes(comp, d, onUnknownEscape)
1042
+ (comp) => decodeEscapes(comp, d, onUnknownEscape, onUnpairedEscape)
958
1043
  )
959
1044
  );
960
1045
  const components = repeats[0] ?? [""];
@@ -1069,7 +1154,15 @@ function buildRecord(line2, recordIndex, d, warnings) {
1069
1154
  unknownEscapeSequence({ recordIndex, recordType: rawType, fieldIndex: fieldIndex + 1 })
1070
1155
  );
1071
1156
  };
1072
- const fields = rawType === "H" ? tokenizeHeader(line2, d, onUnknownEscape) : tokenizeRecord(line2, d, onUnknownEscape);
1157
+ const onUnpairedEscape = (fieldIndex) => {
1158
+ warnings.push(
1159
+ unpairedEscapeCharacter({ recordIndex, recordType: rawType, fieldIndex: fieldIndex + 1 })
1160
+ );
1161
+ };
1162
+ const fields = rawType === "H" ? tokenizeHeader(line2, d, onUnknownEscape, onUnpairedEscape) : tokenizeRecord(line2, d, onUnknownEscape, onUnpairedEscape);
1163
+ if (fields.length === 1 && line2.slice(1).trim().length > 0) {
1164
+ warnings.push(fieldsUnseparated({ recordIndex, recordType: rawType }));
1165
+ }
1073
1166
  switch (rawType) {
1074
1167
  case "H":
1075
1168
  return {
@@ -2203,7 +2296,7 @@ function concatBytes3(chunks) {
2203
2296
  }
2204
2297
 
2205
2298
  // src/index.ts
2206
- var VERSION = "0.0.8";
2299
+ var VERSION = "0.0.9";
2207
2300
 
2208
2301
  exports.ALL_ASTM_WARNING_CODES = ALL_ASTM_WARNING_CODES;
2209
2302
  exports.AMBIGUOUS_CODES = AMBIGUOUS_CODES;
@@ -2250,6 +2343,7 @@ exports.delimitersRedeclared = delimitersRedeclared;
2250
2343
  exports.detectFraming = detectFraming;
2251
2344
  exports.encodeComponent = encodeComponent;
2252
2345
  exports.fieldScalar = fieldScalar;
2346
+ exports.fieldsUnseparated = fieldsUnseparated;
2253
2347
  exports.frameBadChecksum = frameBadChecksum;
2254
2348
  exports.frameOversize = frameOversize;
2255
2349
  exports.frameSequenceGap = frameSequenceGap;
@@ -2302,6 +2396,7 @@ exports.uninterpretedQueryStatus = uninterpretedQueryStatus;
2302
2396
  exports.unitsAbsent = unitsAbsent;
2303
2397
  exports.unknownEscapeSequence = unknownEscapeSequence;
2304
2398
  exports.unknownRecordType = unknownRecordType;
2399
+ exports.unpairedEscapeCharacter = unpairedEscapeCharacter;
2305
2400
  exports.unparseableReferenceRange = unparseableReferenceRange;
2306
2401
  exports.unreadableRedeclaration = unreadableRedeclaration;
2307
2402
  //# sourceMappingURL=index.cjs.map