@cosyte/astm 0.0.18 → 0.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,49 @@ this file is maintained by hand (Changesets handles the version bump and publish
11
11
 
12
12
  ### Added
13
13
 
14
+ - **The PHI commit-gate now reads a stream written as a source string literal, and refuses a sweep
15
+ that did not observe its corpus.** Two separate holes, closed together because closing one alone
16
+ buys only the cross-cutting SSN/email floor.
17
+ - **Scan roots.** The all-mode walk covers `src`, `test` and `scripts`, where it previously
18
+ covered `src` and `test/fixtures` only. Measured against `git ls-files` on the commit before
19
+ this change: 106 of 165 tracked files were scanned by neither enumerating route, 66 of them
20
+ under `test/`, and 33 of those 66 contain a patient-record literal. Those fixtures were read by
21
+ hand before.
22
+ - **A source-embedded view.** The record-aware detector splits on newlines and asks whether a line
23
+ begins with the patient-record type letter. Most of this package's streams are string literals,
24
+ so every one of them arrived as a single line beginning with a quote and was never read,
25
+ including inside `src`, which the walk already covered. The detector now also runs over a view
26
+ with the source escape sequences decoded and the string-literal delimiters treated as record
27
+ boundaries, in addition to the raw bytes. Both are needed: an escape sequence supplies a
28
+ boundary only for a stream written whole in one literal, and this package also writes a single
29
+ record per literal and a stream as array elements joined at run time. The decode is one
30
+ left-to-right pass that consumes an escaped backslash as a pair; it runs only on a closed set of
31
+ source file extensions, so it cannot invent a record boundary in wire data whose backslash is
32
+ the repeat delimiter; and delimiters are read from the line view only, so a quoted fragment of
33
+ prose cannot redefine the delimiter set for a whole file.
34
+ - **An observation rule.** A sweep refuses (exit 2) when a declared root observed no files, when
35
+ the invocation as a whole observed none, and when git tracks in-scope files under a root that
36
+ the walk did not observe. A missing root, an emptied root, a dangling symbolic link as a root,
37
+ and a root that is a regular file are all covered; the last previously raised an uncaught
38
+ `ENOTDIR`, which exits 1, the code that means hits were found.
39
+ - **Two precision guards, each with its bound stated where it is applied.** A line that merely
40
+ starts with the patient-record type letter is not read as a patient record: its second field
41
+ must be a short digit run, which this package's own builder always writes there. And a name
42
+ component of one character is not read, so a bare middle initial does not have to be exempted
43
+ by name for the whole repository.
44
+ - What a green from the scan means is now written out in one place, `scanTarget`, with every
45
+ locus, record shape, token and file it does not cover named there. No other surface restates
46
+ it.
47
+ - The scanner resolves its repository from its own file location rather than from the working
48
+ directory, and every `git` subprocess it runs is pinned to that repository. Run from a parent
49
+ checkout it previously walked that tree and reported clean about this package having never
50
+ opened it.
51
+ - **`pnpm check`, a gate-coverage check** (`scripts/check-gate-coverage.ts`). It asserts that every
52
+ command this repository's own workflows run is reachable as a `pnpm run` script or is declared
53
+ with the reason it is not, that every `run-<x>: true` input passed to a shared pipeline has the
54
+ script that pipeline will call, and that the two gates whose names carry no recognisable prefix
55
+ still exist. Every declared-unreachable entry is printed on every run.
56
+
14
57
  - **Two parse-path warning codes for two readings the parser could not defend, both landing on the
15
58
  profile safety gate** (`ASTM-FRAME-RESIDUALS`, defects 4 and 11). Both are additive: no existing
16
59
  warning was removed or renamed, no split changed, and every extracted value is byte-identical to
@@ -136,7 +179,8 @@ this file is maintained by hand (Changesets handles the version bump and publish
136
179
  `ASTM_RECORD_ALIGNMENT_SHIFTED_FIELDS` fires where two escape alignments disagree about a **field**
137
180
  boundary, the reading taken keeps it, and the escape character that reading resumes on heads **no
138
181
  escape sequence at all**, which is exactly the character the competing alignment uses to close its
139
- own sequence. A gained field boundary shifts every later field one place. It is not tolerable, so
182
+ own sequence. (**That tail bound is widened later in this same release**, to any tail this reader
183
+ cannot interpret; the entry below carries the change and its measurement.) A gained field boundary shifts every later field one place. It is not tolerable, so
140
184
  `{ strict: true }` refuses such a record whatever profile is in force.
141
185
 
142
186
  **What the shift costs, measured on the canonical set.** `R|1|^^^687|28.6&F&|&U/L||||F` reads
@@ -180,16 +224,20 @@ this file is maintained by hand (Changesets handles the version bump and publish
180
224
  escaped again, entirely well formed, and refusing it is the over-refusal that sank a criterion
181
225
  measured for this same family); and where it heads an **unrecognized** sequence, the alignment taken
182
226
  still consumes it while the competing one would leave two escape characters bare, so the preference
183
- is stronger again. That second case still shifts the fields and is silent here. It is recorded as a
184
- residue, measured, not overlooked.
227
+ was read as stronger again, and this shipped silent there while the fields shifted just the same.
228
+ **That residue is closed later in this same release** (see the tail-bound entry below), which
229
+ corrects the reason rather than the code: the preference comparison is true and is not the question
230
+ this code asks.
185
231
 
186
232
  **Measured against the corpus constants committed with the test**, on the same
187
233
  strict-accepted-under-a-gate-legal-profile tier. Over `DECLARATION_ALPHABET`, `SPLITTING_ROLES`,
188
234
  `BODY_ALPHABET` and `TAIL_SUFFIXES` in `test/records/alignment-shifted-fields.test.ts`, **864
189
- tuples**: the code fires on **96**, moves **32** from accepted to refused under a profile built from
190
- the whole tolerable allow-list, and moves **0** back. It fires on **none** of the 96 escape-clean
191
- tuples, and cannot: firing requires an escape character heading no sequence, which this package
192
- already reports as a deviation of its own. The criterion measured and rejected on this same corpus
235
+ tuples**: as this code shipped it fired on **96**, moved **32** from accepted to refused under a
236
+ profile built from the whole tolerable allow-list, and moved **0** back. **The tail-bound widening
237
+ below takes those to 192 and 64 on the same corpus**, which is what the committed test asserts now.
238
+ It fires on **none** of the 96 escape-clean tuples, and cannot, before or after: firing requires an
239
+ escape character heading no sequence this reader can interpret, and this package already reports
240
+ each of those as a deviation of its own. The criterion measured and rejected on this same corpus
193
241
  would have refused 48 of them. The 32 that move are exactly the population the code above left
194
242
  silent through its recognized-body exclusion.
195
243
 
@@ -207,9 +255,9 @@ this file is maintained by hand (Changesets handles the version bump and publish
207
255
  - **A parse-path warning code for a field a competing escape alignment read short**
208
256
  (`ASTM-FRAME-RESIDUALS`, defect 17b). `ASTM_RECORD_ALIGNMENT_TRUNCATED_FIELD` fires where two
209
257
  escape alignments of the same bytes disagree about a **repeat** boundary, the reading taken keeps
210
- it, and the escape character that reading resumes on heads **no sequence at all**. It lands on the
211
- profile safety gate, so `{ strict: true }` refuses a stream carrying it whatever profile is in
212
- force.
258
+ it, and the escape character that reading resumes on heads **no sequence at all**. (**That tail
259
+ bound is widened later in this same release**; see the entry below.) It lands on the profile safety
260
+ gate, so `{ strict: true }` refuses a stream carrying it whatever profile is in force.
213
261
 
214
262
  **Nothing shifts, and that is not the same as nothing being lost, which is the correction this
215
263
  entry carries.** No field-indexed slot moves: the units slot and the result-status slot are read
@@ -244,20 +292,23 @@ this file is maintained by hand (Changesets handles the version bump and publish
244
292
 
245
293
  **Two further bounds, both deliberate.** It is wired to the **repeat** separator only: a gained
246
294
  **component** boundary reaches a modeled slot too, and differently, moving it one slot along rather
247
- than dropping it, and that stays the separate open condition disclosed above. And the tail is
295
+ than dropping it, which is the entry below. And the tail is
248
296
  weighed **one construct deep**, on the same test the shift report uses: where the escape character
249
297
  past the boundary heads a **recognized** sequence the bytes prefer the reading taken and the two
250
298
  repeats are the ones the sender wrote (`28.6&R&\&R&U/L` is the canonical repeat separator
251
299
  escaped, written and escaped again, and refusing it is the over-refusal that sank a criterion
252
300
  measured for this same family), and where it heads an **unrecognized** one the competing alignment
253
- would leave two escape characters bare, so the preference is stronger again. That second case still
254
- truncates and is silent here: recorded as a residue, measured, not overlooked.
301
+ would leave two escape characters bare, so the preference was read as stronger again, and this
302
+ shipped silent there while the field truncated just the same. **That residue is closed later in
303
+ this same release** (see the tail-bound entry below).
255
304
 
256
305
  **Measured against the corpus constants committed with the test**, on the same
257
306
  strict-accepted-under-a-gate-legal-profile tier and the same **864 tuples** as the two measurements
258
- before it, in `test/records/alignment-truncated-field.test.ts`: the code fires on **96**, moves
259
- **32** from accepted to refused under a profile built from the whole tolerable allow-list, moves
260
- **0** back, and fires on **none** of the 96 escape-clean tuples. The pair-count criterion measured
307
+ before it, in `test/records/alignment-truncated-field.test.ts`: as this code shipped it fired on
308
+ **96**, moved **32** from accepted to refused under a profile built from the whole tolerable
309
+ allow-list, moved **0** back, and fired on **none** of the 96 escape-clean tuples. **The
310
+ tail-bound widening below takes the first two to 192 and 64 on the same corpus**, which is what the
311
+ committed test asserts now, and leaves the escape-clean zero where it is. The pair-count criterion measured
261
312
  and rejected on this same corpus would have refused 48 of them, which is why the tail is what gets
262
313
  weighed. **Unlike that rejected criterion's population, this one is not confined to a set naming a
263
314
  mnemonic letter as a delimiter**: the canonical repeat separator reaches it, on 12 of the 108
@@ -276,6 +327,211 @@ this file is maintained by hand (Changesets handles the version bump and publish
276
327
  factory, the `TruncatedFieldSink` type, and a further trailing optional sink parameter on
277
328
  `splitEscapeAware`, `tokenizeRecord` and `tokenizeHeader`.
278
329
 
330
+ - **A parse-path warning code for components a competing escape alignment moved one slot along**
331
+ (`ASTM-FRAME-RESIDUALS`, defect 17c). `ASTM_RECORD_ALIGNMENT_SHIFTED_COMPONENTS` fires where two
332
+ escape alignments of the same bytes disagree about a **component** boundary, the reading taken
333
+ keeps it, and the escape character that reading resumes on heads **no sequence at all**. (**That
334
+ tail bound is widened later in this same release**; see the entry below.) It lands on the profile
335
+ safety gate, so `{ strict: true }` refuses a stream carrying it whatever profile is in force. With it the three roles a split is taken on are all wired; there is no fourth, because
336
+ nothing splits on the escape role.
337
+
338
+ **Nothing shifts between fields and nothing leaves the record: the slots MOVE.** That is the third
339
+ answer to the one question the two entries above ask, and it is why neither of them could carry it.
340
+ Components are modeled **inside** a field, so every component after the gained boundary sits one
341
+ place further right than the competing alignment puts it, and the slots that indexes into are named
342
+ things. Both costs are reachable on the **canonical** set, so no unusual declaration is needed:
343
+ - **A code system and a vendor's local code swap places.** `R|1|&F&^&GLU^L^687|28.6|U/L||||F`
344
+ reads a Universal Test ID of four components, so `L` is the coding scheme and `687` the local
345
+ code; the competing alignment reads three, and `687` is the **coding scheme**. A code-system
346
+ selector and a vendor's local code are not the same thing, so a consumer routing on either is
347
+ routing on the alignment.
348
+ - **A given name and a middle name shift along.** `P|1||MRN-0001||DOE&F&^&JANE^A||19700101|F`
349
+ reads a given name of `&JANE` and a middle name of `A`; under the competing alignment `A` is the
350
+ **given** name and there is no middle name at all.
351
+
352
+ Before this code the only warning on either stream was the tolerable
353
+ `ASTM_UNPAIRED_ESCAPE_CHARACTER`, so the widest gate-legal profile plus `{ strict: true }` accepted
354
+ both. **This closes a claim that had been recorded as false and shipped**: "a gained repeat or
355
+ component boundary divides one field and reaches nothing outside it, so it cannot move a modeled
356
+ slot". The first clause is true and the inference is not.
357
+
358
+ **Every gained boundary at or before the LAST MODELED COMPONENT INDEX moves those slots, not only
359
+ the first**, because the shift propagates from it to the end of the component list. That is the one
360
+ structural asymmetry with the repeat-separator code above, where a field is modeled out of its
361
+ first repeat alone and only the first gained boundary reaches a modeled slot. **Two bounds run the
362
+ other way and this fires inside both**, which is over-reporting relative to the named slots and
363
+ never under-reporting, measured rather than assumed: past the last modeled component index a model
364
+ reads a fixed number of components (a patient name three, a Universal Test ID four), so a contested
365
+ boundary further right shifts the components after it while every named slot reads the same under
366
+ both alignments; and inside a LATER repeat nothing modeled moves at all, because `components` is
367
+ the first repeat. Narrowing the sink to either would change which streams a published package
368
+ refuses and wants its own measurement, so both bounds are written down instead.
369
+
370
+ **Additive, and the split is unchanged.** Every decoded byte is identical and the components read
371
+ are the components that were always read: this reports the moved slots, it does not repair them.
372
+ Picking the other alignment was rejected for the same reason as before, on a published package, and
373
+ **withholding the moved slots stays a separate question**: declining to model a slot changes an
374
+ extracted value for every consumer already on the registry.
375
+
376
+ **The tail is weighed one construct deep**, on the same test the two codes above use. Where the
377
+ escape character past the boundary heads a **recognized** sequence the bytes prefer the reading
378
+ taken and the components are the ones the sender wrote (`&F&^&F&GLU` is a field separator escaped,
379
+ a component separator written, and a field separator escaped again, which raises no warning at all,
380
+ and refusing it is the over-refusal that sank a criterion measured for this same family); where it
381
+ heads an **unrecognized** one the competing alignment would leave two escape characters bare, so
382
+ the preference was read as stronger again, and this shipped silent there while the slots moved just
383
+ the same. **That residue is closed later in this same release** (see the tail-bound entry below).
384
+
385
+ **Measured against the corpus constants committed with the test**, on the same
386
+ strict-accepted-under-a-gate-legal-profile tier and the same **864 tuples** as the three
387
+ measurements before it, in `test/records/alignment-shifted-components.test.ts`: as this code
388
+ shipped it fired on **96**, moved **32** from accepted to refused under a profile built from the
389
+ whole tolerable allow-list, moved **0** back, and fired on **none** of the 96 escape-clean tuples.
390
+ **The tail-bound widening below takes the first two to 192 and 64 on the same corpus**, which is
391
+ what the committed test asserts now, and leaves the escape-clean zero where it is. The pair-count
392
+ criterion measured and rejected on this same corpus would have refused 48 of them, which is why the
393
+ tail is what gets weighed. Its column is **disjoint from both** earlier tail reports, asserted on
394
+ the shared corpus rather than reasoned from the wiring: the three partition the splitting roles.
395
+
396
+ **The index axes that corpus holds fixed are swept beside it, because a criterion measured only
397
+ there inherits its blind spot.** Across every component position of the first repeat the reading
398
+ taken reads exactly one component more than the competing alignment, so the harm is not bounded to
399
+ the first boundary. And the identical 864-tuple alphabet re-run with a clean first repeat in front
400
+ of the contested construct gives the same column and the same delta (**96 fire, 32 move, 0 back, 0
401
+ escape-clean**) while on **all 96** the modeled component list is identical under both alignments,
402
+ which states the over-reporting bound as a measurement rather than as prose.
403
+
404
+ **It does not survive a re-emit**, the same residue its siblings disclosed: the serializer rewrites
405
+ the preserved characters into recognized mnemonics, so a second-generation read is silent and is
406
+ correct about its own bytes. Catch it on the first read of the wire bytes.
407
+
408
+ This is a **narrowing on a published package**, on the strict path only: a lenient parse of the
409
+ same stream returns the same records, with the same values, and one more warning on them. New
410
+ public surface: `ASTM_RECORD_ALIGNMENT_SHIFTED_COMPONENTS`, the `alignmentShiftedComponents`
411
+ warning factory, the `ShiftedComponentsSink` type, and a further trailing optional sink parameter
412
+ on `splitEscapeAware`, `tokenizeRecord` and `tokenizeHeader`.
413
+
414
+ - **The three alignment tail reports now fire on a tail they cannot READ, not only on one they cannot
415
+ CONSUME** (`ASTM-FRAME-RESIDUALS`, defect 17, the disclosed residue). No new code and no rename:
416
+ `ASTM_RECORD_ALIGNMENT_SHIFTED_FIELDS`, `ASTM_RECORD_ALIGNMENT_TRUNCATED_FIELD` and
417
+ `ASTM_RECORD_ALIGNMENT_SHIFTED_COMPONENTS` all keep their names, their claims and their roles. What
418
+ changes is the one predicate behind them: a contested boundary is now reported where the escape
419
+ character the reading taken resumes on heads **no sequence this reader can interpret**, which is
420
+ two cases rather than one.
421
+ - It heads **no sequence at all** and is kept as a bare literal
422
+ (`ASTM_UNPAIRED_ESCAPE_CHARACTER`). Reported before, reported now.
423
+ - It heads a sequence whose body is **not a recognized mnemonic**, so the triple is preserved
424
+ verbatim and never guessed at (`ASTM_UNKNOWN_ESCAPE_SEQUENCE`). **This is what was silent.**
425
+
426
+ **The correction is to the reason, which is why it is worth stating.** The recorded ground for the
427
+ silence was a comparison of preferences: in that case the competing alignment would leave **two**
428
+ escape characters bare while the reading taken carries one unreadable body, so the bytes prefer the
429
+ reading taken more strongly than in the case that already fired. That comparison is true. It is not
430
+ the question these codes ask. They report a **cost**, a modeled slot decided by a boundary the
431
+ bytes do not force, and the cost is the same one: on the canonical set
432
+ `R|1|^^^687|28.6&F&|&Z&U/L||||F` still reads nine fields against the competing alignment's eight
433
+ and still hands back a status of `final` the sender put in no slot; `28.6&S&\&Z&U/L` still reads a
434
+ value of `28.6^`; `&F&^&Z&GLU^L^687` still reads `687` as a vendor local code where the competing
435
+ alignment reads it as the **coding scheme**. Each raised only the tolerable
436
+ `ASTM_UNKNOWN_ESCAPE_SEQUENCE` before, so the widest gate-legal profile plus `{ strict: true }`
437
+ accepted all three. **Consuming a triple is not interpreting one.**
438
+
439
+ **Exactly one tail is still excluded, and it is the one that carries the whole over-refusal
440
+ argument.** Where the escape character heads a sequence this reader **recognizes**, the reading
441
+ taken interprets a construct and nothing is reported: under a set naming the field separator `F`,
442
+ `28.6&F&F&F&U/L` is that separator escaped, written, and escaped again, entirely well formed. That
443
+ is the **only** tail on which a stream's escaping can be clean at all, so it is the only exclusion
444
+ that could ever protect conformant traffic, and refusing it is the over-refusal that sank a
445
+ criterion measured and rejected for this same family. The widened predicate **cannot** refuse an
446
+ escape-clean stream: every position it now rejects already raises an escape deviation of its own.
447
+
448
+ **That remaining silence is a TRADE, and it is not a claim that nothing was lost there.** On the
449
+ excluded tail the gained boundary is exactly as real, and it is not merely under-reported: it is
450
+ `warnings: []`, so a lenient consumer sees nothing at all. On the canonical set,
451
+ `R|1|^^^687|28.6&F&|&F&U/L||||F` reads **nine** fields against the competing alignment's eight and
452
+ hands back a status of **`final`**; `28.6&S&\&S&U/L` reads a value of `28.6^` and `^U/L` leaves
453
+ every modeled slot; `&F&^&F&GLU^L^687` reads one component more, so `687` is a vendor local code
454
+ under one reading and the **coding scheme** under the other. All three are `PRE-EXISTING`, all
455
+ three are now pinned by tests rather than by prose, and **read the raw line when an escape
456
+ character sits next to a delimiter, whether or not anything fired.**
457
+
458
+ **Additive in behaviour terms and still a report, not a repair.** No split changed, every decoded
459
+ byte is identical, and no tolerable code was struck off and no code became tolerable. **What the
460
+ three codes CLAIM about their role did have to move, and that is recorded rather than glossed**:
461
+ see the entry below. It still does not survive a re-emit:
462
+ the serializer rewrites the preserved characters into recognized mnemonics, so a second-generation
463
+ read is silent and correct about its own bytes. Catch it on the first read of the wire bytes.
464
+
465
+ **Measured against the corpus constants committed with the tests**, on the same
466
+ strict-accepted-under-a-gate-legal-profile tier as every measurement in this family. On the shared
467
+ **864-tuple** corpus each of the three codes now fires on **192** where it fired on 96, moves
468
+ **64** from accepted to refused where it moved 32, moves **0** back, and fires on **0** of the 96
469
+ escape-clean tuples exactly as before. The two reported tails contribute equally, so neither half
470
+ of a column carries the other.
471
+
472
+ **The axis that corpus holds fixed is swept beside it**, because a criterion measured only there
473
+ inherits its blind spot: that corpus varies whether the tail is bare, recognized or unrecognized,
474
+ but uses a single character as the unrecognized body. `test/records/alignment-unrecognized-tail.test.ts`
475
+ crosses `DECLARATION_ALPHABET`, `SPLITTING_ROLES` and `BODY_ALPHABET` with the same alphabet as the
476
+ **tail** body: **3,456 tuples**, of which **2,304** fire, **528** move from accepted to refused,
477
+ **0** move back, and **384** are escape-clean with **0** of them refused. Every tuple's observed
478
+ disposition is asserted against a predicate derived from those alphabets rather than against a
479
+ count, and the harness is failed on purpose against a perturbed mnemonic set in both directions.
480
+
481
+ **One universal that sweep refuted, recorded because it is the obvious thing to write.** "The
482
+ reading taken reads exactly one more segment than the competing alignment" is **false**. Where the
483
+ tail sequence's body is the delimiter the split is being taken on, the reading taken keeps that
484
+ character inside an opaque atom while the competing alignment splits on it, so the two readings
485
+ produce the **same** number of segments in **different** places: under `H~\^&`, `28.6&F&~&~&U/L`
486
+ reads `28.6&F&` + `&~&U/L` against the competitor's `28.6&F&~&` + `&U/L`. That is 144 of the 2,304
487
+ firing tuples. What holds on all of them is that the two readings **disagree** and that both
488
+ consume every byte, so neither is forced.
489
+
490
+ This is a **narrowing on a published package**, on the strict path only: a lenient parse of the
491
+ same stream returns the same records, with the same values, and one more warning on them. **No new
492
+ public surface**: no code, type, factory or parameter was added, removed or renamed.
493
+
494
+ - **The three alignment tail reports now state the one class in which nothing moves index**
495
+ (`ASTM-FRAME-RESIDUALS`, defect 17, the claim correction the widening above forced). Widening the
496
+ predicate widened the firing population past the point where each code's own cost claim held
497
+ everywhere, and the claims did not move with it. They do now. Where a surface states the cost, it
498
+ names the exception: the three sink docs in `src/common/escapes.ts`, the three code entries, the
499
+ three factory leads and the three **runtime warning messages** in `src/common/warnings.ts`,
500
+ `src/profiles/safety.ts`, the `referenceCorpus` rationale string, `README.md`,
501
+ `docs-content/quickstart.md`, `docs-content/limitations.md` and
502
+ `docs-content/troubleshooting.md`. Where a surface only paraphrased a cost, the paraphrase was
503
+ **removed** rather than re-qualified, so there is one place to keep true: the `@param` text on
504
+ `splitEscapeAware` in `src/common/escapes.ts` and on `tokenizeRecord` in `src/records/tokenize.ts`
505
+ now defers to the sink docs instead of restating them.
506
+
507
+ **The class, named rather than left to be found.** Where the sequence past the contested boundary
508
+ carries **the splitting delimiter itself** as its body, the reading taken holds that character
509
+ inside an opaque atom while the competing alignment splits on it, so the two readings return the
510
+ **same number** of segments in **different places**. Nothing moves index, and what differs is the
511
+ contents. On the canonical set `R|1|^^^687|28.6&F&|&|&U/L||||F` reads **nine** fields under both,
512
+ with the status `F` in field 9 under both and units of `&|&U/L` against `&U/L`; `28.6&F&\&\&U/L`
513
+ reads **two** repeats under both; `DOE&F&^&^&JANE^A` reads **three** components under both, with
514
+ `A` the middle name under both. So "every later field is one place further right", "the field is
515
+ read as more repeats than the other reading gives it" and "every later component sits one place
516
+ further right" were each false on that class, in `dist/index.d.ts`, in the immutable docs tarball,
517
+ and in three runtime messages.
518
+
519
+ **The class is bounded, and the bound is measured against constants committed with the test, not
520
+ argued.** `test/records/alignment-unrecognized-tail.test.ts` crosses `DECLARATION_ALPHABET`,
521
+ `SPLITTING_ROLES`, `BODY_ALPHABET` and `TAIL_BODY_ALPHABET` with two further constants,
522
+ `PAYLOAD_PREFIXES` and `PAYLOAD_SUFFIXES`, so the carrier shape is not held fixed the way every
523
+ earlier corpus in this family held it: **55,296 tuples**, of which **36,864** fire and **2,304**
524
+ are the class. **0** of those lack `ASTM_RECORD_DELIMITER_SWALLOWED_BY_ESCAPE` and **0** were
525
+ accepted before this release, because that tail body is a splitting delimiter in force. So the
526
+ three codes over-report relative to the indexes there and never under-report, and the class costs
527
+ no stream its disposition. The class is exactly "the tail body is the delimiter being split on",
528
+ with **0** counterexamples in either direction. Every figure is derived from those six constants
529
+ inside its own assertion; **never quote one against a different sha or a different corpus.**
530
+
531
+ **What holds on every firing tuple**, and it is what the claims now lead with: the two readings
532
+ **disagree**, and both **consume every byte**, so neither reading is forced. No code, message
533
+ identity, type, factory or parameter changed, and no stream's disposition moved.
534
+
279
535
  ### Fixed
280
536
 
281
537
  - **A header that is not short no longer reports that it is too short** (`ASTM-FRAME-RESIDUALS`,
@@ -599,10 +855,13 @@ this file is maintained by hand (Changesets handles the version bump and publish
599
855
  unchanged, still `test/fixtures/**` and `src/**.ts`, so a staged link outside it is still not
600
856
  looked at; narrowing what a scope admits is not widening the scope. That scope also bounds the
601
857
  gitlink half: a submodule staged at `test/fixtures/nested` is refused, while one at `src/nested` is
602
- a directory name that fails the `.ts` suffix and is not looked at. `R` (rename) and `C` (copy) are
603
- still **not** enumerated by `--staged` at all, so a staged rename that also appends PHI passes that
858
+ a directory name that fails the `.ts` suffix and is not looked at. **`R` (rename) and `C` (copy)
859
+ are still not enumerated by `--staged` at all, so a staged rename that also appends PHI passes that
604
860
  route; pre-existing, unchanged here, and admitting it needs the two-path record shape handled,
605
- which is a scope decision rather than this one. A tracked file **absent from the worktree** is
861
+ which is a scope decision rather than this one.** That last sentence was true when it was written
862
+ and is **superseded later in this same release**: the entry below closes it, and it turned out to
863
+ need no two-path record shape at all. It is kept rather than rewritten because the measurement it
864
+ records is what the later entry starts from. A tracked file **absent from the worktree** is
606
865
  still caught at `git add` time only.
607
866
 
608
867
  **And this scanner still has no rule that a sweep observing ZERO targets must refuse**, which is
@@ -627,6 +886,87 @@ this file is maintained by hand (Changesets handles the version bump and publish
627
886
  is not, which makes closing it a separate slice with its own argument rather than a rider on a PHI
628
887
  blindness fix. It is a different defect and it fails in the safe direction.
629
888
 
889
+ - **The PHI scanner's staged route no longer lets git decide what it is safe not to look at, so a
890
+ `git mv` into a fixture directory can no longer carry PHI past the pre-commit gate**
891
+ (`PHI-SCAN-RENAME-BLIND-AT-PRECOMMIT`). Development tooling only: `scripts/phi-scan.ts` ships in no
892
+ tarball and the package's public surface is unchanged.
893
+
894
+ Reproduced on `427af85` before any fix, on git 2.39.5, with the same synthetic name-bearing payload
895
+ the symlink cases use (an ASTM `P` record carrying a patient name, a mother's maiden name and a
896
+ birthdate, plus a dashed SSN and an email at a non-test domain). Four ways the enumeration could be
897
+ emptied before any of the refusals above were reached, each measured as its own throwaway index:
898
+ - **A staged rename.** `git mv test/fixtures/original.astm test/fixtures/renamed.astm` on a file
899
+ carrying the payload paired as a single record with **two** paths and a status letter of `R`, and
900
+ `--diff-filter=AMT` returned nothing for it, so `pnpm phi-scan --staged` printed `OK: no hits`
901
+ and exited **0**. The same move with the payload **edited into the destination in the same
902
+ staging** passed identically, because git still pairs it when enough of the old content survives.
903
+ No similarity score is quoted here: it moves with how much survives, so a number taken from one
904
+ fixture is wrong in the next, and what is load-bearing is that the record was paired at all.
905
+ - **A staged rename of a symbolic link.** `git mv` of a tracked link into `test/fixtures/` put a
906
+ mode-`120000` entry under a scan root. The mode refusal added by the previous entry already knew
907
+ how to refuse that; the pairing deleted the record before the mode could be read, and the run
908
+ exited **0**.
909
+ - **A gitlink the caller's config hid.** `diff.ignoreSubmodules = all` in the caller's git config
910
+ dropped a staged gitlink under `test/fixtures/` from the raw output entirely. The **same index**
911
+ refused at exit 2 without that config and reported `OK: no hits` at exit **0** with it.
912
+ - **An unmerged path.** An in-scope path recorded unmerged, with the payload on one side, was
913
+ returned by neither `AM` nor `AMT`, and the run printed `OK: no hits` and exited **0** over an
914
+ index it could not read: such a path is recorded at one or more of stages 1/2/3 and never at
915
+ stage 0, and stage 0 is exactly what the `:<path>` form this route reads with names, so there is
916
+ no one blob for it to read. **Nothing rests on what `git show` does with such a path**: the route
917
+ refuses before it would call it, and an earlier draft that pinned that exit code did not
918
+ reproduce across git versions.
919
+
920
+ **The remedy is entirely in the argv, and it is one rule: stop trusting the caller's git config,
921
+ and stop letting the filter decide what is safe not to look at.** The route now reads
922
+ `git diff --cached --raw -z --no-renames --ignore-submodules=none --diff-filter=AMTUB`.
923
+ `--no-renames` makes a paired record impossible, so the destination arrives as an ordinary
924
+ single-path `A` and the source as a `D` the filter drops; verified under `diff.renames` at
925
+ `true`, `copies` and `false` and under `diff.renameLimit=1`, every one yielding the same
926
+ single-path record, so the two-field record stride is now **structural** rather than conditional on
927
+ the caller's config. `--ignore-submodules=none` restores a record the config could delete.
928
+ `U` is in the filter **so it can be refused, not scanned**, with its own message: an unmerged
929
+ record's destination mode is `000000`, so the existing refusal would have described it with a
930
+ sentence about symbolic links and gitlinks that is false for it. That case builds the unmerged
931
+ index directly, with `git update-index --index-info`, rather than provoking it with a real merge:
932
+ whether a merge conflicts depends on the merge machinery and on the caller having a git identity,
933
+ and the index state is the thing under test. Staged as a real conflict it did not reproduce on
934
+ every git version. None of the four costs the record
935
+ stride anything, because each carries a single path.
936
+
937
+ **Be exact about what that changes.** The new enumeration is a **superset** of the old one, not a
938
+ strictly larger set: the two are **equal** whenever nothing is renamed, copied, unmerged or hidden
939
+ by that config, and larger only when something is. Nothing the old argv enumerated stops being
940
+ enumerated. The `--staged` path scope is **unchanged**, still `test/fixtures/**` and `src/**.ts`, so
941
+ an unmerged or renamed path outside it is still not looked at, and both of those are pinned.
942
+
943
+ **`B` is in the filter because `-B` is not inert, and the mechanism is sharper than "a `B` record
944
+ the filter drops".** A complete rewrite under `-B` prints a **single-path record whose status
945
+ letter is `M`**, with a break score, which the record regex parses happily, so a reader checking
946
+ the raw output concludes the record is an `M` and that `AMTU` therefore keeps it. It does not:
947
+ `--diff-filter` classifies a broken pair as `B` **whatever letter it prints**. Measured on one
948
+ index, over a wholly rewritten in-scope fixture carrying a dashed SSN: `-B --diff-filter=AMTU`
949
+ returns empty while `-B --diff-filter=B` and `-B --diff-filter=AMTUB` each return the same record,
950
+ and a copy of the scanner with `-B` injected exits **0** with `OK: no hits` on the superseded
951
+ filter and **1** with the hit on the shipped one. `B` costs the enumeration nothing today, since
952
+ git only breaks a pair when `-B` is given, so it is there to stop the flag ever being a silent
953
+ blindfold. `-M`, `-C` and `--find-copies-harder` each turn detection back on over the top of
954
+ `--no-renames` and empty the route again, which is measured beside it; none of the four may be
955
+ added.
956
+
957
+ **6 of the file's 41 cases are red on `427af85`**, and the premises each rests on are pinned beside
958
+ it, so a case cannot quietly stop testing anything. Refusal messages still name only the entry's own
959
+ repo-relative path and an engine-owned reason, never a link target or any stream content, and that
960
+ is asserted on the new refusal too.
961
+
962
+ **What this does not cover, stated narrowly.** The all-mode walk is untouched: it has no concept of
963
+ a rename, and nothing here widens or narrows what it enumerates. The scan roots' own paths are still
964
+ outside the `--staged` scope, so a staged blob, link or gitlink at exactly `test/fixtures`, `test`
965
+ or `src` is still not looked at by that route. Neither route reaches `test/**/*.test.ts`, where this
966
+ package keeps most of its inline fixtures, so those are still read by hand. And this scanner still
967
+ has no rule that a sweep observing zero targets must refuse, exactly as recorded above. Each of
968
+ those is a scope decision with its own argument, not a consequence of anything here.
969
+
630
970
  - **The frame encoder no longer writes a different character than the one it was handed**
631
971
  (`ASTM-FRAME-BYTE-RESIDUALS`, closing known defect 7). `composeAstmFrames` turned a record given
632
972
  as a `string` into bytes with `charCodeAt(i) & 0xff`, so any character above `U+00FF` was replaced
package/README.md CHANGED
@@ -1,7 +1,9 @@
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>
1
+ <a href="https://cosyte.com">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="https://cosyte.com/tile/cosyte-lockup-tile-on-dark-1200x300.png">
4
+ <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">
5
+ </picture>
6
+ </a>
5
7
 
6
8
  # @cosyte/astm
7
9
 
@@ -222,9 +224,12 @@ mirror: catch it on the first read.
222
224
 
223
225
  **And where that gained boundary is a FIELD boundary, it moves a result's status, which is a
224
226
  different question and so a different code.** The two alignments resume one character apart, so they
225
- disagree about every byte after the boundary, not only about it. Where the escape character the
226
- reading taken resumes on heads no sequence at all, that reading bought the boundary with a byte it
227
- cannot read while the competing alignment is the one that can use it. On the field separator that
227
+ disagree about the bytes after the boundary, not only about it (they can resync later, and the
228
+ class where they do is named below). Where the escape character the
229
+ reading taken resumes on heads no sequence this reader can _interpret_ (none at all, or one whose
230
+ body is not a recognized mnemonic and is therefore preserved verbatim rather than read), that
231
+ reading bought the boundary with bytes it cannot read while the competing alignment is the one that
232
+ can use them. On the field separator that
228
233
  matters clinically: `R|1|^^^687|28.6&F&|&U/L||||F` reads **nine** fields under the alignment taken
229
234
  and **eight** under the other, so the sender's trailing `F` lands in field 9, the result status,
230
235
  under the first and in no field at all under the second. The parse hands back units of `&U/L` and a
@@ -235,9 +240,24 @@ tolerate**; before it existed the only warning on that stream was the tolerable
235
240
  unchanged: it reports the shift rather than repairing it. It is wired to the **field** separator
236
241
  only, because a gained repeat or component boundary divides one field and so moves no field-indexed
237
242
  slot. **That bound is a choice, not a consequence**: components are modeled inside a field, so a
238
- gained repeat or component boundary does reach a modeled slot. It is also silent where the trailing
239
- escape character heads a sequence of its own, recognized or not; that case still shifts the fields
240
- and is the recorded residue. It does not survive a re-emit either: catch it on the first read.
243
+ gained repeat or component boundary does reach a modeled slot, and each of those two has a code of
244
+ its own below. It stays silent in exactly one case,
245
+ where the trailing escape character heads a sequence this reader RECOGNIZES: that is the escape
246
+ mechanism working, and refusing it would refuse well-formed traffic. That is the only tail on which
247
+ a stream's escaping can be clean, which is why it is the only exclusion. **That silence is a trade
248
+ and not a claim that nothing was lost there**: the gained field boundary is exactly as real, and on
249
+ that tail it is `warnings: []`, so `R|1|^^^687|28.6&F&|&F&U/L||||F` reads nine fields against the
250
+ competing alignment's eight and hands back a status of `final` with nothing reported at all. Read
251
+ the raw line when an escape character sits next to a delimiter, whether or not anything fired.
252
+ **The shift has one measured exception, and it is named rather than left to be found**: where the
253
+ sequence past the boundary carries the field separator itself as its body, the reading taken holds
254
+ that character inside an opaque atom while the competing alignment splits on it, so the two readings
255
+ read the **same number** of fields in **different places**. `R|1|^^^687|28.6&F&|&|&U/L||||F` reads
256
+ nine fields under both, with the status `F` in field 9 under both, and what differs is the units.
257
+ The code still fires there, which is over-reporting relative to the field indexes and never
258
+ under-reporting, and that class costs no stream its disposition, because
259
+ `ASTM_RECORD_DELIMITER_SWALLOWED_BY_ESCAPE` has already refused it. It does not survive a re-emit
260
+ either: catch it on the first read.
241
261
 
242
262
  **And where that gained boundary is a REPEAT boundary, nothing shifts and the field can still be
243
263
  read short.** What the report says is that the field is read as more repeats than the competing
@@ -253,12 +273,44 @@ legal profile accepted a truncated value and an emptied test identity. The readi
253
273
  reports the gained boundary rather than repairing it. **At a LATER boundary it fires and no modeled
254
274
  slot moves**, because the first repeat is then the same under both readings; that is over-reporting
255
275
  relative to the modeled slots and never under-reporting, and it is measured rather than assumed. Its
256
- tail bound is the shift report's, for the same reason: `28.6&R&\&R&U/L` is the repeat separator
257
- escaped, written, and escaped again, and refusing it would refuse a well-formed stream. It is wired to the **repeat** separator only; a gained
258
- **component** boundary reaches a modeled slot differently, moving it one slot along rather than
259
- dropping it (`R|1|&F&^&GLU^L^687|28.6|U/L||||F` reads `687` as a local code under one alignment and
260
- as the coding scheme under the other, with only a tolerable code raised), and that is a separate
261
- open condition. It does not survive a re-emit either: catch it on the first read.
276
+ tail bound is the shift report's, for the same reason, and so is its one exclusion:
277
+ `28.6&R&\&R&U/L` is the repeat separator escaped, written, and escaped again, and refusing it would
278
+ refuse a well-formed stream. **That silence is a trade, not a claim that nothing was lost**:
279
+ `28.6&S&\&S&U/L` still reads a value of `28.6^` with `warnings: []`. **The truncation has the same
280
+ one exception as the shift**: where the sequence past the boundary carries the repeat separator
281
+ itself, the two readings read the same number of repeats in different places, so the field is not
282
+ read as more repeats at all, and that class was already refused by
283
+ `ASTM_RECORD_DELIMITER_SWALLOWED_BY_ESCAPE`. It is wired to the **repeat** separator only; a gained
284
+ **component** boundary reaches a modeled slot differently, and has its own code below. It does not
285
+ survive a re-emit either: catch it on the first read.
286
+
287
+ **And where that gained boundary is a COMPONENT boundary, nothing leaves the record and the slots
288
+ MOVE.** Components are modeled inside a field, so every component after the gained boundary sits one
289
+ place further right than the competing alignment puts it. On the canonical set,
290
+ `R|1|&F&^&GLU^L^687|28.6|U/L||||F` reads a Universal Test ID of four components, so `L` is the coding
291
+ scheme and `687` the vendor's local code; the competing alignment reads three, and `687` is the
292
+ **coding scheme**. A code-system selector and a vendor's local code are not the same thing.
293
+ `P|1||MRN-0001||DOE&F&^&JANE^A||19700101|F` reads a given name of `&JANE` and a middle name of `A`,
294
+ where the competing alignment makes `A` the **given** name with no middle name at all. That raises
295
+ `ASTM_RECORD_ALIGNMENT_SHIFTED_COMPONENTS`, which **no profile may tolerate**; before it existed the
296
+ only warning on either stream was the tolerable `ASTM_UNPAIRED_ESCAPE_CHARACTER`, so a strict parse
297
+ under a legal profile accepted both. The reading is unchanged: it reports the moved slots rather than
298
+ repairing them. **Every gained boundary at or before the last modeled component index moves them,
299
+ not only the first**, because the shift propagates to the end of the component list, which is where
300
+ this differs from the repeat role. **Two bounds run the other way and it fires inside both**,
301
+ over-reporting and never under: past the last modeled index nothing named moves (a name models three
302
+ components, a Universal Test ID four), and inside a later repeat nothing modeled moves at all. Its
303
+ tail bound is the other two reports', for the same reason, and so is its one exclusion, **and that
304
+ silence is a trade rather than a claim that nothing was lost**: `&F&^&F&GLU^L^687` still reads one
305
+ component more than the competing alignment, with `warnings: []`. **A THIRD bound runs the other way
306
+ here, and unlike the two above it is about the bytes past the boundary rather than where the
307
+ boundary sits**: where the sequence past it carries the component separator itself, the two readings
308
+ read the same number of components in different places, so `DOE&F&^&^&JANE^A` reads three components
309
+ under both with `A` the middle name under both, and that class was already refused by
310
+ `ASTM_RECORD_DELIMITER_SWALLOWED_BY_ESCAPE`. What holds wherever any of the three codes fires is
311
+ that the two readings disagree and that both consume every byte, so neither is forced. It does not
312
+ survive a re-emit either: catch it on the first read. With this the three splitting roles are all
313
+ wired, and there is no fourth: nothing splits on the escape role.
262
314
 
263
315
  ### A header that names one character in two delimiter roles
264
316
 
@@ -355,12 +407,17 @@ outside it:
355
407
  where the leftmost alignment lets a delimiter split that a competing alignment would have held,
356
408
  gains a boundary instead of losing one and raises `ASTM_RECORD_AMBIGUOUS_ESCAPE_ALIGNMENT`, also
357
409
  not tolerable. Where that gained boundary is a **field** boundary and the reading taken resumes on
358
- an escape character heading no sequence, every later field shifts one place and a result's units
410
+ an escape character heading no sequence it can interpret, every later field shifts one place and a
411
+ result's units
359
412
  and status are read out of slots the other alignment does not put them in: that raises
360
413
  `ASTM_RECORD_ALIGNMENT_SHIFTED_FIELDS`, not tolerable either. Where it is a **repeat** boundary
361
414
  nothing shifts, but the field is read out of its first repeat alone, so a gained first boundary
362
415
  truncates a value and costs a test identity or a patient name the components that sat after it:
363
- that raises `ASTM_RECORD_ALIGNMENT_TRUNCATED_FIELD`, not tolerable either.
416
+ that raises `ASTM_RECORD_ALIGNMENT_TRUNCATED_FIELD`, not tolerable either. Where it is a
417
+ **component** boundary nothing leaves the record and every component after it moves one slot
418
+ along, so a coding scheme, a vendor local code or a given name is read out of a position the other
419
+ alignment does not put it in: that raises `ASTM_RECORD_ALIGNMENT_SHIFTED_COMPONENTS`, not
420
+ tolerable either.
364
421
 
365
422
  All are accepted limits, for two different reasons: widening the field-separator check would mean
366
423
  deciding which set a record ought to have had, which is the same guess the parser declines to make