@cosyte/cli 0.0.6 → 0.1.1

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/dist/index.mjs CHANGED
@@ -1,28 +1,37 @@
1
- import { readFile } from 'fs/promises';
1
+ import { open } from 'fs/promises';
2
+ import { randomBytes } from 'crypto';
2
3
  import { parseArgs } from 'util';
3
4
 
4
5
  // src/core/version.ts
5
- var VERSION = "0.0.6";
6
+ var VERSION = "0.1.1";
6
7
 
7
8
  // src/core/exit-codes.ts
8
9
  var EXIT = {
9
10
  /** Success: the operation completed; `validate` found the input **valid**. */
10
11
  OK: 0,
11
- /** Operation-level failure: `validate` found the input **invalid** (parseable but non-conformant):
12
- * a real, expected CI signal that the message is bad and the tool worked. Never emitted for
13
- * unparseable input (that is `DATAERR`). */
12
+ /** Operation-level failure: `validate` found the input **invalid** (parseable but non-conformant),
13
+ * or `redact` could not de-identify every locus: a real, expected CI signal that the tool worked
14
+ * and the operation did not complete. Never emitted for unparseable input (that is `DATAERR`). */
14
15
  INVALID: 1,
15
- /** Usage error: unknown command, bad flag, missing argument (`EX_USAGE`). */
16
+ /** Usage error: unknown command, bad flag, missing argument. A value specific to this CLI, not a
17
+ * `sysexits.h` constant. */
16
18
  USAGE: 2,
17
19
  /** Data error: input could not be parsed or its format could not be detected (`EX_DATAERR`). */
18
20
  DATAERR: 65,
19
21
  /** No input: the named file does not exist or is unreadable (`EX_NOINPUT`). */
20
22
  NOINPUT: 66,
21
- /** Unavailable: a required capability is not yet wired (e.g. `redact` before `@cosyte/deid`), a
22
- * distinct non-zero signal that is never a fake success (`EX_UNAVAILABLE`). */
23
+ /** Unavailable: a required capability is not wired for this input (e.g. `redact` on a format
24
+ * `@cosyte/deid` has no adapter for, or with that optional library absent), a distinct non-zero
25
+ * signal that is never a fake success (`EX_UNAVAILABLE`). */
23
26
  UNAVAILABLE: 69,
24
27
  /** Internal error: an unexpected exception (a bug), distinct from a handled bad input (`EX_SOFTWARE`). */
25
- SOFTWARE: 70
28
+ SOFTWARE: 70,
29
+ /** Output error: the stream the CLI was writing to closed before the output had been delivered, so
30
+ * the consumer holds nothing or holds only part of the answer. The shape a downstream consumer
31
+ * that exits early produces (a pager quit, a `head` that has seen enough). A handled condition the
32
+ * consumer owns, never a defect in the CLI, so it is deliberately distinct from `SOFTWARE`
33
+ * (`EX_IOERR`). */
34
+ IOERR: 74
26
35
  };
27
36
 
28
37
  // src/core/diagnostics.ts
@@ -46,12 +55,27 @@ var CLI_CODES = {
46
55
  CLI_PARSER_UNAVAILABLE: "CLI_PARSER_UNAVAILABLE",
47
56
  /** The wrapped parser rejected the input. Positional context only, never the offending bytes. Exit `65`. */
48
57
  CLI_PARSE_FAILED: "CLI_PARSE_FAILED",
58
+ /** The input is larger than the documented size limit the CLI reads in one invocation. Raised
59
+ * against the running byte count while the input is read, so the refusal always arrives before an
60
+ * allocation ceiling could. Names the limit, never the input. Exit `65`, never `70`. */
61
+ CLI_INPUT_TOO_LARGE: "CLI_INPUT_TOO_LARGE",
62
+ /** The output stream could not be written: a consumer that closes the pipe ends the invocation
63
+ * here rather than as an unhandled platform error, whether it went away part way through a record
64
+ * stream or before a single-result write was delivered. Names no input, and never the platform's
65
+ * own error text. Exit `74`. */
66
+ CLI_OUTPUT_WRITE_FAILED: "CLI_OUTPUT_WRITE_FAILED",
49
67
  /** The BYO ConceptMap supplied to `map-codes` is not valid JSON or not a loadable FHIR ConceptMap.
50
68
  * Names the stable terminology-loader code, never the map's bytes. Exit `65`. */
51
69
  CLI_MAP_INVALID: "CLI_MAP_INVALID",
52
- /** A command whose ground-layer library is not yet wired (e.g. `redact` before `@cosyte/deid` is
53
- * wired). Never a fake success: a distinct, value-free "unavailable" signal. Exit `69`. */
70
+ /** A command whose ground-layer library does not cover the resolved format (e.g. `redact` on a
71
+ * format `@cosyte/deid` ships no adapter for). Never a fake success: a distinct, value-free
72
+ * "unavailable" signal. Exit `69`. */
54
73
  CLI_NOT_IMPLEMENTED: "CLI_NOT_IMPLEMENTED",
74
+ /** The delegated de-identifier reported that it could not handle every element of the input (a
75
+ * fail-closed `blocked` locus, or its own fatal). The run is **not** a de-identified copy, so no
76
+ * output is emitted: an operation-level failure, never a bug. Names loci and codes, never a
77
+ * value. Exit `1`. */
78
+ CLI_DEID_INCOMPLETE: "CLI_DEID_INCOMPLETE",
55
79
  /** An unexpected internal error (a bug). Exit `70`. */
56
80
  CLI_INTERNAL: "CLI_INTERNAL"
57
81
  };
@@ -89,7 +113,8 @@ function toCliError(err) {
89
113
  }
90
114
 
91
115
  // src/core/format.ts
