@cosyte/synth 0.0.3 → 0.0.5

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.
Files changed (49) hide show
  1. package/CHANGELOG.md +335 -0
  2. package/README.md +6 -2
  3. package/dist/astm/index.cjs +83 -42
  4. package/dist/astm/index.cjs.map +1 -1
  5. package/dist/astm/index.d.cts +1 -1
  6. package/dist/astm/index.d.ts +1 -1
  7. package/dist/astm/index.mjs +83 -42
  8. package/dist/astm/index.mjs.map +1 -1
  9. package/dist/ccda/index.cjs +89 -44
  10. package/dist/ccda/index.cjs.map +1 -1
  11. package/dist/ccda/index.d.cts +5 -3
  12. package/dist/ccda/index.d.ts +5 -3
  13. package/dist/ccda/index.mjs +89 -44
  14. package/dist/ccda/index.mjs.map +1 -1
  15. package/dist/deid/index.cjs +92 -13
  16. package/dist/deid/index.cjs.map +1 -1
  17. package/dist/deid/index.d.cts +1 -1
  18. package/dist/deid/index.d.ts +1 -1
  19. package/dist/deid/index.mjs +93 -14
  20. package/dist/deid/index.mjs.map +1 -1
  21. package/dist/fhir/index.cjs +78 -5
  22. package/dist/fhir/index.cjs.map +1 -1
  23. package/dist/fhir/index.mjs +78 -5
  24. package/dist/fhir/index.mjs.map +1 -1
  25. package/dist/hl7/index.cjs +89 -41
  26. package/dist/hl7/index.cjs.map +1 -1
  27. package/dist/hl7/index.d.cts +1 -1
  28. package/dist/hl7/index.d.ts +1 -1
  29. package/dist/hl7/index.mjs +89 -41
  30. package/dist/hl7/index.mjs.map +1 -1
  31. package/dist/index.cjs +91 -40
  32. package/dist/index.cjs.map +1 -1
  33. package/dist/index.d.cts +126 -8
  34. package/dist/index.d.ts +126 -8
  35. package/dist/index.mjs +89 -41
  36. package/dist/index.mjs.map +1 -1
  37. package/dist/ncpdp/index.cjs +56 -4
  38. package/dist/ncpdp/index.cjs.map +1 -1
  39. package/dist/ncpdp/index.mjs +56 -4
  40. package/dist/ncpdp/index.mjs.map +1 -1
  41. package/dist/{quirk-JLyO1Ncj.d.ts → quirk-C9t9CkPS.d.ts} +17 -6
  42. package/dist/{quirk-DmkgoZdh.d.cts → quirk-DYMDojVw.d.cts} +17 -6
  43. package/dist/x12/index.cjs +82 -13
  44. package/dist/x12/index.cjs.map +1 -1
  45. package/dist/x12/index.d.cts +2 -1
  46. package/dist/x12/index.d.ts +2 -1
  47. package/dist/x12/index.mjs +83 -14
  48. package/dist/x12/index.mjs.map +1 -1
  49. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -460,6 +460,56 @@ its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` until firs
460
460
 
461
461
  ### Changed
462
462
 
463
+ - **Fatal messages come from a frozen registry, and `SynthError` takes no value parameter.** Every
464
+ message is a fixed entry in the newly exported `SYNTH_FATAL_MESSAGES` table, keyed by code, and the
465
+ constructor is `SynthError(code)`: there is no longer a position through which a caller-supplied
466
+ string can reach `message`, `stack`, or any field on the thrown object.
467
+
468
+ **The severity, stated honestly.** This is not a leak of patient data and describing it as one
469
+ would be its own dishonesty. `@cosyte/synth` generates synthetic fixtures, so the values a refusal
470
+ used to quote were quirk names, format labels, code-system URIs and money strings. What was wrong
471
+ was the _shape_: every fatal took a value parameter, and the only thing keeping PHI out of a
472
+ diagnostic was that callers happened to be passing harmless values. The audit of the thirteen
473
+ cosyte repos found that the single property separating the packages that leak from the ones that
474
+ are genuinely prevented is exactly this: whether the message factory takes a value parameter at
475
+ all. It now does not.
476
+
477
+ The prose mattered as much as the code here. The claim that warning messages are PHI-free by
478
+ construction spread through this ecosystem as a _sentence_, not as shared runtime code, and this
479
+ package had inherited the sentence and used it as a reason not to bound anything. Every surface
480
+ carrying it has been corrected in the same change rather than restated in fresher words:
481
+ `src/codes.ts` (whose `@param message` read "never contains PHI, there is none"),
482
+ `docs-content/troubleshooting.md`, `docs-content/concepts-archetype.md`,
483
+ `docs-content/limitations.md`, `docs-content/guides-quirks.md` and `README.md`.
484
+
485
+ - **Every caller-supplied selector is resolved against its closed set, not trusted.** `resolveKind`
486
+ and `resolveMix` (new, exported, `src/select.ts`) sit at the entry point of every option that names
487
+ a member of a union: a message kind, an `ADT` trigger, a document type, a corpus mix entry, an `837`
488
+ variant, a Bundle type, a `Patient` profile, a quirk kind. An unrecognised one is a fatal
489
+ `SYNTH_UNSUPPORTED_KIND`.
490
+
491
+ **This is the same defect as the message one, not a separate tidy-up**, and it was found by the
492
+ refuter after the message fix had been called done. A selector union does not exist at run time, and
493
+ an unresolved selector did three things at once. It travelled into an optional peer builder, which is
494
+ entitled to quote it back in its own `TypeError` and does, so a caller-supplied string reached an
495
+ `err.message` and an `err.stack` through a `@cosyte/synth` entry point (`generateCcda`,
496
+ `generateCcdaQuirk`, `ccdaCorpus`, `ccdaQuirkCorpus`). It became an `Artifact.kind` and a
497
+ `manifest.counts` key, which is the structural-identifier position the whole model half of this work
498
+ is about (`x12Corpus({ mix })`). And it fell out of an exhaustive `switch` as `undefined`, which
499
+ neither reads as an error nor is one.
500
+
501
+ - **`assertIntendedWarnings` loses its leading `quirk` parameter**, and is now
502
+ `assertIntendedWarnings(intendedWarnings, bareWarnings)`. It existed only to be interpolated into
503
+ the refusal. A parameter whose sole job is to reach a message is the shape being removed, so it was
504
+ deleted rather than left in place and ignored.
505
+
506
+ - **`resolveQuirk` refuses a descriptor found under a different format's registry**, on the same
507
+ `SYNTH_UNSUPPORTED_QUIRK` code. The `format` argument used to be read only to build the message
508
+ text; it is now compared, never rendered.
509
+
510
+ - **`defineSynthProfile` raises `SynthError` with `SYNTH_INVALID_PROFILE`** for a missing or blank
511
+ `name`, where it previously raised a `TypeError`.
512
+
463
513
  - Replaced the parser-archetype scaffold stubs (`parseSynth`, `WARNING_CODES`, `FATAL_CODES`) with the
464
514
  generator surface — `@cosyte/synth` is a synthetic-fixture **generator**, not a parser.
465
515
  - **Docs:** refreshed the `README.md` status block to describe the **feature-complete** generator surface
@@ -497,6 +547,234 @@ its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` until firs
497
547
 
