@cosyte/astm 0.0.13 → 0.0.15
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 +145 -0
- package/README.md +7 -1
- package/dist/index.cjs +27 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +117 -25
- package/dist/index.d.ts +117 -25
- package/dist/index.mjs +27 -3
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,123 @@ this file is maintained by hand (Changesets handles the version bump and publish
|
|
|
11
11
|
|
|
12
12
|
### Fixed
|
|
13
13
|
|
|
14
|
+
- **The escape encoder protected the escape character it introduced but not the `E`/`F`/`S`/`R`
|
|
15
|
+
mnemonics, so a delimiter set naming one of those letters in another role altered the values it
|
|
16
|
+
emitted** (`ASTM-FRAME-RESIDUALS` follow-up, defect 12). `PRE-EXISTING`, byte-identical on
|
|
17
|
+
`4bb62f1`. `encodeLeaf` ran as four chained whole-string substitutions (escape, then field,
|
|
18
|
+
component, repeat), and each pass could see what the previous ones had written. Going first
|
|
19
|
+
protected the escape character; nothing protected the mnemonic letters. Under
|
|
20
|
+
`{ field: "E", escape: "R" }` a leaf of `R` went out as `RRFRR` and read back as `RER`.
|
|
21
|
+
|
|
22
|
+
**Re-measured here rather than carried over, because the recorded figures came from a sweep whose
|
|
23
|
+
parameters were not written down.** Over the whole four-role space drawn from the 18-character
|
|
24
|
+
alphabet `|` `\` `^` `&` `E` `F` `S` `R` `*` `~` `:` `#` `+` `@` `!` `$` `%` `/` (the four
|
|
25
|
+
canonical delimiters, the four mnemonic letters, a common vendor set, neutral punctuation), that is
|
|
26
|
+
P(18,4) = 73,440 sets, against **exactly the `STREAM` constant in
|
|
27
|
+
`test/records/escape-mnemonic-roles.test.ts`**, a synthetic ten-record
|
|
28
|
+
`H`/`P`/`O`/`R`/`R`/`R`/`C`/`M`/`S`/`L` message that parses with `warnings: []`, the serializer
|
|
29
|
+
refused 23,040 and accepted **50,400**. Of those, **10,450** emitted
|
|
30
|
+
a stream this library re-read with a **different field tree**, and **9,287** reported nothing
|
|
31
|
+
outside `TOLERABLE_CODES`, so a gate-legal profile plus `{ strict: true }` accepted an altered
|
|
32
|
+
value.
|
|
33
|
+
|
|
34
|
+
**The "0 silent" reading is confirmed and is weaker than it looks, which is worth more than the
|
|
35
|
+
number.** Zero of the 10,450 re-parsed with `warnings: []`, but a non-canonical set always reports
|
|
36
|
+
`ASTM_NONSTANDARD_DELIMITERS`, and that code is itself tolerable, so `warnings: []` was never
|
|
37
|
+
reachable for any set that could exhibit this defect. Silence was not available as a measure here.
|
|
38
|
+
The tier that does discriminate is the strict-accepted one above.
|
|
39
|
+
|
|
40
|
+
**The fix is one left-to-right pass.** Each character is read once and written either as itself or
|
|
41
|
+
as a whole `escape` + mnemonic + `escape` triple, and nothing the encoder writes is examined again,
|
|
42
|
+
which makes it the **exact** inverse of `decodeEscapes` rather than approximately so. No accepted
|
|
43
|
+
set in that sweep diverges after it. Bytes change for **exactly** the 10,450 sets that were
|
|
44
|
+
diverging and for no others (measured both directions: 0 sets diverged with unchanged bytes, 0 sets
|
|
45
|
+
changed bytes without having diverged), so a canonical stream and any set naming none of the four
|
|
46
|
+
mnemonic letters is byte-identical. The accept/refuse partition is unchanged over all 73,440 sets:
|
|
47
|
+
`ASTM_EMIT_TYPE_LETTER_COLLISION` reads the first character actually written, so it needed no
|
|
48
|
+
change when the encoder underneath it did, which is the argument for testing bytes rather than
|
|
49
|
+
enumerating dangerous roles. **No clause is claimed**: the grounding is this package's own decoder.
|
|
50
|
+
|
|
51
|
+
Pinned in `test/records/escape-mnemonic-roles.test.ts`, which transcribes the four chained
|
|
52
|
+
substitutions (nothing shipped still reproduces them) and **checks that transcription against the
|
|
53
|
+
shipped encoder on all 1,680 sets where the two must agree**, so it cannot decay into a strawman.
|
|
54
|
+
8 of its assertions are red against a `git archive` of `4bb62f1`. Five of them pin the **emitted bytes as literals**, each derived in a comment rather than snapshotted, because every other assertion here reads the stream back through this package's own parser and so cannot see an encoder and decoder that agree with each other and are both wrong. The committed test pins a
|
|
55
|
+
12-character subspace (P(12,4) = 11,880) rather than the 18, because the full space costs about a
|
|
56
|
+
minute of suite time and the extra six characters are neutral punctuation already represented. The
|
|
57
|
+
corpus is the same in both: the 73,440-set figures above differ from the test's only by alphabet. The claim in
|
|
58
|
+
`test/records/type-letter-collision.test.ts` that a type letter equal to the escape character
|
|
59
|
+
"does not always encode as letter + E + letter" was pinned to `RRFRR`/`RRSRR` and is now false:
|
|
60
|
+
it encodes that way for every set, and the assertion was updated to say so rather than deleted.
|
|
61
|
+
|
|
62
|
+
- **`composeAstmFrames` wrote an out-of-range `startFrameNumber` straight into the frame-number
|
|
63
|
+
position, so a value that was not a frame number produced a stream whose records the decoder
|
|
64
|
+
refused to emit** (`ASTM-FRAME-RESIDUALS`, defect 13). `PRE-EXISTING`, byte-identical on `64c2fd5`.
|
|
65
|
+
The option is now checked before `composeAstmFrames` reads a record, and refused with the new code
|
|
66
|
+
`ASTM_FRAME_INVALID_START_FRAME_NUMBER` (a fourth member of `AstmFrameEncodeErrorCode`) unless it is
|
|
67
|
+
a whole number from `0` to `7`.
|
|
68
|
+
|
|
69
|
+
**What went out instead, measured on this package's own round trip.** A frame's number is one ASCII
|
|
70
|
+
digit, written as `FN_ZERO + n`, and nothing checked `n`. `-1` put a `/` in the frame-number
|
|
71
|
+
position, and `NaN`, `Infinity` and `-Infinity` each put a `NUL` there in **every** frame of the
|
|
72
|
+
stream, after which `decodeAstmFrames` recognised no frame number at all, warned
|
|
73
|
+
`ASTM_FRAME_SEQUENCE_GAP` on every frame, and emitted **none** of the records: a whole four-record
|
|
74
|
+
message in, zero out, and `parseFramedAstm` throwing on empty input. **The recorded defect said an
|
|
75
|
+
out-of-range value writes a non-digit byte and named a space for `NaN`. It is a `NUL`**, which is
|
|
76
|
+
the difference between an ordinary inter-frame byte the decoder skips and a byte that costs the
|
|
77
|
+
stream every one of its records.
|
|
78
|
+
|
|
79
|
+
**The quieter half is why the check covers the whole domain rather than the ends.** Values that
|
|
80
|
+
truncated back onto a digit were accepted silently and behaved as some other start value: `1.5` and
|
|
81
|
+
`257` each produced the byte-for-byte stream a `startFrameNumber` of `1` produces. The option
|
|
82
|
+
documented `0`-`7` and enforced neither bound nor integrality in either direction.
|
|
83
|
+
|
|
84
|
+
**The domain is derived from what a frame can carry, and the refusal is not a clamp.** The writable
|
|
85
|
+
numbers are exactly the whole numbers `0`-`7`, the modulo-8 sequence `decodeAstmFrames` reads and
|
|
86
|
+
rolls over. Clamping or taking a modulo would pick a frame number the caller did not ask for, and
|
|
87
|
+
the frame number is the decoder's only evidence that no frame was dropped, so a stream numbered from
|
|
88
|
+
a value nobody chose is a stream whose sequence check certifies the wrong thing. The refusal is
|
|
89
|
+
raised before `composeAstmFrames` reads a record, so on that entry point it never depends on the
|
|
90
|
+
caller's data (`serializeFramedAstm` serializes every record first, so a record that cannot be
|
|
91
|
+
serialized at all is refused ahead of it on that route). Its message names the value received: that
|
|
92
|
+
value is the caller's own option, not stream content, and nothing from the records reaches it.
|
|
93
|
+
|
|
94
|
+
**The whole `0`-`7` range is still accepted, because a non-default start has a real use that was
|
|
95
|
+
measured rather than assumed.** It composes a **continuation** of a transfer already in progress:
|
|
96
|
+
`composeAstmFrames(head)` joined with `composeAstmFrames(tail, { startFrameNumber: n })`, where `n`
|
|
97
|
+
is the number after the last frame `head` used, is **byte-identical** to composing the whole list in
|
|
98
|
+
one call and decodes with an empty warnings array, across the `7 → 0` rollover included. Narrowing
|
|
99
|
+
the option to `1` would have removed that.
|
|
100
|
+
|
|
101
|
+
**What a continuation is not, now stated on the option rather than guarded against.** Read on its
|
|
102
|
+
own, a stream that starts anywhere but `1` opens on a sequence gap; the decoder never bridges a gap
|
|
103
|
+
silently, so it warns and does not emit that first record. **What `parseFramedAstm` does after that
|
|
104
|
+
varies with the message shape, and no rule is offered for it:** it may throw, under more than one
|
|
105
|
+
code (`ASTM_RECORD_NO_HEADER`, `EMPTY_INPUT` and `ASTM_RECORD_UNDECLARED_DELIMITERS` were each
|
|
106
|
+
measured), and it may return a message one record short. **The one statement that generalizes is
|
|
107
|
+
that the record layer never reports the loss**, because `parseFramedAstm` hands the record parser
|
|
108
|
+
only the frames the codec vouched for, so `message.warnings` carries what the surviving records
|
|
109
|
+
warrant and nothing about the record that did not survive. Read `frameWarnings`. Nothing returns the number to continue a sequence from either, and the frame count
|
|
110
|
+
is not it once a record splits, so the supported computation is named on the option. The
|
|
111
|
+
documented-valid `0` has always behaved that way. It is the cost of the option, not a defect in the
|
|
112
|
+
caller's records, and it is why `1` is the default.
|
|
113
|
+
|
|
114
|
+
**This is a narrowing on a published package, in a small population.** `1.5` and `257` used to
|
|
115
|
+
produce a working stream, the byte-for-byte start-at-`1` one, and now throw. Every value the check
|
|
116
|
+
turns away was already outside what the option documented: a frame number, a whole number `0`-`7`.
|
|
117
|
+
|
|
118
|
+
Both the old bytes and the new refusal are pinned in `test/frames/start-frame-number.test.ts`,
|
|
119
|
+
asserted on what the old encoder produced (rebuilt with the test-only `frame()` builder, so nothing
|
|
120
|
+
is transcribed twice), with a biconditional property: a value is refused **if and only if** it is
|
|
121
|
+
not a whole number in `0`-`7`. 14 of the 32 new tests are red against `64c2fd5`.
|
|
122
|
+
|
|
123
|
+
- **Three `{@link}` targets in the published `.d.ts` did not name a symbol declared there**
|
|
124
|
+
(`ASTM-FRAME-RESIDUALS`, the sibling minors). `QuirkTolerance` is `AstmQuirkTolerance` (twice, in
|
|
125
|
+
`src/profiles/types.ts`); the `startFrameNumber` doc linked `FIRST_FRAME_NUMBER`, which this
|
|
126
|
+
package does not export, and that sentence was rewritten and no longer links it; and
|
|
127
|
+
`AstmMessage.profile` linked a bare `warnings`, meaning its sibling member, which resolves only for
|
|
128
|
+
a tool that resolves against the enclosing declaration. Every `{@link}` target in
|
|
129
|
+
`dist/index.d.ts` now names a symbol declared in that file.
|
|
130
|
+
|
|
14
131
|
- **A delimiter set colliding with a record's type letter emitted a stream that read back as
|
|
15
132
|
DIFFERENT records, and in its silent branch it fabricated a final lab result out of patient
|
|
16
133
|
identifiers** (`ASTM-FRAME-RESIDUALS`, defect 5). `PRE-EXISTING`, byte-identical on `7253098`,
|
|
@@ -323,6 +440,34 @@ this file is maintained by hand (Changesets handles the version bump and publish
|
|
|
323
440
|
that only ever fails is not a gate and one that swallows the status is not one either. **No
|
|
324
441
|
runtime code changed and the built output is identical.**
|
|
325
442
|
|
|
443
|
+
### Documented
|
|
444
|
+
|
|
445
|
+
- **A header delimiter-declaration surplus loses all 31 of the C0/`DEL` characters that can reach
|
|
446
|
+
it, and the published surfaces stated the preservation rule without that exception.**
|
|
447
|
+
`PRE-EXISTING`; the
|
|
448
|
+
behaviour is unchanged and argued at `declarationResidual`. What changed is that it is measured,
|
|
449
|
+
pinned and stated, and that **one of the recorded reasons for it is now known to be false**:
|
|
450
|
+
"carrying the byte through would turn a spec-clean header into a stream the frame layer refuses"
|
|
451
|
+
holds for `STX`, `ETX` and `ETB` only. Measured, the other **28 of the 31** round-trip byte-exactly
|
|
452
|
+
through `composeAstmFrames` into `decodeAstmFrames` with `warnings: []`, and all 31 round-trip at
|
|
453
|
+
the record layer. The disposition still stands, on the reason the code site actually gives: rather
|
|
454
|
+
than enumerate the bytes each layer happens to reserve and re-derive that list every time a layer
|
|
455
|
+
is added, no control character is carried at all. Do not restate the frame-layer reason. Probing one character at a time in the surplus of an otherwise spec-clean
|
|
456
|
+
`H|\^&` header: **31 of the 33** C0/`DEL` characters reach the rule and are dropped, each with
|
|
457
|
+
`warnings: []` and no error; the other two are `CR` and `LF`, which end the record while it is being
|
|
458
|
+
read, so no surplus ever holds them. The drop is **all-or-nothing**, so a surplus of `#` + `US` + `$`
|
|
459
|
+
loses the `#` and the `$` as well, and that is deliberate: a subsequence of an opaque run whose
|
|
460
|
+
meaning is unresolved is a different run, not a shorter version of the same one, and choosing which
|
|
461
|
+
characters to keep is the guess this library declines everywhere else. `docs-content/quickstart.md`
|
|
462
|
+
attached the control-character drop to the transcoding case alone, which is wrong: it fires on the
|
|
463
|
+
**default canonical path** too. The `serializeAstmRecords` and `serializeAstmRecord` doc comments
|
|
464
|
+
and this module's own doc block, all of which compile into the published declarations, stated the
|
|
465
|
+
preservation rule unqualified. All are
|
|
466
|
+
widened rather than rewritten, and `docs-content/limitations.md`, which already disclosed the drop,
|
|
467
|
+
gains the all-or-nothing shape and the count. Pinned in
|
|
468
|
+
`test/records/declaration-surplus-residual.test.ts`, which passes on `4bb62f1` by design: it
|
|
469
|
+
measures behaviour this entry does not change.
|
|
470
|
+
|
|
326
471
|
### Added
|
|
327
472
|
|
|
328
473
|
- **`ASTM_RECORD_FIELDS_UNSEPARATED`: a record that lost its fields no longer loses them in
|
package/README.md
CHANGED
|
@@ -53,7 +53,13 @@ reference parser, [`@cosyte/hl7`](https://github.com/cosyte/hl7).
|
|
|
53
53
|
of a frame, framing has no escape sequence for them, and writing one through truncated the frame at
|
|
54
54
|
that byte, silently losing a whole record whenever the two bytes after it happened to be the short
|
|
55
55
|
frame's checksum. The **record** layer still carries them, because a returned string is not yet on a
|
|
56
|
-
wire and a raw-transport consumer round-trips such a value exactly.
|
|
56
|
+
wire and a raw-transport consumer round-trips such a value exactly. `startFrameNumber` lets you
|
|
57
|
+
compose one transfer across several calls, and `composeAstmFrames` checks it before it reads a
|
|
58
|
+
record: a frame's number is a single ASCII digit, so a value that is not a whole number from `0` to
|
|
59
|
+
`7` is a typed error rather than whatever byte the arithmetic truncated to. The round trip above is
|
|
60
|
+
the **default** start; a non-default one writes a continuation of a sequence already in progress,
|
|
61
|
+
and a continuation read on its own opens on a frame-sequence gap, so its first record is warned
|
|
62
|
+
about and not emitted.
|
|
57
63
|
- **Vendor profiles.** `defineAstmProfile()` builds a provenance-backed profile whose tolerances
|
|
58
64
|
downgrade _expected_, non-safety-critical deviations to a `PROFILE_QUIRK_APPLIED` warning without
|
|
59
65
|
ever altering a value, behind a safety gate that refuses to tolerate any result value, flag,
|
package/dist/index.cjs
CHANGED
|
@@ -1435,6 +1435,13 @@ function encodeComponent(leaf, d, recordIndex) {
|
|
|
1435
1435
|
assertEmittableDelimiters(d, recordIndex);
|
|
1436
1436
|
return encodeLeaf(leaf, d, recordIndex);
|
|
1437
1437
|
}
|
|
1438
|
+
function escapeMnemonic(ch, d) {
|
|
1439
|
+
if (ch === d.escape) return "E";
|
|
1440
|
+
if (ch === d.field) return "F";
|
|
1441
|
+
if (ch === d.component) return "S";
|
|
1442
|
+
if (ch === d.repeat) return "R";
|
|
1443
|
+
return void 0;
|
|
1444
|
+
}
|
|
1438
1445
|
function encodeLeaf(leaf, d, recordIndex) {
|
|
1439
1446
|
if (leaf.includes("\r") || leaf.includes("\n")) {
|
|
1440
1447
|
throw new AstmSerializeError(
|
|
@@ -1442,7 +1449,13 @@ function encodeLeaf(leaf, d, recordIndex) {
|
|
|
1442
1449
|
recordIndex
|
|
1443
1450
|
);
|
|
1444
1451
|
}
|
|
1445
|
-
|
|
1452
|
+
let out = "";
|
|
1453
|
+
for (let i = 0; i < leaf.length; i += 1) {
|
|
1454
|
+
const ch = leaf.charAt(i);
|
|
1455
|
+
const mnemonic = escapeMnemonic(ch, d);
|
|
1456
|
+
out += mnemonic === void 0 ? ch : d.escape + mnemonic + d.escape;
|
|
1457
|
+
}
|
|
1458
|
+
return out;
|
|
1446
1459
|
}
|
|
1447
1460
|
function encodeField(repeats, d, recordIndex) {
|
|
1448
1461
|
return repeats.map((rep) => rep.map((c) => encodeLeaf(c, d, recordIndex)).join(d.component)).join(d.repeat);
|
|
@@ -2290,6 +2303,15 @@ function assertFramable(bytes, recordIndex) {
|
|
|
2290
2303
|
}
|
|
2291
2304
|
}
|
|
2292
2305
|
}
|
|
2306
|
+
function assertStartFrameNumber(value) {
|
|
2307
|
+
if (!Number.isInteger(value) || value < 0 || value > FRAME_NUMBER_MODULUS - 1) {
|
|
2308
|
+
throw new AstmFrameEncodeError(
|
|
2309
|
+
`startFrameNumber must be a whole number from 0 to 7: a frame's number is one ASCII digit and the sequence is modulo 8. Received: ${String(value)}.`,
|
|
2310
|
+
void 0,
|
|
2311
|
+
"ASTM_FRAME_INVALID_START_FRAME_NUMBER"
|
|
2312
|
+
);
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2293
2315
|
function encodeFrame(text, frameNumber, isFinal) {
|
|
2294
2316
|
const term = isFinal ? ETX : ETB;
|
|
2295
2317
|
const bytes = [STX, FN_ZERO + frameNumber, ...text, term];
|
|
@@ -2298,11 +2320,13 @@ function encodeFrame(text, frameNumber, isFinal) {
|
|
|
2298
2320
|
return [...bytes, hex.charCodeAt(0), hex.charCodeAt(1), CR, LF];
|
|
2299
2321
|
}
|
|
2300
2322
|
function composeAstmFrames(records, options = {}) {
|
|
2323
|
+
const start = options.startFrameNumber ?? FIRST_FRAME_NUMBER;
|
|
2324
|
+
assertStartFrameNumber(start);
|
|
2301
2325
|
if (records.length === 0) {
|
|
2302
2326
|
throw new AstmFrameEncodeError("Cannot frame an empty record list: nothing to transmit.");
|
|
2303
2327
|
}
|
|
2304
2328
|
const out = [];
|
|
2305
|
-
let frameNumber =
|
|
2329
|
+
let frameNumber = start;
|
|
2306
2330
|
records.forEach((record, recordIndex) => {
|
|
2307
2331
|
const bytes = toBytes(record, recordIndex);
|
|
2308
2332
|
if (bytes.length === 0) {
|
|
@@ -2347,7 +2371,7 @@ function concatBytes3(chunks) {
|
|
|
2347
2371
|
}
|
|
2348
2372
|
|
|
2349
2373
|
// src/index.ts
|
|
2350
|
-
var VERSION = "0.0.
|
|
2374
|
+
var VERSION = "0.0.15";
|
|
2351
2375
|
|
|
2352
2376
|
exports.ALL_ASTM_WARNING_CODES = ALL_ASTM_WARNING_CODES;
|
|
2353
2377
|
exports.AMBIGUOUS_CODES = AMBIGUOUS_CODES;
|