@cosyte/x12 0.0.2 → 0.0.4
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 +381 -3
- package/README.md +11 -6
- package/dist/index.cjs +309 -251
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +611 -230
- package/dist/index.d.ts +611 -230
- package/dist/index.mjs +304 -252
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,8 +7,383 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`X12OrphanSegment.anchor`**, plus the `X12OrphanAnchor` / `X12OrphanAnchorKind` types. Every
|
|
13
|
+
retained orphan now records **where it sat in the structure** rather than only where it sat in the
|
|
14
|
+
byte stream: `{ kind: "interchange", groupIndex }` for a segment outside every functional group,
|
|
15
|
+
`{ kind: "group", groupIndex, transactionIndex }` for one inside a group but outside every
|
|
16
|
+
transaction set, and `{ kind: "transaction", groupIndex, transactionIndex, segmentOffset }` for one
|
|
17
|
+
inside an open `ST..SE` - which only a `TA1` can be, since anything else arriving there is body
|
|
18
|
+
content. An index equal to the eventual length means "after the last one" (immediately before the
|
|
19
|
+
`GE` or the `IEA`), and `segmentOffset` is never `0` because `rawSegments[0]` is always the `ST`.
|
|
20
|
+
- **`X12Interchange.orphanSegments`** and the `X12OrphanSegment` type. Every segment that falls
|
|
21
|
+
outside an `ST..SE` transaction set is now retained verbatim (`raw`, the decoded `segment`, its
|
|
22
|
+
`segmentIndex`, and the library-owned `context` discriminant) instead of being discarded.
|
|
23
|
+
`segmentIndex` equals the `position.segmentIndex` of that segment's `X12_UNEXPECTED_SEGMENT`
|
|
24
|
+
warning, so the two surfaces join without string matching. Empty for a well-formed interchange.
|
|
25
|
+
**Treat it as PHI when logging.** It sits on the model side of this library's diagnostic boundary:
|
|
26
|
+
a warning `message` is a frozen-registry lookup with positional metadata only, but an orphan is
|
|
27
|
+
document content, verbatim, exactly like `tx.rawSegments`. Log `context` and `segmentIndex`.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- **A segment outside a transaction now survives a round trip, and so does its warning.**
|
|
32
|
+
`serializeX12` re-emits every entry of `ix.orphanSegments` at its structural `anchor`, so a
|
|
33
|
+
consumer who serializes an interchange and re-derives warnings from the copy no longer loses the
|
|
34
|
+
segment or the `X12_UNEXPECTED_SEGMENT` that described it. Placement is **by the anchor and never
|
|
35
|
+
by `segmentIndex`** - that index addresses the _input_ stream, which the emit does not follow, and
|
|
36
|
+
a replay keyed on it was measured splicing a stray segment into an 835's `ST..SE` body with no
|
|
37
|
+
warning on the re-parse. An anchor names a slot in the typed tree, which is invariant under both
|
|
38
|
+
the `ta1Segments` hoist and the skipped zero-length segment. Use `segmentIndex` to join an orphan
|
|
39
|
+
to its warning, never to place it.
|
|
40
|
+
|
|
41
|
+
Measured on a stray segment inserted at every position of a two-group, three-transaction
|
|
42
|
+
interchange, over five segment ids (`ZZ`, `SE`, `GE`, `ST`, `TA1`) covering all five orphan
|
|
43
|
+
`context` values and all three anchor kinds: **50 of the 50 insertions that produce an orphan
|
|
44
|
+
round-trip byte-exactly** on a base with no envelope-level `TA1`. On the same base _with_ one, all
|
|
45
|
+
**54** differ - and all 54 are byte-identical once the `TA1` is removed from both sides, so the
|
|
46
|
+
only thing that moved is the `TA1`, which moves on that base with no orphan present at all. Across
|
|
47
|
+
all 104: transaction bodies, `orphanSegments` (raw, context, anchor), `ta1Segments`,
|
|
48
|
+
`trailingBytes` and the warning multiset are unchanged by the round trip, and every emit is a fixed
|
|
49
|
+
point.
|
|
50
|
+
|
|
51
|
+
**SE-01 now counts an orphan re-emitted between the `ST` and the `SE`.** A `TA1` that arrived
|
|
52
|
+
inside an open transaction set is lifted off `tx.rawSegments` by the walker but is re-emitted
|
|
53
|
+
where it came from, so it is a segment of that transaction set for SE-01 purposes ("segments
|
|
54
|
+
included in the transaction set, including ST and SE", X12.6). Reconciling against the model alone
|
|
55
|
+
would describe bytes the serializer did not write: `{ specClean: true, recomputeCounts: true }`
|
|
56
|
+
would shrink a **correct** `SE*4*` to `SE*3*` over four emitted segments, and the inverse input
|
|
57
|
+
would draw no mismatch warning at all. Both counts now come from the emitted range, so recompute
|
|
58
|
+
is idempotent under the library's own reconciliation. An orphan emitted before the `ST` or after
|
|
59
|
+
the `SE` is outside the range and is not counted, and GE-01 / IEA-01 are unaffected because an
|
|
60
|
+
orphan is never a `GS` and never opens a transaction set.
|
|
61
|
+
|
|
62
|
+
**`KNOWN-LIMITATIONS.md` is therefore down from seven constructs to six**, and the remaining five
|
|
63
|
+
silent ones no longer include anything that loses a warning: line breaks, a doubled terminator, a
|
|
64
|
+
missing final terminator, the `TA1` reorder, and a segment whose first element is empty outside a
|
|
65
|
+
transaction (still the one construct that loses a value with no diagnostic at all - it is skipped
|
|
66
|
+
by the walker, so there is nothing on the model to re-emit, and it is deliberately unchanged here).
|
|
67
|
+
Retention and placement are still **not** promotion: no `get*` reader sees an orphan, and a `TA1`
|
|
68
|
+
inside a group still does not join `ta1Segments`. No new warning code (registry unchanged at 22
|
|
69
|
+
codes, 4 fatals), and no construct became fatal.
|
|
70
|
+
|
|
71
|
+
- **Silent data loss: a segment outside a transaction was dropped from the model, and a
|
|
72
|
+
double-spaced file lost its entire interchange body.** Two defects with one cause. The envelope
|
|
73
|
+
walker raised `X12_UNEXPECTED_SEGMENT` for a segment it could not place and then discarded it, so
|
|
74
|
+
the segment's bytes were unrecoverable. Separately the line-break tolerance was exactly one
|
|
75
|
+
optional CR then one optional LF, which admitted **4 of the 15** CR/LF sequences of length 0 to 3;
|
|
76
|
+
the other 11 left a break in the stream that opened an unrecognized segment, so via the first
|
|
77
|
+
defect a uniformly **double-spaced file returned `groups: []`**.
|
|
78
|
+
|
|
79
|
+
The parser now absorbs any run of CR / LF bytes between segments (safe because a CR or LF in the
|
|
80
|
+
segment-terminator position, the byte immediately after ISA-16, is refused as the Tier-3 fatal
|
|
81
|
+
`X12_INVALID_DELIMITERS` (as it is at all four delimiter positions), so such a run is never
|
|
82
|
+
structural), and every unplaceable segment is retained on `ix.orphanSegments` through a
|
|
83
|
+
single chokepoint that raises the warning and records the segment together, so the two can never
|
|
84
|
+
disagree. Measured before, then after: CR/LF sequences of length 0 to 3 that frame correctly, **4
|
|
85
|
+
of 15 then 15 of 15**; orphan cases that retain the segment, **0 of 10 then 10 of 10** (ten
|
|
86
|
+
constructed cases over nine distinct positions). Across the
|
|
87
|
+
56 committed fixtures nothing changed: zero model divergences, zero warning divergences, zero
|
|
88
|
+
fixed-point failures, and no fixture produces an orphan.
|
|
89
|
+
|
|
90
|
+
**This fixed the model; the emit was closed separately, by anchor.** See the round-trip entry
|
|
91
|
+
below. A positional replay keyed on `segmentIndex` was built and then removed during _this_ change
|
|
92
|
+
because it was unsound: the emit is not in input order (it hoists `ta1Segments`) and skips the
|
|
93
|
+
zero-length segment a doubled terminator produces, so replaying by input index spliced the orphan
|
|
94
|
+
into whatever occupied that slot. Measured on a two-group interchange with a TA1 after the first
|
|
95
|
+
group, that put a stray segment inside an 835's `ST..SE` body between `CLP` and `SE` with **no
|
|
96
|
+
warning at all** on the re-parse, made a stray `SE` close the transaction early and corrupt SE-01,
|
|
97
|
+
and carried an orphan across the IEA into `trailingBytes`. A documented omission was preferable to
|
|
98
|
+
silent structural corruption, and there are regression tests fencing each of those shapes.
|
|
99
|
+
|
|
100
|
+
**Retention is not placement.** An orphan is not decoded by any `get*` reader, and a `TA1` inside
|
|
101
|
+
an open group is not added to `ta1Segments` (that surface means "envelope-level TA1", and is what
|
|
102
|
+
`parseTA1` reads). Neither a doubled segment terminator nor a segment whose first element is empty
|
|
103
|
+
is recorded as an orphan; both are long-standing behaviour, now stated rather than implied.
|
|
104
|
+
|
|
105
|
+
Also in this change: a trailing CR/LF run after the final segment terminator is absorbed rather
|
|
106
|
+
than surfacing as `trailingBytes` (previously `~\n\n` there produced a `trailingBytes` of `"\n~"`,
|
|
107
|
+
a byte the input never contained, plus an `X12_TRAILING_GARBAGE` warning); a double-spaced file
|
|
108
|
+
that previously produced 7 warnings now parses cleanly with none; and the five
|
|
109
|
+
`X12_UNEXPECTED_SEGMENT` messages were rewritten, since each stated that the segment was not
|
|
110
|
+
retained. No warning code was added or removed (registry unchanged at 22 warnings and 4 fatals) and
|
|
111
|
+
nothing new throws.
|
|
112
|
+
|
|
113
|
+
### Documentation
|
|
114
|
+
|
|
115
|
+
> **Read the "Fixed" entry above first: it ships in the same release and supersedes two statements
|
|
116
|
+
> made below.** The entry below describes the state of the parser BEFORE that fix, and two of its
|
|
117
|
+
> present-tense sentences are no longer true as of this release: the parser now absorbs **any run of
|
|
118
|
+
> CR / LF** between segments, not "an optional CR then an optional LF"; and a segment outside a
|
|
119
|
+
> transaction is **retained on `ix.orphanSegments`**, not "discarded". What still holds from it, and
|
|
120
|
+
> is the reason it is kept, is the round-trip scope: `serialize(parse(s)) === s` is not guaranteed,
|
|
121
|
+
> the absence of line breaks is not sufficient, `KNOWN-LIMITATIONS.md` is the canonical list at
|
|
122
|
+
> **seven** constructs, and an orphan is still not re-emitted.
|
|
123
|
+
|
|
124
|
+
- **Corrected the round-trip claim: `serialize(parse(s)) === s` is NOT guaranteed, and the emit is
|
|
125
|
+
byte-faithful only for the segments the parser recorded on the model.** The README described the
|
|
126
|
+
default emit mode as "byte-faithful by default" with no qualification, and line-ending handling was
|
|
127
|
+
absent from the entire consumer surface (zero mentions across the README, all nine `docs-content/`
|
|
128
|
+
pages, and `KNOWN-LIMITATIONS.md`). Meanwhile the parser absorbs an optional CR then an optional LF
|
|
129
|
+
after every segment terminator and the model has nowhere to record it. Re-measured against the
|
|
130
|
+
committed corpus: **42 of the 56 fixtures do not return byte-identical, and all 42 differ from their
|
|
131
|
+
source by line breaks and nothing else**; the remaining 14 carry no line breaks and do return
|
|
132
|
+
byte-identical.
|
|
133
|
+
|
|
134
|
+
Nothing about the emit changed. What changed is the claim. Line breaks turned out to be only the
|
|
135
|
+
most common of **six** constructs the emit does not reproduce, and the other five fire on inputs
|
|
136
|
+
containing no line breaks at all, so "my file is compact" is not sufficient grounds to expect byte
|
|
137
|
+
equality: segments outside a transaction (raised as `X12_UNEXPECTED_SEGMENT` on the first parse, then
|
|
138
|
+
discarded, so the segment **and its warning** are absent from the emit), a doubled segment terminator
|
|
139
|
+
outside a transaction, a missing final terminator (the emit supplies one), post-IEA `trailingBytes`
|
|
140
|
+
(re-joined from segment slices rather than preserved verbatim), and a **TA1 that followed a
|
|
141
|
+
functional group**, which is collected onto `ix.ta1Segments` and emitted immediately after the ISA,
|
|
142
|
+
so the emit **reorders** it (nothing is lost there: the model and warning stream round-trip
|
|
143
|
+
identically, and no position is taken on where ASC X12 requires a TA1 to sit). **Four of the six are
|
|
144
|
+
silent**, so a clean `ix.warnings` is not evidence that a round trip will be byte-exact.
|
|
145
|
+
`KNOWN-LIMITATIONS.md` now holds the canonical list, and the other sites link to it and carry the
|
|
146
|
+
load-bearing warnings rather than restating a count: `serialize(parse(s)) === s` is not guaranteed,
|
|
147
|
+
the absence of line breaks is not sufficient, and `serializeX12(parseX12(source))` must not be used
|
|
148
|
+
as a normalization step before comparing warnings.
|
|
149
|
+
|
|
150
|
+
The properties that hold are now stated **as measured over the committed corpus** rather than as
|
|
151
|
+
universals: every emit is a fixed point and re-parses to an identical model with an identical warning
|
|
152
|
+
stream, the 14 line-break-free fixtures return byte-identical, and the other 42 differ by line breaks
|
|
153
|
+
and nothing else. Two caveats bound that sweep and are stated with it: the corpus contains no
|
|
154
|
+
instance of the five non-line-break cases, and 13 of the 14 byte-identical fixtures are
|
|
155
|
+
`golden/*.edi`, which are serializer output by construction, leaving `envelope/no-trailing-crlf.edi`
|
|
156
|
+
as the only independent witness.
|
|
157
|
+
|
|
158
|
+
Also corrected: `KNOWN-LIMITATIONS.md` and `troubleshooting.md` both opened by promising the lenient
|
|
159
|
+
parser "never silently drops or garbles data". Both now scope that to a **decoded value** and name
|
|
160
|
+
the two things that are discarded. `spec-notes-tolerance.md` presented a three-tier taxonomy with no
|
|
161
|
+
slot for a silent normalization; the tiers now name it, and note that a Tier-2 unexpected segment is
|
|
162
|
+
warned about but not kept.
|
|
163
|
+
|
|
164
|
+
### Documented, not fixed
|
|
165
|
+
|
|
166
|
+
> **Superseded in part by the "Fixed" entry above, which ships in the same release.** The model half
|
|
167
|
+
> of the entry below is now fixed: the segment is retained on `ix.orphanSegments`, and a double-spaced
|
|
168
|
+
> file no longer loses its interchange body. The **round-trip** half still stands: `serializeX12` does
|
|
169
|
+
> not re-emit an orphan, so the warning still does not survive. The two entries are kept separate
|
|
170
|
+
> because they were separate changes, and the sentences below describe the release before this one.
|
|
171
|
+
|
|
172
|
+
- **A segment that falls outside a transaction is dropped from the model, and its warning does not
|
|
173
|
+
survive a round trip.** The envelope walker keeps body segments only while an ST..SE transaction is
|
|
174
|
+
open; anything else raises `X12_UNEXPECTED_SEGMENT` and is then discarded. A **blank** line between
|
|
175
|
+
segments (`~\n\n`) exceeds the one-CR-plus-one-LF tolerance and triggers exactly this, so a uniformly
|
|
176
|
+
double-spaced file loses its **entire interchange body** and parses to `groups: []`. The first parse
|
|
177
|
+
does warn, so it is detectable rather than silent, but the warning is the only signal and it cannot
|
|
178
|
+
be recovered from the emit. Long-standing behaviour, unchanged here and reproducing on the previous
|
|
179
|
+
release; now disclosed in `README`, `KNOWN-LIMITATIONS.md`, `troubleshooting.md` and
|
|
180
|
+
`spec-notes-envelope.md` rather than latent, and pinned by tests, while the fix is scoped as its own
|
|
181
|
+
item.
|
|
182
|
+
|
|
183
|
+
### Tests
|
|
184
|
+
|
|
185
|
+
- **The default emit mode's guarantees are locked against the whole committed corpus, not just the 13
|
|
186
|
+
goldens.** The goldens are already in the serializer's image, so they could only ever demonstrate
|
|
187
|
+
the easy half of the round trip. `test/serialize.test.ts` now discovers every `.edi` fixture from
|
|
188
|
+
disk (so a fixture added later is covered without anyone remembering) and asserts four properties per
|
|
189
|
+
fixture: the emit differs from its source by line breaks and nothing else, it re-parses to an
|
|
190
|
+
identical model with an identical warning stream, it is a fixed point, and it is byte-identical to
|
|
191
|
+
its source exactly when the source has no line breaks. A guard test asserts the corpus really
|
|
192
|
+
contains both pretty-printed and compact fixtures, so the sweep cannot pass vacuously. Separate cases
|
|
193
|
+
lock that LF, CRLF, and bare CR all normalize to one identical compact form.
|
|
194
|
+
|
|
195
|
+
- **A `round-trip escape hatches` suite pins the five inputs that falsify the corpus sweep's
|
|
196
|
+
biconditional**, none of which contains a line break: a segment outside a transaction (asserting
|
|
197
|
+
both that the value is gone from the emit and that its warning does not recur on re-parse), a
|
|
198
|
+
doubled terminator, a missing final terminator, post-IEA trailing bytes, and a TA1 following a
|
|
199
|
+
functional group (asserting it is reordered ahead of the GS but not lost, and still a fixed point).
|
|
200
|
+
Each case asserts its own warning count rather than describing it, so the fact that three of them
|
|
201
|
+
are silent is pinned rather than claimed. The committed corpus contains no instance of any of them,
|
|
202
|
+
so without these the sweep would stay green while the prose around it claimed more than the sweep
|
|
203
|
+
could see. The blank-line case now asserts the actual outcome (`groups: []` and an empty emit body)
|
|
204
|
+
instead of only the warning; an earlier revision described that input as "reported not swallowed",
|
|
205
|
+
which is exactly backwards.
|
|
206
|
+
|
|
207
|
+
### Security
|
|
208
|
+
|
|
209
|
+
- **Warning messages are built from a frozen registry instead of from the document, closing a PHI
|
|
210
|
+
leak in every envelope control number and declared count.** Before this change
|
|
211
|
+
`X12_CONTROL_NUMBER_MISMATCH` rendered **both** sides of the disagreeing pair into `message`
|
|
212
|
+
verbatim and unbounded, on all six slots (`ISA-13`/`IEA-02`, `GS-06`/`GE-02`, `ST-02`/`SE-02`); a
|
|
213
|
+
300,000-byte trailer control number produced a 300,062-byte `message`. `X12_GROUP_COUNT_MISMATCH`,
|
|
214
|
+
`X12_TRANSACTION_COUNT_MISMATCH`, `X12_SEGMENT_COUNT_MISMATCH` and `X12_PRE_005010` did the same
|
|
215
|
+
with `IEA-01`, `GE-01`, `SE-01` and `ISA-12`, and `X12_835_REMIT_BALANCE_MISMATCH` rendered three
|
|
216
|
+
monetary amounts. Five of those six control-number slots are variable-width, so "it is only a
|
|
217
|
+
control number" was never a bound: it is free-form trading-partner text that routinely carries a
|
|
218
|
+
batch or patient-account identifier.
|
|
219
|
+
|
|
220
|
+
**No warning factory takes a value parameter any more.** Each takes a `X12Position` plus, where one
|
|
221
|
+
code covers several situations, a library-owned discriminant (`CONTROL_NUMBER_PAIRS`,
|
|
222
|
+
`UNEXPECTED_SEGMENT_CONTEXTS`, `BALANCE_INVARIANTS`, `REQUIRED_LOOPS`), and `message` is a lookup
|
|
223
|
+
into a frozen table. That replaces the previous posture, which shape-validated an echoed value
|
|
224
|
+
against a spec grammar and substituted `(non-spec)` when it did not match. The shape test held for
|
|
225
|
+
the code-list slots (CARC, RARC, HI qualifier, CSCC, CSC, maintenance type, 837 variant, segment
|
|
226
|
+
id) and could not hold for a control number, whose grammar is whatever the sender sent. Taking no
|
|
227
|
+
value at all is a property of the signature rather than a filter someone has to remember to apply.
|
|
228
|
+
|
|
229
|
+
Nothing is discarded: every value is still preserved verbatim on the model, which is where a
|
|
230
|
+
consumer that has decided it may handle PHI reads it.
|
|
231
|
+
|
|
232
|
+
- **A strict-mode escalation no longer carries a snippet.** `parseX12(raw, { strict: true })` turned
|
|
233
|
+
the first Tier-2 warning into an `X12ParseError` carrying 64 bytes of the interchange, which put
|
|
234
|
+
document bytes into `err.stack` and from there into any error reporter. The escalated error now
|
|
235
|
+
carries `snippet: ""`; its `message` is the registry entry the warning carried and `position`
|
|
236
|
+
locates it. `snippet` remains on the four Tier-3 structural fatals, which are raised before the
|
|
237
|
+
envelope is readable, and remains the library's one deliberate exception.
|
|
238
|
+
|
|
239
|
+
- **`X12Segment.id` is bounded to the X12 segment-id grammar.** It is a derived structural
|
|
240
|
+
identifier, the field a downstream package interpolates to say where something is, and it was a
|
|
241
|
+
verbatim copy of the segment's first element. A sender that put a 300,000-byte value there had it
|
|
242
|
+
copied into any locus built from `seg.id`. A first element outside the grammar now yields the
|
|
243
|
+
exported `NON_SPEC_SEGMENT_ID` sentinel; `seg.raw` and `seg.elements[0]` keep the bytes, so
|
|
244
|
+
round-trip stays byte-exact.
|
|
245
|
+
|
|
246
|
+
- **`X12_INVALID_DELIMITERS` no longer echoes the detected element separator byte.** The message names
|
|
247
|
+
the fixed ISA position that broke instead.
|
|
248
|
+
|
|
249
|
+
### Added
|
|
250
|
+
|
|
251
|
+
- **`ALL_WARNING_MESSAGES`**, the frozen set of every message string the library can emit, so a
|
|
252
|
+
consumer or a conformance gate can assert `ix.warnings.every((w) => ALL_WARNING_MESSAGES.has(w.message))`.
|
|
253
|
+
- **`CONTROL_NUMBER_PAIRS`, `UNEXPECTED_SEGMENT_CONTEXTS`, `BALANCE_INVARIANTS`, `REQUIRED_LOOPS`**
|
|
254
|
+
and their `X12ControlNumberPair` / `X12UnexpectedSegmentContext` / `X12BalanceInvariant` /
|
|
255
|
+
`X12RequiredLoop` types: the library-owned discriminants the warning factories now take in place of
|
|
256
|
+
a value.
|
|
257
|
+
- **`NON_SPEC_SEGMENT_ID`**, the sentinel `X12Segment.id` takes when the first element is not a spec
|
|
258
|
+
segment id.
|
|
259
|
+
|
|
260
|
+
### Changed
|
|
261
|
+
|
|
262
|
+
- **BREAKING (pre-alpha): every exported warning factory changed signature.**
|
|
263
|
+
`controlNumberMismatch(position, pair)`, `unexpectedSegment(position, context)`,
|
|
264
|
+
`remitBalanceMismatch(position, invariant)` and `missingRequiredLoop(position, loop)` now take a
|
|
265
|
+
discriminant from the constants above; `pre005010`, `groupCountMismatch`,
|
|
266
|
+
`transactionCountMismatch`, `segmentCountMismatch`, `trailingGarbage`, `unknownCarc`,
|
|
267
|
+
`unknownRarc`, `hlParentMismatch`, `hlParentLevelInvalid`, `unknownHiQualifier`,
|
|
268
|
+
`unknown837Variant`, `unknownClaimStatusCategory`, `unknownClaimStatus` and
|
|
269
|
+
`unknownMaintenanceType` now take a `position` only.
|
|
270
|
+
- **Warning messages no longer carry the declared-versus-actual counts or the balance amounts.** Both
|
|
271
|
+
sides of each are on the model (`iea.elements[1]` against `ix.groups.length`,
|
|
272
|
+
`claim.totalChargeAmount` against `claim.totalPaymentAmount` and the CAS adjustments), so the
|
|
273
|
+
information is one dereference away rather than rendered into a string a consumer logs by default.
|
|
274
|
+
|
|
275
|
+
### Fixed
|
|
276
|
+
|
|
277
|
+
- **Every out-of-balance claim and service line now gets a distinct warning position.** `CLP`
|
|
278
|
+
segment positions were hard-coded to `0` (`clpSegmentIndex: 0, // populated in the future`), which
|
|
279
|
+
was harmless while the message rendered the amounts and is not once the message names only the
|
|
280
|
+
equation: two claims failing the same invariant produced byte-identical warnings at byte-identical
|
|
281
|
+
positions. `position.segmentIndex` is now the CLP's own 1-based index in the transaction body, so
|
|
282
|
+
`tx.segments[position.segmentIndex]` is that exact CLP. A service-line warning's is that index plus
|
|
283
|
+
the line's zero-based ordinal plus one, which is a **unique locator rather than a pointer at the
|
|
284
|
+
SVC**: read it as "the claim whose CLP is at `segmentIndex - (ordinal + 1)`, service line
|
|
285
|
+
`ordinal`". Those cannot collide across claims: a claim with `n` service lines spans at least
|
|
286
|
+
`n + 1` body segments.
|
|
287
|
+
|
|
288
|
+
- **Five `X12_UNEXPECTED_SEGMENT` messages claimed the segment was preserved; it is not.** A `GE`
|
|
289
|
+
with no open `GS`, an `ST` with no open group, an `SE` with no open transaction set, a `TA1` inside
|
|
290
|
+
a group, and a body segment outside any transaction set are all warned about and then dropped, so
|
|
291
|
+
"preserved on the prior open container" (and, in the last case, "its bytes are on `seg.raw`") named
|
|
292
|
+
a field that does not exist. Each message now says the segment is not retained and points at
|
|
293
|
+
`position.segmentIndex` in the input.
|
|
294
|
+
|
|
295
|
+
- **The disclosure overclaimed on the builders, and the first correction of it still did.** It said
|
|
296
|
+
builder refusals carry structural locators and numeric totals only. Measured against the source:
|
|
297
|
+
at least **sixteen** refusal sites across **ten** `build*` modules interpolate a caller-supplied
|
|
298
|
+
value verbatim and unbounded. Nine are the shared over-long-control-number template, where the
|
|
299
|
+
branch fires **because** the value is over-long. Seven are not gated on length at all:
|
|
300
|
+
`build999` echoes the supplied ST-02 transaction-set control number in two refusals,
|
|
301
|
+
`buildInterchange` the supplied transaction-set id code, `build837` a service line's `variant`,
|
|
302
|
+
`build834` an unrecognized INS-03 and an unrecognized HD-01 maintenance type, and `buildTA1` an
|
|
303
|
+
unrecognized TA1-05 note code. Measured with a 120,000-byte value: a 120,155-byte
|
|
304
|
+
`AckBuildError.message` from `build999` (with a longer `stack` still, whose exact size depends on
|
|
305
|
+
the frame text and so is not quoted), and a 120,069-byte
|
|
306
|
+
`X12BuildError.message` from `buildInterchange`. The README, `docs-content/troubleshooting.md` and
|
|
307
|
+
`KNOWN-LIMITATIONS.md` now name the whole surface, including the ungated sites, and say to log
|
|
308
|
+
`err.code` from a builder. Bounding the builders is a separate change.
|
|
309
|
+
|
|
310
|
+
- **The shipped PHI disclosure said the opposite of what the code did, in five places.**
|
|
311
|
+
`README.md`, `docs-content/troubleshooting.md`, `docs-content/spec-notes-tolerance.md`,
|
|
312
|
+
`docs-content/cookbook.md` and `KNOWN-LIMITATIONS.md` described warning messages as "bounded and
|
|
313
|
+
PHI-free by construction" and told consumers "you can log the full `.warnings` array without
|
|
314
|
+
leaking", naming `.snippet` as the one exception. The leak was in `.message`, and `.snippet` is not
|
|
315
|
+
a field on a warning at all, so the disclosure was actively green-lighting bulk logging of the
|
|
316
|
+
field that leaked. Each now describes the frozen registry, and `troubleshooting.md` states plainly
|
|
317
|
+
what `0.0.3` and earlier did so a reader on an older version is not misled.
|
|
318
|
+
- **The PHI tests were green over unreachable space.** `test/transactions-remit-835.test.ts` swept a
|
|
319
|
+
fixture whose CARC and RARC values were clean for shapes that could never have appeared there, and
|
|
320
|
+
its balance-mismatch assertion **required** the leak (it matched `/spec="\d/` and `/computed=/`).
|
|
321
|
+
`test/parser-envelope.test.ts` proved the hostile segment id was filtered and said nothing about
|
|
322
|
+
the spec-shaped one. All three are replaced.
|
|
323
|
+
|
|
324
|
+
### Added
|
|
325
|
+
|
|
326
|
+
- **Brand lockup on the README, following the reader's colour scheme (ASSETS-P8).** The README now
|
|
327
|
+
opens with the shared Cosyte lockup, above the `# @cosyte/x12` heading. It is a `<picture>`
|
|
328
|
+
element: a `<source>` carrying the on-dark cut for `prefers-color-scheme: dark`
|
|
329
|
+
(`https://cosyte.com/tile/cosyte-lockup-tile-on-dark-1200x300.png`) and an `<img>` carrying the
|
|
330
|
+
on-light cut as the fallback (`https://cosyte.com/tile/cosyte-lockup-tile-on-light-1200x300.png`).
|
|
331
|
+
Both were verified `200` / `image/png` before landing. On GitHub a dark-mode reader gets the dark
|
|
332
|
+
cut; on npm the `<img>` is lifted out of the `<picture>` by the surrounding anchor so the light cut
|
|
333
|
+
renders, which is correct there because npmjs.com has no dark mode. The heading and the blockquote
|
|
334
|
+
under it are unchanged: the lockup reads "Cosyte" while the heading reads `@cosyte/x12`, so the two
|
|
335
|
+
strings do not collide and nothing is duplicated. The alt text describes the artwork rather than
|
|
336
|
+
the package, since that is what a screen reader announces on the npm page and what a reader gets if
|
|
337
|
+
the image fails to load.
|
|
338
|
+
|
|
339
|
+
**Recorded because it changed inside the unreleased window rather than after it.** This entry first
|
|
340
|
+
landed describing a _per-package_ banner (`cosyte-banner-x12-1200x300.png`), which baked the
|
|
341
|
+
package name and the tagline into pixels and duplicated the two lines beneath it. It explicitly
|
|
342
|
+
chose a plain markdown image over an `<img>` or a `<picture>` pair, on the ground that whether
|
|
343
|
+
npm's markdown sanitizer preserves `<picture>` was unverified. That was an accurate statement of
|
|
344
|
+
what was known at the time. It has since been measured on a published package page: the sanitizer
|
|
345
|
+
keeps the `<picture>`, and the anchor wrapper hoists the `<img>` out of it, so the light cut
|
|
346
|
+
renders. The per-package banner was replaced before any release carried it, so no published
|
|
347
|
+
version of this package ever had it, and this is written as one change rather than an addition
|
|
348
|
+
followed by a replacement. The superseded reason is kept here rather than removed, because a
|
|
349
|
+
record that quietly flips a stated reason is worse than one that shows the correction.
|
|
350
|
+
|
|
10
351
|
### Fixed
|
|
11
352
|
|
|
353
|
+
- **Stale version claim removed from five public pages (ASSETS-P8).** `README.md`,
|
|
354
|
+
`KNOWN-LIMITATIONS.md`, `docs-content/intro.md`, `docs-content/installation.md`, and
|
|
355
|
+
`docs-content/troubleshooting.md` all asserted the package was "published on npm at `0.0.1`". The registry says
|
|
356
|
+
`0.0.2`. The literal was pinned by a previous docs sweep and went stale on the very next release,
|
|
357
|
+
so it is now removed rather than re-pinned: the npm badge renders the live version on the README,
|
|
358
|
+
and each page points at `npm view @cosyte/x12 version` as the source of truth. Docs only, no
|
|
359
|
+
runtime or public-API change.
|
|
360
|
+
- **Scope claim corrected: the 270 and 276 inquiries have no typed model (ASSETS-P8).** The README
|
|
361
|
+
status line and "What's inside" list, and `docs-content/intro.md`, claimed "the full v1 read scope
|
|
362
|
+
(270/271, 276/277/277CA, …) and emit scope … are complete". There is no `get270` / `get276` reader
|
|
363
|
+
and no `build270` / `build276` builder, and no 270 or 276 dispatch anywhere in `src/`;
|
|
364
|
+
`get271Eligibility` and `get277Status` return `undefined` for any other `ST-01`. The inquiry
|
|
365
|
+
directions parse into segments and dot-paths like any other X12 input but decode into no typed
|
|
366
|
+
model. The claim is now stated per transaction on every page that made it: the README status line
|
|
367
|
+
and "What's inside" list, `docs-content/intro.md` (both the status block **and** the "transaction
|
|
368
|
+
sets it covers" list under it), `docs-content/spec-notes-transaction-sets.md` (the lead-in and the
|
|
369
|
+
`270 / 271` + `276 / 277` rows of the reader/builder map), and `docs-content/cookbook.md`, which
|
|
370
|
+
said the 270 was "a read-only surface" when it has no reader either. The gap is recorded in
|
|
371
|
+
`KNOWN-LIMITATIONS.md`, `docs-content/troubleshooting.md`, and this repo's `CLAUDE.md`. Docs only,
|
|
372
|
+
no runtime or public-API change.
|
|
373
|
+
- **Serializer description corrected to its actual defaults (ASSETS-P8).** The README described "a
|
|
374
|
+
strict, spec-clean serializer with recomputed envelope counts", which reads as default behaviour,
|
|
375
|
+
and `CLAUDE.md` said the serializer "always emits spec-clean X12". `serializeX12` is byte-faithful
|
|
376
|
+
by default; `{ specClean: true }` reconciles the envelope, and the corrected counts need
|
|
377
|
+
`{ specClean: true, recomputeCounts: true }` together, because `recomputeCounts` is inert on its
|
|
378
|
+
own (`const recompute = specClean && opts.recomputeCounts === true`). A mismatch is always warned
|
|
379
|
+
and never silently corrected. Docs only, no runtime or public-API change.
|
|
380
|
+
- **PHI claim narrowed to what the library actually guarantees (ASSETS-P8).** The README said
|
|
381
|
+
"warnings/errors that carry codes and positions but never patient data". Warning messages and
|
|
382
|
+
builder refusals are PHI-free by construction, but `X12ParseError.snippet` is a bounded (≤ 64
|
|
383
|
+
character) copy of the offending input, so on real traffic it can carry PHI, and the library does
|
|
384
|
+
not redact it. That was documented in the source JSDoc and nowhere a consumer reads. The README now
|
|
385
|
+
states the exception, and it is written up in `KNOWN-LIMITATIONS.md` and
|
|
386
|
+
`docs-content/troubleshooting.md`. Docs only, no runtime or public-API change.
|
|
12
387
|
- **Developer-docs publish status corrected to the published reality (README-ORG-SWEEP).** The
|
|
13
388
|
docs.cosyte.com pages (`docs-content/intro.md`, `installation.md`, `troubleshooting.md`) and
|
|
14
389
|
`KNOWN-LIMITATIONS.md` still said "not yet published to npm" / "gated on the coordinated public
|
|
@@ -479,9 +854,12 @@ EV/SS`), and HL-04 has-child flag from the nested input tree, so an
|
|
|
479
854
|
byte-faithful: reconstructed purely from the verbatim `.raw`
|
|
480
855
|
strings the parser preserved (ISA + terminator, then each
|
|
481
856
|
TA1 / GS / segment / GE / IEA terminator-joined, then any
|
|
482
|
-
`trailingBytes`), so for
|
|
483
|
-
|
|
484
|
-
|
|
857
|
+
`trailingBytes`), so for an input carrying none of the known
|
|
858
|
+
unrecorded constructs catalogued in `KNOWN-LIMITATIONS.md` it
|
|
859
|
+
reproduces the source bytes exactly. (As shipped, this
|
|
860
|
+
entry said "for a Tier-1 input", which later proved too weak: a
|
|
861
|
+
pretty-printed file is Tier 1 and does NOT reproduce exactly.)
|
|
862
|
+
With `{ specClean: true }` it ALSO
|
|
485
863
|
reconciles the envelope (SE-01 / GE-01 / IEA-01 counts + the
|
|
486
864
|
ISA-13↔IEA-02 / GS-06↔GE-02 / ST-02↔SE-02 control pairs),
|
|
487
865
|
surfacing every mismatch via `opts.onWarning` and NEVER silently
|
package/README.md
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
|
+
<picture>
|
|
2
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://cosyte.com/tile/cosyte-lockup-tile-on-dark-1200x300.png">
|
|
3
|
+
<img alt="Cosyte: a plus mark set in two overlapping rounded squares, one solid and one outlined, beside the Cosyte wordmark" src="https://cosyte.com/tile/cosyte-lockup-tile-on-light-1200x300.png">
|
|
4
|
+
</picture>
|
|
5
|
+
|
|
1
6
|
# @cosyte/x12
|
|
2
7
|
|
|
3
|
-
> Parse real-world, vendor-quirky ASC X12 healthcare EDI (835 remits, 837 claims,
|
|
8
|
+
> Parse real-world, vendor-quirky ASC X12 healthcare EDI (835 remits, 837 claims, 271 eligibility responses, 277/277CA status, 278, 820, 834, 999/TA1) and pull the fields you need without reading a TR3.
|
|
4
9
|
|
|
5
10
|
[](https://www.npmjs.com/package/@cosyte/x12)
|
|
6
11
|
[](https://github.com/cosyte/x12/actions/workflows/ci.yml)
|
|
7
12
|
[](./LICENSE)
|
|
8
13
|
[](https://nodejs.org)
|
|
9
14
|
|
|
10
|
-
A developer-focused ASC X12 EDI parser and utility library for Node.js and TypeScript: the payer-side sibling of [`@cosyte/hl7`](https://github.com/cosyte/hl7). Zero runtime dependencies, dual ESM/CJS, strict types. Lenient on the way in (vendor deviations become warnings, not exceptions), spec-clean on the way out.
|
|
15
|
+
A developer-focused ASC X12 EDI parser and utility library for Node.js and TypeScript: the payer-side sibling of [`@cosyte/hl7`](https://github.com/cosyte/hl7). Zero runtime dependencies, dual ESM/CJS, strict types. Lenient on the way in (vendor deviations become warnings, not exceptions), spec-clean on the way out: every domain builder emits spec-clean X12 by construction, and the general serializer does so on request.
|
|
11
16
|
|
|
12
|
-
> **Status:** pre-alpha, **published on npm
|
|
17
|
+
> **Status:** pre-alpha, **published on npm** from a public repo, on the `0.0.x`-until-first-alpha ladder. `npm view @cosyte/x12 version` is the source of truth for the current version and this page does not restate it; the badge above is a convenience, not the record. Typed **read** and **emit** support is complete for 271, 277/277CA, 278 (request + response), 820, 834, 835, 837P/I/D, 999, and TA1: every one of those has both a per-transaction reader and a matching domain builder, on top of a general serializer and interchange builder. The **270** and **276** inquiry directions do not: they parse into segments and dot-paths like any other X12, but nothing decodes them into a typed model yet. Pre-alpha means the public API may still move before `0.1`. Pin an exact version.
|
|
13
18
|
|
|
14
19
|
## Quickstart
|
|
15
20
|
|
|
@@ -36,11 +41,11 @@ That's the pitch: no schema upload, no spec knowledge. The parser accepts vendor
|
|
|
36
41
|
|
|
37
42
|
## What's inside
|
|
38
43
|
|
|
39
|
-
- **
|
|
40
|
-
- **Postel's Law**: a lenient parser (deviations → warnings with a stable code + positional context) and a
|
|
44
|
+
- **Typed read + emit, per transaction**: 271, 277/277CA, 278, 820, 834, 835, 837P/I/D, plus 999/TA1 acknowledgments. Per-transaction helpers (`get835`, `get837Claims`, `get271Eligibility`, …) and a matching domain builder for each (`build835`, `build837P/I/D`, `build271`, …). The 270 and 276 inquiries parse as generic segments; they have no typed model yet.
|
|
45
|
+
- **Postel's Law**: a lenient parser (deviations → warnings with a stable code + positional context) and a conservative serializer. `serializeX12` is byte-faithful **for the segments on the model** by default: each comes back verbatim, including element padding, composites, and `?`-release escapes. **`serialize(parse(s)) === s` is not guaranteed in general.** Six constructs are known not to survive: line breaks between segments (any run of CR / LF between segments is absorbed at parse, so a pretty-printed or double-spaced file emits compact), a doubled terminator outside a transaction, a missing final terminator (the emit supplies one), post-IEA `trailingBytes` (re-joined, not verbatim), a TA1 that followed a functional group (emitted right after the ISA, so it is **reordered**, though nothing is lost), and a segment whose first element is empty outside a transaction (skipped entirely, with no warning at all). A **segment outside a transaction is not on that list**: it is reported as `X12_UNEXPECTED_SEGMENT`, kept on the model at `ix.orphanSegments` with a structural `anchor`, and re-emitted at that anchor, so the segment, its value and its warning all survive the round trip - placement is by the anchor and never by `segmentIndex`, which indexes the input stream the emit does not follow. The last five break the round trip on inputs with no line breaks at all, so do not treat "no line breaks" as sufficient; five of the six are silent, so a clean warnings list does not mean byte-exact either. Measured across this repo's 56 fixtures: every emit is a fixed point and re-parses to an identical model with an identical warning stream, the 14 with no line breaks return byte-identical, and the other 42 differ by line breaks and nothing else. See [Line endings between segments](./docs-content/spec-notes-envelope.md). `{ specClean: true }` reconciles the envelope, and `{ specClean: true, recomputeCounts: true }` also emits the corrected counts (`recomputeCounts` does nothing on its own). A mismatch is always warned, never silently corrected. Only **4 structural failures** are ever fatal.
|
|
41
46
|
- **Money is exact**: every monetary/percent/quantity field decodes as `X12Decimal` (string-backed, BigInt arithmetic). The library **never `parseFloat`s** an EDI amount.
|
|
42
47
|
- **Safety-critical fidelity**: TRN reassociation traces, 835 balance invariants, 837 HL hierarchy integrity, 834 maintenance types, and 278 certification actions are preserved **verbatim** and never inferred; ambiguity yields a warning or a typed refusal, never a confident wrong answer.
|
|
43
|
-
- **PHI-disciplined**: synthetic-only fixtures, a PHI commit-gate, and
|
|
48
|
+
- **PHI-disciplined**: synthetic-only fixtures, a PHI commit-gate, and a warning `message` that is a **lookup into a frozen registry**, not something built from your document. No warning factory in the library takes a value parameter, so no element can reach a diagnostic: the code and `position` say what and where, and the bytes stay on the model. `ALL_WARNING_MESSAGES` is exported so you can assert it. **Builder refusals are a different, and weaker, surface:** a `build*` function that refuses a spec carries structural locators and numeric totals in most cases, but at least sixteen refusal sites across ten builder modules interpolate a caller-supplied value into the thrown message verbatim and unbounded. Nine of them are the over-long control number, where the branch fires because the value is over-long; seven are not gated on length at all (`build999`'s ST-02 trace, `buildInterchange`'s transaction-set id, `build837`'s line variant, `build834`'s maintenance types, `buildTA1`'s note code). Measured: a 120,000-byte value gives a 120,155-byte `Error.message` from `build999`. Log `err.code`, not `err.message`, from a builder. Tracked in [KNOWN-LIMITATIONS.md](./KNOWN-LIMITATIONS.md). **The one deliberate exception:** `X12ParseError.snippet` on the four Tier-3 structural fatals is a bounded (≤ 64 character) copy of the start of the input, so on real traffic it can carry PHI. The library does not redact it. Redact at your call site, or log `err.code` and `err.position` instead. See [Keeping PHI out of logs](./docs-content/troubleshooting.md).
|
|
44
49
|
|
|
45
50
|
See the [**Cookbook**](./docs-content/cookbook.md) for task-oriented recipes (post an 835, route 277CA rejections, round-trip a 271, walk an 837, read a 999) and [**KNOWN-LIMITATIONS.md**](./KNOWN-LIMITATIONS.md) for the honest do-not-over-trust list.
|
|
46
51
|
|