@cosyte/x12 0.0.4 → 0.0.6

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
@@ -9,6 +9,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  ### Added
11
11
 
12
+ - **`renderCallerJson`** (internal), the type-preserving half of the caller-value bound, held to the
13
+ same `BUILD_REFUSAL_VALUE_MAX_RENDERED` ceiling as `renderCallerValue`. It exists because
14
+ `defineProfile()` reports a bad `name` / `id` / `effect` / `fixture` with `JSON.stringify`, and that
15
+ distinction is diagnostically load-bearing: `null` and `"null"` are different mistakes, and a
16
+ coercing renderer would flatten them together. It bounds the JSON **text** rather than the argument,
17
+ never throws (a circular structure, a `BigInt`, a hostile `toJSON`), and fabricates no closing quote,
18
+ because JSON does not always open one.
19
+ - **`requireCallerArray`** (internal), the single route a caller-supplied array takes into a builder
20
+ loop. Each builder passes its own `refuse` callback, so a forged list draws that module's existing
21
+ typed error and code rather than a new shared one.
22
+ - **`renderCallerValue`**, plus the `BUILD_REFUSAL_VALUE_MAX_LENGTH` (63) and
23
+ `BUILD_REFUSAL_VALUE_MAX_RENDERED` (90) bounds. This is the single sanctioned route a
24
+ caller-supplied value takes into a `build*` refusal message, and both ceilings are exported so a
25
+ consumer can assert them rather than take them on trust - the builder-side counterpart to
26
+ `ALL_WARNING_MESSAGES` on the parse side. `BUILD_REFUSAL_VALUE_MAX_RENDERED` bounds the rendered
27
+ **fragment**, not the whole message: a message is that plus the site's own fixed template text.
12
28
  - **`X12OrphanSegment.anchor`**, plus the `X12OrphanAnchor` / `X12OrphanAnchorKind` types. Every
13
29
  retained orphan now records **where it sat in the structure** rather than only where it sat in the
14
30
  byte stream: `{ kind: "interchange", groupIndex }` for a segment outside every functional group,
@@ -28,6 +44,94 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
28
44
 
29
45
  ### Fixed
30
46
 
