@juspay/neurolink 9.94.1 → 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.
Files changed (39) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/agent/directTools.js +3 -1
  3. package/dist/browser/neurolink.min.js +404 -403
  4. package/dist/cli/factories/commandFactory.d.ts +6 -0
  5. package/dist/cli/factories/commandFactory.js +36 -8
  6. package/dist/lib/agent/directTools.js +3 -1
  7. package/dist/lib/neurolink.js +5 -0
  8. package/dist/lib/rag/document/loaders.d.ts +12 -1
  9. package/dist/lib/rag/document/loaders.js +64 -34
  10. package/dist/lib/types/file.d.ts +43 -0
  11. package/dist/lib/types/generate.d.ts +3 -11
  12. package/dist/lib/types/rag.d.ts +7 -0
  13. package/dist/lib/types/stream.d.ts +2 -6
  14. package/dist/lib/utils/csvProcessor.d.ts +121 -5
  15. package/dist/lib/utils/csvProcessor.js +563 -114
  16. package/dist/lib/utils/csvUtils.d.ts +26 -0
  17. package/dist/lib/utils/csvUtils.js +85 -0
  18. package/dist/lib/utils/errorHandling.d.ts +23 -0
  19. package/dist/lib/utils/errorHandling.js +65 -0
  20. package/dist/lib/utils/multimodalOptionsBuilder.d.ts +1 -5
  21. package/dist/lib/utils/textEncoding.d.ts +25 -0
  22. package/dist/lib/utils/textEncoding.js +141 -0
  23. package/dist/neurolink.js +5 -0
  24. package/dist/rag/document/loaders.d.ts +12 -1
  25. package/dist/rag/document/loaders.js +64 -34
  26. package/dist/types/file.d.ts +43 -0
  27. package/dist/types/generate.d.ts +3 -11
  28. package/dist/types/rag.d.ts +7 -0
  29. package/dist/types/stream.d.ts +2 -6
  30. package/dist/utils/csvProcessor.d.ts +121 -5
  31. package/dist/utils/csvProcessor.js +562 -113
  32. package/dist/utils/csvUtils.d.ts +26 -0
  33. package/dist/utils/csvUtils.js +84 -0
  34. package/dist/utils/errorHandling.d.ts +23 -0
  35. package/dist/utils/errorHandling.js +65 -0
  36. package/dist/utils/multimodalOptionsBuilder.d.ts +1 -5
  37. package/dist/utils/textEncoding.d.ts +25 -0
  38. package/dist/utils/textEncoding.js +140 -0
  39. package/package.json +3 -1
@@ -4,8 +4,31 @@
4
4
  * Uses streaming for memory efficiency with large files
5
5
  */
6
6
  import csvParser from "csv-parser";
7
+ import iconv from "iconv-lite";
7
8
  import { Readable } from "stream";
9
+ import { extname } from "path";
8
10
  import { logger } from "./logger.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;
9
32
  // ============================================================================
10
33
  // Data Type Detection Patterns
11
34
  // ============================================================================
@@ -66,6 +89,131 @@ function validateColumnName(name) {
66
89
  return issues;
67
90
  }
68
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
+ // ============================================================================
69
217
  // Data Type Detection
70
218
  // ============================================================================
71
219
  /**
@@ -439,8 +587,12 @@ function detectHasHeaders(headerValues, dataRows) {
439
587
  * - Lines with significantly different delimiter count than line 2
440
588
  * - Lines that don't match CSV structure of subsequent lines
441
589
  */
