@cosyte/synth 0.0.3 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +297 -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,196 @@ 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
+ - **The test suite could fail on a busy machine while the code under test was correct.** The
551
+ per-test timeout is a wall-clock budget, so it measures the machine as much as the code. These
552
+ suites are CPU-bound, in that their running time is set by how much processor they actually get
553
+ rather than by the work the code does, so the two heaviest C-CDA sweeps ran within about 2x of the 10 s limit
554
+ with a core to themselves, and crossed it when sharing one, reporting a failure that reproduced
555
+ nowhere else. A failure that means "the machine was busy" costs more than it saves, because the
556
+ next real one is read as noise too.
557
+
558
+ Two fixes, chosen so that a genuine hang still fails fast. The C-CDA quirk sweeps, the heaviest
559
+ cases in the package and the ones that build a document and parse it twice, now carry their own explicit
560
+ generous ceilings, matching the sibling C-CDA suites that already had them. And the PHI-gate suite,
561
+ which runs the scanner in a real subprocess ~65 times, now starts those subprocesses with `node`
562
+ rather than the on-the-fly TypeScript runner: measured on one box, 0.6 s against 2.1 s per start,
563
+ taking the file from ~145 s to ~75 s. Nothing about the scanner changed, and one test still runs it
564
+ the original way so the two invocations are held to the same verdict.
565
+
566
+ **The global timeout is deliberately unchanged.** Raising it would have been the shorter fix and
567
+ would have traded a false failure for a false pass, letting a genuinely stuck test sit for a minute
568
+ before anyone heard about it.
569
+
570
+ - **Three of the PHI gate's seven structured detectors decided what to inspect from the file's NAME,
571
+ so byte-identical content was refused as `probe.xml` and passed as `probe.ts`.** `scanCcda` and
572
+ `scanNcpdpScript` returned early unless the path ended `.xml`; `scanFhir` unless it ended `.json`.
573
+ That mattered in this repo specifically, because it builds documents as inline string literals in
574
+ TypeScript tests — the scan enumerated those files and then structurally read nothing in them.
575
+
576
+ **Every arm now keys off the bytes.** Each of the three admits a target by its extension **or** what
577
+ the bytes say — a CDA root/namespace, a SCRIPT `<Message>` envelope, FHIR's `resourceType` for its
578
+ textual route. **Admission is additive**: no target admitted before is refused admission now.
579
+ Extensions are matched case-insensitively, so `.XML` is an XML file too. `scanFhir` gained a second
580
+ route for the hard half: a resource in a TypeScript object literal is not valid JSON, so a `.json`
581
+ gate plus `JSON.parse` could never have reached it however the extension check was widened. Its
582
+ **structural** route is not marker-gated — it runs whenever `JSON.parse` succeeds, with no
583
+ `resourceType` check, exactly as it did for `.json` before.
584
+
585
+ **The marker is the false-positive defence for the CONTENT routes**, which is narrower than "nothing
586
+ is examined without a marker" — an `.xml` path still reaches the C-CDA arm with no marker at all, and
587
+ FHIR's structural route still reads any parseable JSON. A gate that flagged every `family:` in every
588
+ source file would be turned off, which is worse than the gap it closes.
589
+
590
+ **NOTHING SUBTRACTS. No detector was taught to skip anything**, and getting to that took three
591
+ review passes. Widening the arms made this scanner's own test suite one of its targets, and that
592
+ suite necessarily writes name elements. The first three attempts answered it by teaching the
593
+ **scanner** to skip template placeholders — and on a PHI detector a skip rule has to be exactly
594
+ right, which it twice was not. It silenced `Anderson ...` (a containment test), then `${"Anderson"}`
595
+ (any interpolation body elided, left open on a claim that closing it would break an existing idiom
596
+ when `git show main` shows the same branch had just introduced that idiom), then `{{Anderson ${s}}`
597
+ (one regex pass matching straight across two constructs the source never nested). Each remedy bought
598
+ one more evasion shape, which is the signature of a rule that does not belong in a PHI gate.
599
+
600
+ The rule is **gone**. The suite assembles its fixture elements at run time instead, so the detector
601
+ stays maximally literal and there is no subtraction to audit. Measured across sixteen shapes —
602
+ including every one a refuter used to break the earlier drafts — `main` and this branch return the
603
+ **identical** verdict; the only differences anywhere are files the widened arms now reach that
604
+ `main` never read.
605
+
606
+ **The measurement that justified the deleted predicate is corrected here too**, because the wrong
607
+ version had been written down as the evidence: the first run over 170 files returned four hits across
608
+ two files — **two real names** the widening had just made visible (now declared in the allow-list) and
609
+ two name elements in this scanner's own fixtures. Not "four placeholders, none a name".
610
+
611
+ Two things the change surfaced, both fixed here. The C-CDA name/telecom sweep was always
612
+ **document-wide** but labelled every hit `recordTarget/…`; a name in `<author>` came back labelled
613
+ with a location it was not in. The sweep is kept (an informant's name in a committed fixture is as
614
+ real as a patient's) and only the false claim about _where_ is withdrawn. And two hand-written
615
+ tokens in a de-identifier test became visible for the first time; they are **declared in the
616
+ allow-list**, with what that costs written next to them, rather than edited out of a test whose
617
+ point requires exactly those shapes.
618
+
619
+ The characterization test that pinned the old behaviour asserted the **opposite** of what is true
620
+ now. It was rewritten in place, not deleted — the same probe bytes, the same two paths, the opposite
621
+ expectation — alongside new pins for the limits this did **not** close: admission is file-scoped
622
+ rather than object-scoped, the C-CDA name loci remain
623
+ namespace-prefix-naive, and the textual FHIR route misses a phone whose `system` and `value` are
624
+ separated by another key (the structural route catches it — the two are not at parity, and the
625
+ routes are mutually exclusive).
626
+
627
+ - **The two scan modes disagreed about a git-ignored file and neither direction was tested.** All-mode
628
+ drops git-ignored files; `--staged` does not. Both directions are now exercised. **Neither is
629
+ changed**: all-mode walks the working tree, where an ignored path is build output; `--staged`
630
+ enumerates the index, which a file reaches only via `git add -f` and which a commit will therefore
631
+ carry. Refusing to read that one is the shipping direction.
632
+
633
+ - **The PHI commit gate could be argued into scanning nothing and still print `OK`.**
634
+ `scripts/phi-scan.ts` treated an `--allow-fixture <path>` as both a subtraction _and_ a scan
635
+ target, so `pnpm phi-scan --allow-fixture X` with no other argument built the target set `[X]`,
636
+ subtracted `X`, scanned zero files and exited 0 with the same `OK — no hits` line a real pass
637
+ prints. `--allow-fixture` is now purely subtractive and never seeds the target set; the mode is
638
+ decided by `--staged` and positional paths alone. Three invariants (`enforceObservation`) are
639
+ checked before any hit counting: an override that subtracts no enumerated target is an error
640
+ rather than an inert entry that reads as a live bypass, a target set emptied by overrides is
641
+ refused, and an enumeration that finds nothing is refused in every mode but `--staged` (where
642
+ "nothing staged" is legitimate). Both **summary** lines now carry the **denominator** — the count
643
+ of files scanned — so an `OK` is never read without the number it is an `OK` over. Ported from the
644
+ same defect closed in `ncpdp`.
645
+
646
+ - **The staged enumerator dropped whole classes of staged file, because its filter was the wrong
647
+ polarity.** `git diff --cached --diff-filter=AM` is an **allow-list of git status letters**: every
648
+ letter it does not name is skipped by default and silently. That is how it missed `R` — a fixture
649
+ `git mv`'d and edited to add PHI in one commit stages as a single rename entry — and `T`, a
650
+ tracked symlink replaced by a regular file carrying PHI. Each was found by a separate refuter pass
651
+ in `ncpdp`, which is the argument for the general fix rather than the two patches: the enumerator
652
+ now asks for `--diff-filter=d` (everything **except** deletions, which have no blob to read), so an
653
+ unknown or future status letter costs a wasted scan instead of a missed file. It also passes
654
+ `--no-renames`, which decomposes a rename into `D` + `A` so the **destination** path — the one
655
+ carrying the new content — is the path that gets read. Both directions are measured in the suite:
656
+ the tests assert the new flags catch the `R` and `T` cases **and** that the superseded `AM`
657
+ allow-list did not list the violator's path for either. That second assertion is also what keeps
658
+ the first honest — had git degraded the fixture to a plain `A`/`M`, `AM` would have listed it and
659
+ the test would red rather than pass for the wrong reason.
660
+
661
+ - **The scan stopped at `test/fixtures/`, leaving most of the repository unswept.** `SCAN_ROOTS` is
662
+ now `src/`, `test/` and `scripts/`, walked whole and shared by both all-mode and `--staged` through
663
+ a single `isScannable` predicate, so the **roots** narrow in one place. The gap mattered here
664
+ specifically: this repo builds messages as inline string literals in tests **outside** `fixtures/`,
665
+ and `scripts/` is hand-written tracked text that can carry a real address as easily as a fixture
666
+ can. Measured on the widening: **114 in-scope files became 170**. Markdown stays out of scope in
667
+ every mode, because documentation — including this scanner's own override log and allow-list —
668
+ legitimately quotes violator values.
669
+
670
+ **What the widening does NOT buy, stated because the obvious reading is wrong.** Over a `.ts` file
671
+ it delivers the format-agnostic floor and the content-gated arms (HL7 v2, X12, ASTM, NCPDP
672
+ Telecom), which sniff their own framing. It delivers nothing from the three **extension-gated**
673
+ arms: `scanCcda` and `scanNcpdpScript` return early unless the path ends `.xml`, and `scanFhir`
674
+ unless it ends `.json`. A byte-identical payload is caught as `probe.xml` and missed as `probe.ts`.
675
+ That gating predates this change and is deliberately left alone — content-sniffing XML and JSON out
676
+ of arbitrary TypeScript is a much larger job with its own false-positive surface, and doing it as a
677
+ side effect of a root change would grow the gate's teeth without review. It is recorded as a known
678
+ gap in the scanner's header rather than implied away, and the roots list no longer claims otherwise.
679
+
680
+ **SUPERSEDED — see "Three of the PHI gate's seven structured detectors…" under Fixed below.** The
681
+ paragraph above described the state of the gate when the roots were widened, and is kept as that
682
+ record. The three extension gates were subsequently removed as the separate job this paragraph said
683
+ they needed, so its present tense no longer holds.
684
+
685
+ - **The widened scan surfaced one real finding, and it is declared rather than suppressed.** The ten
686
+ vendored US Core 6.1.0 StructureDefinitions under `test/us-core-profiles/` each carry their
687
+ publisher's work-group contact address in `contact.telecom`. It is a real address, so it is a real
688
+ hit; it is not PHI, because it denotes a standards-body mailing list rather than a person receiving
689
+ care. It is cleared with a new, narrower allow-list tag — `EMAIL <address>`, one exact address —
690
+ rather than an `EMAILDOMAIN` entry that would clear every address at a live organizational domain,
691
+ and rather than an `--allow-fixture` bypass that would silence every other check on those ten files.
692
+
693
+ - **A missing allow-list exited 1, which reads as "hits found".** `loadAllowList` now runs inside the
694
+ invocation-error boundary, so an absent `scripts/phi-allow-list.txt` exits **2** like every other
695
+ invocation error.
696
+
697
+ - **The override log was parsed file-wide, so its own format template was a bypass.**
698
+ `phi-scan-overrides.md` documents itself with a literal `### <path>` heading above the entries
699
+ section; only `### <path>` subsections **under `## Entries`** are read as approvals now.
700
+
701
+ - **A false assertion in the scanner's test suite, deleted rather than reworded.** Its docblock said
702
+ the suite "deliberately does NOT test structured, field-level PHI detection". That was untrue when
703
+ written and stayed untrue for six formats — the HL7, FHIR, X12, NCPDP, ASTM and quirk suites had
704
+ been exercising exactly that. The dangerous half of a wrong assertion is not the code around it but
705
+ the next reader trusting it, so the sentence is gone. Two more went with it: the `STARTER` banner —
706
+ carried in `scripts/phi-scan.ts`, `phi-scan-overrides.md` and `.github/workflows/ci.yml`, all three
707
+ still describing a scanner that detects "SSN and email only" after every structured arm had landed —
708
+ and a claim in `docs-content/limitations.md` that this project's own fixtures carry a
709
+ `# synthetic: true` header. **No fixture does**; two shipped value-pool sources do.
710
+
711
+ - **A corpus could report a transaction it did not contain.** `x12Corpus({ seed: 9, mix: ["270"] })`
712
+ returned a corpus whose manifest said `{"270": 1}` and whose bytes were an `837` professional claim,
713
+ byte-identical to `mix: ["837P"]` at the same seed, because the kind dispatcher ended in an
714
+ unguarded fallback. Every corpus dispatcher is now an exhaustive `switch` behind a resolved
715
+ selector, so an unrecognised kind is a fatal `SYNTH_UNSUPPORTED_KIND` and can no longer be silently
716
+ relabelled. A golden file that lies about what it holds is the failure the intended-warning contract
717
+ exists to prevent, in the one format that had no equivalent check.
718
+
719
+ - **Five exhaustive `switch`es over run-time-erased unions returned `undefined` typed as a value.**
720
+ `generateHl7`, `generateHl7Quirk`'s base-message dispatcher, `fhirCorpus`, `ncpdpCorpus` and
721
+ `astmCorpus` each took no branch for a kind outside its union and handed the result on, surfacing
722
+ later as a `TypeError` from the runtime with no code to branch on. All five are now guarded by a
723
+ resolved selector.
724
+
725
+ **Three more of the same shape are known and deliberately left**, in `src/deid/`:
726
+ `x12DeidLoop({ variant })` and `ncpdpTelecomDeidLoop({ transaction })` fall through to an uncoded
727
+ `TypeError`, and `ccdaDeidLoop({ documentType })` silently generates a Referral Note for anything but
728
+ `"ccd"`. None echoes a caller value into a message, so none is a diagnostic leak. They are named here
729
+ rather than swept in, because two review passes had each found one more position after this change
730
+ described its own coverage as general, and widening the change a third time is the wrong response to
731
+ that.
732
+
733
+ - **`injectCcdaQuirk` returned `undefined` for an unrecognised quirk name, as though it were a
734
+ document.** `CcdaQuirkName` is a union that does not exist at run time, so a JavaScript caller (or
735
+ a cast) reaching the function with any other string fell out of the transform's switch with no
736
+ branch taken and no error. The name is now resolved against `CCDA_QUIRKS` first and an unknown one
737
+ is a fatal `SYNTH_UNSUPPORTED_QUIRK`. Found by the diagnostic-surface slot table below, which
738
+ recorded it as the one slot that produced neither a value nor a diagnostic.
739
+
500
740
  - **`README.md` said the package was not published to npm. It is.** The summary blockquote opened
501
741
  with "pre-alpha (`0.0.x`), not yet published to npm", so the npm package page asserted that the
502
742
  package did not exist on npm, directly under the version npm renders in its own header. A reader
@@ -541,4 +781,61 @@ its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` until firs
541
781
 
542
782
  ### Security
543
783
 
784
+ - **A per-slot diagnostic-surface gate, run red before it was run green.**
785
+ `test/phi/diagnostic-surface.test.ts` drives `assertNoDiagnosticPhiLeak` from `@cosyte/test-utils`
786
+ over a table of **44** consumer-controlled positions: the quirk name, format label and registry key
787
+ on `resolveQuirk`; the quirk selector on all three `generate*Quirk`, all three `*QuirkRoundTrip` and
788
+ all three `*QuirkCorpus` entry points, by explicit list and through a `defineSynthProfile` profile;
789
+ both code lists on `assertIntendedWarnings`; both parameters of `injectCcdaQuirk`; the code-system
790
+ URI on `toBuildCode`; the money string on `dec`; **eighteen selector positions** across the six
791
+ formats (message kind, `ADT` trigger, document type, corpus mix, `837` variant, Bundle type,
792
+ `Patient` profile, quirk kind); and the artifact `content` each quirk round-trip harness hands to a
793
+ sibling parser. Each slot plants an eight-byte marker and a 32 KiB one, names the code it must
794
+ reach, and fails if any four-byte run of the marker turns up in a thrown value, a `message`, a
795
+ `stack`, or a structural identifier on a returned model.
796
+
797
+ **Measured against the unfixed source first, because a suite never seen red is indistinguishable
798
+ from one that cannot go red.** Of the 44 slots, **3 were clean and 41 failed**. The failure
799
+ categories **overlap** — a slot can both echo the marker and throw an uncoded error, and 6 do — so
800
+ they are counted per slot per category and do not sum to 41: **20** echoed the planted value
801
+ verbatim into an error message or stack, **4** put it on a model identifier, **16** threw an error
802
+ carrying no code to branch on, and **7** accepted it with neither an error nor a usable value. The
803
+ method: check out the base tree's `src/`, add the new codes and the frozen table as a purely
804
+ additive patch with an _optional_ message parameter so no base call site changes, then invoke each
805
+ slot's plant directly with both probe sizes.
806
+
807
+ **The model half of that sweep is vacuous, and the file says so rather than letting a reader infer
808
+ coverage.** Every slot throws, because every position fails closed before a model exists, so
809
+ `getModelIdentifiers` is never reached during the sweep. That is the result of the fix, not a gap in
810
+ the table, and it is carried instead by a separate assertion that runs the three identifier helpers
811
+ over real corpora and real quirk round-trips and checks that every identifier they yield comes from
812
+ a set this package controls. Between them: the slots prove no caller value survives to a model, and
813
+ the closed-set test proves the identifiers that exist were derived rather than passed through.
814
+
815
+ It also asserts the structural half: no `throw new` under `src/` names anything but `SynthError`,
816
+ and no construction of one carries an interpolation. The scanner is positive-controlled against a
817
+ constructed string containing the thing it hunts, not against a file that happens to be nearby, and
818
+ **its title says what a regex can see while its docblock says what it cannot** — it cannot see a
819
+ `TypeError` the runtime raises on its own, which is exactly the route the selector chokepoint
820
+ closes.
821
+
822
+ What the gate does not prove is in the runner's own documentation and is not restated more
823
+ favourably here: it does not catch a re-encoded echo, an echo under four bytes, a leak carried only
824
+ as a number, or a leak through a position nobody declared. The slot table is the deliverable. It was
825
+ derived by enumerating the exported types with the TypeScript checker rather than by sweeping the
826
+ source from memory. **Even so it was refuted twice, on the same class both times.** The first
827
+ version covered only `throw` sites and missed eighteen selector positions. The second called the
828
+ selectors "generalised, not patched" and missed the tail of a quirk list a `count` never reaches,
829
+ which lands on `manifest.quirks` unresolved. Enumerating the types was necessary and not sufficient:
830
+ both misses were judgements about which positions "reach a diagnostic", made before it was
831
+ established that an unresolved selector reaches a peer builder's diagnostic and a derived manifest
832
+ key.
833
+
834
+ **So the table is no longer described as exhaustive, on any surface.** A third claim of completeness
835
+ would be worth exactly what the first two were. What holds generally is the mechanism — the error
836
+ type has no value parameter, and a selector is read in one place — and the table is an enumeration
837
+ of the positions that have been checked. That wording is now in the test file's own header, in
838
+ `docs-content/limitations.md` and in `CLAUDE.md`, so the next person to add a position adds a slot
839
+ rather than trusting a count.
840
+
544
841
  [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) => {