47
+ - **A `defineProfile()` refusal message no longer grows with the value you passed in.** Twelve
48
+ refusal sites in `src/profiles/validate.ts` hold twenty-three caller-value holes between them, and
49
+ every one now routes through `renderCallerValue` or `renderCallerJson`. Re-derived on this tree
50
+ before the fix, driving the same thirteen cases the suite ships: the worst `X12ProfileError.message`
51
+ was **360,181 characters**, at the `fixture` refusal. Three of the thirteen exceed 360,000, and they
52
+ are the three that name **three** caller values (profile name + quirk id + a `JSON.stringify`d
53
+ value) rather than two; a 120,000-digit quirk id reaches them because `QUIRK_ID_RE` carries no
54
+ length bound. The same `fixture` refusal now measures **431 characters**.
55
+
56
+ **431 is a measurement, not a maximum.** The ` (N characters)` suffix widens with the decimal width
57
+ of the value's length, so the same refusal measures 434 at a 1,000,000-character value and 437 at
58
+ 10,000,000. The site's ceiling, derived from its fixed text plus its three fragment ceilings, is
59
+ **443**; the suite asserts every one of the twelve under 500.
60
+
61
+ **The figure this was filed on, 120,093, did not reproduce**, the same way `X12-BUILDER-BOUNDS`'s
62
+ own filed figures did not: it depends on which site is hit and what the probe passes.
63
+
64
+ Scope it the way the builder half is scoped. This is **not** `PHI-WARNING-MESSAGE-LEAK`, where the
65
+ value was the document's: here you passed it in and still hold it, so bounding it **redacts
66
+ nothing**. What it buys is a fixed ceiling on anything reaching a log line, a crash report or a JSON
67
+ error envelope. The surviving characters are **not escaped**, and the bound is on UTF-16 **code
68
+ units, not bytes**. **`X12ProfileError.profileName` is deliberately left unbounded**, so it still
69
+ matches the name you passed.
70
+
71
+ - **A builder handed a forged non-array now refuses instead of hanging.** Every domain builder took
72
+ its loop bound from a caller-supplied `.length`, so `{ length: "9".repeat(120000) }` coerced to
73
+ `Infinity`, every element read `undefined`, every guard `continue`d, and the builder **spun forever
74
+ rather than refusing**. Measured at base over the nineteen probes that drive a forged list at a
75
+ builder ENTRY point (17 `FORGED_ARRAY_CASES` + 2 `RESIDUAL_CASES`), each in a child process under a
76
+ 20-second wall-clock timeout because a hang cannot be observed in-process: **16 of 19 hung** with no
77
+ refusal and the other **3 threw an untyped `TypeError`**. The suite ships three further forged
78
+ probes on optional LEAF arrays, which are `TypeError` at base and at head alike and move nothing:
79
+ counting all 22, base is 16 hung / 6 untyped and head is 17 typed / 5 untyped. All **32 indexed
80
+ loops across 7 builder modules** now take their bound from a `requireCallerArray` binding, and at
81
+ head the same nineteen give **17 typed, code-tagged refusals** (messages 169 to 194 characters) and
82
+ **2 untyped `TypeError`s**. `build835`'s `spec.traces` is the one that moved from the untyped group
83
+ to the typed one, because its guard reads the list; it never hung, and this changelog does not claim
84
+ it did.
85
+
86
+ A hang is a worse failure than a refusal: a refusal hands control back with something to branch on,
87
+ a hang takes the worker with it. But state the class correctly. This is a **forged non-array input,
88
+ not a mis-read clinical value** - nothing decodes a document differently because of it, and the
89
+ reachable harm is availability. It is unreachable from TypeScript and reachable from JavaScript,
90
+ JSON, and therefore `@cosyte/cli`.
91
+
92
+ **Disclosed rather than fixed, and identical at base and head:** where a builder reads a caller array
93
+ with `for...of` - `buildInterchange`'s `spec.groups`, `build999`'s
94
+ `functionalGroup.transactionResponses`, and every optional leaf array such as `claim.dates` - a
95
+ forged list throws `TypeError: ... is not iterable`. That terminates, so it is not the hang, but it
96
+ carries **no `code`**. Pinned by a test so it cannot quietly become a hang.
97
+
98
+ - **The `QUIRK_ID_RE` comment claimed a length bound the pattern never had.** It said "2-64
99
+ lowercase-alphanumeric chars"; the regex accepts one character and it accepts 120,000, which was in
100
+ fact the path to the largest profile error message on the tree. The comment was corrected to the
101
+ code, not the grammar tightened: rejecting ids that define cleanly today is a separate decision from
102
+ bounding a message.
103
+
104
+ - **A `build*` refusal message no longer grows with the value you passed in.** All twenty-three
105
+ caller-value slots across the ten builder modules route through `renderCallerValue`, capping the
106
+ rendered fragment at 90 characters. Nine are the `control number "…" exceeds the N-char spec limit`
107
+ refusal, where the branch fires _because_ the value is over-long; seven had no length gate at all
108
+ (`build999`'s ST-02 trace twice, `buildInterchange`'s transaction-set id, `build837`'s service-line
109
+ variant, `build834`'s INS-03 and HD-01 maintenance types, `buildTA1`'s note code); and seven are in
110
+ `build999`, found by adversarial review rather than by the census - the AK9-02 / AK9-03 / AK9-04
111
+ counts, typed `number` but reachable with a string from a `JSON.parse`d spec at 120,063 characters,
112
+ and three `.length` reads on caller-supplied arrays that a forged `{ length: … }` drove to 120,152.
113
+ Measured: a 120,000-character control number produced a **120,066-character**
114
+ `X12BuildError.message` from `buildInterchange` and now produces a **150-character** one. (150, not
115
+ 90: the 90 is the ceiling on the interpolated fragment, not on the message.) **This is robustness
116
+ and log hygiene, not redaction, and the docs now say so** - you passed the value in and still hold
117
+ it, so bounding it hides nothing from you; what it buys is a fixed ceiling on anything that reaches
118
+ a log line or a JSON error envelope. The surviving characters are bounded but **not escaped**, and
119
+ on the ack path the value is not always strictly your own (TR3 005010X231A1 has AK2-02 echo the
120
+ acknowledged ST-02 verbatim). `err.code` remains the thing to branch on.
121
+ - **A builder refusal handed a non-string where the types say `string` stays a typed error.**
122
+ `renderCallerValue` coerces rather than reading `.length` off whatever it is given, so a spec built
123
+ from `JSON.parse` that carries a numeric control number still raises `X12BuildError` with its
124
+ `code`, instead of an uncaught `TypeError` with none.
125
+ - **The 835 remit-total balance warning points at the BPR instead of at the ST.** Its
126
+ `position.segmentIndex` was a literal `0`, which reads like "no segment" but is not one:
127
+ `tx.segments[0]` is the `ST`, so a consumer resolving the position landed on a segment with nothing
128
+ to do with the invariant. It is now the BPR's own 1-based body index, so
129
+ `tx.segments[w.position.segmentIndex]` is the segment carrying the BPR-02 the equation compares
130
+ against, matching the treatment claim-level and service-line warnings already had. The only
131
+ remaining `0` is a transaction that carries no BPR at all. The corresponding position inside
132
+ `build835` stays synthetic and is now named and documented as such: the builder has no parsed
133
+ segment stream to index into, and it consumes only the warning's `message`, which is a
134
+ registry lookup keyed by the invariant and therefore position-independent.
31
135
  - **A segment outside a transaction now survives a round trip, and so does its warning.**