442
- /** Strip a leading UTF-8 BOM (U+FEFF) if present. */
443
- function stripBom(text) {
590
+ /**
591
+ * Strip a leading UTF-8 BOM (U+FEFF) if present. Exported so every entry
592
+ * point that sniffs raw text (including the RAG CSVLoader) normalizes BOMs
593
+ * the same way before metadata/delimiter detection.
594
+ */
595
+ export function stripBom(text) {
444
596
  return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
445
597
  }
446
598
  /**
@@ -485,6 +637,31 @@ function isMetadataLine(lines) {
485
637
  }
486
638
  return false;
487
639
  }
640
+ /**
641
+ * Strip a leading metadata line (e.g. Excel's `sep=;` preamble) from
642
+ * already-split lines, and read the delimiter it explicitly declares.
643
+ * Excel semantics: an explicit `sep=<char>` always wins over frequency-based
644
+ * detection; a metadata line without a recognized delimiter character is
645
+ * simply dropped from the sample without forcing a delimiter. This is the
646
+ * single place process(), detectDelimiter(), parseCSVFile(), and
647
+ * parseCSVString() all go through, so none of them detect a delimiter over
648
+ * the raw, un-stripped lines (which lets the preamble skew detection).
649
+ * Exported so non-CSVProcessor callers (the RAG CSVLoader) share it too.
650
+ */
651
+ export function stripMetadataLine(lines) {
652
+ if (!isMetadataLine(lines)) {
653
+ return { dataLines: lines, hasMetadataLine: false };
654
+ }
655
+ const sepChar = lines[0]?.trim().match(/^sep=(.)/i)?.[1];
656
+ const explicitDelimiter = sepChar && CANDIDATE_DELIMITERS.includes(sepChar)
657
+ ? sepChar
658
+ : undefined;
659
+ return {
660
+ dataLines: lines.slice(1),
661
+ hasMetadataLine: true,
662
+ explicitDelimiter,
663
+ };
664
+ }
488
665
  /**
489
666
  * Split CSV text into logical rows for metadata detection and raw row limiting.
490
667
  *
@@ -494,8 +671,12 @@ function isMetadataLine(lines) {
494
671
  * `""` inside quotes is the RFC-4180 escaped quote and does not toggle the quote
495
672
  * state. For unquoted input this yields exactly the same result as a plain
496
673
  * `split(/\r\n|\n|\r/)`.
674
+ *
675
+ * Exported so non-CSVProcessor callers (the RAG CSVLoader) get the same
676
+ * quote-aware row boundaries instead of a physical `content.split("\n")`,
677
+ * which would break rows on a newline embedded in a quoted field.
497
678
  */
498
- function splitCsvLines(csvString) {
679
+ export function splitCsvLines(csvString) {
499
680
  const rows = [];
500
681
  let current = "";
501
682
  let inQuotes = false;
@@ -556,7 +737,7 @@ export class CSVProcessor {
556
737
  * @returns Formatted CSV data ready for LLM (JSON or Markdown)
557
738
  */
558
739
  static async process(content, options) {
559
- 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 || {};
560
741
  const maxRows = Math.max(1, Math.min(10000, rawMaxRows));
561
742
  logger.debug("[CSVProcessor] Starting CSV processing", {
562
743
  contentSize: content.length,
@@ -564,21 +745,41 @@ export class CSVProcessor {
564
745
  maxRows,
565
746
  includeHeaders,
566
747
  });
567
- 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);
751
+ // #361: split once and strip a leading Excel `sep=` metadata line before
752
+ // detecting the delimiter (comma/tab/semicolon/pipe), so the preamble
753
+ // can't skew detection — then reuse dataLines below instead of
754
+ // re-splitting csvString for the raw-format row handling.
755
+ const lines = splitCsvLines(csvString);
756
+ const { dataLines, hasMetadataLine, explicitDelimiter } = stripMetadataLine(lines);
757
+ const delimiter = explicitDelimiter ??
758
+ detectDelimiterUtil(dataLines, extension ?? undefined);
568
759
  // For raw format, return CSV text with row limit (no parsing needed)
569
760
  // This preserves the CSV shape while normalizing line endings for row handling.
570
761
  if (formatStyle === "raw") {
571
- const lines = splitCsvLines(csvString);
572
- const hasMetadataLine = isMetadataLine(lines);
573
762
  if (hasMetadataLine) {
574
763
  logger.debug("[CSVProcessor] Detected metadata line, skipping first line");
575
764
  }
576
- // Skip metadata line if present, then take header + maxRows data rows
577
- const csvLines = hasMetadataLine
578
- ? lines.slice(1) // Skip metadata line
579
- : lines;
765
+ // dataLines already has the metadata line (if any) stripped.
766
+ const csvLines = dataLines;
580
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
+ }
581
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);
582
783
  const rowCount = limitedLines
583
784
  .slice(1)
584
785
  .filter((line) => line.trim() !== "").length;
@@ -597,11 +798,22 @@ export class CSVProcessor {
597
798
  logger.info("[CSVProcessor] ✅ Processed CSV file", {
598
799
  formatStyle: "raw",
599
800
  rowCount,
600
- columnCount: (limitedLines[0] || "").split(",").length,
801
+ columnCount: headerFields.length,
601
802
  truncated: wasTruncated,
602
803
  });
603
- // Parse a sample for enhanced metadata analysis (raw format still benefits from column analysis)
604
- const sampleForAnalysis = await this.parseCSVString(limitedCSV, Math.min(rowCount, 500));
804
+ // Parse a sample for enhanced metadata analysis (raw format still
805
+ // benefits from column analysis). Reuse the already-split, already
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.
810
+ const sampleRows = Math.min(rowCount, 500);
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
+ });
605
817
  const { columnMetadata, dataQualityWarnings, dataQualityScore } = analyzeColumns(sampleForAnalysis);
606
818
  // Log data quality summary
607
819
  if (dataQualityWarnings.length > 0) {
@@ -619,13 +831,19 @@ export class CSVProcessor {
619
831
  size: content.length,
620
832
  rowCount,
621
833
  totalLines: limitedLines.length,
622
- columnCount: (limitedLines[0] || "").split(",").length,
834
+ columnCount: headerFields.length,
623
835
  extension,
624
836
  columnMetadata,
625
837
  dataQualityWarnings,
626
838
  dataQualityScore,
627
- hasHeaders: detectHasHeaders((limitedLines[0] || "").split(","), undefined),
628
- detectedDelimiter: ",",
839
+ hasHeaders: detectHasHeaders(headerFields, undefined),
840
+ detectedDelimiter: delimiter,
841
+ detectedEncoding,
842
+ encodingConfidence,
843
+ ...(rawColumnNameMapping
844
+ ? { columnNameMapping: rawColumnNameMapping }
845
+ : {}),
846
+ ...(rawTimedOut ? { parseTimedOut: true } : {}),
629
847
  },
630
848
  };
631
849
  }
@@ -634,9 +852,13 @@ export class CSVProcessor {
634
852
  formatStyle,
635
853
  maxRows,
636
854
  });
637
- const rows = await this.parseCSVString(csvString, maxRows);
855
+ // dataLines was already split and metadata-stripped above (alongside
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 });
638
860
  // Filter out empty rows (empty objects or rows with only whitespace values from blank lines)
