@cosyte/astm 0.0.9 → 0.0.11

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,193 @@ this file is maintained by hand (Changesets handles the version bump and publish
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ### Fixed
13
+
14
+ - **A symbolic link under a scan root read CLEAN on BOTH of the PHI scanner's enumerating routes, so
15
+ a link pointing at a file full of real identifiers passed the gate twice over**
16
+ (`PHI-SCAN-SYMLINK-BLIND-ON-BOTH-ROUTES`). Development tooling only: `scripts/phi-scan.ts` ships in
17
+ no tarball and the package's public surface is unchanged.
18
+
19
+ Reproduced on `8e8012d` before any fix, with a synthetic name-bearing payload (an ASTM `P` record
20
+ carrying a patient name, a mother's maiden name and a birthdate, plus a dashed SSN and an email at
21
+ a non-test domain) placed outside the walk roots and a link to it at `src/leak.ts`. `pnpm phi-scan`
22
+ printed `OK: no hits` and exited **0**; `pnpm phi-scan --staged`, after `git add`, printed
23
+ `OK: no hits` and exited **0**. Naming the link's target explicitly on the command line reported
24
+ **six** hits and exited 1, so the payload was always detectable, and detectable by this package's
25
+ own structured `P`-record detectors rather than only by the cross-cutting SSN/email floor. The two
26
+ routes simply never looked at it.
27
+
28
+ The two blindnesses are separate mechanisms and needed separate fixes. The walk enumerates
29
+ `Dirent.isFile()`, which is an **lstat** answer, so a link is neither a file nor a directory and
30
+ fell out of the loop with no record that anything had been skipped, and `isDirectory()` answers
31
+ false for a linked directory too, so a whole subtree could disappear the same way. The `--staged`
32
+ route reads content with `git show :<path>`, and **git stores a symbolic link as its target path
33
+ under mode `120000`**, so it was handed the target's path text and scanned that. That second route
34
+ is this repo's `pre-commit` hook, which is exactly where the claim that `--staged` covers a link
35
+ would have been trusted.
36
+
37
+ **Neither route is made to follow the link.** Following would read bytes the enumeration does not
38
+ control (outside the repo, a loop, a device, a FIFO that blocks the gate forever) and git does not
39
+ carry those bytes anyway, so a hit on them would be a claim about something no commit contains.
40
+ Instead the enumeration is narrowed: an entry under a scan root that is not a regular file
41
+ **refuses the scan** (exit 2, the existing "could not complete" code), naming every offender rather
42
+ than the first. The walk classifies by `Dirent` (symbolic link, FIFO, socket, block device,
43
+ character device); `--staged` now reads `git diff --cached --raw -z` instead of `--name-only` so
44
+ the destination mode is visible, and refuses mode `120000` and `160000` (a gitlink) wherever that
45
+ route's own path scope reaches them. A `--raw` record that does not parse refuses as well, rather
46
+ than being skipped into a silently shortened list.
47
+
48
+ **`T` (typechange) is in the `--diff-filter`, and leaving it out would have made the mode check
49
+ unreachable whenever the file being replaced was already tracked.** Replacing a **tracked** regular
50
+ file with a link is neither an add nor a modify: git raises `:100644 120000 <sha> <sha> T`, so
51
+ `--diff-filter=AM` deletes the record before any mode can be read. Measured on git 2.39.5 on this
52
+ tree: with `AM` the raw output for that stage is empty, and the unfixed scanner passed that stage
53
+ green. Typechange carries a single path, exactly like `A` and `M`, so admitting it costs the record
54
+ stride nothing, and it means the reverse typechange, a link replaced by a real file, is now scanned
55
+ as the ordinary file it became.
56
+
57
+ **A refusal names the entry's own repo-relative path and an engine-owned token for its kind, and
58
+ never the link target**, because a target path is text off the working tree and can itself carry
59
+ PHI. That is asserted rather than argued: the pinning payload and the target's own filename both
60
+ carry a synthetic person name, and every refusal message is checked to contain none of it. 10 of
61
+ the file's 30 cases are red on `8e8012d`.
62
+
63
+ **What this does not cover, stated narrowly, each measured.** Explicit-path mode already read
64
+ through a link and reported the target's hits; it is unchanged. The `--staged` path scope is
65
+ unchanged, still `test/fixtures/**` and `src/**.ts`, so a staged link outside it is still not
66
+ looked at; narrowing what a scope admits is not widening the scope. That scope also bounds the
67
+ gitlink half: a submodule staged at `test/fixtures/nested` is refused, while one at `src/nested` is
68
+ a directory name that fails the `.ts` suffix and is not looked at. `R` (rename) and `C` (copy) are
69
+ still **not** enumerated by `--staged` at all, so a staged rename that also appends PHI passes that
70
+ route; pre-existing, unchanged here, and admitting it needs the two-path record shape handled,
71
+ which is a scope decision rather than this one. A tracked file **absent from the worktree** is
72
+ still caught at `git add` time only.
73
+
74
+ **And this scanner still has no rule that a sweep observing ZERO targets must refuse**, which is
75
+ worth naming rather than leaving to be discovered. Measured: run from a tree with no `src/` and no
76
+ `test/fixtures/`, all-mode prints `OK: no hits` and exits 0, so a clean report can be a report
77
+ about a tree the scan never looked at, and **nothing backstops it**: the all-mode sweep is the
78
+ route with the hole, so it cannot be its own compensating control, and neither the `pre-commit`
79
+ hook nor CI asserts that the sweep observed anything. Five of the sibling scanners carry that rule
80
+ (`ccda`, `hl7`, `mllp`, `ncpdp`, `synth`) and the rest, this one included, do not; do not read that
81
+ as a majority practice. It is unchanged by this slice in either direction, and adding it is its own
82
+ slice, because the threshold is a judgement about this repo's corpus rather than a consequence of
83
+ anything here.
84
+
85
+ **The enumerate-then-read race is deliberately NOT closed here, and the deferral is measured
86
+ rather than assumed.** The scanner still has no tolerance for a file that vanishes between
87
+ enumeration and read, so a transient appearing under a walk root can refuse a whole sweep (exit 2,
88
+ fails closed). This repo's walk roots are `test/fixtures/` and `src/`, and nothing was observed
89
+ landing in either: polling both roots continuously across a real `pnpm build` saw only the tracked
90
+ corpus, while `tsup.config.bundled_*.mjs` (the transient that blocked a sibling's publish) appeared
91
+ at the **repo root**, which is not a walk root here. This repo's own suite writes its throwaway
92
+ trees under the OS temp directory, not under a scan root. So the shape is present and its trigger
93
+ is not, which makes closing it a separate slice with its own argument rather than a rider on a PHI
94
+ blindness fix. It is a different defect and it fails in the safe direction.
95
+
96
+ - **The frame encoder no longer writes a different character than the one it was handed**
97
+ (`ASTM-FRAME-BYTE-RESIDUALS`, closing known defect 7). `composeAstmFrames` turned a record given
98
+ as a `string` into bytes with `charCodeAt(i) & 0xff`, so any character above `U+00FF` was replaced
99
+ by its low byte. That low byte is another perfectly ordinary character, which is why the
100
+ substitution was **silent**: it reached the wire, checksummed clean, and came back with an empty
101
+ warnings array from **both** layers.
102
+
103
+ Measured, on this package's own round trip, three shapes on the canonical set:
104
+ - `R|1|^^^687|28.6|μmol/L||N||F` read back `28.6` in `¼mol/L` (`U+03BC` low byte `0xBC`). A lab
105
+ number whose **units** silently changed.
106
+ - `P|1||LAB-0001||ŁUKASZ^JAN||19800101|M` read back the name spelled `AUKASZ` (`U+0141` low byte
107
+ `0x41`).
108
+ - `P|1||LAB-0001||GRAżYNA^ANNA||19800101|F` read back as **ten fields where the record models
109
+ nine** (`U+017C` low byte `0x7C`, the field separator). Every field after the split shifted one
110
+ along, so `patient().sex` answered `19800101` and the birth date was gone. That is **silent
111
+ re-read divergence**: a different field tree, `warnings: []` at both layers.
112
+
113
+ The known-defect entry said this class fails loudly in every case measured, and for the cases it
114
+ had measured that was true: they all truncated onto `STX`/`ETX`/`ETB`/`CR`. Nothing had measured a
115
+ code point whose low byte is an ordinary character, and that is the larger and quieter class.
116
+
117
+ **The disposition is a refusal, and it invents nothing.** `composeAstmFrames` now throws
118
+ `AstmFrameEncodeError` with the new `ASTM_FRAME_UNENCODABLE_CHARACTER` code, carrying the record
119
+ index and the character's index, never the character and never its code point. The alternative,
120
+ encoding as UTF-8, was **considered and rejected**: turning a character into bytes requires a
121
+ character encoding, this library reads no character-set declaration from any ASTM record, and
122
+ picking one would be a guess at bytes the caller never supplied, in a package that declines to
123
+ infer a delimiter set, a record type letter or a code system. Emit also has no warning channel, so
124
+ a warning could only have been ignored while the wrong bytes still shipped. That is the same
125
+ argument already written for `ASTM_EMIT_UNENCODABLE_VALUE` and `ASTM_EMIT_INVALID_DELIMITERS`.
126
+
127
+ **What grounds the Latin-1 boundary**, since no clause is claimed for it and none was read. Two
128
+ things, both firsthand. (1) This package's own byte-to-string boundary is already Latin-1, on the
129
+ read side, deliberately: `parseAstmRecords` decodes with one `String.fromCharCode` per byte "so
130
+ every byte survives 1:1". The encoder is documented as the exact inverse of the decoder, and the
131
+ decoder can never produce a code point above `U+00FF`, so a string carrying one is outside the
132
+ codec's domain by construction. (2) The redistributable OSS reference implementation
133
+ (`kxepal/python-astm`, BSD) parameterizes the codec on `encoding` throughout
134
+ (`encode(records, encoding=ENCODING, ...)` / `decode(data, encoding=ENCODING)`) and defines
135
+ `ENCODING = 'latin-1'` in `astm/constants.py`: the wire code page there is caller-supplied
136
+ out-of-band knowledge with a latin-1 default, not something a reader discovers from the stream.
137
+ **The normative text was not reachable and no clause is cited**: LIS02-A2 is withheld from CLSI's
138
+ free sample and E1394-97 / LIS01-A2 are paywalled, so this is a reasoned choice, exactly as the
139
+ delimiter-scoping and surplus-declaration choices before it were.
140
+
141
+ **The refusal removes no capability.** `composeAstmFrames` already accepted `Uint8Array`, so a
142
+ consumer who knows their instrument's code page encodes with their own encoder and passes bytes,
143
+ which are written through untouched. A stream whose every character is Latin-1 is byte-identical
144
+ to before, the full `0x00`–`0xFF` range included. The **record** layer is deliberately unchanged:
145
+ `serializeAstmRecords` returns a `string`, which is not yet bytes, so the caller keeps the choice.
146
+
147
+ **Stated no wider than it was measured.** This closes the string-to-bytes step only. It is not a
148
+ claim that every accepted record reads back: a record already carrying a raw `STX`/`ETX`/`ETB`
149
+ byte is framed as given and re-decodes wrong (known defect 6), which this change does not touch.
150
+ **Both** of that defect's branches are now pinned as out of scope in
151
+ `test/frames/unencodable-character.test.ts`, the warned one and the **silent** one: where the two
152
+ bytes following the embedded control character happen to be that truncated frame's checksum, the
153
+ short frame verifies and a whole result record is absorbed into the previous record's text with
154
+ `warnings: []` at both layers. That branch was found grading this change, reproduces byte-identically
155
+ on the base, and is why known defect 6's "it fails loudly" ranking was withdrawn rather than restated. A surrogate pair needs no
156
+ separate rule, each half being above `U+00FF` itself. New export `AstmFrameEncodeErrorCode`;
157
+ `AstmFrameEncodeError.code` is now that union and gains a `characterIndex`. The property "either
158
+ refuse the record string or reproduce it byte for byte" is asserted over 2,000 generated cases,
159
+ with its bound written into the test and a non-vacuity check that both sides of it are reached.
160
+
161
+ - **The `attw` publish gate now fails when the packed tarball carries no types, where the CLI exits
162
+ `0`** (`ATTW-FALSE-GREEN-PORT`). The `attw` script was the bare `attw --pack .`.
163
+ `@arethetypeswrong/cli` opens `getExitCode()` with `if (!analysis.types) return 0`, returning
164
+ before it ever reads the problem list, because an untyped package is a legitimate npm package and
165
+ the CLI treats "no types at all" as a description rather than a problem. No `--profile`,
166
+ `--ignore-rules` or config setting reaches that branch. For a package that ships types the same
167
+ result means the declarations were **not in the tarball**, which is a broken publish reported as a
168
+ pass. A false red costs an hour; a false green merges.
169
+
170
+ Measured on this package at `0.0.9`, on a quiet box with **no** concurrency, both states printing
171
+ "This package does not contain types." and exiting `0`: `rm -rf dist`, and
172
+ `rm -f dist/index.d.ts dist/index.d.cts`. The second is the realistic one, and the trigger is the
173
+ build rather than any race: timed on one real `tsup` run here, `dist/index.mjs` and
174
+ `dist/index.cjs` appeared at 1,176 ms and `dist/index.d.ts` and `dist/index.d.cts` at 3,063 ms, a
175
+ **1,887 ms window** in which `dist/` held JS and no declarations. A concurrent build or `clean` in
176
+ the same working tree lands the gate in that window. This is deliberately **not** answered with a
177
+ lock or a build queue: the gate has to be able to report that its own inputs were missing,
178
+ whatever removed them.
179
+
180
+ `attw` is now `node scripts/attw.mjs`, which keeps two nets because they catch different things. A
181
+ **preflight** checks that every relative path `package.json` promises (`main`, `module`, `types`,
182
+ `typings`, and every string leaf of `exports`) exists and is non-empty, and names the missing file
183
+ rather than leaving it to be inferred. A **post-check** promotes `attw`'s untyped sentence to a
184
+ failure, catching what the preflight structurally cannot: declarations present on disk but
185
+ excluded from the tarball by `files` or an `.npmignore`. No instance of that second case is on
186
+ record in this repo. Because the post-check reads a printed string, the routes that would hide it
187
+ are refused rather than tolerated: `--quiet`, `--format`, a `.attw.json` setting either, and
188
+ `--config-path` at a file setting either were each measured here to restore the exact exit `0`. The
189
+ refusal is by option name, wholesale, not by value, and the list is a record of what is closed
190
+ rather than a proof that the post-check cannot be blinded at all: `--definitely-typed` suppresses
191
+ the sentence by making the analysis typed, is equally true of the bare CLI, and is deliberately not
192
+ refused. The preflight is the net that reads no string. Other arguments are forwarded, so
193
+ `--profile node16` still works. `test/scripts/attw-gate.test.ts` pins the upstream exit `0` itself,
194
+ so an `attw` upgrade that fixes it or rewords the sentence reds the suite instead of letting the
195
+ net go quietly slack, and it carries a negative control plus a real `attw` failure, because a gate
196
+ that only ever fails is not a gate and one that swallows the status is not one either. **No
197
+ runtime code changed and the built output is identical.**
198
+
12
199
  ### Added
13
200
 
14
201
  - **`ASTM_RECORD_FIELDS_UNSEPARATED`: a record that lost its fields no longer loses them in
@@ -481,11 +668,11 @@ phase 8` passes while `Phase 8` reds). An arm keyed on a following digit was wri
481
668
  behind nothing but an `ASTM_FRAME_BAD_CHECKSUM`. Rather than enumerate the bytes each layer happens
482
669
  to reserve, and re-derive that list whenever a layer is added, no control character is carried at
483
670
  all. Two things that rule does **not** do, said here rather than left to be found: it is keyed on
484
- the character while the frame layer's structure is keyed on the low byte (`charCodeAt(i) & 0xff`),
485
- so a non-control character truncating onto `STX`/`ETX`/`ETB` still breaks framing, loudly, with a
486
- typed error or an unknown-record-type warning, never silently; and refusing a control character in
487
- a _surplus_ does not mean one cannot be a _delimiter role_, since only `CR`/`LF` are refused
488
- there. Those are
671
+ the character, so a surplus character above `U+00FF` passes it (the frame layer is where that is
672
+ settled now, and refuses such a character rather than truncating it, see the entry above; when
673
+ this was written the frame encoder truncated to the low byte and the case broke framing instead);
674
+ and refusing a control character in a _surplus_ does not mean one cannot be a _delimiter role_,
675
+ since only `CR`/`LF` are refused there. Those are
489
676
  structural losses; dropping inert bytes is not.
490
677
 
491
678
  **2. A caller-supplied delimiter set was never validated.** `serializeAstmRecords(msg, d)` and its
package/README.md CHANGED
@@ -39,7 +39,10 @@ reference parser, [`@cosyte/hl7`](https://github.com/cosyte/hl7).
39
39
  frame them with computed checksums, frame numbers, and the 240-byte split. Both layers round-trip by
40
40
  construction, and a delimiter set that fails any of the three conditions readback requires (one
41
41
  character per separator, no `CR`/`LF`, no two the same) is a typed error rather than bytes written
42
- and lost.
42
+ and lost. A frame carries **bytes**, so a record handed to `composeAstmFrames` as a `string` is one
43
+ byte per character and a character above `U+00FF` is a typed error too: the encoder will not pick a
44
+ character encoding for you, and it never quietly writes a different character than the one you gave
45
+ it. Encode such content yourself and pass the `Uint8Array`.
43
46
  - **Vendor profiles.** `defineAstmProfile()` builds a provenance-backed profile whose tolerances
44
47
  downgrade _expected_, non-safety-critical deviations to a `PROFILE_QUIRK_APPLIED` warning without
45
48
  ever altering a value, behind a safety gate that refuses to tolerate any result value, flag,
package/dist/index.cjs CHANGED
@@ -2226,18 +2226,37 @@ var AstmFrameEncodeError = class extends Error {
2226
2226
  code;
2227
2227
  /** Index of the offending record within the input, when applicable. */
2228
2228
  recordIndex;
2229
+ /**
2230
+ * Position of the offending character within that record, when applicable:
2231
+ * its index in the string, never the character itself and never its code
2232
+ * point. Enough to find it in the caller's own data.
2233
+ */
2234
+ characterIndex;
2229
2235
  /** @internal */
2230
- constructor(message, recordIndex) {
2236
+ constructor(message, recordIndex, code = "ASTM_FRAME_EMPTY_RECORD", characterIndex) {
2231
2237
  super(message);
2232
2238
  this.name = "AstmFrameEncodeError";
2233
- this.code = "ASTM_FRAME_EMPTY_RECORD";
2239
+ this.code = code;
2234
2240
  if (recordIndex !== void 0) this.recordIndex = recordIndex;
2241
+ if (characterIndex !== void 0) this.characterIndex = characterIndex;
2235
2242
  }
2236
2243
  };
2237
- function toBytes(input) {
2244
+ var MAX_SINGLE_BYTE_CODE_POINT = 255;
2245
+ function toBytes(input, recordIndex) {
2238
2246
  if (typeof input !== "string") return input;
2239
2247
  const out = new Uint8Array(input.length);
2240
- for (let i = 0; i < input.length; i += 1) out[i] = input.charCodeAt(i) & 255;
2248
+ for (let i = 0; i < input.length; i += 1) {
2249
+ const code = input.charCodeAt(i);
2250
+ if (code > MAX_SINGLE_BYTE_CODE_POINT) {
2251
+ throw new AstmFrameEncodeError(
2252
+ "A record contains a character above U+00FF, which is not a single byte. Encode the record with the character encoding your instrument uses and pass the bytes.",
2253
+ recordIndex,
2254
+ "ASTM_FRAME_UNENCODABLE_CHARACTER",
2255
+ i
2256
+ );
2257
+ }
2258
+ out[i] = code;
2259
+ }
2241
2260
  return out;
2242
2261
  }
2243
2262
  function encodeFrame(text, frameNumber, isFinal) {
@@ -2254,7 +2273,7 @@ function composeAstmFrames(records, options = {}) {
2254
2273
  const out = [];
2255
2274
  let frameNumber = options.startFrameNumber ?? FIRST_FRAME_NUMBER;
2256
2275
  records.forEach((record, recordIndex) => {
2257
- const bytes = toBytes(record);
2276
+ const bytes = toBytes(record, recordIndex);
2258
2277
  if (bytes.length === 0) {
2259
2278
  throw new AstmFrameEncodeError(
2260
2279
  "Cannot frame an empty record: a frame must carry record bytes.",
@@ -2296,7 +2315,7 @@ function concatBytes3(chunks) {
2296
2315
  }
2297
2316
 
2298
2317
  // src/index.ts
2299
- var VERSION = "0.0.9";
2318
+ var VERSION = "0.0.11";
2300
2319
 
2301
2320
  exports.ALL_ASTM_WARNING_CODES = ALL_ASTM_WARNING_CODES;
2302
2321
  exports.AMBIGUOUS_CODES = AMBIGUOUS_CODES;