@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.
- package/CHANGELOG.md +6 -0
- package/dist/agent/directTools.js +3 -1
- package/dist/browser/neurolink.min.js +400 -397
- package/dist/cli/factories/commandFactory.d.ts +6 -0
- package/dist/cli/factories/commandFactory.js +36 -8
- package/dist/lib/agent/directTools.js +3 -1
- package/dist/lib/neurolink.js +5 -0
- package/dist/lib/rag/document/loaders.js +20 -4
- package/dist/lib/types/file.d.ts +43 -0
- package/dist/lib/types/generate.d.ts +3 -11
- package/dist/lib/types/rag.d.ts +7 -0
- package/dist/lib/types/stream.d.ts +2 -6
- package/dist/lib/utils/csvProcessor.d.ts +68 -10
- package/dist/lib/utils/csvProcessor.js +499 -135
- package/dist/lib/utils/errorHandling.d.ts +23 -0
- package/dist/lib/utils/errorHandling.js +65 -0
- package/dist/lib/utils/multimodalOptionsBuilder.d.ts +1 -5
- package/dist/lib/utils/textEncoding.d.ts +25 -0
- package/dist/lib/utils/textEncoding.js +141 -0
- package/dist/neurolink.js +5 -0
- package/dist/rag/document/loaders.js +20 -4
- package/dist/types/file.d.ts +43 -0
- package/dist/types/generate.d.ts +3 -11
- package/dist/types/rag.d.ts +7 -0
- package/dist/types/stream.d.ts +2 -6
- package/dist/utils/csvProcessor.d.ts +68 -10
- package/dist/utils/csvProcessor.js +498 -134
- package/dist/utils/errorHandling.d.ts +23 -0
- package/dist/utils/errorHandling.js +65 -0
- package/dist/utils/multimodalOptionsBuilder.d.ts +1 -5
- package/dist/utils/textEncoding.d.ts +25 -0
- package/dist/utils/textEncoding.js +140 -0
- package/package.json +3 -1
|
@@ -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 iconv from "iconv-lite";
|
|
7
8
|
import { Readable } from "stream";
|
|
8
9
|
import { extname } from "path";
|
|
9
10
|
import { logger } from "./logger.js";
|
|
10
|
-
import {
|
|
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
|
-
|
|
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:
|
|
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`
|
|
645
|
-
// instead of routing `limitedCSV`
|
|
646
|
-
// would re-split/re-join
|
|
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.
|
|
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:
|
|
834
|
+
columnCount: headerFields.length,
|
|
667
835
|
extension,
|
|
668
836
|
columnMetadata,
|
|
669
837
|
dataQualityWarnings,
|
|
670
838
|
dataQualityScore,
|
|
671
|
-
hasHeaders: detectHasHeaders(
|
|
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
|
|
683
|
-
//
|
|
684
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
logger.warn(`[CSVProcessor]
|
|
812
|
-
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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
|
-
//
|
|
838
|
-
//
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
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
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
916
|
-
|
|
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
|
-
|
|
922
|
-
|
|
923
|
-
|
|
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
|
-
|
|
926
|
-
|
|
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 >=
|
|
934
|
-
logger.debug(`[CSVProcessor] Reached row limit ${
|
|
935
|
-
|
|
936
|
-
|
|
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
|
-
|
|
941
|
-
|
|
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
|
-
|
|
945
|
-
|
|
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;
|