639
- const nonEmptyRows = rows.filter((row) => {
861
+ const filteredRows = rows.filter((row) => {
640
862
  if (!row || typeof row !== "object") {
641
863
  return false;
642
864
  }
@@ -647,11 +869,34 @@ export class CSVProcessor {
647
869
  // Check if all values are empty or whitespace-only
648
870
  return !Object.values(row).every((val) => val === "" || (typeof val === "string" && val.trim() === ""));
649
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
+ }
650
897
  // Extract metadata from parsed results
651
898
  const rowCount = nonEmptyRows.length;
652
- const columnNames = nonEmptyRows.length > 0
653
- ? Object.keys(nonEmptyRows[0])
654
- : [];
899
+ const columnNames = nonEmptyRows.length > 0 ? Object.keys(nonEmptyRows[0]) : [];
655
900
  const columnCount = columnNames.length;
656
901
  const hasEmptyColumns = columnNames.some((col) => !col || col.trim() === "");
657
902
  const sampleRows = nonEmptyRows.slice(0, 3);
@@ -666,6 +911,15 @@ export class CSVProcessor {
666
911
  }
667
912
  // Perform enhanced column analysis
668
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
+ }
669
923
  // Log data quality summary
670
924
  if (dataQualityWarnings.length > 0) {
671
925
  logger.debug("[CSVProcessor] Data quality warnings detected", {
@@ -701,14 +955,28 @@ export class CSVProcessor {
701
955
  dataQualityWarnings,
702
956
  dataQualityScore,
703
957
  hasHeaders: detectHasHeaders(columnNames, nonEmptyRows),
704
- detectedDelimiter: ",",
958
+ detectedDelimiter: delimiter,
959
+ detectedEncoding,
960
+ encodingConfidence,
961
+ ...(structuredColumnNameMapping
962
+ ? { columnNameMapping: structuredColumnNameMapping }
963
+ : {}),
964
+ ...(structuredTimedOut ? { parseTimedOut: true } : {}),
705
965
  },
706
966
  };
707
967
  }
708
968
  /**
709
- * Parse CSV string into array of row objects using streaming
710
- * Memory-efficient for large files
969
+ * Detect the delimiter of DSV content (comma/tab/semicolon/pipe), respecting
970
+ * RFC-4180 quoting. An optional extension hint (`tsv` → tab, `psv` → pipe)
971
+ * breaks ambiguity. Used by callers (e.g. the RAG CSV loader) that parse
972
+ * DSV content themselves and need the same detection (#361). A leading
973
+ * Excel `sep=` metadata line is stripped before sampling so it can't skew
974
+ * detection, and an explicit `sep=<char>` value wins outright.
711
975
  */
976
+ static detectDelimiter(csvString, extensionHint) {
977
+ const { dataLines, explicitDelimiter } = stripMetadataLine(splitCsvLines(stripBom(csvString)));
978
+ return explicitDelimiter ?? detectDelimiterUtil(dataLines, extensionHint);
979
+ }
712
980
  /**
713
981
  * Parse CSV file from disk using streaming (memory efficient)
714
982
  *
@@ -716,83 +984,158 @@ export class CSVProcessor {
716
984
  * @param maxRows - Maximum rows to parse (default: 1000)
717
985
  * @returns Array of row objects
718
986
  */
719
- 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) {
720
996
  if (typeof filePath !== "string" || filePath.trim().length === 0) {
721
- 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");
722
998
  }
723
999
  const clampedMaxRows = Math.max(1, Math.min(10000, maxRows));
724
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));
725
1007
  logger.debug("[CSVProcessor] Starting file parsing", {
726
1008
  filePath,
727
1009
  maxRows: clampedMaxRows,
728
1010
  });
729
- // Read first 2 lines to detect metadata
730
- const fileHandle = await fs.promises.open(filePath, "r");
731
- const firstLines = [];
732
- const lineReader = fileHandle.createReadStream({ encoding: "utf-8" });
733
- await new Promise((resolve) => {
734
- let buffer = "";
735
- lineReader.on("data", (chunk) => {
736
- buffer += chunk.toString();
737
- const splitBuffer = buffer.endsWith("\r")
738
- ? buffer.slice(0, -1)
739
- : buffer;
740
- const lines = splitCsvLines(splitBuffer);
741
- if (lines.length >= 2) {
742
- firstLines.push(lines[0], lines[1]);
743
- lineReader.destroy();
744
- resolve();
745
- }
746
- });
747
- lineReader.on("end", () => resolve());
748
- // Metadata sniffing is best-effort — a read error here must not crash.
749
- lineReader.on("error", () => resolve());
750
- });
751
- await fileHandle.close();
752
- const hasMetadataLine = isMetadataLine(firstLines);
753
- const skipLines = hasMetadataLine ? 1 : 0;
754
- if (hasMetadataLine) {
755
- 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);
756
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);
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) {
757
1063
  return new Promise((resolve, reject) => {
758
- const rows = [];
759
- let count = 0;
760
- let lineCount = 0;
761
- const source = fs.createReadStream(filePath, { encoding: "utf-8" });
762
- const parser = csvParser();
763
- const abort = () => {
764
- source.destroy();
765
- 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);
766
1072
  };
767
- // .pipe() does not forward source errors (disk read/permission failures)
768
- // to the parser, so listen on the source directly or it throws unhandled.
769
- source.on("error", (error) => {
770
- abort();
771
- reject(new Error(`[CSVProcessor] CSV file read failed after ${count} row(s) (${filePath}): ${error.message}`));
772
- });
773
- source
774
- .pipe(parser)
775
- .on("data", (row) => {
776
- lineCount++;
777
- if (lineCount <= skipLines) {
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
+ });
1112
+ }
1113
+ };
1114
+ const onEnd = () => {
1115
+ if (settled) {
778
1116
  return;
779
1117
  }
780
- rows.push(row);
781
- count++;
782
- if (count >= clampedMaxRows) {
783
- logger.debug(`[CSVProcessor] Reached row limit ${clampedMaxRows}, stopping parse`);
784
- abort();
785
- resolve(rows);
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;
786
1131
  }
787
- })
788
- .on("end", () => {
789
- logger.debug(`[CSVProcessor] File parsing complete: ${rows.length} rows parsed`);
790
- resolve(rows);
791
- })
792
- .on("error", (error) => {
793
- logger.error("[CSVProcessor] File parsing failed:", error);
794
- reject(new Error(`[CSVProcessor] CSV parsing failed after ${count} row(s): ${error.message}`));
795
- });
1132
+ settled = true;
1133
+ cleanup();
1134
+ reject(error);
1135
+ };
1136
+ rawSource.on("data", onData);
1137
+ rawSource.on("end", onEnd);
1138
+ rawSource.on("error", onError);
796
1139
  });