32
136
  `serializeX12` re-emits every entry of `ix.orphanSegments` at its structural `anchor`, so a
33
137
  consumer who serializes an interchange and re-derives warnings from the copy no longer loses the
package/README.md CHANGED
@@ -45,7 +45,7 @@ That's the pitch: no schema upload, no spec knowledge. The parser accepts vendor
45
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.
46
46
  - **Money is exact**: every monetary/percent/quantity field decodes as `X12Decimal` (string-backed, BigInt arithmetic). The library **never `parseFloat`s** an EDI amount.
47
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.
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).
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 deliberately weaker, surface:** twenty-three refusal sites across ten builder modules name a value you passed in, so you can see which control number, count or code was refused. Each goes through `renderCallerValue`, capping the rendered **fragment** at `BUILD_REFUSAL_VALUE_MAX_RENDERED` (90 characters); all three names are exported so you can assert the ceiling. The whole `message` is that plus the site's own fixed text, so it is bounded by a constant but a larger one: a 120,000-character control number gave a 120,066-character `X12BuildError.message` before this and gives 150 now. That is robustness, not redaction: the value is one you supplied and bounding it hides nothing from you. It is also not escaped, and on the **ack** path it is not always strictly your own, since `build999`'s AK2-02 and `buildTA1` echo an inbound document's control numbers by design. Log `err.code`, not `err.message`, from a builder. **`defineProfile()` is bounded on the same terms since `0.0.6`** (twelve refusal sites, twenty-three caller values; the worst message measured **360,181 characters** before and **431** now, both at the `fixture` refusal, which names three caller values; the ceiling for that site is 443 and the suite asserts every site under 500; `X12ProfileError.profileName` is deliberately left unbounded so it still matches the name you passed). **And a forged non-array in a builder spec now refuses instead of hanging:** every indexed loop takes its bound from a checked array, so `{ length: "9".repeat(120000) }` draws a typed refusal rather than coercing to `Infinity` and looping forever. A few `for…of` reads still throw an untyped `TypeError` instead; both are 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).
49
49
 
50
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.
51
51