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