@juspay/neurolink 9.94.2 → 9.94.3

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.
@@ -4,10 +4,31 @@
4
4
  * Uses streaming for memory efficiency with large files
5
5
  */
6
6
  import csvParser from "csv-parser";
7
- import { Readable } from "stream";
7
+ import iconv from "iconv-lite";
8
+ import { Readable, Transform } from "stream";
8
9
  import { extname } from "path";
9
10
  import { logger } from "./logger.js";
10
- import { countCSVColumns, splitCSVFields, detectDelimiter as detectDelimiterUtil, CANDIDATE_DELIMITERS, } from "./csvUtils.js";
11
+ import { splitCSVFields, detectDelimiter as detectDelimiterUtil, CANDIDATE_DELIMITERS, } from "./csvUtils.js";
12
+ import { isObject } from "./typeUtils.js";
13
+ import { decodeBuffer } from "./textEncoding.js";
14
+ import { ErrorFactory } from "./errorHandling.js";
15
+ import { withTimeout, TimeoutError } from "./async/withTimeout.js";
16
+ // ============================================================================
17
+ // Parse Safety Limits (#371 / #379)
18
+ // ============================================================================
19
+ /**
20
+ * Upper bound on a single CSV row's byte size (#371). csv-parser defaults to an
21
+ * effectively-unbounded `maxRowBytes`; a pathological unbroken row would grow
22
+ * memory without limit and never fire the parser's own error path. 10 MB/row is
23
+ * generous for legitimate data (e.g. a large JSON blob in one cell) while
24
+ * bounding a single allocation and making the parser-error cleanup path
25
+ * reachable/testable.
26
+ */
27
+ const CSV_MAX_ROW_BYTES = 10 * 1024 * 1024;
28
+ /** Default wall-clock cap for `parseCSVString` (#379). */
29
+ const DEFAULT_CSV_STRING_PARSE_TIMEOUT_MS = 30_000;
30
+ /** Default wall-clock cap for `parseCSVFile` (#379). */
31
+ const DEFAULT_CSV_FILE_PARSE_TIMEOUT_MS = 300_000;
11
32
  // ============================================================================
12
33
  // Data Type Detection Patterns
13
34
  // ============================================================================
@@ -68,6 +89,131 @@ function validateColumnName(name) {
68
89
  return issues;
69
90
  }
70
91
  // ============================================================================
92
+ // Column Name Sanitization (#378)
93
+ // ============================================================================
94
+ /**
95
+ * Rewrite a raw header into a valid identifier. Trims, replaces non-identifier
96
+ * runs with `_`, prefixes a `col_` when it starts with a digit or is empty,
97
+ * then applies the requested case style. Never returns an empty string.
98
+ */
99
+ export function sanitizeColumnName(name, style = "snake_case", index = 0) {
100
+ const trimmed = (name ?? "").trim();
101
+ // Split on runs of non-alphanumeric characters. Using split (linear) instead
102
+ // of a `^_+|_+$` trim regex avoids the polynomial-backtracking ReDoS CodeQL
103
+ // flags on long underscore/separator runs, and drops leading/trailing/empty
104
+ // segments naturally.
105
+ let parts = trimmed.split(/[^A-Za-z0-9]+/).filter((p) => p.length > 0);
106
+ let base = parts.join("_");
107
+ if (base === "") {
108
+ base = `col_${index + 1}`;
109
+ parts = [base];
110
+ }
111
+ else if (/^[0-9]/.test(base)) {
112
+ base = `col_${base}`;
113
+ parts = base.split("_").filter((p) => p.length > 0);
114
+ }
115
+ if (style === "camelCase") {
116
+ return parts
117
+ .map((p, i) => i === 0
118
+ ? p.charAt(0).toLowerCase() + p.slice(1)
119
+ : p.charAt(0).toUpperCase() + p.slice(1))
120
+ .join("");
121
+ }
122
+ return parts.map((p) => p.toLowerCase()).join("_");
123
+ }
124
+ /**
125
+ * Deduplicate a list of (already sanitized) names by suffixing `_2`, `_3`, … on
126
+ * collisions, preserving order. The suffixed candidate is bumped until it does
127
+ * not collide with ANY already-emitted name — so an existing `name_2` can't be
128
+ * silently overwritten by a generated `name_2` (which the naive counter did for
129
+ * `["name","name","name_2"]`).
130
+ */
131
+ export function dedupeColumnNames(names) {
132
+ const used = new Set();
133
+ return names.map((name) => {
134
+ if (!used.has(name)) {
135
+ used.add(name);
136
+ return name;
137
+ }
138
+ let n = 2;
139
+ let candidate = `${name}_${n}`;
140
+ while (used.has(candidate)) {
141
+ n++;
142
+ candidate = `${name}_${n}`;
143
+ }
144
+ used.add(candidate);
145
+ return candidate;
146
+ });
147
+ }
148
+ /**
149
+ * Compute sanitized (and deduped) column names plus the original→sanitized
150
+ * mapping for the names that actually changed.
151
+ */
152
+ function buildColumnNameMapping(headers, style) {
153
+ const sanitized = dedupeColumnNames(headers.map((h, i) => sanitizeColumnName(h, style, i)));
154
+ const mapping = headers
155
+ .map((original, i) => ({ original, sanitized: sanitized[i] }))
156
+ .filter((m) => m.original !== m.sanitized);
157
+ return { sanitized, mapping };
158
+ }
159
+ // ============================================================================
160
+ // Row Validation (#384)
161
+ // ============================================================================
162
+ /**
163
+ * Predicate form of the row-shape invariant: a non-null, non-array object whose
164
+ * every value is a string or undefined. csv-parser's default output always
165
+ * satisfies this; the guard is defense-in-depth at the parse boundary.
166
+ */
167
+ export function isValidCsvRow(row) {
168
+ if (!isObject(row) || Array.isArray(row)) {
169
+ return false;
170
+ }
171
+ return Object.values(row).every((v) => typeof v === "string" || v === undefined);
172
+ }
173
+ /**
174
+ * Assertion form used inside the streaming `data` handlers. Throws a
175
+ * `[CSVProcessor]`-prefixed error (so existing `includes("CSV")` catches still
176
+ * match) when a row violates the invariant.
177
+ */
178
+ export function assertValidCsvRow(row, rowNumber) {
179
+ if (!isValidCsvRow(row)) {
180
+ throw ErrorFactory.csvRowInvalid(`[CSVProcessor] Invalid CSV row ${rowNumber}: expected a string-keyed object with string values, got ${Array.isArray(row) ? "array" : typeof row}`, rowNumber);
181
+ }
182
+ }
183
+ // ============================================================================
184
+ // Parse Error Context (#375)
185
+ // ============================================================================
186
+ /**
187
+ * Build an enriched, consistent parse-failure message shared by all four
188
+ * source/parser reject sites so they can't re-diverge.
189
+ *
190
+ * Deliberately structural-only: `headerCount` and `lastRowColumnCount` report
191
+ * how many columns the header/last row had — counts, never the actual header
192
+ * or cell string values. Parsed header text is user-controlled CSV data (and,
193
+ * for headerless input, may literally be first-row cell values), so it must
194
+ * never be embedded in a thrown/logged message (#1199).
195
+ */
196
+ function buildCsvParseErrorMessage(info) {
197
+ const parts = [
198
+ `[CSVProcessor] CSV ${info.stage === "read" ? "file read" : "parsing"} failed after ${info.count} row(s)`,
199
+ ];
200
+ if (info.location) {
201
+ parts.push(`(${info.location})`);
202
+ }
203
+ parts.push(`: ${info.cause}`);
204
+ parts.push(typeof info.headerCount === "number"
205
+ ? `| headerCount: ${info.headerCount}`
206
+ : "| headerCount: unknown (failed before headers were parsed)");
207
+ if (typeof info.bytesRead === "number") {
208
+ parts.push(`| bytesRead: ${info.bytesRead}`);
209
+ }
210
+ if (typeof info.lastRowColumnCount === "number") {
211
+ parts.push(`| lastRow columns: ${info.lastRowColumnCount}`);
212
+ }
213
+ parts.push("| Expected RFC 4180 CSV (comma-separated, double-quote escaped)");
214
+ return parts.join(" ");
215
+ }
216
+ // ============================================================================
71
217
  // Data Type Detection