498
548
  ### Fixed
499
549
 
550
+ - **A file that appeared and disappeared while the PHI gate was running made it refuse the whole
551
+ sweep.** A full scan lists every file under `src/`, `test/` and `scripts/` and then reads them one
552
+ by one. Anything created and deleted between those two steps was read after it was gone, and the
553
+ scanner stopped with an error rather than a verdict. That is reachable in this repository rather
554
+ than theoretical: the gate's own test suite writes short-lived files into two of the three
555
+ directories it scans, and sweeping the working tree while that suite ran stopped 8 of 165 sweeps.
556
+ A sibling package hit the same shape from its build tool and it blocked a release.
557
+
558
+ **The refusal was right; the file list was wrong, so the list is what changed.** Exactly one case
559
+ is now tolerated: a file the scan listed itself, that is not committed, and that is missing when
560
+ the scan reaches it. It is reported on stderr as skipped, never dropped in silence, and the
561
+ "files scanned" figure counts what was actually read, so a skip lowers it instead of padding it.
562
+
563
+ **Everything else still stops the scan.** A committed file that cannot be read, a read that fails
564
+ for any other reason (unreadable is not the same as absent), a skipped file that is back on disk
565
+ when the scan finishes, and a repository that cannot report what it tracks. A full scan that ends
566
+ up having read nothing at all is refused outright, so tolerating a missing file can never turn
567
+ into a clean report of a tree nothing was read from. Pre-commit scans read committed content
568
+ directly and never depended on any of this.
569
+
570
+ Each of those bounds is pinned by a test that reds when the bound is widened, verified by widening
571
+ each one in turn. **One is not pinned and is named here rather than implied:** the check that a
572
+ skipped file has not come back. Reaching it needs a timing-dependent test, which is the failure
573
+ this defect teaches, so it is left uncovered deliberately; losing it would cost that re-check, not
574
+ the tolerance's limits. **One residual is disclosed rather than closed:** the re-check matches on
575
+ the file's path, not its contents, so an uncommitted file _renamed_ mid-scan goes unread under a
576
+ clean report. Committing it makes it tracked and no longer tolerable, and pre-commit reads
577
+ committed content either way. Closing it in general needs a content-addressed scan; re-listing the
578
+ scanned directories afterwards would close the in-directory half more cheaply, at the cost of a
579
+ second walk and a new way to refuse. Both are a design trade for a later change rather than
580
+ something impossible.
581
+
582
+ **Two smaller limits are recorded rather than closed, and both fail in the refusing direction.**
583
+ When the repository cannot report what it tracks the scan still stops, but the message names the
584
+ file it could not read rather than that reason. And the tracked-file list is read through a 1 MiB
585
+ buffer, so a repository large enough to exceed it would quietly stop applying the tolerance —
586
+ measured here at 6,556 bytes, three orders of magnitude of headroom.
587
+
588
+ - **The test suite could fail on a busy machine while the code under test was correct.** The
589
+ per-test timeout is a wall-clock budget, so it measures the machine as much as the code. These
590
+ suites are CPU-bound, in that their running time is set by how much processor they actually get
591
+ rather than by the work the code does, so the two heaviest C-CDA sweeps ran within about 2x of the 10 s limit
592
+ with a core to themselves, and crossed it when sharing one, reporting a failure that reproduced
593
+ nowhere else. A failure that means "the machine was busy" costs more than it saves, because the
594
+ next real one is read as noise too.
595
+
596
+ Two fixes, chosen so that a genuine hang still fails fast. The C-CDA quirk sweeps, the heaviest
597
+ cases in the package and the ones that build a document and parse it twice, now carry their own explicit
598
+ generous ceilings, matching the sibling C-CDA suites that already had them. And the PHI-gate suite,
599
+ which runs the scanner in a real subprocess ~65 times, now starts those subprocesses with `node`
600
+ rather than the on-the-fly TypeScript runner: measured on one box, 0.6 s against 2.1 s per start,
601
+ taking the file from ~145 s to ~75 s. Nothing about the scanner changed, and one test still runs it
602
+ the original way so the two invocations are held to the same verdict.
603
+
604
+ **The global timeout is deliberately unchanged.** Raising it would have been the shorter fix and
605
+ would have traded a false failure for a false pass, letting a genuinely stuck test sit for a minute
606
+ before anyone heard about it.
607
+
608
+ - **Three of the PHI gate's seven structured detectors decided what to inspect from the file's NAME,
609
+ so byte-identical content was refused as `probe.xml` and passed as `probe.ts`.** `scanCcda` and
610
+ `scanNcpdpScript` returned early unless the path ended `.xml`; `scanFhir` unless it ended `.json`.
611
+ That mattered in this repo specifically, because it builds documents as inline string literals in
612
+ TypeScript tests — the scan enumerated those files and then structurally read nothing in them.
613
+
614
+ **Every arm now keys off the bytes.** Each of the three admits a target by its extension **or** what
615
+ the bytes say — a CDA root/namespace, a SCRIPT `<Message>` envelope, FHIR's `resourceType` for its
616
+ textual route. **Admission is additive**: no target admitted before is refused admission now.
617
+ Extensions are matched case-insensitively, so `.XML` is an XML file too. `scanFhir` gained a second
618
+ route for the hard half: a resource in a TypeScript object literal is not valid JSON, so a `.json`
619
+ gate plus `JSON.parse` could never have reached it however the extension check was widened. Its
620
+ **structural** route is not marker-gated — it runs whenever `JSON.parse` succeeds, with no
621
+ `resourceType` check, exactly as it did for `.json` before.
622
+
623
+ **The marker is the false-positive defence for the CONTENT routes**, which is narrower than "nothing
624
+ is examined without a marker" — an `.xml` path still reaches the C-CDA arm with no marker at all, and
625
+ FHIR's structural route still reads any parseable JSON. A gate that flagged every `family:` in every
626
+ source file would be turned off, which is worse than the gap it closes.
627
+
628
+ **NOTHING SUBTRACTS. No detector was taught to skip anything**, and getting to that took three
629
+ review passes. Widening the arms made this scanner's own test suite one of its targets, and that
630
+ suite necessarily writes name elements. The first three attempts answered it by teaching the
631
+ **scanner** to skip template placeholders — and on a PHI detector a skip rule has to be exactly
632
+ right, which it twice was not. It silenced `Anderson ...` (a containment test), then `${"Anderson"}`
633
+ (any interpolation body elided, left open on a claim that closing it would break an existing idiom
634
+ when `git show main` shows the same branch had just introduced that idiom), then `{{Anderson ${s}}`
635
+ (one regex pass matching straight across two constructs the source never nested). Each remedy bought
636
+ one more evasion shape, which is the signature of a rule that does not belong in a PHI gate.
637
+
638
+ The rule is **gone**. The suite assembles its fixture elements at run time instead, so the detector
639
+ stays maximally literal and there is no subtraction to audit. Measured across sixteen shapes —
640
+ including every one a refuter used to break the earlier drafts — `main` and this branch return the
641
+ **identical** verdict; the only differences anywhere are files the widened arms now reach that
642
+ `main` never read.
643
+
644
+ **The measurement that justified the deleted predicate is corrected here too**, because the wrong
645
+ version had been written down as the evidence: the first run over 170 files returned four hits across
646
+ two files — **two real names** the widening had just made visible (now declared in the allow-list) and
647
+ two name elements in this scanner's own fixtures. Not "four placeholders, none a name".
648
+
649
+ Two things the change surfaced, both fixed here. The C-CDA name/telecom sweep was always
650
+ **document-wide** but labelled every hit `recordTarget/…`; a name in `<author>` came back labelled
651
+ with a location it was not in. The sweep is kept (an informant's name in a committed fixture is as
652
+ real as a patient's) and only the false claim about _where_ is withdrawn. And two hand-written
653
+ tokens in a de-identifier test became visible for the first time; they are **declared in the
654
+ allow-list**, with what that costs written next to them, rather than edited out of a test whose
655
+ point requires exactly those shapes.
656
+
657
+ The characterization test that pinned the old behaviour asserted the **opposite** of what is true
658
+ now. It was rewritten in place, not deleted — the same probe bytes, the same two paths, the opposite
659
+ expectation — alongside new pins for the limits this did **not** close: admission is file-scoped
660
+ rather than object-scoped, the C-CDA name loci remain
661
+ namespace-prefix-naive, and the textual FHIR route misses a phone whose `system` and `value` are
662
+ separated by another key (the structural route catches it — the two are not at parity, and the
663
+ routes are mutually exclusive).
664
+
665
+ - **The two scan modes disagreed about a git-ignored file and neither direction was tested.** All-mode
666
+ drops git-ignored files; `--staged` does not. Both directions are now exercised. **Neither is
667
+ changed**: all-mode walks the working tree, where an ignored path is build output; `--staged`
668
+ enumerates the index, which a file reaches only via `git add -f` and which a commit will therefore
669
+ carry. Refusing to read that one is the shipping direction.
670
+
671
+ - **The PHI commit gate could be argued into scanning nothing and still print `OK`.**
672
+ `scripts/phi-scan.ts` treated an `--allow-fixture <path>` as both a subtraction _and_ a scan
673
+ target, so `pnpm phi-scan --allow-fixture X` with no other argument built the target set `[X]`,
674
+ subtracted `X`, scanned zero files and exited 0 with the same `OK — no hits` line a real pass
675
+ prints. `--allow-fixture` is now purely subtractive and never seeds the target set; the mode is
676
+ decided by `--staged` and positional paths alone. Three invariants (`enforceObservation`) are
677
+ checked before any hit counting: an override that subtracts no enumerated target is an error
678
+ rather than an inert entry that reads as a live bypass, a target set emptied by overrides is
679
+ refused, and an enumeration that finds nothing is refused in every mode but `--staged` (where
680
+ "nothing staged" is legitimate). Both **summary** lines now carry the **denominator** — the count
681
+ of files scanned — so an `OK` is never read without the number it is an `OK` over. Ported from the
682
+ same defect closed in `ncpdp`.
683
+
684
+ - **The staged enumerator dropped whole classes of staged file, because its filter was the wrong
685
+ polarity.** `git diff --cached --diff-filter=AM` is an **allow-list of git status letters**: every
686
+ letter it does not name is skipped by default and silently. That is how it missed `R` — a fixture
687
+ `git mv`'d and edited to add PHI in one commit stages as a single rename entry — and `T`, a
688
+ tracked symlink replaced by a regular file carrying PHI. Each was found by a separate refuter pass
689
+ in `ncpdp`, which is the argument for the general fix rather than the two patches: the enumerator
690
+ now asks for `--diff-filter=d` (everything **except** deletions, which have no blob to read), so an
691
+ unknown or future status letter costs a wasted scan instead of a missed file. It also passes
692
+ `--no-renames`, which decomposes a rename into `D` + `A` so the **destination** path — the one
693
+ carrying the new content — is the path that gets read. Both directions are measured in the suite:
694
+ the tests assert the new flags catch the `R` and `T` cases **and** that the superseded `AM`
695
+ allow-list did not list the violator's path for either. That second assertion is also what keeps
696
+ the first honest — had git degraded the fixture to a plain `A`/`M`, `AM` would have listed it and
697
+ the test would red rather than pass for the wrong reason.
698
+
699
+ - **The scan stopped at `test/fixtures/`, leaving most of the repository unswept.** `SCAN_ROOTS` is
700
+ now `src/`, `test/` and `scripts/`, walked whole and shared by both all-mode and `--staged` through
701
+ a single `isScannable` predicate, so the **roots** narrow in one place. The gap mattered here
702
+ specifically: this repo builds messages as inline string literals in tests **outside** `fixtures/`,
703
+ and `scripts/` is hand-written tracked text that can carry a real address as easily as a fixture
704
+ can. Measured on the widening: **114 in-scope files became 170**. Markdown stays out of scope in
705
+ every mode, because documentation — including this scanner's own override log and allow-list —
706
+ legitimately quotes violator values.
707
+
708
+ **What the widening does NOT buy, stated because the obvious reading is wrong.** Over a `.ts` file
709
+ it delivers the format-agnostic floor and the content-gated arms (HL7 v2, X12, ASTM, NCPDP
710
+ Telecom), which sniff their own framing. It delivers nothing from the three **extension-gated**
711
+ arms: `scanCcda` and `scanNcpdpScript` return early unless the path ends `.xml`, and `scanFhir`
712
+ unless it ends `.json`. A byte-identical payload is caught as `probe.xml` and missed as `probe.ts`.
713
+ That gating predates this change and is deliberately left alone — content-sniffing XML and JSON out
714
+ of arbitrary TypeScript is a much larger job with its own false-positive surface, and doing it as a
715
+ side effect of a root change would grow the gate's teeth without review. It is recorded as a known
716
+ gap in the scanner's header rather than implied away, and the roots list no longer claims otherwise.
717
+
718
+ **SUPERSEDED — see "Three of the PHI gate's seven structured detectors…" under Fixed below.** The
719
+ paragraph above described the state of the gate when the roots were widened, and is kept as that
720
+ record. The three extension gates were subsequently removed as the separate job this paragraph said
721
+ they needed, so its present tense no longer holds.
722
+
723
+ - **The widened scan surfaced one real finding, and it is declared rather than suppressed.** The ten
724
+ vendored US Core 6.1.0 StructureDefinitions under `test/us-core-profiles/` each carry their
725
+ publisher's work-group contact address in `contact.telecom`. It is a real address, so it is a real
726
+ hit; it is not PHI, because it denotes a standards-body mailing list rather than a person receiving
727
+ care. It is cleared with a new, narrower allow-list tag — `EMAIL <address>`, one exact address —
728
+ rather than an `EMAILDOMAIN` entry that would clear every address at a live organizational domain,
729
+ and rather than an `--allow-fixture` bypass that would silence every other check on those ten files.
730
+
731
+ - **A missing allow-list exited 1, which reads as "hits found".** `loadAllowList` now runs inside the
732
+ invocation-error boundary, so an absent `scripts/phi-allow-list.txt` exits **2** like every other
733
+ invocation error.
734
+
735
+ - **The override log was parsed file-wide, so its own format template was a bypass.**
736
+ `phi-scan-overrides.md` documents itself with a literal `### <path>` heading above the entries
737
+ section; only `### <path>` subsections **under `## Entries`** are read as approvals now.
738
+
739
+ - **A false assertion in the scanner's test suite, deleted rather than reworded.** Its docblock said
740
+ the suite "deliberately does NOT test structured, field-level PHI detection". That was untrue when
741
+ written and stayed untrue for six formats — the HL7, FHIR, X12, NCPDP, ASTM and quirk suites had
742
+ been exercising exactly that. The dangerous half of a wrong assertion is not the code around it but
743
+ the next reader trusting it, so the sentence is gone. Two more went with it: the `STARTER` banner —
744
+ carried in `scripts/phi-scan.ts`, `phi-scan-overrides.md` and `.github/workflows/ci.yml`, all three
745
+ still describing a scanner that detects "SSN and email only" after every structured arm had landed —
746
+ and a claim in `docs-content/limitations.md` that this project's own fixtures carry a
747
+ `# synthetic: true` header. **No fixture does**; two shipped value-pool sources do.
748
+
749
+ - **A corpus could report a transaction it did not contain.** `x12Corpus({ seed: 9, mix: ["270"] })`
750
+ returned a corpus whose manifest said `{"270": 1}` and whose bytes were an `837` professional claim,
751
+ byte-identical to `mix: ["837P"]` at the same seed, because the kind dispatcher ended in an
752
+ unguarded fallback. Every corpus dispatcher is now an exhaustive `switch` behind a resolved
753
+ selector, so an unrecognised kind is a fatal `SYNTH_UNSUPPORTED_KIND` and can no longer be silently
754
+ relabelled. A golden file that lies about what it holds is the failure the intended-warning contract
755
+ exists to prevent, in the one format that had no equivalent check.
756
+
757
+ - **Five exhaustive `switch`es over run-time-erased unions returned `undefined` typed as a value.**
758
+ `generateHl7`, `generateHl7Quirk`'s base-message dispatcher, `fhirCorpus`, `ncpdpCorpus` and
759
+ `astmCorpus` each took no branch for a kind outside its union and handed the result on, surfacing
760
+ later as a `TypeError` from the runtime with no code to branch on. All five are now guarded by a
761
+ resolved selector.
762
+
763
+ **Three more of the same shape are known and deliberately left**, in `src/deid/`:
764
+ `x12DeidLoop({ variant })` and `ncpdpTelecomDeidLoop({ transaction })` fall through to an uncoded
765
+ `TypeError`, and `ccdaDeidLoop({ documentType })` silently generates a Referral Note for anything but
766
+ `"ccd"`. None echoes a caller value into a message, so none is a diagnostic leak. They are named here
767
+ rather than swept in, because two review passes had each found one more position after this change
768
+ described its own coverage as general, and widening the change a third time is the wrong response to
769
+ that.
770
+
771
+ - **`injectCcdaQuirk` returned `undefined` for an unrecognised quirk name, as though it were a
772
+ document.** `CcdaQuirkName` is a union that does not exist at run time, so a JavaScript caller (or
773
+ a cast) reaching the function with any other string fell out of the transform's switch with no
774
+ branch taken and no error. The name is now resolved against `CCDA_QUIRKS` first and an unknown one
775
+ is a fatal `SYNTH_UNSUPPORTED_QUIRK`. Found by the diagnostic-surface slot table below, which
776
+ recorded it as the one slot that produced neither a value nor a diagnostic.
777
+
500
778
  - **`README.md` said the package was not published to npm. It is.** The summary blockquote opened
501
779
  with "pre-alpha (`0.0.x`), not yet published to npm", so the npm package page asserted that the
502
780
  package did not exist on npm, directly under the version npm renders in its own header. A reader
@@ -541,4 +819,61 @@ its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` until firs
541
819
 
542
820
  ### Security
543
821
 
822
+ - **A per-slot diagnostic-surface gate, run red before it was run green.**
823
+ `test/phi/diagnostic-surface.test.ts` drives `assertNoDiagnosticPhiLeak` from `@cosyte/test-utils`
824
+ over a table of **44** consumer-controlled positions: the quirk name, format label and registry key
825
+ on `resolveQuirk`; the quirk selector on all three `generate*Quirk`, all three `*QuirkRoundTrip` and
826
+ all three `*QuirkCorpus` entry points, by explicit list and through a `defineSynthProfile` profile;
827
+ both code lists on `assertIntendedWarnings`; both parameters of `injectCcdaQuirk`; the code-system
828
+ URI on `toBuildCode`; the money string on `dec`; **eighteen selector positions** across the six
829
+ formats (message kind, `ADT` trigger, document type, corpus mix, `837` variant, Bundle type,
830
+ `Patient` profile, quirk kind); and the artifact `content` each quirk round-trip harness hands to a
831
+ sibling parser. Each slot plants an eight-byte marker and a 32 KiB one, names the code it must
832
+ reach, and fails if any four-byte run of the marker turns up in a thrown value, a `message`, a
833
+ `stack`, or a structural identifier on a returned model.
834
+
835
+ **Measured against the unfixed source first, because a suite never seen red is indistinguishable
836
+ from one that cannot go red.** Of the 44 slots, **3 were clean and 41 failed**. The failure
837
+ categories **overlap** — a slot can both echo the marker and throw an uncoded error, and 6 do — so
838
+ they are counted per slot per category and do not sum to 41: **20** echoed the planted value
839
+ verbatim into an error message or stack, **4** put it on a model identifier, **16** threw an error
840
+ carrying no code to branch on, and **7** accepted it with neither an error nor a usable value. The
841
+ method: check out the base tree's `src/`, add the new codes and the frozen table as a purely
842
+ additive patch with an _optional_ message parameter so no base call site changes, then invoke each
843
+ slot's plant directly with both probe sizes.
844
+
845
+ **The model half of that sweep is vacuous, and the file says so rather than letting a reader infer
846
+ coverage.** Every slot throws, because every position fails closed before a model exists, so
847
+ `getModelIdentifiers` is never reached during the sweep. That is the result of the fix, not a gap in
848
+ the table, and it is carried instead by a separate assertion that runs the three identifier helpers
849
+ over real corpora and real quirk round-trips and checks that every identifier they yield comes from
850
+ a set this package controls. Between them: the slots prove no caller value survives to a model, and
851
+ the closed-set test proves the identifiers that exist were derived rather than passed through.
852
+
853
+ It also asserts the structural half: no `throw new` under `src/` names anything but `SynthError`,
854
+ and no construction of one carries an interpolation. The scanner is positive-controlled against a
855
+ constructed string containing the thing it hunts, not against a file that happens to be nearby, and
856
+ **its title says what a regex can see while its docblock says what it cannot** — it cannot see a
857
+ `TypeError` the runtime raises on its own, which is exactly the route the selector chokepoint
858
+ closes.
859
+
860
+ What the gate does not prove is in the runner's own documentation and is not restated more
861
+ favourably here: it does not catch a re-encoded echo, an echo under four bytes, a leak carried only
862
+ as a number, or a leak through a position nobody declared. The slot table is the deliverable. It was
863
+ derived by enumerating the exported types with the TypeScript checker rather than by sweeping the
864
+ source from memory. **Even so it was refuted twice, on the same class both times.** The first
865
+ version covered only `throw` sites and missed eighteen selector positions. The second called the
866
+ selectors "generalised, not patched" and missed the tail of a quirk list a `count` never reaches,
867
+ which lands on `manifest.quirks` unresolved. Enumerating the types was necessary and not sufficient:
868
+ both misses were judgements about which positions "reach a diagnostic", made before it was
869
+ established that an unresolved selector reaches a peer builder's diagnostic and a derived manifest
870
+ key.
871
+
872
+ **So the table is no longer described as exhaustive, on any surface.** A third claim of completeness
873
+ would be worth exactly what the first two were. What holds generally is the mechanism — the error
874
+ type has no value parameter, and a selector is read in one place — and the table is an enumeration
875
+ of the positions that have been checked. That wording is now in the test file's own header, in
876
+ `docs-content/limitations.md` and in `CLAUDE.md`, so the next person to add a position adds a slot
877
+ rather than trusting a count.
878
+
544
879
  [Unreleased]: https://github.com/cosyte/synth/commits/main
package/README.md CHANGED
@@ -255,8 +255,12 @@ astmQuirkRoundTrip(generateAstmQuirk({ seed: 1, quirk: "unknown-escape" })).inte
255
255
  ```
256
256
 
257
257
  A quirk a format's profile system does not support fails closed with a stable `SYNTH_UNSUPPORTED_QUIRK`
258
- diagnostic — never a silently-wrong fixture. Every quirk is grounded in a **publicly-documented**
259
- deviation or a parser's **public** profile, never a private vendor corpus.
258
+ diagnostic — never a silently-wrong fixture — and so do the other selectors: a message kind, a
259
+ document type, a corpus mix entry, an `837` variant, a Bundle type. Each is checked against its own set
260
+ before anything is generated, so an unrecognised one is a fatal `SYNTH_UNSUPPORTED_KIND` rather than a
261
+ mislabelled corpus. Every refusal carries a code and a fixed message from a frozen table, and quotes
262
+ nothing you passed. Every quirk is grounded in a **publicly-documented** deviation or a parser's
263
+ **public** profile, never a private vendor corpus.
260
264
 
261
265
  **Deferred:** quirk recipes for **FHIR, X12, and NCPDP**, and any quirk that would need a **private,
262
266
  vendor-attributed corpus** to ground.
@@ -31,6 +31,63 @@ function sfc32Next(s) {
31
31
  return t >>> 0;
32
32
  }
33
33
 
34
+ // src/codes.ts
35
+ var SYNTH_FATAL_CODES = {
36
+ /**
37
+ * A vendor quirk was requested that the target format's profile system does not support. Fatal —
38
+ * never a silent no-op and never a fabricated quirk.
39
+ */
40
+ SYNTH_UNSUPPORTED_QUIRK: "SYNTH_UNSUPPORTED_QUIRK",
41
+ /**
42
+ * A quirk transform found no structural anchor to mutate, so the fixture would not carry the
43
+ * deviation it is labelled with. Fatal — a golden file that lies about its parser verdict is worse
44
+ * than no golden file.
45
+ */
46
+ SYNTH_QUIRK_ANCHOR_ABSENT: "SYNTH_QUIRK_ANCHOR_ABSENT",
47
+ /**
48
+ * A bare parse of a freshly-generated quirk artifact did not produce exactly the declared intended
49
+ * warning code(s). Fatal — never emit a mislabeled fixture.
50
+ */
51
+ SYNTH_INTENDED_WARNING_MISMATCH: "SYNTH_INTENDED_WARNING_MISMATCH",
52
+ /** An integer range was requested with its maximum below its minimum. Fatal. */
53
+ SYNTH_INVALID_RANGE: "SYNTH_INVALID_RANGE",
54
+ /** A value was drawn from an empty pool. Fatal — never a fabricated substitute. */
55
+ SYNTH_EMPTY_POOL: "SYNTH_EMPTY_POOL",
56
+ /** A `defineSynthProfile` spec was not usable (a missing or blank `name`). Fatal. */
57
+ SYNTH_INVALID_PROFILE: "SYNTH_INVALID_PROFILE",
58
+ /**
59
+ * A caller-supplied selector (a message kind, a document type, a corpus mix entry, a claim
60
+ * variant, a Bundle type, a resource profile) is not in the closed set that governs it. Fatal —
61
+ * see `resolveKind`: a selector union is erased at run time, and a selector that falls through
62
+ * either mislabels the fixture or hands the value to a peer builder that quotes it back.
63
+ */
64
+ SYNTH_UNSUPPORTED_KIND: "SYNTH_UNSUPPORTED_KIND"
65
+ };
66
+ var SYNTH_FATAL_MESSAGES = Object.freeze({
67
+ SYNTH_UNSUPPORTED_FORMAT: "The requested format is not generable by this build. A generator has no byte fallback: it builds through a parser's own serializer or it refuses.",
68
+ SYNTH_UNSUPPORTED_QUIRK: "The requested vendor quirk is not in the target format's quirk registry. Compare the request against that format's exported registry (HL7_QUIRKS, CCDA_QUIRKS, ASTM_QUIRKS).",
69
+ SYNTH_QUIRK_ANCHOR_ABSENT: "The quirk transform found no structural anchor to mutate, so the fixture would not carry the deviation it is labelled with. Refusing to emit a mislabeled fixture.",
70
+ SYNTH_INTENDED_WARNING_MISMATCH: "A bare parse of the generated quirk artifact did not produce exactly the declared intended warning code(s). Refusing to emit a mislabeled fixture.",
71
+ SYNTH_UNMAPPED_CODE_SYSTEM: "The concept's code-system URI has no OID mapping in the C-CDA example-code table.",
72
+ SYNTH_INVALID_DECIMAL: "The value could not be read as an X12 decimal.",
73
+ SYNTH_INVALID_RANGE: "An integer range was requested with its maximum below its minimum.",
74
+ SYNTH_EMPTY_POOL: "A value was drawn from an empty pool.",
75
+ SYNTH_INVALID_PROFILE: "defineSynthProfile requires a non-empty string name.",
76
+ SYNTH_UNSUPPORTED_KIND: "The requested kind, document type, corpus mix entry, variant or profile is not one this generator supports. The supported set is the exported union for that option."
77
+ });
78
+ var SynthError = class extends Error {
79
+ /** The stable fatal code. */
80
+ code;
81
+ /**
82
+ * @param code - The stable {@link SynthFatalCode}. The message comes from the frozen registry.
83
+ */
84
+ constructor(code) {
85
+ super(SYNTH_FATAL_MESSAGES[code]);
86
+ this.name = "SynthError";
87
+ this.code = code;
88
+ }
89
+ };
90
+
34
91
  // src/rng/rng.ts
35
92
  var Sfc32Rng = class {
36
93
  seed;
@@ -48,7 +105,7 @@ var Sfc32Rng = class {
48
105
  return this.nextUint32() / 4294967296;
49
106
  }
50
107
  int(min, max) {
51
- if (max < min) throw new RangeError(`Rng.int: max (${String(max)}) < min (${String(min)})`);
108
+ if (max < min) throw new SynthError(SYNTH_FATAL_CODES.SYNTH_INVALID_RANGE);
52
109
  const span = max - min + 1;
53
110
  return min + Math.floor(this.float() * span);
54
111
  }
@@ -56,7 +113,7 @@ var Sfc32Rng = class {
56
113
  return this.float() < p;
57
114
  }
58
115
  pick(items) {
59
- if (items.length === 0) throw new RangeError("Rng.pick: empty array");
116
+ if (items.length === 0) throw new SynthError(SYNTH_FATAL_CODES.SYNTH_EMPTY_POOL);
60
117
  return items[this.int(0, items.length - 1)];
61
118
  }
62
119
  digits(n) {
@@ -584,10 +641,21 @@ function latin1(bytes) {
584
641
  return out;
585
642
  }
586
643
 
644
+ // src/select.ts
645
+ function resolveKind(allowed, requested) {
646
+ const match = allowed.find((value) => value === requested);
647
+ if (match === void 0) throw new SynthError(SYNTH_FATAL_CODES.SYNTH_UNSUPPORTED_KIND);
648
+ return match;
649
+ }
650
+ function resolveMix(allowed, requested, fallback) {
651
+ if (requested === void 0) return fallback;
652
+ return requested.map((entry) => resolveKind(allowed, entry));
653
+ }
654
+
587
655
  // src/profile.ts
588
656
  function defineSynthProfile(spec) {
589
657
  if (typeof spec.name !== "string" || spec.name.trim().length === 0) {
590
- throw new TypeError("defineSynthProfile: `name` is required and must be a non-empty string.");
658
+ throw new SynthError(SYNTH_FATAL_CODES.SYNTH_INVALID_PROFILE);
591
659
  }
592
660
  return Object.freeze({
593
661
  name: spec.name,
@@ -597,28 +665,6 @@ function defineSynthProfile(spec) {
597
665
  });
598
666
  }
599
667
 
600
- // src/codes.ts
601
- var SYNTH_FATAL_CODES = {
602
- /**
603
- * A vendor quirk was requested that the target format's profile system does not support. Fatal —
604
- * never a silent no-op and never a fabricated quirk.
605
- */
606
- SYNTH_UNSUPPORTED_QUIRK: "SYNTH_UNSUPPORTED_QUIRK"
607
- };
608
- var SynthError = class extends Error {
609
- /** The stable fatal code. */
610
- code;
611
- /**
612
- * @param code - The stable {@link SynthFatalCode}.
613
- * @param message - A human-readable detail (never contains PHI — there is none).
614
- */
615
- constructor(code, message) {
616
- super(message);
617
- this.name = "SynthError";
618
- this.code = code;
619
- }
620
- };
621
-
622
668
  // src/quirk.ts
623
669
  var PROFILE_QUIRK_APPLIED = "PROFILE_QUIRK_APPLIED";
624
670
  function sameCodeSet(a, b) {
@@ -635,12 +681,8 @@ function sameCodeSet(a, b) {
635
681
  }
636
682
  function resolveQuirk(registry, format, name2) {
637
683
  const descriptor = registry[name2];
638
- if (descriptor === void 0) {
639
- const supported = Object.keys(registry).sort().join(", ");
640
- throw new SynthError(
641
- SYNTH_FATAL_CODES.SYNTH_UNSUPPORTED_QUIRK,
642
- `${format}: unsupported quirk "${name2}". The ${format} profile system supports: ${supported}.`
643
- );
684
+ if (descriptor === void 0 || descriptor.format !== format) {
685
+ throw new SynthError(SYNTH_FATAL_CODES.SYNTH_UNSUPPORTED_QUIRK);
644
686
  }
645
687
  return descriptor;
646
688
  }
@@ -655,11 +697,9 @@ function profileTolerated(disposition, intendedWarnings, warningsUnderProfile) {
655
697
  return false;
656
698
  }
657
699
  }
658
- function assertIntendedWarnings(quirk, intendedWarnings, bareWarnings) {
700
+ function assertIntendedWarnings(intendedWarnings, bareWarnings) {
659
701
  if (!sameCodeSet(bareWarnings, intendedWarnings)) {
660
- throw new Error(
661
- `quirk "${quirk}": the intended-warning contract does not hold \u2014 expected exactly [${intendedWarnings.join(", ")}] but a bare parse produced [${bareWarnings.join(", ")}]. Refusing to emit a mislabeled fixture.`
662
- );
702
+ throw new SynthError(SYNTH_FATAL_CODES.SYNTH_INTENDED_WARNING_MISMATCH);
663
703
  }
664
704
  }
665
705
  function validateProfileQuirks(profile, registry, format) {
@@ -668,6 +708,7 @@ function validateProfileQuirks(profile, registry, format) {
668
708
  }
669
709
 
670
710
  // src/astm/quirk.ts
711
+ var ALL_QUIRK_KINDS = Object.freeze(["Result"]);
671
712
  var ASTM_QUIRKS = Object.freeze({
672
713
  "unknown-escape": Object.freeze({
673
714
  name: "unknown-escape",
@@ -721,17 +762,14 @@ function applyQuirk(quirk, records) {
721
762
  }
722
763
  function generateAstmQuirk(options) {
723
764
  const seed = options.seed ?? 0;
724
- const kind = "Result";
765
+ const kind = resolveKind(ALL_QUIRK_KINDS, options.kind ?? "Result");
725
766
  const descriptor = resolveQuirk(ASTM_QUIRKS, "astm", options.quirk);
726
767
  const clean = generateAstmResult({ seed });
727
768
  const content = applyQuirk(options.quirk, clean);
728
769
  if (content === clean) {
729
- throw new Error(
730
- `generateAstmQuirk: quirk "${options.quirk}" found no structural anchor in a ${kind} report at seed ${seed} \u2014 refusing to emit a fixture that does not carry the intended deviation.`
731
- );
770
+ throw new SynthError(SYNTH_FATAL_CODES.SYNTH_QUIRK_ANCHOR_ABSENT);
732
771
  }
733
772
  assertIntendedWarnings(
734
- descriptor.name,
735
773
  descriptor.intendedWarnings,
736
774
  astm.parseAstmRecords(content).warnings.map((w) => String(w.code))
737
775
  );
@@ -777,6 +815,7 @@ var ALL_ASTM_QUIRKS = Object.freeze(
777
815
  function astmQuirkCorpus(options) {
778
816
  const quirks = options.profile ? validateProfileQuirks(options.profile, ASTM_QUIRKS, "astm") : options.quirks ?? ALL_ASTM_QUIRKS;
779
817
  const names = quirks.length > 0 ? quirks : ALL_ASTM_QUIRKS;
818
+ for (const name2 of names) resolveQuirk(ASTM_QUIRKS, "astm", name2);
780
819
  const count = options.count ?? names.length;
781
820
  const seedStream = createRng(options.seed);
782
821
  const artifacts = Array.from({ length: count }, (_unused, i) => {
@@ -798,7 +837,8 @@ var astmQuirkProfile = defineSynthProfile({
798
837
  });
799
838
 
800
839
  // src/astm/index.ts
801
- var DEFAULT_MIX = Object.freeze(["Result", "Order"]);
840
+ var ALL_KINDS = Object.freeze(["Result", "Order"]);
841
+ var DEFAULT_MIX = ALL_KINDS;
802
842
  function generateKind(kind, seed) {
803
843
  switch (kind) {
804
844
  case "Result":
@@ -808,7 +848,8 @@ function generateKind(kind, seed) {
808
848
  }
809
849
  }
810
850
  function astmCorpus(options) {
811
- const { seed, mix = DEFAULT_MIX } = options;
851
+ const { seed } = options;
852
+ const mix = resolveMix(ALL_KINDS, options.mix, DEFAULT_MIX);
812
853
  const count = options.count ?? mix.length;
813
854
  const seedStream = createRng(seed);
814
855
  const artifacts = Array.from({ length: count }, (_unused, i) => {