92
- var SNIFF_BYTES = 512;
116
+ var DETECT_PREFIX_BYTES = 512;
117
+ var SNIFF_BYTES = DETECT_PREFIX_BYTES;
93
118
  function leadingText(bytes) {
94
119
  const slice = bytes.subarray(0, SNIFF_BYTES);
95
120
  let text = new TextDecoder("utf-8", { fatal: false }).decode(slice);
@@ -186,6 +211,21 @@ function asCosyteFormat(value) {
186
211
  return KNOWN_FORMATS.includes(value) ? value : null;
187
212
  }
188
213
 
214
+ // src/core/limits.ts
215
+ var BYTES_PER_MIB = 1024 * 1024;
216
+ var MAX_INPUT_BYTES = 64 * BYTES_PER_MIB;
217
+ function describeByteLimit(limit = MAX_INPUT_BYTES) {
218
+ const mib = limit / BYTES_PER_MIB;
219
+ return Number.isInteger(mib) ? `${String(limit)} bytes (${String(mib)} MiB)` : `${String(limit)} bytes`;
220
+ }
221
+ function inputTooLargeError(limit = MAX_INPUT_BYTES) {
222
+ return new CliError(
223
+ CLI_CODES.CLI_INPUT_TOO_LARGE,
224
+ EXIT.DATAERR,
225
+ `input is larger than the ${describeByteLimit(limit)} this command reads in one invocation; split the input and re-run`
226
+ );
227
+ }
228
+
189
229
  // src/core/findings.ts
190
230
  function formatHl7Position(pos) {
191
231
  let out = `seg[${String(pos.segmentIndex)}]`;
@@ -535,11 +575,7 @@ async function validateFormat(format, bytes) {
535
575
  }
536
576
  async function deframeMllp(bytes) {
537
577
  if (bytes.lastIndexOf(11) > bytes.lastIndexOf(28)) {
538
- throw new CliError(
539
- CLI_CODES.CLI_PARSE_FAILED,
540
- EXIT.DATAERR,
541
- "truncated MLLP stream: an unterminated frame at the end of input (no closing FS/CR)"
542
- );
578
+ throw truncatedMllpError();
543
579
  }
544
580
  const { FrameReader } = await loadOptional("mllp", () => import('@cosyte/mllp'));
545
581
  const payloads = [];
@@ -551,6 +587,13 @@ async function deframeMllp(bytes) {
551
587
  reader.push(Buffer.from(bytes));
552
588
  return { payloads, warningCount };
553
589
  }
590
+ function truncatedMllpError() {
591
+ return new CliError(
592
+ CLI_CODES.CLI_PARSE_FAILED,
593
+ EXIT.DATAERR,
594
+ "truncated MLLP stream: an unterminated frame at the end of input (no closing FS/CR)"
595
+ );
596
+ }
554
597
  function decode(bytes) {
555
598
  return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
556
599
  }
@@ -566,6 +609,84 @@ function unsupportedInternal(format, op) {
566
609
  );
567
610
  }
568
611
 
612
+ // src/core/records.ts
613
+ var LINE_FEED = 10;
614
+ var CARRIAGE_RETURN = 13;
615
+ var VERTICAL_TAB = 11;
616
+ var FILE_SEPARATOR = 28;
617
+ function asBuffer(chunk) {
618
+ return Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk.buffer, chunk.byteOffset, chunk.length);
619
+ }
620
+ async function* oneChunk(bytes) {
621
+ await Promise.resolve();
622
+ if (bytes.length > 0) yield bytes;
623
+ }
624
+ async function* withinLimit(chunks, limit = MAX_INPUT_BYTES) {
625
+ let total = 0;
626
+ for await (const chunk of chunks) {
627
+ total += chunk.length;
628
+ if (total > limit) throw inputTooLargeError(limit);
629
+ yield chunk;
630
+ }
631
+ }
632
+ async function collectChunks(chunks) {
633
+ const parts = [];
634
+ for await (const chunk of chunks) parts.push(asBuffer(chunk));
635
+ return Buffer.concat(parts);
636
+ }
637
+ function isBlank(line) {
638
+ return new TextDecoder("utf-8", { fatal: false }).decode(line).trim().length === 0;
639
+ }
640
+ function stripCarriageReturn(line) {
641
+ return line.length > 0 && line[line.length - 1] === CARRIAGE_RETURN ? line.subarray(0, line.length - 1) : line;
642
+ }
643
+ async function* ndjsonRecords(chunks) {
644
+ let carry = Buffer.alloc(0);
645
+ for await (const chunk of chunks) {
646
+ const buf = carry.length > 0 ? Buffer.concat([carry, asBuffer(chunk)]) : asBuffer(chunk);
647
+ let start = 0;
648
+ for (; ; ) {
649
+ const end = buf.indexOf(LINE_FEED, start);
650
+ if (end < 0) break;
651
+ const line = stripCarriageReturn(buf.subarray(start, end));
652
+ if (!isBlank(line)) yield line;
653
+ start = end + 1;
654
+ }
655
+ carry = start === 0 ? asBuffer(buf) : Buffer.from(buf.subarray(start));
656
+ }
657
+ const last = stripCarriageReturn(carry);
658
+ if (!isBlank(last)) yield last;
659
+ }
660
+ async function* mllpFrames(chunks) {
661
+ const { FrameReader } = await loadOptional("mllp", () => import('@cosyte/mllp'));
662
+ const ready = [];
663
+ const reader = new FrameReader({
664
+ onFrame: (payload) => ready.push(Buffer.from(payload)),
665
+ onWarning: () => void 0,
666
+ // The CLI's own documented input limit is the binding one, so the reader's smaller default frame
667
+ // ceiling is raised to it: an over-limit MLLP stream is refused by the limit the help output and
668
+ // the command reference state, not by an undocumented one underneath it.
669
+ maxFrameSizeBytes: MAX_INPUT_BYTES
670
+ });
671
+ let lastStart = -1;
672
+ let lastEnd = -1;
673
+ let offset = 0;
674
+ for await (const chunk of chunks) {
675
+ const buf = asBuffer(chunk);
676
+ const start = buf.lastIndexOf(VERTICAL_TAB);
677
+ if (start >= 0) lastStart = offset + start;
678
+ const end = buf.lastIndexOf(FILE_SEPARATOR);
679
+ if (end >= 0) lastEnd = offset + end;
680
+ offset += buf.length;
681
+ reader.push(buf);
682
+ while (ready.length > 0) {
683
+ const frame = ready.shift();
684
+ if (frame !== void 0) yield frame;
685
+ }
686
+ }
687
+ if (lastStart > lastEnd) throw truncatedMllpError();
688
+ }
689
+
569
690
  // src/core/input.ts
570
691
  async function resolveInput(source, formatOverride, deps, op) {
571
692
  if (source === void 0) {
@@ -587,34 +708,91 @@ async function resolveInput(source, formatOverride, deps, op) {
587
708
  if (bytes.length === 0) {
588
709
  return fail(new CliError(CLI_CODES.CLI_EMPTY_INPUT, EXIT.DATAERR, "input is empty"));
589
710
  }
711
+ const format = resolveFormat(bytes, formatOverride, op);
712
+ if (format instanceof CliError) return fail(format);
713
+ return { ok: true, input: { format, bytes } };
714
+ }
715
+ async function resolveInputStream(source, formatOverride, deps, op, limit = MAX_INPUT_BYTES) {
716
+ if (source === void 0) {
717
+ return fail(
718
+ new CliError(
719
+ CLI_CODES.CLI_USAGE,
720
+ EXIT.USAGE,
721
+ "missing <file> argument; pass a path or `-` to read stdin"
722
+ )
723
+ );
724
+ }
725
+ const iterator = withinLimit(openChunks(source, deps), limit)[Symbol.asyncIterator]();
726
+ const need = formatOverride === void 0 ? DETECT_PREFIX_BYTES : 1;
727
+ const head = [];
728
+ let headBytes = 0;
729
+ try {
730
+ while (headBytes < need) {
731
+ const step = await iterator.next();
732
+ if (step.done === true) break;
733
+ head.push(step.value);
734
+ headBytes += step.value.length;
735
+ }
736
+ } catch (e) {
737
+ if (e instanceof CliError) return fail(e);
738
+ throw e;
739
+ }
740
+ if (headBytes === 0) {
741
+ return fail(new CliError(CLI_CODES.CLI_EMPTY_INPUT, EXIT.DATAERR, "input is empty"));
742
+ }
743
+ const prefix = Buffer.concat(
744
+ head.map((c) => Buffer.from(c.buffer, c.byteOffset, c.length)),
745
+ Math.min(headBytes, DETECT_PREFIX_BYTES)
746
+ );
747
+ const format = resolveFormat(prefix, formatOverride, op);
748
+ if (format instanceof CliError) {
749
+ await iterator.return(void 0);
750
+ return fail(format);
751
+ }
752
+ return { ok: true, input: { format, chunks: rejoin(head, iterator) } };
753
+ }
754
+ function openChunks(source, deps) {
755
+ if (source === "-") {
756
+ return deps.openStdin === void 0 ? readerAsChunks(() => deps.readStdin()) : deps.openStdin();
757
+ }
758
+ return deps.openFile === void 0 ? readerAsChunks(() => deps.readFile(source)) : deps.openFile(source);
759
+ }
760
+ async function* readerAsChunks(read) {
761
+ yield* oneChunk(await read());
762
+ }
763
+ async function* rejoin(head, rest) {
764
+ for (const chunk of head) yield chunk;
765
+ for (; ; ) {
766
+ const step = await rest.next();
767
+ if (step.done === true) return;
768
+ yield step.value;
769
+ }
770
+ }
771
+ function resolveFormat(prefix, formatOverride, op) {
590
772
  let format;
591
773
  if (formatOverride !== void 0) {
592
774
  const narrowed = asCosyteFormat(formatOverride);
593
775
  if (narrowed === null) {
594
- return fail(
595
- new CliError(
596
- CLI_CODES.CLI_USAGE,
597
- EXIT.USAGE,
598
- "unknown --format value; expected one of hl7, fhir, dicom, x12, ccda, ncpdp, astm, mllp"
599
- )
776
+ return new CliError(
777
+ CLI_CODES.CLI_USAGE,
778
+ EXIT.USAGE,
779
+ "unknown --format value; expected one of hl7, fhir, dicom, x12, ccda, ncpdp, astm, mllp"
600
780
  );
601
781
  }
602
782
  format = narrowed;
603
783
  } else {
604
- const detected = detectFormat(bytes);
605
- if (detected.format === null) return fail(detectionError(detected));
784
+ const detected = detectFormat(prefix);
785
+ if (detected.format === null) return detectionError(detected);
606
786
  format = detected.format;
607
787
  }
608
- if (!supportsOp(format, op)) {
609
- return fail(
610
- new CliError(
611
- CLI_CODES.CLI_FORMAT_UNSUPPORTED,
612
- EXIT.DATAERR,
613
- `format '${format}' does not support \`${op}\` in this CLI build (${op} supports: ${formatsSupporting(op).join(", ")})`
614
- )
788
+ if (op !== null && !supportsOp(format, op)) {
789
+ return new CliError(
790
+ CLI_CODES.CLI_FORMAT_UNSUPPORTED,
791
+ EXIT.DATAERR,
792
+ `format '${format}' does not support \`${op}\` in this CLI build (${op} supports: ${formatsSupporting(op).join(", ")})`
615
793
  );
616
794
  }
617
- return { ok: true, input: { format, bytes } };
795
+ return format;
618
796
  }
619
797
  function fail(e) {
620
798
  return { ok: false, result: errorResult(e) };
@@ -656,29 +834,204 @@ function parseFailureResult(format, bytes, posture, e) {
656
834
  exit: EXIT.DATAERR
657
835
  };
658
836
  }
659
- async function readFileBytes(path) {
837
+ function noInputError(path) {
838
+ return new CliError(
839
+ CLI_CODES.CLI_NO_INPUT,
840
+ EXIT.NOINPUT,
841
+ `cannot read input file: ${path} (does it exist and is it readable?)`
842
+ );
843
+ }
844
+ async function readFileBytes(path, limit = MAX_INPUT_BYTES) {
845
+ let handle;
660
846
  try {
661
- return await readFile(path);
847
+ handle = await open(path, "r");
662
848
  } catch {
663
- throw new CliError(
664
- CLI_CODES.CLI_NO_INPUT,
665
- EXIT.NOINPUT,
666
- `cannot read input file: ${path} (does it exist and is it readable?)`
667
- );
849
+ throw noInputError(path);
850
+ }
851
+ try {
852
+ const info = await handle.stat();
853
+ if (info.isFile() && info.size > limit) throw inputTooLargeError(limit);
854
+ return await handle.readFile();
855
+ } catch (e) {
856
+ if (e instanceof CliError) throw e;
857
+ throw noInputError(path);
858
+ } finally {
859
+ await handle.close();
668
860
  }
669
861
  }
670
- async function readStreamBytes(stream) {
862
+ async function readStreamBytes(stream, limit = MAX_INPUT_BYTES) {
671
863
  const chunks = [];
864
+ let total = 0;
672
865
  for await (const chunk of stream) {
673
- chunks.push(typeof chunk === "string" ? Buffer.from(chunk, "utf-8") : chunk);
866
+ const buf = typeof chunk === "string" ? Buffer.from(chunk, "utf-8") : chunk;
867
+ total += buf.length;
868
+ if (total > limit) throw inputTooLargeError(limit);
869
+ chunks.push(buf);
674
870
  }
675
871
  return Buffer.concat(chunks);
676
872
  }
677
-
678
- // src/core/deid.ts
679
- var DEID_UNAVAILABLE_REASON = "redact/deid is not yet available. It delegates to @cosyte/deid, which the CLI does not wire yet. The CLI ships no built-in redactor: a partial Safe-Harbor scrub over only the obvious PHI loci would leave PHI behind and present a false-safety impression, so nothing is emitted. This command will produce a de-identified copy once that integration lands and is vetted.";
680
- function deidStatus() {
681
- return { available: false, reason: DEID_UNAVAILABLE_REASON };
873
+ async function* streamChunks(stream) {
874
+ for await (const chunk of stream) {
875
+ yield typeof chunk === "string" ? Buffer.from(chunk, "utf-8") : chunk;
876
+ }
877
+ }
878
+ async function* fileChunks(path) {
879
+ let handle;
880
+ try {
881
+ handle = await open(path, "r");
882
+ } catch {
883
+ throw noInputError(path);
884
+ }
885
+ try {
886
+ const stream = handle.createReadStream();
887
+ try {
888
+ for await (const chunk of stream) yield chunk;
889
+ } catch {
890
+ throw noInputError(path);
891
+ }
892
+ } finally {
893
+ await handle.close();
894
+ }
895
+ }
896
+ var DEID_PACKAGE = "@cosyte/deid";
897
+ var DEID_COVERAGE = {
898
+ hl7: "covered",
899
+ fhir: "covered",
900
+ x12: "covered",
901
+ ccda: "covered",
902
+ // The delegate's NCPDP adapter is Telecom; this CLI resolves SCRIPT.
903
+ ncpdp: "no-adapter",
904
+ astm: "no-adapter",
905
+ mllp: "no-adapter",
906
+ // Covered by the delegate, but its result is Part 10 bytes and stdout here is text.
907
+ dicom: "unserializable"
908
+ };
909
+ function deidCoverage(format) {
910
+ return DEID_COVERAGE[format];
911
+ }
912
+ function isDeidCovered(format) {
913
+ return DEID_COVERAGE[format] === "covered";
914
+ }
915
+ function deidCoveredFormats() {
916
+ return Object.keys(DEID_COVERAGE).filter(isDeidCovered).sort();
917
+ }
918
+ var DEID_BLOCKED = "blocked";
919
+ var DEID_UNAVAILABLE_REASON = `the ${DEID_PACKAGE} de-identification library is not installed; install it to use redact/deid (it is an optional dependency). The CLI ships no built-in redactor: a partial Safe-Harbor scrub over only the obvious PHI loci would leave PHI behind and present a false-safety impression, so nothing is emitted`;
920
+ function adapterUnavailable(format) {
921
+ return `the ${DEID_PACKAGE}/${format} de-identification adapter could not be loaded; it needs both ${DEID_PACKAGE} and the @cosyte/${format} parser to be installed`;
922
+ }
923
+ function parseOrFail(format, parse) {
924
+ try {
925
+ return parse();
926
+ } catch (e) {
927
+ const code = extractStableCode(e);
928
+ throw new CliError(
929
+ CLI_CODES.CLI_PARSE_FAILED,
930
+ EXIT.DATAERR,
931
+ `the ${format} parser rejected the input${code === null ? "" : ` (${code})`}`
932
+ );
933
+ }
934
+ }
935
+ function deidentifyOrFail(run2) {
936
+ try {
937
+ return run2();
938
+ } catch (e) {
939
+ const code = extractStableCode(e);
940
+ if (code === null) throw e;
941
+ throw new CliError(
942
+ CLI_CODES.CLI_DEID_INCOMPLETE,
943
+ EXIT.INVALID,
944
+ `the ${DEID_PACKAGE} de-identifier could not complete this input (${code}); no output was emitted`
945
+ );
946
+ }
947
+ }
948
+ function decode2(bytes) {
949
+ return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
950
+ }
951
+ function toRecords(manifest) {
952
+ return manifest.map((e) => ({
953
+ category: e.category,
954
+ transform: e.transform,
955
+ locus: e.locus,
956
+ count: e.count,
957
+ disposition: e.disposition,
958
+ code: e.code
959
+ }));
960
+ }
961
+ async function loadDeidDelegate(importCore = () => import('@cosyte/deid')) {
962
+ const core = await loadOptionalPackage(DEID_UNAVAILABLE_REASON, importCore);
963
+ const context = core.createDeidContext({ key: randomBytes(32) });
964
+ return {
965
+ label: core.OUTPUT_LABEL,
966
+ version: core.VERSION,
967
+ redact: async (format, bytes) => {
968
+ switch (format) {
969
+ case "hl7": {
970
+ const { parseHL7 } = await import('@cosyte/hl7');
971
+ const { deidentifyHl7 } = await loadOptionalPackage(
972
+ adapterUnavailable("hl7"),
973
+ () => import('@cosyte/deid/hl7')
974
+ );
975
+ const msg = parseOrFail("hl7", () => parseHL7(Buffer.from(bytes)));
976
+ return deidentifyOrFail(() => {
977
+ const { document, manifest } = deidentifyHl7(msg, { context });
978
+ return { output: document.toString(), manifest: toRecords(manifest) };
979
+ });
980
+ }
981
+ case "fhir": {
982
+ const { parseResource, serializeResource } = await loadFhir();
983
+ const { deidentifyFhir } = await loadOptionalPackage(
984
+ adapterUnavailable("fhir"),
985
+ () => import('@cosyte/deid/fhir')
986
+ );
987
+ const resource = parseOrFail("fhir", () => parseResource(decode2(bytes)).resource);
988
+ return deidentifyOrFail(() => {
989
+ const { document, manifest } = deidentifyFhir(resource, { context });
990
+ return { output: serializeResource(document), manifest: toRecords(manifest) };
991
+ });
992
+ }
993
+ case "x12": {
994
+ const { parseX12 } = await loadOptional("x12", () => import('@cosyte/x12'));
995
+ const { deidentifyX12 } = await loadOptionalPackage(
996
+ adapterUnavailable("x12"),
997
+ () => import('@cosyte/deid/x12')
998
+ );
999
+ const interchange = parseOrFail("x12", () => parseX12(Buffer.from(bytes)));
1000
+ return deidentifyOrFail(() => {
1001
+ const { x12, manifest } = deidentifyX12(interchange, { context });
1002
+ return { output: x12, manifest: toRecords(manifest) };
1003
+ });
1004
+ }
1005
+ case "ccda": {
1006
+ const { parseCcda, serializeCcda } = await loadOptional(
1007
+ "ccda",
1008
+ () => import('@cosyte/ccda')
1009
+ );
1010
+ const { deidentifyCcda } = await loadOptionalPackage(
1011
+ adapterUnavailable("ccda"),
1012
+ () => import('@cosyte/deid/ccda')
1013
+ );
1014
+ const doc = parseOrFail("ccda", () => parseCcda(decode2(bytes)));
1015
+ return deidentifyOrFail(() => {
1016
+ const { document, manifest } = deidentifyCcda(doc, { context });
1017
+ return { output: serializeCcda(document), manifest: toRecords(manifest) };
1018
+ });
1019
+ }
1020
+ }
1021
+ }
1022
+ };
1023
+ }
1024
+ async function deidStatus(load = loadDeidDelegate) {
1025
+ try {
1026
+ const delegate = await load();
1027
+ return {
1028
+ available: true,
1029
+ reason: `${DEID_PACKAGE} ${delegate.version}: ${delegate.label}`
1030
+ };
1031
+ } catch (e) {
1032
+ if (e instanceof CliError) return { available: false, reason: DEID_UNAVAILABLE_REASON };
1033
+ throw e;
1034
+ }
682
1035
  }
683
1036
 
684
1037
  // src/commands/completion.ts
@@ -1150,11 +1503,24 @@ async function parseCommand(args, deps, posture = VALUE_FREE) {
1150
1503
  )
1151
1504
  );
1152
1505
  }
1153
- const resolved = await resolveInput(positionals[0], values.format, deps, "parse");
1506
+ const resolved = await resolveInputStream(positionals[0], values.format, deps, "parse");
1154
1507
  if (!resolved.ok) return resolved.result;
1155
- const { format, bytes } = resolved.input;
1508
+ const { format, chunks } = resolved.input;
1156
1509
  if (format === "mllp" || values.ndjson === true) {
1157
- return await parseMulti(format, bytes, values.ndjson === true, values.quiet === true);
1510
+ return await parseMulti(
1511
+ format,
1512
+ chunks,
1513
+ values.ndjson === true,
1514
+ values.quiet === true,
1515
+ deps.writeStdout
1516
+ );
1517
+ }
1518
+ let bytes;
1519
+ try {
1520
+ bytes = await collectChunks(chunks);
1521
+ } catch (e) {
1522
+ if (e instanceof CliError) return errorResult(e);
1523
+ throw e;
1158
1524
  }
1159
1525
  return await parseSingle(format, bytes, values.json === true, values.quiet === true, posture);
1160
1526
  }
@@ -1173,23 +1539,57 @@ async function parseSingle(format, bytes, json, quiet, posture) {
1173
1539
  ` : "";
1174
1540
  return { stdout, stderr, exit: EXIT.OK };
1175
1541
  }
1176
- async function parseMulti(format, bytes, ndjson, quiet) {
1177
- let records;
1178
- let recordFormat;
1542
+ async function parseMulti(format, chunks, ndjson, quiet, writeStdout) {
1543
+ const recordFormat = format === "mllp" ? "hl7" : format;
1544
+ const records = format === "mllp" ? mllpFrames(chunks) : ndjsonRecords(chunks);
1545
+ const held = [];
1546
+ const emit = (line) => {
1547
+ if (writeStdout === void 0) {
1548
+ held.push(line);
1549
+ return;
1550
+ }
1551
+ try {
1552
+ writeStdout(line);
1553
+ } catch {
1554
+ throw new CliError(
1555
+ CLI_CODES.CLI_OUTPUT_WRITE_FAILED,
1556
+ EXIT.IOERR,
1557
+ "could not write to the output stream; it closed before the record stream finished"
1558
+ );
1559
+ }
1560
+ };
1561
+ const written = () => held.join("");
1562
+ let total = 0;
1563
+ let failed = 0;
1564
+ let warnings = 0;
1179
1565
  try {
1180
- if (format === "mllp") {
1181
- const { payloads } = await deframeMllp(bytes);
1182
- records = payloads;
1183
- recordFormat = "hl7";
1184
- } else {
1185
- records = splitLines(bytes);
1186
- recordFormat = format;
1566
+ for await (const record of records) {
1567
+ let line;
1568
+ try {
1569
+ const { model, warnings: ws } = await parseFormat(recordFormat, record);
1570
+ warnings += ws.length;
1571
+ line = { record: total, format: recordFormat, model, warnings: ws };
1572
+ } catch (e) {
1573
+ if (e instanceof CliError) throw e;
1574
+ failed += 1;
1575
+ line = {
1576
+ record: total,
1577
+ format: recordFormat,
1578
+ error: extractStableCode(e) ?? "CLI_PARSE_FAILED"
1579
+ };
1580
+ }
1581
+ total += 1;
1582
+ emit(JSON.stringify(line) + "\n");
1187
1583
  }
1188
1584
  } catch (e) {
1189
- if (e instanceof CliError) return errorResult(e);
1190
- return parseFailureResult(format, bytes, VALUE_FREE, e);
1585
+ if (e instanceof CliError) {
1586
+ return { stdout: written(), stderr: `${formatDiagnostic(e)}
1587
+ `, exit: e.exit };
1588
+ }
1589
+ const rejected = parseFailureResult(format, new Uint8Array(), VALUE_FREE, e);
1590
+ return { stdout: written(), stderr: rejected.stderr, exit: rejected.exit };
1191
1591
  }
1192
- if (records.length === 0) {
1592
+ if (total === 0) {
1193
1593
  return errorResult(
1194
1594
  new CliError(
1195
1595
  CLI_CODES.CLI_PARSE_FAILED,
@@ -1198,57 +1598,104 @@ async function parseMulti(format, bytes, ndjson, quiet) {
1198
1598
  )
1199
1599
  );
1200
1600
  }
1201
- const lines = [];
1202
- let failed = 0;
1203
- let warnings = 0;
1204
- for (let i = 0; i < records.length; i += 1) {
1205
- const rec = records[i];
1206
- try {
1207
- const { model, warnings: ws } = await parseFormat(recordFormat, rec);
1208
- warnings += ws.length;
1209
- lines.push({ record: i, format: recordFormat, model, warnings: ws });
1210
- } catch (e) {
1211
- if (e instanceof CliError) return errorResult(e);
1212
- failed += 1;
1213
- lines.push({
1214
- record: i,
1215
- format: recordFormat,
1216
- error: extractStableCode(e) ?? "CLI_PARSE_FAILED"
1217
- });
1218
- }
1219
- }
1220
- const stdout = lines.map((l) => JSON.stringify(l)).join("\n") + "\n";
1221
1601
  const exit = failed > 0 ? EXIT.DATAERR : EXIT.OK;
1222
- const stderr = quiet ? "" : `cosyte: parsed ${String(records.length)} ${recordFormat} record(s) (${String(warnings)} warning(s), ${String(failed)} failed)` + (ndjson ? " [ndjson]" : format === "mllp" ? " [mllp]" : "") + "\n";
1223
- return { stdout, stderr, exit };
1224
- }
1225
- function splitLines(bytes) {
1226
- const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
1227
- const enc = new TextEncoder();
1228
- return text.split(/\r?\n/).filter((line) => line.trim().length > 0).map((line) => enc.encode(line));
1602
+ const stderr = quiet ? "" : `cosyte: parsed ${String(total)} ${recordFormat} record(s) (${String(warnings)} warning(s), ${String(failed)} failed)` + (ndjson ? " [ndjson]" : format === "mllp" ? " [mllp]" : "") + "\n";
1603
+ return { stdout: written(), stderr, exit };
1229
1604
  }
1230
1605
  var REDACT_OPTIONS = {
1231
1606
  format: { type: "string" }
1232
1607
  };
1233
- function redactCommand(args) {
1608
+ function manifestLine(entry) {
1609
+ return `cosyte: redact: ${entry.category} ${entry.transform} ${entry.locus} x${String(entry.count)} ${entry.disposition} ${entry.code}
1610
+ `;
1611
+ }
1612
+ function attribution(delegate) {
1613
+ return `cosyte: redact: delegated to ${DEID_PACKAGE} ${delegate.version}: ${delegate.label}
1614
+ `;
1615
+ }
1616
+ function manifestLines(outcome) {
1617
+ return outcome.manifest.map(manifestLine).join("");
1618
+ }
1619
+ function countOf(outcome, disposition) {
1620
+ return outcome.manifest.filter((e) => e.disposition === disposition).length;
1621
+ }
1622
+ function tally(format, outcome) {
1623
+ const transformed = countOf(outcome, "transformed");
1624
+ const removed = countOf(outcome, "removed");
1625
+ const blocked = countOf(outcome, DEID_BLOCKED);
1626
+ return `cosyte: redact: ${format}: ${String(outcome.manifest.length)} loci acted on (${String(transformed)} transformed, ${String(removed)} removed, ${String(blocked)} blocked)
1627
+ `;
1628
+ }
1629
+ var EPHEMERAL_KEY_NOTE = "cosyte: redact: identifier surrogates are keyed with a per-invocation ephemeral key: consistent within this output, not stable across runs\n";
1630
+ async function redactCommand(args, deps, loadDelegate = loadDeidDelegate) {
1631
+ let values;
1632
+ let positionals;
1234
1633
  try {
1235
- parseArgs({ args, options: REDACT_OPTIONS, allowPositionals: true });
1634
+ const parsed = parseArgs({ args, options: REDACT_OPTIONS, allowPositionals: true });
1635
+ values = parsed.values;
1636
+ positionals = parsed.positionals;
1236
1637
  } catch {
1638
+ return errorResult(
1639
+ new CliError(
1640
+ CLI_CODES.CLI_USAGE,
1641
+ EXIT.USAGE,
1642
+ "invalid arguments to `redact` (see `cosyte --help`)"
1643
+ )
1644
+ );
1645
+ }
1646
+ let delegate;
1647
+ try {
1648
+ delegate = await loadDelegate();
1649
+ } catch (e) {
1650
+ if (e instanceof CliError) return errorResult(e);
1651
+ throw e;
1652
+ }
1653
+ const resolved = await resolveInput(positionals[0], values.format, deps, null);
1654
+ if (!resolved.ok) return resolved.result;
1655
+ const { format, bytes } = resolved.input;
1656
+ if (!isDeidCovered(format)) return errorResult(uncovered(format));
1657
+ let outcome;
1658
+ try {
1659
+ outcome = await delegate.redact(format, bytes);
1660
+ } catch (e) {
1661
+ if (e instanceof CliError) return errorResult(e);
1662
+ throw e;
1663
+ }
1664
+ const blocked = outcome.manifest.filter((e) => e.disposition === DEID_BLOCKED);
1665
+ if (blocked.length > 0) {
1666
+ const incomplete = new CliError(
1667
+ CLI_CODES.CLI_DEID_INCOMPLETE,
1668
+ EXIT.INVALID,
1669
+ `${DEID_PACKAGE} blocked ${String(blocked.length)} of ${String(outcome.manifest.length)} loci in this ${format} input; no output was emitted`
1670
+ );
1237
1671
  return {
1238
1672
  stdout: "",
1239
- stderr: `cosyte: ${CLI_CODES.CLI_USAGE}: invalid arguments to \`redact\` (see \`cosyte --help\`)
1673
+ stderr: attribution(delegate) + manifestLines(outcome) + tally(format, outcome) + `cosyte: ${incomplete.code}: ${incomplete.message}
1240
1674
  `,
1241
- exit: EXIT.USAGE
1675
+ exit: incomplete.exit
1242
1676
  };
1243
1677
  }
1244
- const status = deidStatus();
1245
1678
  return {
1246
- stdout: "",
1247
- stderr: `cosyte: ${CLI_CODES.CLI_NOT_IMPLEMENTED}: ${status.reason}
1679
+ stdout: `${outcome.output}
1248
1680
  `,
1249
- exit: EXIT.UNAVAILABLE
1681
+ stderr: attribution(delegate) + manifestLines(outcome) + tally(format, outcome) + EPHEMERAL_KEY_NOTE,
1682
+ exit: EXIT.OK
1250
1683
  };
1251
1684
  }
1685
+ function uncovered(format) {
1686
+ if (deidCoverage(format) === "unserializable") {
1687
+ return new CliError(
1688
+ CLI_CODES.CLI_FORMAT_UNSUPPORTED,
1689
+ EXIT.DATAERR,
1690
+ `format '${format}' cannot be redacted onto this CLI's text output channel (its de-identified form is a binary stream, so \`parse\` and \`fmt\` are unwired too)`
1691
+ );
1692
+ }
1693
+ return new CliError(
1694
+ CLI_CODES.CLI_NOT_IMPLEMENTED,
1695
+ EXIT.UNAVAILABLE,
1696
+ `${DEID_PACKAGE} does not de-identify the ${format} standard this CLI resolves, and the CLI ships no redactor of its own, so nothing is emitted`
1697
+ );
1698
+ }
1252
1699
  var VALIDATE_OPTIONS = {
1253
1700
  format: { type: "string" },
1254
1701
  profile: { type: "string" },
@@ -1317,7 +1764,8 @@ function renderReport(format, verdict) {
1317
1764
  }
1318
1765
 
1319
1766
  // src/core/run.ts
1320
- var HELP = `cosyte: a PHI-safe developer CLI over the @cosyte/* healthcare parsers
1767
+ function helpText(limitBytes = MAX_INPUT_BYTES) {
1768
+ return `cosyte: a PHI-safe developer CLI over the @cosyte/* healthcare parsers
1321
1769
 
1322
1770
  Usage:
1323
1771
  cosyte <command> [options]
@@ -1329,7 +1777,7 @@ Commands:
1329
1777
  fmt <file|-> Canonically re-serialize via the parser's spec-clean serializer
1330
1778
  convert <file|-> Convert HL7 v2 \u2192 FHIR R4 via @cosyte/transform (use --to fhir)
1331
1779
  map-codes <cmap|-> Translate a code through a BYO FHIR ConceptMap via @cosyte/terminology
1332
- redact <file|-> De-identify a message (alias: deid): gated on @cosyte/deid, not yet wired
1780
+ redact <file|-> De-identify a message via @cosyte/deid (alias: deid): ${deidCoveredFormats().join(" ")}
1333
1781
  mcp Start the stdio MCP server (agent front door; also the cosyte-mcp bin)
1334
1782
  completion <shell> Print a shell completion script (bash | zsh | fish)
1335
1783
 
@@ -1350,22 +1798,44 @@ Common options (parse / validate / inspect / fmt):
1350
1798
  convert options:
1351
1799
  --to fhir The conversion target (required; only HL7 v2 \u2192 FHIR R4 today)
1352
1800
 
1801
+ redact / deid (de-identification is delegated to @cosyte/deid; the CLI adds no policy of its own):
1802
+ Covered formats: ${deidCoveredFormats().join(", ")}. Every other format is refused rather than
1803
+ approximated: astm, mllp and ncpdp have no adapter (69), and dicom is covered by the library but
1804
+ its de-identified form is a binary stream this text stdout cannot carry (65). If the library
1805
+ reports it could not handle an element, the run exits 1 and emits no output at all: a partial
1806
+ scrub presented as de-identified is the one outcome this command exists to prevent.
1807
+ stdout carries the de-identified document; stderr carries the library's own value-free manifest
1808
+ (category, transform, locus, count, disposition code) plus its published label and version.
1809
+ Identifier surrogates are keyed with a per-invocation ephemeral key: consistent within one
1810
+ output, not stable across runs.
1811
+
1353
1812
  map-codes options (the positional is a BYO FHIR ConceptMap; a code is not PHI):
1354
1813
  --code <code> The source code to translate (required)
1355
1814
  --system <uri> The source code system (optional; selects the ConceptMap group)
1356
1815
  --version <v> The source code system version (optional)
1357
1816
  --display <d> The source display (optional)
1358
1817
 
1818
+ Input size:
1819
+ parse reads at most ${describeByteLimit(limitBytes)} of input per invocation. A larger input is
1820
+ refused with CLI_INPUT_TOO_LARGE and the data-error exit code (65), never the internal-error
1821
+ code; split the input and re-run. Multi-record output (--ndjson, MLLP) is written record by
1822
+ record as it is parsed, so a refusal can arrive after some records have already been emitted:
1823
+ the exit code, not the output, is the signal that the run did not complete.
1824
+
1359
1825
  Exit codes:
1360
- 0 success / valid 65 data error (unparseable / undetected format)
1826
+ 0 success / valid 65 data error (unparseable / undetected format / input too large)
1361
1827
  1 invalid (validate) 66 no input (missing/unreadable file)
1362
- 2 usage error 69 unavailable (a capability is not yet built, e.g. redact)
1828
+ or de-identification incomplete (redact)
1829
+ 2 usage error 69 unavailable (a capability is not wired here, e.g. redact on a
1830
+ format @cosyte/deid has no adapter for)
1363
1831
  70 internal error
1364
1832
 
1365
1833
  PHI posture: the parsed model goes to stdout (the data channel you chose); every diagnostic on
1366
1834
  stderr is value-free (codes and positions only, never a field value) unless you pass the loud,
1367
1835
  opt-in --unsafe-show-values (which permits a bounded input excerpt in a failure diagnostic).
1368
1836
  `;
1837
+ }
1838
+ var HELP = helpText();
1369
1839
  function wantsHelp(argv) {
1370
1840
  return argv.includes("--help") || argv.includes("-h");
1371
1841
  }
@@ -1395,7 +1865,7 @@ async function run(argv, deps) {
1395
1865
  return await mapCodesCommand(rest, deps);
1396
1866
  case "redact":
1397
1867
  case "deid":
1398
- return redactCommand(rest);
1868
+ return await redactCommand(rest, deps);
1399
1869
  case "completion":
1400
1870
  return completionCommand(rest);
1401
1871
  default:
@@ -1416,6 +1886,6 @@ function resolve(e) {
1416
1886
  `, exit: e.exit };
1417
1887
  }
1418
1888
 
1419
- export { CLI_CODES, CliError, DEID_UNAVAILABLE_REASON, DETECTABLE_FORMATS, EXIT, KNOWN_FORMATS, OP_SUPPORT, SHOW_VALUES, UNSAFE_EXCERPT_MAX, UNSAFE_SHOW_VALUES_FLAG, VALUE_FREE, VERSION, asCosyteFormat, classifyCandidates, completionCommand, convertCommand, convertOutcome, deframeMllp, deidStatus, detectFormat, detectionError, errorResult, extractPhiPosture, extractStableCode, fmtCommand, fmtFormat, formatDiagnostic, formatHl7Position, formatsSupporting, inspectCommand, inspectFormat, loadOptional, loadOptionalPackage, mapCodesCommand, parseCommand, parseFailureResult, parseFormat, readFileBytes, readStreamBytes, redactCommand, resolveInput, run, supportsOp, toCliError, unsafeInputSuffix, validateCommand, validateFormat, valueFreeLocator };
1889
+ export { CLI_CODES, CliError, DEID_BLOCKED, DEID_COVERAGE, DEID_PACKAGE, DEID_UNAVAILABLE_REASON, DETECTABLE_FORMATS, DETECT_PREFIX_BYTES, EXIT, KNOWN_FORMATS, MAX_INPUT_BYTES, OP_SUPPORT, SHOW_VALUES, UNSAFE_EXCERPT_MAX, UNSAFE_SHOW_VALUES_FLAG, VALUE_FREE, VERSION, asCosyteFormat, classifyCandidates, collectChunks, completionCommand, convertCommand, convertOutcome, deframeMllp, deidCoverage, deidCoveredFormats, deidStatus, describeByteLimit, detectFormat, detectionError, errorResult, extractPhiPosture, extractStableCode, fileChunks, fmtCommand, fmtFormat, formatDiagnostic, formatHl7Position, formatsSupporting, helpText, inputTooLargeError, inspectCommand, inspectFormat, isDeidCovered, loadOptional, loadOptionalPackage, mapCodesCommand, mllpFrames, ndjsonRecords, oneChunk, parseCommand, parseFailureResult, parseFormat, readFileBytes, readStreamBytes, redactCommand, resolveInput, resolveInputStream, run, streamChunks, supportsOp, toCliError, truncatedMllpError, unsafeInputSuffix, validateCommand, validateFormat, valueFreeLocator, withinLimit };
1420
1890
  //# sourceMappingURL=index.mjs.map
1421
1891
  //# sourceMappingURL=index.mjs.map