797
1140
  }
798
1141
  /**
@@ -801,11 +1144,20 @@ export class CSVProcessor {
801
1144
  *
802
1145
  * @param csvString - CSV data as string
803
1146
  * @param maxRows - Maximum rows to parse (default: 1000)
1147
+ * @param timeoutMs - Wall-clock parse cap; returns partial rows on timeout
804
1148
  * @returns Array of row objects
805
1149
  */
806
- static async parseCSVString(csvString, maxRows = 1000) {
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) {
807
1159
  if (typeof csvString !== "string" || csvString.trim().length === 0) {
808
- 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");
809
1161
  }
810
1162
  // Strip a leading UTF-8 BOM (common in Excel exports) so it does not glue
811
1163
  // onto the first column name and break downstream key lookups.
@@ -815,49 +1167,148 @@ export class CSVProcessor {
815
1167
  inputLength: normalized.length,
816
1168
  maxRows: clampedMaxRows,
817
1169
  });
818
- // Detect and skip metadata line
1170
+ // Detect and skip a leading metadata line before detecting the
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.
819
1174
  const lines = splitCsvLines(normalized);
820
- const hasMetadataLine = isMetadataLine(lines);
821
- const csvData = hasMetadataLine
822
- ? lines.slice(1).join("\n")
823
- : lines.join("\n");
1175
+ const { dataLines, hasMetadataLine, explicitDelimiter } = stripMetadataLine(lines);
1176
+ const effectiveDelimiter = explicitDelimiter ?? detectDelimiterUtil(dataLines);
824
1177
  if (hasMetadataLine) {
825
1178
  logger.debug("[CSVProcessor] Detected metadata line in string, skipping");
826
1179
  }