72
218
  // ============================================================================
73
219
  /**
@@ -591,7 +737,7 @@ export class CSVProcessor {
591
737
  * @returns Formatted CSV data ready for LLM (JSON or Markdown)
592
738
  */
593
739
  static async process(content, options) {
594
- const { maxRows: rawMaxRows = 1000, formatStyle = "raw", includeHeaders = true, sampleDataFormat = "json", extension = null, } = options || {};
740
+ const { maxRows: rawMaxRows = 1000, formatStyle = "raw", includeHeaders = true, sampleDataFormat = "json", extension = null, encoding, sanitizeColumnNames = false, columnNameCase = "snake_case", parseTimeoutMs = DEFAULT_CSV_STRING_PARSE_TIMEOUT_MS, } = options || {};
595
741
  const maxRows = Math.max(1, Math.min(10000, rawMaxRows));
596
742
  logger.debug("[CSVProcessor] Starting CSV processing", {
597
743
  contentSize: content.length,
@@ -599,7 +745,9 @@ export class CSVProcessor {
599
745
  maxRows,
600
746
  includeHeaders,
601
747
  });
602
- const csvString = content.toString("utf-8");
748
+ // #362: detect the encoding (BOM → chardet → UTF-8 fallback) or honor the
749
+ // caller's override, instead of the previous hard-coded UTF-8 decode.
750
+ const { text: csvString, encoding: detectedEncoding, confidence: encodingConfidence, } = decodeBuffer(content, encoding);
603
751
  // #361: split once and strip a leading Excel `sep=` metadata line before
604
752
  // detecting the delimiter (comma/tab/semicolon/pipe), so the preamble
605
753
  // can't skew detection — then reuse dataLines below instead of
@@ -617,7 +765,21 @@ export class CSVProcessor {
617
765
  // dataLines already has the metadata line (if any) stripped.
618
766
  const csvLines = dataLines;
619
767
  const limitedLines = csvLines.slice(0, 1 + maxRows); // header + data rows
768
+ // #378: opt-in — rewrite the literal header line with sanitized,
769
+ // deduped identifiers so the raw CSV text shown to the LLM has clean
770
+ // column names too. (Splits on commas to match the existing raw-branch
771
+ // columnCount logic.)
772
+ let rawColumnNameMapping;
773
+ if (sanitizeColumnNames && limitedLines.length > 0) {
774
+ const headerCols = splitCSVFields(limitedLines[0] || "", delimiter);
775
+ const { sanitized, mapping } = buildColumnNameMapping(headerCols, columnNameCase);
776
+ limitedLines[0] = sanitized.join(delimiter);
777
+ rawColumnNameMapping = mapping.length > 0 ? mapping : undefined;
778
+ }
620
779
  const limitedCSV = limitedLines.join("\n");
780
+ // #1199: quote-aware, delimiter-aware split (matches the
781
+ // sanitizeColumnNames branch above and #1192's detected delimiter).
782
+ const headerFields = splitCSVFields(limitedLines[0] || "", delimiter);
621
783
  const rowCount = limitedLines
622
784
  .slice(1)
623
785
  .filter((line) => line.trim() !== "").length;
@@ -636,16 +798,22 @@ export class CSVProcessor {
636
798
  logger.info("[CSVProcessor] ✅ Processed CSV file", {
637
799
  formatStyle: "raw",
638
800
  rowCount,
639
- columnCount: countCSVColumns(limitedLines[0] || "", delimiter),
801
+ columnCount: headerFields.length,
640
802
  truncated: wasTruncated,
641
803
  });
642
804
  // Parse a sample for enhanced metadata analysis (raw format still
643
805
  // benefits from column analysis). Reuse the already-split, already
644
- // metadata-stripped `dataLines` via the shared parseCSVLines() core
645
- // instead of routing `limitedCSV` back through parseCSVString(), which
646
- // would re-split/re-join it and re-run a redundant BOM/metadata pass.
806
+ // metadata-stripped `dataLines` and the already-detected `delimiter`
807
+ // via the shared streamParse() core instead of routing `limitedCSV`
808
+ // back through parseCSVStringWithMeta(), which would re-split/re-join
809
+ // it and re-run a redundant BOM/metadata/delimiter-detection pass.
647
810
  const sampleRows = Math.min(rowCount, 500);
648
- const sampleForAnalysis = await this.parseCSVLines(dataLines.slice(0, 1 + sampleRows), sampleRows, delimiter);
811
+ const { rows: sampleForAnalysis, timedOut: rawTimedOut } = await this.streamParse(Readable.from([dataLines.slice(0, 1 + sampleRows).join("\n")]), undefined, {
812
+ maxRows: sampleRows,
813
+ skipLines: 0,
814
+ timeoutMs: parseTimeoutMs,
815
+ delimiter,
816
+ });
649
817
  const { columnMetadata, dataQualityWarnings, dataQualityScore } = analyzeColumns(sampleForAnalysis);
650
818
  // Log data quality summary
651
819
  if (dataQualityWarnings.length > 0) {
@@ -663,13 +831,19 @@ export class CSVProcessor {
663
831
  size: content.length,
664
832
  rowCount,
665
833
  totalLines: limitedLines.length,
666
- columnCount: countCSVColumns(limitedLines[0] || "", delimiter),
834
+ columnCount: headerFields.length,
667
835
  extension,
668
836
  columnMetadata,
669
837
  dataQualityWarnings,
670
838
  dataQualityScore,
671
- hasHeaders: detectHasHeaders(splitCSVFields(limitedLines[0] || "", delimiter), undefined),
839
+ hasHeaders: detectHasHeaders(headerFields, undefined),
672
840
  detectedDelimiter: delimiter,
841
+ detectedEncoding,
842
+ encodingConfidence,
843
+ ...(rawColumnNameMapping
844
+ ? { columnNameMapping: rawColumnNameMapping }
845
+ : {}),
846
+ ...(rawTimedOut ? { parseTimedOut: true } : {}),
673
847
  },
674
848
  };
675
849
  }
@@ -679,11 +853,12 @@ export class CSVProcessor {
679
853
  maxRows,
680
854
  });
681
855
  // dataLines was already split and metadata-stripped above (alongside
682
- // `delimiter`) — reuse it instead of routing through parseCSVString(),
683
- // which would re-split csvString and re-run metadata detection.
684
- const rows = await this.parseCSVLines(dataLines, maxRows, delimiter);
856
+ // `delimiter`) — reuse both via the shared streamParse() core instead of
857
+ // routing csvString back through parseCSVStringWithMeta(), which would
858
+ // re-split it and re-run metadata/delimiter detection a second time.
859
+ const { rows, timedOut: structuredTimedOut } = await this.streamParse(Readable.from([dataLines.join("\n")]), undefined, { maxRows, skipLines: 0, timeoutMs: parseTimeoutMs, delimiter });
685
860
  // Filter out empty rows (empty objects or rows with only whitespace values from blank lines)
686
- const nonEmptyRows = rows.filter((row) => {
861
+ const filteredRows = rows.filter((row) => {
687
862
  if (!row || typeof row !== "object") {
688
863
  return false;
689
864
  }
@@ -694,11 +869,34 @@ export class CSVProcessor {
694
869
  // Check if all values are empty or whitespace-only
695
870
  return !Object.values(row).every((val) => val === "" || (typeof val === "string" && val.trim() === ""));
696
871
  });
872
+ // #378: opt-in — remap each row's keys from original → sanitized identifiers.
873
+ let structuredColumnNameMapping;
874
+ const sanitizedToOriginal = new Map();
875
+ let nonEmptyRows = filteredRows;
876
+ if (sanitizeColumnNames && filteredRows.length > 0) {
877
+ const origHeaders = Object.keys(filteredRows[0]);
878
+ const { sanitized, mapping } = buildColumnNameMapping(origHeaders, columnNameCase);
879
+ origHeaders.forEach((original, i) => {
880
+ if (original !== sanitized[i]) {
881
+ sanitizedToOriginal.set(sanitized[i], original);
882
+ }
883
+ });
884
+ nonEmptyRows = filteredRows.map((row) => {
885
+ // Defense-in-depth: a null-prototype target means an attacker-chosen
886
+ // header literally named "__proto__"/"constructor"/"prototype" can
887
+ // only ever create a harmless own property, never touch
888
+ // Object.prototype (#1199).
889
+ const out = Object.create(null);
890
+ origHeaders.forEach((h, i) => {
891
+ out[sanitized[i]] = row[h];
892
+ });
893
+ return out;
894
+ });
895
+ structuredColumnNameMapping = mapping.length > 0 ? mapping : undefined;
896
+ }
697
897
  // Extract metadata from parsed results
698
898
  const rowCount = nonEmptyRows.length;
699
- const columnNames = nonEmptyRows.length > 0
700
- ? Object.keys(nonEmptyRows[0])
701
- : [];
899
+ const columnNames = nonEmptyRows.length > 0 ? Object.keys(nonEmptyRows[0]) : [];
702
900
  const columnCount = columnNames.length;
703
901
  const hasEmptyColumns = columnNames.some((col) => !col || col.trim() === "");
704
902
  const sampleRows = nonEmptyRows.slice(0, 3);
@@ -713,6 +911,15 @@ export class CSVProcessor {
713
911
  }
714
912
  // Perform enhanced column analysis
715
913
  const { columnMetadata, dataQualityWarnings, dataQualityScore } = analyzeColumns(nonEmptyRows);
914
+ // #378: carry the pre-sanitization header on each renamed column.
915
+ if (sanitizedToOriginal.size > 0) {
916
+ for (const col of columnMetadata) {
917
+ const original = sanitizedToOriginal.get(col.name);
918
+ if (original) {
919
+ col.originalName = original;
920
+ }
921
+ }
922
+ }
716
923
  // Log data quality summary
717
924
  if (dataQualityWarnings.length > 0) {
718
925
  logger.debug("[CSVProcessor] Data quality warnings detected", {
@@ -749,6 +956,12 @@ export class CSVProcessor {
749
956
  dataQualityScore,
750
957
  hasHeaders: detectHasHeaders(columnNames, nonEmptyRows),
751
958
  detectedDelimiter: delimiter,
959
+ detectedEncoding,
960
+ encodingConfidence,
961
+ ...(structuredColumnNameMapping
962
+ ? { columnNameMapping: structuredColumnNameMapping }
963
+ : {}),
964
+ ...(structuredTimedOut ? { parseTimedOut: true } : {}),
752
965
  },
753
966
  };
754
967
  }
@@ -771,94 +984,158 @@ export class CSVProcessor {
771
984
  * @param maxRows - Maximum rows to parse (default: 1000)
772
985
  * @returns Array of row objects
773
986
  */
774
- static async parseCSVFile(filePath, maxRows = 1000) {
987
+ static async parseCSVFile(filePath, maxRows = 1000, timeoutMs = DEFAULT_CSV_FILE_PARSE_TIMEOUT_MS) {
988
+ const { rows } = await this.parseCSVFileWithMeta(filePath, maxRows, timeoutMs);
989
+ return rows;
990
+ }
991
+ /**
992
+ * File-parse variant that also reports whether the parse timed out, used by
993
+ * `process()` to surface `metadata.parseTimedOut`.
994
+ */
995
+ static async parseCSVFileWithMeta(filePath, maxRows = 1000, timeoutMs = DEFAULT_CSV_FILE_PARSE_TIMEOUT_MS, encoding) {
775
996
  if (typeof filePath !== "string" || filePath.trim().length === 0) {
776
- throw new Error("CSVProcessor.parseCSVFile: filePath must be a non-empty string");
997
+ throw ErrorFactory.csvInvalidInput("CSVProcessor.parseCSVFile: filePath must be a non-empty string");
777
998
  }
778
999
  const clampedMaxRows = Math.max(1, Math.min(10000, maxRows));
779
1000
  const fs = await import("fs");
1001
+ // #1199: a single wall-clock deadline covers access() + prepareFileSource()
1002
+ // + streamParse() together, not just the streamParse phase — otherwise a
1003
+ // stalled disk/network filesystem during the open/peek steps defeats the
1004
+ // #379 timeout guard entirely.
1005
+ const startTime = Date.now();
1006
+ const remaining = () => Math.max(0, timeoutMs - (Date.now() - startTime));
780
1007
  logger.debug("[CSVProcessor] Starting file parsing", {
781
1008
  filePath,
782
1009
  maxRows: clampedMaxRows,
783
1010
  });
784
- // Read first 2 lines to detect metadata
785
- const fileHandle = await fs.promises.open(filePath, "r");
786
- const firstLines = [];
787
- const lineReader = fileHandle.createReadStream({ encoding: "utf-8" });
788
- await new Promise((resolve) => {
789
- let buffer = "";
790
- lineReader.on("data", (chunk) => {
791
- buffer += chunk.toString();
792
- // Strip a leading UTF-8 BOM before the `sep=` check below, same as
793
- // process(), parseCSVString(), and detectDelimiter() — otherwise an
794
- // Excel export starting with a BOM + "sep=;" fails the /^sep=/i match.
795
- const normalizedBuffer = stripBom(buffer);
796
- const splitBuffer = normalizedBuffer.endsWith("\r")
797
- ? normalizedBuffer.slice(0, -1)
798
- : normalizedBuffer;
799
- const lines = splitCsvLines(splitBuffer);
800
- if (lines.length >= 2) {
801
- firstLines.push(lines[0], lines[1]);
802
- lineReader.destroy();
803
- resolve();
804
- }
805
- });
806
- lineReader.on("end", () => resolve());
807
- // Metadata sniffing is best-effort — a read error here must not crash
808
- // (the main read stream below will surface a real failure properly),
809
- // but silently dropping it loses the diagnostic entirely, so log it.
810
- lineReader.on("error", (error) => {
811
- logger.warn(`[CSVProcessor] Metadata sniffing read failed for ${filePath}, proceeding without it: ${error.message}`, { cause: error });
812
- resolve();
813
- });
814
- });
815
- await fileHandle.close();
816
- const { dataLines, hasMetadataLine, explicitDelimiter } = stripMetadataLine(firstLines);
817
- const skipLines = hasMetadataLine ? 1 : 0;
818
- // #361: pick the delimiter from the sniffed lines (metadata line
819
- // stripped) + file extension so .tsv / semicolon / pipe files aren't
820
- // collapsed into one column, and a `sep=` preamble can't skew detection.
821
- const fileDelimiter = explicitDelimiter ?? detectDelimiterUtil(dataLines, extname(filePath));
822
- if (hasMetadataLine) {
823
- logger.debug("[CSVProcessor] Detected metadata line in file, will skip first line");
1011
+ // #375: a bad path (ENOENT/EACCES) should fail fast with clear context
1012
+ // instead of a raw Node error surfacing from the stream. access() is a
1013
+ // permission check, not a content read, so #368's single-read contract
1014
+ // (one createReadStream, no second full pass) is preserved. Metadata-line
1015
+ // and delimiter (#361) detection happen inside prepareFileSource() below,
1016
+ // from the same peeked bytes used for encoding detection — not a second
1017
+ // read of the file.
1018
+ try {
1019
+ await fs.promises.access(filePath, fs.constants.R_OK);
1020
+ }
1021
+ catch (error) {
1022
+ throw ErrorFactory.csvFileAccessFailed(`[CSVProcessor] Failed to open CSV file (${filePath}): ${error instanceof Error ? error.message : String(error)}`, filePath, error instanceof Error ? error : undefined);
1023
+ }
1024
+ // #368: a SINGLE createReadStream. We peek its leading bytes to sniff the
1025
+ // encoding (#362) and detect a metadata line + delimiter (#361), then
1026
+ // replay those bytes back onto the same stream — no second disk read.
1027
+ const rawSource = fs.createReadStream(filePath);
1028
+ let prepared;
1029
+ try {
1030
+ prepared = await withTimeout(this.prepareFileSource(rawSource, encoding, extname(filePath)), remaining(), "[CSVProcessor] Timed out preparing CSV file source");
1031
+ }
1032
+ catch (error) {
1033
+ rawSource.destroy();
1034
+ if (error instanceof TimeoutError) {
1035
+ // Same contract as streamParse's own timeout path (#379): report a
1036
+ // clean partial result instead of throwing, so the whole call never
1037
+ // hangs OR surfaces a timeout as an exception.
1038
+ logger.warn(`[CSVProcessor] Prepare phase exceeded the ${timeoutMs}ms parse budget for ${filePath}; returning an empty partial result`);
1039
+ return { rows: [], timedOut: true };
1040
+ }
1041
+ throw ErrorFactory.csvParseFailed(buildCsvParseErrorMessage({
1042
+ stage: "read",
1043
+ count: 0,
1044
+ location: filePath,
1045
+ cause: error instanceof Error ? error.message : String(error),
1046
+ }), { filePath }, error instanceof Error ? error : undefined);
824
1047
  }
1048
+ return this.streamParse(prepared.source, rawSource, {
1049
+ maxRows: clampedMaxRows,
1050
+ skipLines: prepared.skipLines,
1051
+ timeoutMs: remaining(),
1052
+ location: filePath,
1053
+ delimiter: prepared.delimiter,
1054
+ });
1055
+ }
1056
+ /**
1057
+ * Peek the head of a raw file stream to resolve encoding, metadata-line
1058
+ * skipping, and the delimiter (#361), then return a decoded text stream
1059
+ * that still delivers the whole file from byte 0 (via `unshift`) — a
1060
+ * single disk read (#368/#362).
1061
+ */
1062
+ static prepareFileSource(rawSource, encodingOverride, extensionHint) {
825
1063
  return new Promise((resolve, reject) => {
826
- const rows = [];
827
- let count = 0;
828
- const source = fs.createReadStream(filePath, { encoding: "utf-8" });
829
- // Pass skipLines through to csv-parser itself so it skips the `sep=`
830
- // metadata line BEFORE picking headers — otherwise csv-parser treats
831
- // that line as the header row and the real header becomes a data row.
832
- const parser = csvParser({ separator: fileDelimiter, skipLines });
833
- const abort = () => {
834
- source.destroy();
835
- parser.destroy();
1064
+ const PEEK_LIMIT = 64 * 1024;
1065
+ const chunks = [];
1066
+ let total = 0;
1067
+ let settled = false;
1068
+ const cleanup = () => {
1069
+ rawSource.removeListener("data", onData);
1070
+ rawSource.removeListener("end", onEnd);
1071
+ rawSource.removeListener("error", onError);
836
1072
  };
837
- // .pipe() does not forward source errors (disk read/permission failures)
838
- // to the parser, so listen on the source directly or it throws unhandled.
839
- source.on("error", (error) => {
840
- abort();
841
- reject(new Error(`[CSVProcessor] CSV file read failed after ${count} row(s) (${filePath}): ${error.message}`));
842
- });
843
- source
844
- .pipe(parser)
845
- .on("data", (row) => {
846
- rows.push(row);
847
- count++;
848
- if (count >= clampedMaxRows) {
849
- logger.debug(`[CSVProcessor] Reached row limit ${clampedMaxRows}, stopping parse`);
850
- abort();
851
- resolve(rows);
1073
+ // #361: resolve the metadata-line skip count and the delimiter
1074
+ // together from the same peeked/decoded text an explicit `sep=`
1075
+ // line wins outright, otherwise fall back to frequency-based
1076
+ // detection (+ the file extension as a tie-breaker) over the
1077
+ // post-metadata lines.
1078
+ const resolveMeta = (text) => {
1079
+ const { dataLines, hasMetadataLine, explicitDelimiter } = stripMetadataLine(splitCsvLines(text));
1080
+ const delimiter = explicitDelimiter ?? detectDelimiterUtil(dataLines, extensionHint);
1081
+ return { hasMetadataLine, dataLines, delimiter };
1082
+ };
1083
+ const onData = (chunk) => {
1084
+ chunks.push(chunk);
1085
+ total += chunk.length;
1086
+ const head = Buffer.concat(chunks);
1087
+ // isCompleteBuffer=false: `head` is only a peek, not necessarily the
1088
+ // whole file (#1199) — see the confidence note on decodeBuffer.
1089
+ const { text, encoding } = decodeBuffer(head, encodingOverride, false);
1090
+ if (splitCsvLines(text).length >= 2 || total >= PEEK_LIMIT) {
1091
+ if (settled) {
1092
+ return;
1093
+ }
1094
+ settled = true;
1095
+ cleanup();
1096
+ const { hasMetadataLine, delimiter } = resolveMeta(text);
1097
+ // Replay the consumed head bytes, then decode the full raw stream.
1098
+ // NOTE (#1282): the encoding is committed from the peeked head to keep
1099
+ // the parse streaming (which the parse-timeout guard #379 relies on).
1100
+ // A file whose head is pure ASCII but which switches to a legacy
1101
+ // encoding only later would be read as UTF-8; pass `encoding`
1102
+ // explicitly for such files.
1103
+ rawSource.pause();
1104
+ rawSource.unshift(head);
1105
+ const decodeStream = iconv.decodeStream(encoding);
1106
+ rawSource.on("error", (e) => decodeStream.destroy(e));
1107
+ resolve({
1108
+ source: rawSource.pipe(decodeStream),
1109
+ skipLines: hasMetadataLine ? 1 : 0,
1110
+ delimiter,
1111
+ });
852
1112
  }
853
- })
854
- .on("end", () => {
855
- logger.debug(`[CSVProcessor] File parsing complete: ${rows.length} rows parsed`);
856
- resolve(rows);
857
- })
858
- .on("error", (error) => {
859
- logger.error("[CSVProcessor] File parsing failed:", error);
860
- reject(new Error(`[CSVProcessor] CSV parsing failed after ${count} row(s): ${error.message}`));
861
- });
1113
+ };
1114
+ const onEnd = () => {
1115
+ if (settled) {
1116
+ return;
1117
+ }
1118
+ settled = true;
1119
+ cleanup();
1120
+ // Whole (small) file already buffered decode it directly. unshift on
1121
+ // an ended stream throws, so replay via an in-memory Readable instead.
1122
+ const head = Buffer.concat(chunks);
1123
+ const { text } = decodeBuffer(head, encodingOverride);
1124
+ const { hasMetadataLine, dataLines, delimiter } = resolveMeta(text);
1125
+ const data = hasMetadataLine ? dataLines.join("\n") : text;
1126
+ resolve({ source: Readable.from([data]), skipLines: 0, delimiter });
1127
+ };
1128
+ const onError = (error) => {
1129
+ if (settled) {
1130
+ return;
1131
+ }
1132
+ settled = true;
1133
+ cleanup();
1134
+ reject(error);
1135
+ };
1136
+ rawSource.on("data", onData);
1137
+ rawSource.on("end", onEnd);
1138
+ rawSource.on("error", onError);
862
1139
  });
863
1140
  }
864
1141
  /**
@@ -867,82 +1144,171 @@ export class CSVProcessor {
867
1144
  *
868
1145
  * @param csvString - CSV data as string
869
1146
  * @param maxRows - Maximum rows to parse (default: 1000)
1147
+ * @param timeoutMs - Wall-clock parse cap; returns partial rows on timeout
870
1148
  * @returns Array of row objects
871
1149
  */
872
- static async parseCSVString(csvString, maxRows = 1000, delimiter) {
1150
+ static async parseCSVString(csvString, maxRows = 1000, timeoutMs = DEFAULT_CSV_STRING_PARSE_TIMEOUT_MS) {
1151
+ const { rows } = await this.parseCSVStringWithMeta(csvString, maxRows, timeoutMs);
1152
+ return rows;
1153
+ }
1154
+ /**
1155
+ * String-parse variant that also reports whether the parse timed out, used by
1156
+ * `process()` to surface `metadata.parseTimedOut`.
1157
+ */
1158
+ static async parseCSVStringWithMeta(csvString, maxRows = 1000, timeoutMs = DEFAULT_CSV_STRING_PARSE_TIMEOUT_MS) {
873
1159
  if (typeof csvString !== "string" || csvString.trim().length === 0) {
874
- throw new Error("CSVProcessor.parseCSVString: csvString must be a non-empty string");
1160
+ throw ErrorFactory.csvInvalidInput("CSVProcessor.parseCSVString: csvString must be a non-empty string");
875
1161
  }
876
1162
  // Strip a leading UTF-8 BOM (common in Excel exports) so it does not glue
877
1163
  // onto the first column name and break downstream key lookups.
878
1164
  const normalized = stripBom(csvString);
1165
+ const clampedMaxRows = Math.max(1, Math.min(10000, maxRows));
879
1166
  logger.debug("[CSVProcessor] Starting string parsing", {
880
1167
  inputLength: normalized.length,
881
- maxRows,
1168
+ maxRows: clampedMaxRows,
882
1169
  });
883
1170
  // Detect and skip a leading metadata line before detecting the
884
- // delimiter, so a `sep=` preamble can't skew detection (#361).
1171
+ // delimiter, so a `sep=` preamble can't skew detection (#361). No
1172
+ // caller currently needs to override the delimiter here (#1192's own
1173
+ // tests rely on self-detection), so it's always auto-detected.
885
1174
  const lines = splitCsvLines(normalized);
886
1175
  const { dataLines, hasMetadataLine, explicitDelimiter } = stripMetadataLine(lines);
887
- // Caller-provided delimiter wins, then an explicit `sep=` declaration,
888
- // then frequency-based detection over the post-metadata lines.
889
- const effectiveDelimiter = delimiter ?? explicitDelimiter ?? detectDelimiterUtil(dataLines);
1176
+ const effectiveDelimiter = explicitDelimiter ?? detectDelimiterUtil(dataLines);
890
1177
  if (hasMetadataLine) {
891
1178
  logger.debug("[CSVProcessor] Detected metadata line in string, skipping");
892
1179
  }
893
- return this.parseCSVLines(dataLines, maxRows, effectiveDelimiter);
894
- }
895
- /**
896
- * Stream-parse already-split, metadata-stripped data lines into row
897
- * objects with a known delimiter. Shared core for parseCSVString() and
898
- * process(), both of which need this after already splitting the content
899
- * and resolving the delimiter once — calling parseCSVString() directly
900
- * from process() would re-split and re-run metadata/delimiter detection
901
- * over the same content a second time.
902
- */
903
- static parseCSVLines(dataLines, maxRows, delimiter) {
904
- const clampedMaxRows = Math.max(1, Math.min(10000, maxRows));
905
1180
  const csvData = dataLines.join("\n");
906
1181
  if (csvData.trim().length === 0) {
907
- // Shared core for both process() and parseCSVString() — use a generic
908
- // message rather than naming one specific entry point, since either
909
- // caller can land here with empty post-metadata data lines.
910
- return Promise.reject(new Error("CSVProcessor: CSV data must be a non-empty string"));
1182
+ throw ErrorFactory.csvInvalidInput("CSVProcessor.parseCSVString: csvString must be a non-empty string");
911
1183
  }
1184
+ return this.streamParse(Readable.from([csvData]), undefined, {
1185
+ maxRows: clampedMaxRows,
1186
+ skipLines: 0,
1187
+ timeoutMs,
1188
+ delimiter: effectiveDelimiter,
1189
+ });
1190
+ }
1191
+ /**
1192
+ * Shared streaming consumer for both parse methods. Guarantees:
1193
+ * - source, rawSource, and parser are all destroyed on ANY settle path
1194
+ * (#371) — `rawSource` matters for the file-parse path, where `source`
1195
+ * is a decode Transform piped FROM `rawSource`; `.pipe()` does not
1196
+ * propagate `.destroy()` upstream, so without this the underlying
1197
+ * `fs.createReadStream` fd would leak on abort/timeout/error,
1198
+ * - a wall-clock timeout returns partial rows instead of hanging (#379),
1199
+ * - every row is validated at the boundary (#384),
1200
+ * - reject messages carry columns/last-row-shape/format context (#375),
1201
+ * - the delimiter (#361, comma/tab/semicolon/pipe) detected by the caller
1202
+ * is honored, instead of csv-parser's hard-coded comma default.
1203
+ *
1204
+ * @param rawSource - The original file stream `source` was piped from, when
1205
+ * applicable (file-parse path only; `undefined` for string parsing).
1206
+ */
1207
+ static streamParse(source, rawSource, opts) {
912
1208
  return new Promise((resolve, reject) => {
913
1209
  const rows = [];
914
1210
  let count = 0;
915
- const source = Readable.from([csvData]);
916
- const parser = csvParser({ separator: delimiter });
1211
+ let settled = false;
1212
+ let capturedHeaders;
1213
+ let lastRowColumnCount;
1214
+ const startTime = Date.now();
1215
+ // maxRowBytes bounds a single-row allocation (#371) — without it a
1216
+ // pathological unbroken row grows memory unbounded and never errors.
1217
+ // skipLines drops any pre-header metadata line (e.g. Excel's `sep=,`)
1218
+ // BEFORE csv-parser reads the header, so the real header is used.
1219
+ // separator (#361) defaults to comma when the caller has no better
1220
+ // signal, matching csv-parser's own default.
1221
+ const parser = csvParser({
1222
+ separator: opts.delimiter ?? ",",
1223
+ maxRowBytes: CSV_MAX_ROW_BYTES,
1224
+ ...(opts.skipLines > 0 ? { skipLines: opts.skipLines } : {}),
1225
+ });
917
1226
  const abort = () => {
918
1227
  source.destroy();
1228
+ rawSource?.destroy();
919
1229
  parser.destroy();
920
1230
  };
921
- // .pipe() does not forward source-stream errors to the destination, so a
922
- // source failure must be listened for directly or it throws as an
923
- // unhandled EventEmitter error and can crash the process.
1231
+ const finish = (fn) => {
1232
+ if (settled) {
1233
+ return;
1234
+ }
1235
+ settled = true;
1236
+ clearTimeout(timer);
1237
+ fn();
1238
+ };
1239
+ const timer = setTimeout(() => {
1240
+ finish(() => {
1241
+ logger.warn(`[CSVProcessor] Parse timed out after ${Date.now() - startTime}ms with ${rows.length} partial row(s)`);
1242
+ abort();
1243
+ resolve({ rows, timedOut: true });
1244
+ });
1245
+ }, opts.timeoutMs);
1246
+ if (typeof timer.unref === "function") {
1247
+ timer.unref();
1248
+ }
1249
+ parser.on("headers", (h) => {
1250
+ capturedHeaders = h;
1251
+ });
1252
+ // .pipe() does not forward source errors (disk/permission failures) to
1253
+ // the destination, so listen on the source directly or it throws.
924
1254
  source.on("error", (error) => {
925
- abort();
926
- reject(new Error(`[CSVProcessor] CSV source stream failed after ${count} row(s): ${error.message}`));
1255
+ finish(() => {
1256
+ abort();
1257
+ reject(ErrorFactory.csvParseFailed(buildCsvParseErrorMessage({
1258
+ stage: "read",
1259
+ count,
1260
+ headerCount: capturedHeaders?.length,
1261
+ lastRowColumnCount,
1262
+ location: opts.location,
1263
+ cause: error.message,
1264
+ }), { location: opts.location, rowCount: count }, error));
1265
+ });
927
1266
  });
928
1267
  source
929
1268
  .pipe(parser)
930
1269
  .on("data", (row) => {
1270
+ try {
1271
+ assertValidCsvRow(row, count + 1);
1272
+ }
1273
+ catch (err) {
1274
+ finish(() => {
1275
+ abort();
1276
+ reject(err);
1277
+ });
1278
+ return;
1279
+ }
1280
+ lastRowColumnCount = Object.keys(row).length;
931
1281
  rows.push(row);
932
1282
  count++;
933
- if (count >= clampedMaxRows) {
934
- logger.debug(`[CSVProcessor] Reached row limit ${clampedMaxRows}, stopping parse`);
935
- abort();
936
- resolve(rows);
1283
+ if (count >= opts.maxRows) {
1284
+ logger.debug(`[CSVProcessor] Reached row limit ${opts.maxRows}, stopping parse`);
1285
+ finish(() => {
1286
+ abort();
1287
+ resolve({ rows, timedOut: false });
1288
+ });
937
1289
  }
938
1290
  })
939
1291
  .on("end", () => {
940
- logger.debug(`[CSVProcessor] String parsing complete: ${rows.length} rows parsed`);
941
- resolve(rows);
1292
+ finish(() => {
1293
+ logger.debug(`[CSVProcessor] Parsing complete: ${rows.length} rows parsed`);
1294
+ resolve({ rows, timedOut: false });
1295
+ });
942
1296
  })
943
1297
  .on("error", (error) => {
944
- logger.error("[CSVProcessor] Parsing failed:", error);
945
- reject(new Error(`[CSVProcessor] CSV parsing failed after ${count} row(s): ${error.message}`));
1298
+ // #371: destroy the source too — .pipe() does NOT auto-destroy the
1299
+ // upstream when the parser Transform errors, leaking the fd/stream.
1300
+ finish(() => {
1301
+ abort();
1302
+ logger.error("[CSVProcessor] Parsing failed:", error);
1303
+ reject(ErrorFactory.csvParseFailed(buildCsvParseErrorMessage({
1304
+ stage: "parse",
1305
+ count,
1306
+ headerCount: capturedHeaders?.length,
1307
+ lastRowColumnCount,
1308
+ location: opts.location,
1309
+ cause: error.message,
1310
+ }), { location: opts.location, rowCount: count }, error));
1311
+ });
946
1312
  });
947
1313
  });
948
1314
  }
@@ -981,9 +1347,7 @@ export class CSVProcessor {
981
1347
  rows.forEach((row) => {
982
1348
  markdown +=
983
1349
  "| " +
984
- headers
985
- .map((h) => escapePipe(String(row[h] || "")))
986
- .join(" | ") +
1350
+ headers.map((h) => escapePipe(String(row[h] || ""))).join(" | ") +
987
1351
  " |\n";
988
1352
  });
989
1353
  return markdown;