1180
+ const csvData = dataLines.join("\n");
1181
+ if (csvData.trim().length === 0) {
1182
+ throw ErrorFactory.csvInvalidInput("CSVProcessor.parseCSVString: csvString must be a non-empty string");
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) {
827
1208
  return new Promise((resolve, reject) => {
828
1209
  const rows = [];
829
1210
  let count = 0;
830
- const source = Readable.from([csvData]);
831
- const parser = csvParser();
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
+ });
832
1226
  const abort = () => {
833
1227
  source.destroy();
1228
+ rawSource?.destroy();
834
1229
  parser.destroy();
835
1230
  };
836
- // .pipe() does not forward source-stream errors to the destination, so a
837
- // source failure must be listened for directly or it throws as an
838
- // 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.
839
1254
  source.on("error", (error) => {
840
- abort();
841
- 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
+ });
842
1266
  });
843
1267
  source
844
1268
  .pipe(parser)
845
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;
846
1281
  rows.push(row);
847
1282
  count++;
848
- if (count >= clampedMaxRows) {
849
- logger.debug(`[CSVProcessor] Reached row limit ${clampedMaxRows}, stopping parse`);
850
- abort();
851
- 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
+ });
852
1289
  }
853
1290
  })
854
1291
  .on("end", () => {
855
- logger.debug(`[CSVProcessor] String parsing complete: ${rows.length} rows parsed`);
856
- resolve(rows);
1292
+ finish(() => {
1293
+ logger.debug(`[CSVProcessor] Parsing complete: ${rows.length} rows parsed`);
1294
+ resolve({ rows, timedOut: false });
1295
+ });
857
1296
  })
858
1297
  .on("error", (error) => {
859
- logger.error("[CSVProcessor] Parsing failed:", error);
860
- 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
+ });
861
1312
  });
862
1313
  });
863
1314
  }
@@ -896,9 +1347,7 @@ export class CSVProcessor {
896
1347
  rows.forEach((row) => {
897
1348
  markdown +=
898
1349
  "| " +
899
- headers
900
- .map((h) => escapePipe(String(row[h] || "")))
901
- .join(" | ") +
1350
+ headers.map((h) => escapePipe(String(row[h] || ""))).join(" | ") +
902
1351
  " |\n";
903
1352
  });
904
1353
  return markdown;