UNPKG

@juspay/neurolink

Version:

The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002

1,575 lines • 68.3 kB
/**
 * CSV Processing Utility
 * Converts CSV files to LLM-friendly text formats
 * Uses streaming for memory efficiency with large files
 */
import csvParser from "csv-parser";
import iconv from "iconv-lite";
import { Readable } from "stream";
import { extname } from "path";
import { logger } from "./logger.js";
import { splitCSVFields, detectDelimiter as detectDelimiterUtil, CANDIDATE_DELIMITERS, } from "./csvUtils.js";
import { isObject } from "./typeUtils.js";
import { decodeBuffer } from "./textEncoding.js";
import { ErrorFactory } from "./errorHandling.js";
import { withTimeout, TimeoutError } from "./async/withTimeout.js";
// ============================================================================
// Parse Safety Limits (#371 / #379)
// ============================================================================
/**
 * Upper bound on a single CSV row's byte size (#371). csv-parser defaults to an
 * effectively-unbounded `maxRowBytes`; a pathological unbroken row would grow
 * memory without limit and never fire the parser's own error path. 10 MB/row is
 * generous for legitimate data (e.g. a large JSON blob in one cell) while
 * bounding a single allocation and making the parser-error cleanup path
 * reachable/testable.
 */
const CSV_MAX_ROW_BYTES = 10 * 1024 * 1024;
/** Default wall-clock cap for `parseCSVString` (#379). */
const DEFAULT_CSV_STRING_PARSE_TIMEOUT_MS = 30_000;
/** Default wall-clock cap for `parseCSVFile` (#379). */
const DEFAULT_CSV_FILE_PARSE_TIMEOUT_MS = 300_000;
// ============================================================================
// Data Type Detection Patterns
// ============================================================================
const DATE_PATTERNS = [
    { regex: /^\d{4}-\d{2}-\d{2}$/, format: "YYYY-MM-DD" },
    { regex: /^\d{2}\/\d{2}\/\d{4}$/, format: "MM/DD/YYYY" },
    { regex: /^\d{2}-\d{2}-\d{4}$/, format: "DD-MM-YYYY" },
    { regex: /^\d{2}\.\d{2}\.\d{4}$/, format: "DD.MM.YYYY" },
    { regex: /^\d{4}\/\d{2}\/\d{2}$/, format: "YYYY/MM/DD" },
];
const DATETIME_PATTERNS = [
    { regex: /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}/, format: "ISO8601" },
    { regex: /^\d{2}\/\d{2}\/\d{4} \d{2}:\d{2}/, format: "MM/DD/YYYY HH:mm" },
];
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const URL_REGEX = /^(https?:\/\/|www\.)[^\s]+$/i;
const INTEGER_REGEX = /^-?\d+$/;
const FLOAT_REGEX = /^-?\d+\.\d+$/;
const BOOLEAN_VALUES = new Set([
    "true",
    "false",
    "yes",
    "no",
    "1",
    "0",
    "t",
    "f",
    "y",
    "n",
]);
// ============================================================================
// Column Name Validation
// ============================================================================
/**
 * Validate column name and return issues
 */
function validateColumnName(name) {
    const issues = [];
    if (!name || name.trim() === "") {
        issues.push("Empty or blank column name");
        return issues;
    }
    if (name !== name.trim()) {
        issues.push("Leading or trailing whitespace");
    }
    if (/^\d/.test(name)) {
        issues.push("Starts with a number");
    }
    if (/[^a-zA-Z0-9_\- ]/.test(name)) {
        issues.push("Contains special characters");
    }
    if (name.length > 64) {
        issues.push("Name exceeds 64 characters");
    }
    if (/\s{2,}/.test(name)) {
        issues.push("Contains multiple consecutive spaces");
    }
    return issues;
}
// ============================================================================
// Column Name Sanitization (#378)
// ============================================================================
/**
 * Rewrite a raw header into a valid identifier. Trims, replaces non-identifier
 * runs with `_`, prefixes a `col_` when it starts with a digit or is empty,
 * then applies the requested case style. Never returns an empty string.
 */
export function sanitizeColumnName(name, style = "snake_case", index = 0) {
    const trimmed = (name ?? "").trim();
    // Split on runs of non-alphanumeric characters. Using split (linear) instead
    // of a `^_+|_+$` trim regex avoids the polynomial-backtracking ReDoS CodeQL
    // flags on long underscore/separator runs, and drops leading/trailing/empty
    // segments naturally.
    let parts = trimmed.split(/[^A-Za-z0-9]+/).filter((p) => p.length > 0);
    let base = parts.join("_");
    if (base === "") {
        base = `col_${index + 1}`;
        parts = [base];
    }
    else if (/^[0-9]/.test(base)) {
        base = `col_${base}`;
        parts = base.split("_").filter((p) => p.length > 0);
    }
    if (style === "camelCase") {
        return parts
            .map((p, i) => i === 0
            ? p.charAt(0).toLowerCase() + p.slice(1)
            : p.charAt(0).toUpperCase() + p.slice(1))
            .join("");
    }
    return parts.map((p) => p.toLowerCase()).join("_");
}
/**
 * Deduplicate a list of (already sanitized) names by suffixing `_2`, `_3`, … on
 * collisions, preserving order. The suffixed candidate is bumped until it does
 * not collide with ANY already-emitted name — so an existing `name_2` can't be
 * silently overwritten by a generated `name_2` (which the naive counter did for
 * `["name","name","name_2"]`).
 */
export function dedupeColumnNames(names) {
    const used = new Set();
    return names.map((name) => {
        if (!used.has(name)) {
            used.add(name);
            return name;
        }
        let n = 2;
        let candidate = `${name}_${n}`;
        while (used.has(candidate)) {
            n++;
            candidate = `${name}_${n}`;
        }
        used.add(candidate);
        return candidate;
    });
}
/**
 * Compute sanitized (and deduped) column names plus the original→sanitized
 * mapping for the names that actually changed.
 */
function buildColumnNameMapping(headers, style) {
    const sanitized = dedupeColumnNames(headers.map((h, i) => sanitizeColumnName(h, style, i)));
    const mapping = headers
        .map((original, i) => ({ original, sanitized: sanitized[i] }))
        .filter((m) => m.original !== m.sanitized);
    return { sanitized, mapping };
}
// ============================================================================
// Row Validation (#384)
// ============================================================================
/**
 * Predicate form of the row-shape invariant: a non-null, non-array object whose
 * every value is a string or undefined. csv-parser's default output always
 * satisfies this; the guard is defense-in-depth at the parse boundary.
 */
export function isValidCsvRow(row) {
    if (!isObject(row) || Array.isArray(row)) {
        return false;
    }
    return Object.values(row).every((v) => typeof v === "string" || v === undefined);
}
/**
 * Assertion form used inside the streaming `data` handlers. Throws a
 * `[CSVProcessor]`-prefixed error (so existing `includes("CSV")` catches still
 * match) when a row violates the invariant.
 */
export function assertValidCsvRow(row, rowNumber) {
    if (!isValidCsvRow(row)) {
        throw ErrorFactory.csvRowInvalid(`[CSVProcessor] Invalid CSV row ${rowNumber}: expected a string-keyed object with string values, got ${Array.isArray(row) ? "array" : typeof row}`, rowNumber);
    }
}
/**
 * True when a parsed CSV row is blank: no keys, or every value is empty /
 * whitespace-only (#373). Shared by the structured post-filter and by
 * `streamParse` so blank rows do not consume `maxRows`.
 */
export function isBlankCsvDataRow(row) {
    const keys = Object.keys(row);
    if (keys.length === 0) {
        return true;
    }
    return Object.values(row).every((val) => val === "" || (typeof val === "string" && val.trim() === ""));
}
/**
 * Raw-line counterpart of {@link isBlankCsvDataRow} (#373): a fully blank /
 * whitespace line, or a delimiter-only line such as `,,` / `  ,  ` whose
 * fields are all empty after a CSV-aware split.
 */
export function isBlankCsvRawLine(line, delimiter) {
    if (line.trim() === "") {
        return true;
    }
    const fields = splitCSVFields(line, delimiter);
    if (fields.length === 0) {
        return true;
    }
    const row = Object.create(null);
    for (let i = 0; i < fields.length; i++) {
        row[`c${i}`] = fields[i];
    }
    return isBlankCsvDataRow(row);
}
// ============================================================================
// Parse Error Context (#375)
// ============================================================================
/**
 * Build an enriched, consistent parse-failure message shared by all four
 * source/parser reject sites so they can't re-diverge.
 *
 * Deliberately structural-only: `headerCount` and `lastRowColumnCount` report
 * how many columns the header/last row had — counts, never the actual header
 * or cell string values. Parsed header text is user-controlled CSV data (and,
 * for headerless input, may literally be first-row cell values), so it must
 * never be embedded in a thrown/logged message (#1199).
 */
function buildCsvParseErrorMessage(info) {
    const parts = [
        `[CSVProcessor] CSV ${info.stage === "read" ? "file read" : "parsing"} failed after ${info.count} row(s)`,
    ];
    if (info.location) {
        parts.push(`(${info.location})`);
    }
    parts.push(`: ${info.cause}`);
    parts.push(typeof info.headerCount === "number"
        ? `| headerCount: ${info.headerCount}`
        : "| headerCount: unknown (failed before headers were parsed)");
    if (typeof info.bytesRead === "number") {
        parts.push(`| bytesRead: ${info.bytesRead}`);
    }
    if (typeof info.lastRowColumnCount === "number") {
        parts.push(`| lastRow columns: ${info.lastRowColumnCount}`);
    }
    parts.push("| Expected RFC 4180 CSV (comma-separated, double-quote escaped)");
    return parts.join(" ");
}
// ============================================================================
// Data Type Detection
// ============================================================================
/**
 * Detect the data type of a single value
 */
function detectValueType(value) {
    if (value === "" || value === null || value === undefined) {
        return "empty";
    }
    const trimmed = value.trim();
    if (trimmed === "") {
        return "empty";
    }
    // Check boolean first (before numbers since "1" and "0" could be both)
    if (BOOLEAN_VALUES.has(trimmed.toLowerCase())) {
        return "boolean";
    }
    // Check integer
    if (INTEGER_REGEX.test(trimmed)) {
        return "integer";
    }
    // Check float
    if (FLOAT_REGEX.test(trimmed)) {
        return "float";
    }
    // Check email
    if (EMAIL_REGEX.test(trimmed)) {
        return "email";
    }
    // Check URL
    if (URL_REGEX.test(trimmed)) {
        return "url";
    }
    // Check datetime (before date since datetime is more specific)
    for (const pattern of DATETIME_PATTERNS) {
        if (pattern.regex.test(trimmed)) {
            return "datetime";
        }
    }
    // Check date
    for (const pattern of DATE_PATTERNS) {
        if (pattern.regex.test(trimmed)) {
            return "date";
        }
    }
    return "string";
}
/**
 * Detect date format from value
 */
function detectDateFormat(value) {
    const trimmed = value.trim();
    for (const pattern of DATETIME_PATTERNS) {
        if (pattern.regex.test(trimmed)) {
            return pattern.format;
        }
    }
    for (const pattern of DATE_PATTERNS) {
        if (pattern.regex.test(trimmed)) {
            return pattern.format;
        }
    }
    return undefined;
}
/**
 * Determine the predominant type for a column based on sampled values
 */
function determineColumnType(types) {
    const nonEmpty = types.filter((t) => t !== "empty");
    if (nonEmpty.length === 0) {
        return { type: "empty", confidence: 100 };
    }
    // Count occurrences of each type
    const typeCounts = new Map();
    for (const t of nonEmpty) {
        typeCounts.set(t, (typeCounts.get(t) || 0) + 1);
    }
    // Find the most common type
    let maxType = "string";
    let maxCount = 0;
    for (const [type, count] of typeCounts) {
        if (count > maxCount) {
            maxCount = count;
            maxType = type;
        }
    }
    // Calculate confidence
    const confidence = Math.round((maxCount / nonEmpty.length) * 100);
    // Consolidate integer and float into number if the column contains only numeric types
    // This check must happen before the mixed-type check to avoid classifying numeric-only columns as mixed
    if (typeCounts.has("integer") && typeCounts.has("float")) {
        // Check if these are the only two types (purely numeric column)
        if (typeCounts.size === 2) {
            const totalNumeric = (typeCounts.get("integer") || 0) + (typeCounts.get("float") || 0);
            const numericConfidence = Math.round((totalNumeric / nonEmpty.length) * 100);
            return { type: "number", confidence: numericConfidence };
        }
    }
    // If confidence is low and multiple types exist, mark as mixed
    if (confidence < 70 && typeCounts.size > 1) {
        return { type: "mixed", confidence };
    }
    return { type: maxType, confidence };
}
/**
 * Analyze a single column and return rich metadata
 */
function analyzeColumn(columnName, columnIndex, values) {
    const types = [];
    const uniqueValues = new Set();
    const numericValues = [];
    let nullCount = 0;
    let dateFormat;
    for (const value of values) {
        const trimmed = value?.trim() ?? "";
        if (trimmed === "") {
            nullCount++;
            types.push("empty");
            continue;
        }
        uniqueValues.add(trimmed);
        const type = detectValueType(trimmed);
        types.push(type);
        // Collect numeric values for statistics
        if (type === "integer" || type === "float") {
            const num = parseFloat(trimmed);
            if (!isNaN(num)) {
                numericValues.push(num);
            }
        }
        // Detect date format
        if ((type === "date" || type === "datetime") && !dateFormat) {
            dateFormat = detectDateFormat(trimmed);
        }
    }
    const { type: detectedType, confidence } = determineColumnType(types);
    // Get sample values (up to 5 unique non-empty)
    const sampleValues = Array.from(uniqueValues).slice(0, 5);
    // Calculate numeric statistics
    let minValue;
    let maxValue;
    let avgValue;
    if (numericValues.length > 0) {
        minValue = Math.min(...numericValues);
        maxValue = Math.max(...numericValues);
        avgValue =
            Math.round((numericValues.reduce((a, b) => a + b, 0) / numericValues.length) * 100) / 100;
    }
    // Validate column name
    const nameIssues = validateColumnName(columnName);
    const metadata = {
        name: columnName,
        index: columnIndex,
        detectedType,
        typeConfidence: confidence,
        nullCount,
        uniqueCount: uniqueValues.size,
        sampleValues,
    };
    if (minValue !== undefined) {
        metadata.minValue = minValue;
    }
    if (maxValue !== undefined) {
        metadata.maxValue = maxValue;
    }
    if (avgValue !== undefined) {
        metadata.avgValue = avgValue;
    }
    if (dateFormat) {
        metadata.dateFormat = dateFormat;
    }
    if (nameIssues.length > 0) {
        metadata.nameIssues = nameIssues;
    }
    return metadata;
}
/**
 * Generate data quality warnings based on column analysis
 */
function generateDataQualityWarnings(columns, totalRows) {
    const warnings = [];
    for (const col of columns) {
        // Check for high null rate (>20%)
        const nullRate = totalRows > 0 ? col.nullCount / totalRows : 0;
        if (nullRate > 0.2) {
            warnings.push({
                column: col.name,
                type: "high_null_rate",
                message: `Column has ${Math.round(nullRate * 100)}% empty/null values (${col.nullCount} of ${totalRows} rows)`,
                severity: nullRate > 0.5 ? "warning" : "info",
                affectedRows: col.nullCount,
            });
        }
        // Check for invalid column names
        if (col.nameIssues && col.nameIssues.length > 0) {
            warnings.push({
                column: col.name,
                type: "invalid_name",
                message: `Column name issues: ${col.nameIssues.join(", ")}`,
                severity: col.name.trim() === "" ? "error" : "warning",
            });
        }
        // Check for mixed types (low confidence)
        if (col.detectedType === "mixed" || col.typeConfidence < 70) {
            warnings.push({
                column: col.name,
                type: "mixed_types",
                message: `Column has inconsistent data types (${col.typeConfidence}% confidence for ${col.detectedType})`,
                severity: "warning",
            });
        }
        // Check for potential duplicates (very low unique count)
        if (totalRows > 10 && col.uniqueCount === 1 && col.nullCount === 0) {
            warnings.push({
                column: col.name,
                type: "duplicates",
                message: `All ${totalRows} rows have the same value`,
                severity: "info",
                affectedRows: totalRows,
            });
        }
        // Check for all empty column
        if (col.detectedType === "empty") {
            warnings.push({
                column: col.name,
                type: "empty_values",
                message: "Column is entirely empty",
                severity: "warning",
                affectedRows: totalRows,
            });
        }
    }
    return warnings;
}
/**
 * Calculate overall data quality score
 */
function calculateDataQualityScore(columns, warnings, totalRows) {
    if (columns.length === 0 || totalRows === 0) {
        return 0;
    }
    let score = 100;
    // Deduct for warnings
    for (const warning of warnings) {
        switch (warning.severity) {
            case "error":
                score -= 15;
                break;
            case "warning":
                score -= 8;
                break;
            case "info":
                score -= 3;
                break;
        }
    }
    // Deduct for overall null rate
    const totalNulls = columns.reduce((sum, col) => sum + col.nullCount, 0);
    const totalCells = columns.length * totalRows;
    const overallNullRate = totalCells > 0 ? totalNulls / totalCells : 0;
    score -= Math.round(overallNullRate * 30);
    // Deduct for low type confidence
    const avgConfidence = columns.reduce((sum, col) => sum + col.typeConfidence, 0) / columns.length;
    if (avgConfidence < 80) {
        score -= Math.round((80 - avgConfidence) / 2);
    }
    return Math.max(0, Math.min(100, score));
}
/**
 * Analyze all columns in parsed CSV data
 */
/**
 * Confidence for a CSV processing result, weighted by data quality.
 *
 * The result used to report a static `confidence: 100` regardless of how clean
 * the data was. This ties confidence to `dataQualityScore` so it actually
 * varies with quality factors (ragged rows, high null rate, type
 * inconsistency): a cleanly-parsed CSV stays at 100, a messy one is pulled
 * halfway toward its quality score. It never drops below 50 for a CSV that
 * parsed — the *format* is certain, only the *data* is imperfect.
 */
function csvResultConfidence(dataQualityScore) {
    const clamped = Math.max(0, Math.min(100, dataQualityScore));
    return Math.round((100 + clamped) / 2);
}
function analyzeColumns(rows) {
    if (rows.length === 0) {
        return {
            columnMetadata: [],
            dataQualityWarnings: [],
            dataQualityScore: 0,
        };
    }
    const columnNames = Object.keys(rows[0]);
    const columnMetadata = [];
    for (let i = 0; i < columnNames.length; i++) {
        const colName = columnNames[i];
        const values = rows.map((row) => String(row[colName] ?? ""));
        columnMetadata.push(analyzeColumn(colName, i, values));
    }
    const dataQualityWarnings = generateDataQualityWarnings(columnMetadata, rows.length);
    const dataQualityScore = calculateDataQualityScore(columnMetadata, dataQualityWarnings, rows.length);
    return {
        columnMetadata,
        dataQualityWarnings,
        dataQualityScore,
    };
}
/**
 * Detect if the first row appears to be a header row
 *
 * Heuristics used:
 * 1. Header values should be text/string type (not numbers, dates, emails, etc.)
 * 2. Header values should be unique (no duplicate column names)
 * 3. If data rows exist, headers should have different type profile than data
 *
 * @param headerValues - The values from the first row (potential headers)
 * @param dataRows - Sample of data rows for comparison (optional)
 * @returns true if the first row appears to be headers
 */
function detectHasHeaders(headerValues, dataRows) {
    if (headerValues.length === 0) {
        return false;
    }
    // Check 1: All header values should look like text labels, not data values
    let textLikeCount = 0;
    for (const value of headerValues) {
        const trimmed = value?.trim() ?? "";
        if (trimmed === "") {
            continue; // Empty headers are allowed but don't count toward text-like
        }
        const type = detectValueType(trimmed);
        // Headers are typically strings - not numbers, dates, emails, URLs, or booleans
        if (type === "string") {
            textLikeCount++;
        }
    }
    // If most header values are text-like (not numeric/date/etc.), likely headers
    const nonEmptyHeaders = headerValues.filter((v) => v?.trim()).length;
    if (nonEmptyHeaders === 0) {
        return false;
    }
    const textRatio = textLikeCount / nonEmptyHeaders;
    // Check 2: Headers should be unique
    const uniqueHeaders = new Set(headerValues.map((v) => v?.trim().toLowerCase()));
    const hasUniqueHeaders = uniqueHeaders.size === headerValues.length;
    // Check 3: Compare with data rows if available
    if (dataRows && dataRows.length > 0) {
        // If first data row has different type profile than headers, likely has headers
        const firstDataRow = Object.values(dataRows[0] || {}).map((v) => String(v ?? ""));
        let dataTextCount = 0;
        for (const value of firstDataRow) {
            const type = detectValueType(value?.trim() ?? "");
            if (type === "string") {
                dataTextCount++;
            }
        }
        const dataTextRatio = firstDataRow.length > 0 ? dataTextCount / firstDataRow.length : 0;
        // If headers are mostly text but data has more varied types, likely has headers
        if (textRatio > 0.7 && dataTextRatio < textRatio - 0.2) {
            return true;
        }
    }
    // Default: if >70% of header values are text-like and unique, assume headers
    return textRatio >= 0.7 && hasUniqueHeaders;
}
/**
 * Detect if first line is CSV metadata (not actual data/headers)
 * Common patterns:
 * - Excel separator line: "SEP=,"
 * - Lines with significantly different delimiter count than line 2
 * - Lines that don't match CSV structure of subsequent lines
 */
/**
 * Strip a leading UTF-8 BOM (U+FEFF) if present. Exported so every entry
 * point that sniffs raw text (including the RAG CSVLoader) normalizes BOMs
 * the same way before metadata/delimiter detection.
 */
export function stripBom(text) {
    return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
}
/**
 * Count commas that fall OUTSIDE double-quoted fields (RFC 4180, escaped-quote
 * aware). A quoted comma is field content, not a column separator, so counting
 * naively would misclassify a quoted-comma header line.
 */
function countUnquotedCommas(line) {
    let count = 0;
    let inQuotes = false;
    for (let i = 0; i < line.length; i++) {
        const ch = line[i];
        if (ch === '"') {
            if (inQuotes && line[i + 1] === '"') {
                i++;
                continue;
            }
            inQuotes = !inQuotes;
        }
        else if (ch === "," && !inQuotes) {
            count++;
        }
    }
    return count;
}
function isMetadataLine(lines) {
    if (!lines[0] || lines.length < 2) {
        return false;
    }
    const firstLine = lines[0].trim();
    const secondLine = lines[1].trim();
    // Excel's explicit-delimiter preamble is metadata regardless of what follows.
    if (firstLine.match(/^sep=/i)) {
        return true;
    }
    // #373: this check has to come BEFORE the comma-count heuristics, not after.
    // Both of them read a higher comma count on line 2 as "line 1 was a preamble"
    // — but a delimiter-only row (`,,`) is a blank DATA row, and its commas are
    // structure, not fields. Ordered the other way, a legitimate single-column
    // header followed by a blank row (`name` / `,,`) tripped the zero-vs-nonzero
    // rule and stripMetadataLine() deleted the real header, leaving unnamed
    // columns. Verified against `name\n,,\nalice\nbob`.
    if (isDelimiterOnlyOrBlankLine(secondLine)) {
        return false;
    }
    const firstCommaCount = countUnquotedCommas(firstLine);
    const secondCommaCount = countUnquotedCommas(secondLine);
    // A title/preamble line carries no delimiters while the header below it does.
    if (firstCommaCount === 0 && secondCommaCount > 0) {
        return true;
    }
    // Differing field counts mean line 1 is not part of the same table as line 2.
    return secondCommaCount > 0 && firstCommaCount !== secondCommaCount;
}
/**
 * True when `line` is empty/whitespace or contains only common CSV
 * delimiters (comma / tab / semicolon / pipe) and whitespace — i.e. a
 * delimiter-only blank row like `,,` or `  ;  ;  `. Used before the
 * delimiter is known (metadata detection).
 */
function isDelimiterOnlyOrBlankLine(line) {
    const trimmed = line.trim();
    if (trimmed === "") {
        return true;
    }
    return /^[\s,;\t|]+$/.test(trimmed) && /[,;\t|]/.test(trimmed);
}
/**
 * Strip a leading metadata line (e.g. Excel's `sep=;` preamble) from
 * already-split lines, and read the delimiter it explicitly declares.
 * Excel semantics: an explicit `sep=<char>` always wins over frequency-based
 * detection; a metadata line without a recognized delimiter character is
 * simply dropped from the sample without forcing a delimiter. This is the
 * single place process(), detectDelimiter(), parseCSVFile(), and
 * parseCSVString() all go through, so none of them detect a delimiter over
 * the raw, un-stripped lines (which lets the preamble skew detection).
 * Exported so non-CSVProcessor callers (the RAG CSVLoader) share it too.
 */
export function stripMetadataLine(lines) {
    if (!isMetadataLine(lines)) {
        return { dataLines: lines, hasMetadataLine: false };
    }
    const sepChar = lines[0]?.trim().match(/^sep=(.)/i)?.[1];
    const explicitDelimiter = sepChar && CANDIDATE_DELIMITERS.includes(sepChar)
        ? sepChar
        : undefined;
    return {
        dataLines: lines.slice(1),
        hasMetadataLine: true,
        explicitDelimiter,
    };
}
/**
 * Split CSV text into logical rows for metadata detection and raw row limiting.
 *
 * Supports Unix (LF), Windows (CRLF), and classic Mac (CR) line endings, and is
 * quote-aware: a line ending that appears *inside* a quoted field (RFC 4180) is
 * kept as part of that field rather than treated as a row boundary. A doubled
 * `""` inside quotes is the RFC-4180 escaped quote and does not toggle the quote
 * state. For unquoted input this yields exactly the same result as a plain
 * `split(/\r\n|\n|\r/)`.
 *
 * Exported so non-CSVProcessor callers (the RAG CSVLoader) get the same
 * quote-aware row boundaries instead of a physical `content.split("\n")`,
 * which would break rows on a newline embedded in a quoted field.
 */
export function splitCsvLines(csvString) {
    const rows = [];
    let current = "";
    let inQuotes = false;
    for (let i = 0; i < csvString.length; i++) {
        const ch = csvString[i];
        if (ch === '"') {
            if (inQuotes && csvString[i + 1] === '"') {
                // Escaped quote ("") — stays inside the field, quote state unchanged.
                current += '""';
                i++;
                continue;
            }
            inQuotes = !inQuotes;
            current += ch;
        }
        else if (!inQuotes && (ch === "\n" || ch === "\r")) {
            // Row boundary outside quotes; collapse CRLF into a single boundary.
            if (ch === "\r" && csvString[i + 1] === "\n") {
                i++;
            }
            rows.push(current);
            current = "";
        }
        else {
            current += ch;
        }
    }
    rows.push(current);
    return rows;
}
/**
 * CSV processor for converting CSV data to LLM-optimized formats
 *
 * Supports three output formats:
 * - raw: Original CSV format with proper escaping (RECOMMENDED for best LLM performance)
 * - json: JSON array format (best for structured data processing)
 * - markdown: Markdown table format (best for small datasets <100 rows)
 *
 * All formats use csv-parser for reliable parsing, then convert to the target format.
 *
 * @example
 * ```typescript
 * const csvBuffer = Buffer.from('name,age\nAlice,30\nBob,25');
 * const result = await CSVProcessor.process(csvBuffer, {
 *   maxRows: 1000,
 *   formatStyle: 'raw'
 * });
 * console.log(result.content); // CSV string with proper escaping
 * ```
 */
export class CSVProcessor {
    /**
     * Process CSV Buffer to LLM-friendly format
     * Content already loaded by FileDetector
     *
     * @param content - CSV file as Buffer
     * @param options - Processing options
     * @returns Formatted CSV data ready for LLM (JSON or Markdown)
     */
    static async process(content, options) {
        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, skipEmptyLines = true, } = options || {};
        const maxRows = Math.max(1, Math.min(10000, rawMaxRows));
        logger.debug("[CSVProcessor] Starting CSV processing", {
            contentSize: content.length,
            formatStyle,
            maxRows,
            includeHeaders,
            skipEmptyLines,
        });
        // #362: detect the encoding (BOM → chardet → UTF-8 fallback) or honor the
        // caller's override, instead of the previous hard-coded UTF-8 decode.
        const { text: csvString, encoding: detectedEncoding, confidence: encodingConfidence, } = decodeBuffer(content, encoding);
        // #361: split once and strip a leading Excel `sep=` metadata line before
        // detecting the delimiter (comma/tab/semicolon/pipe), so the preamble
        // can't skew detection — then reuse dataLines below instead of
        // re-splitting csvString for the raw-format row handling.
        const lines = splitCsvLines(csvString);
        const { dataLines, hasMetadataLine, explicitDelimiter } = stripMetadataLine(lines);
        const delimiter = explicitDelimiter ??
            detectDelimiterUtil(dataLines, extension ?? undefined);
        // For raw format, return CSV text with row limit (no parsing needed)
        // This preserves the CSV shape while normalizing line endings for row handling.
        if (formatStyle === "raw") {
            if (hasMetadataLine) {
                logger.debug("[CSVProcessor] Detected metadata line, skipping first line");
            }
            // dataLines already has the metadata line (if any) stripped.
            const csvLines = dataLines;
            const headerLine = csvLines[0] ?? "";
            const rawDataLines = csvLines.slice(1);
            // #373: drop blank AND delimiter-only data lines (e.g. `,,`) so raw
            // content matches structured `isBlankCsvDataRow` semantics.
            const effectiveDataLines = skipEmptyLines
                ? rawDataLines.filter((line) => !isBlankCsvRawLine(line, delimiter))
                : rawDataLines;
            const limitedDataLines = effectiveDataLines.slice(0, maxRows);
            const limitedLines = csvLines.length === 0 ? [] : [headerLine, ...limitedDataLines];
            // #378: opt-in — rewrite the literal header line with sanitized,
            // deduped identifiers so the raw CSV text shown to the LLM has clean
            // column names too. (Splits on commas to match the existing raw-branch
            // columnCount logic.)
            let rawColumnNameMapping;
            if (sanitizeColumnNames && limitedLines.length > 0) {
                const headerCols = splitCSVFields(limitedLines[0] || "", delimiter);
                const { sanitized, mapping } = buildColumnNameMapping(headerCols, columnNameCase);
                limitedLines[0] = sanitized.join(delimiter);
                rawColumnNameMapping = mapping.length > 0 ? mapping : undefined;
            }
            const limitedCSV = limitedLines.join("\n");
            // #1199: quote-aware, delimiter-aware split (matches the
            // sanitizeColumnNames branch above and #1192's detected delimiter).
            const headerFields = splitCSVFields(limitedLines[0] || "", delimiter);
            // When preserving empties, blank lines count as rows; when skipping,
            // only non-empty data lines do.
            const rowCount = limitedDataLines.length;
            const originalRowCount = effectiveDataLines.length;
            const wasTruncated = rowCount < originalRowCount;
            if (wasTruncated) {
                logger.warn(`[CSVProcessor] CSV data truncated: showing ${rowCount} of ${originalRowCount} rows (limit: ${maxRows})`);
            }
            logger.debug(`[CSVProcessor] raw format: ${rowCount} rows (original: ${originalRowCount}) → ${limitedCSV.length} chars`, {
                formatStyle: "raw",
                originalSize: csvString.length,
                limitedSize: limitedCSV.length,
            });
            logger.info("[CSVProcessor] ✅ Processed CSV file", {
                formatStyle: "raw",
                rowCount,
                columnCount: headerFields.length,
                truncated: wasTruncated,
            });
            // Parse a sample for enhanced metadata analysis (raw format still
            // benefits from column analysis). Feed the already-filtered limited
            // lines and honor skipEmptyRows so blank/delimiter-only rows do not
            // consume the sample limit (#373).
            const sampleRows = Math.min(rowCount, 500);
            const { rows: sampleForAnalysis, timedOut: rawTimedOut } = await this.streamParse(Readable.from([limitedLines.join("\n")]), undefined, {
                maxRows: sampleRows,
                skipLines: 0,
                timeoutMs: parseTimeoutMs,
                delimiter,
                skipEmptyRows: skipEmptyLines,
            });
            const { columnMetadata, dataQualityWarnings, dataQualityScore } = analyzeColumns(sampleForAnalysis);
            // Log data quality summary
            if (dataQualityWarnings.length > 0) {
                logger.debug("[CSVProcessor] Data quality warnings detected", {
                    warningCount: dataQualityWarnings.length,
                    score: dataQualityScore,
                });
            }
            return {
                type: "csv",
                content: limitedCSV,
                mimeType: "text/csv",
                metadata: {
                    confidence: csvResultConfidence(dataQualityScore),
                    size: content.length,
                    rowCount,
                    totalLines: limitedLines.length,
                    columnCount: headerFields.length,
                    extension,
                    columnMetadata,
                    dataQualityWarnings,
                    dataQualityScore,
                    hasHeaders: detectHasHeaders(headerFields, undefined),
                    detectedDelimiter: delimiter,
                    detectedEncoding,
                    encodingConfidence,
                    ...(rawColumnNameMapping
                        ? { columnNameMapping: rawColumnNameMapping }
                        : {}),
                    ...(rawTimedOut ? { parseTimedOut: true } : {}),
                },
            };
        }
        // Parse CSV for JSON and Markdown formats only
        logger.debug("[CSVProcessor] Parsing CSV for structured format conversion", {
            formatStyle,
            maxRows,
        });
        // dataLines was already split and metadata-stripped above (alongside
        // `delimiter`) — reuse both via the shared streamParse() core instead of
        // routing csvString back through parseCSVStringWithMeta(), which would
        // re-split it and re-run metadata/delimiter detection a second time.
        const { rows, timedOut: structuredTimedOut, headers: parsedHeaders, } = await this.streamParse(Readable.from([dataLines.join("\n")]), undefined, {
            maxRows,
            skipLines: 0,
            timeoutMs: parseTimeoutMs,
            delimiter,
            // Count only non-blank rows toward maxRows when skipping empties
            // (#373), so maxRows: 2 still yields two real data rows if blanks
            // appear first.
            skipEmptyRows: skipEmptyLines,
        });
        // Filter out empty rows (empty objects or rows with only whitespace values
        // from blank lines). #373: `skipEmptyLines` (default true) controls this;
        // set false to preserve blank-line rows in structured output.
        const filteredRows = rows.filter((row) => {
            if (!row || typeof row !== "object") {
                return false;
            }
            if (!skipEmptyLines) {
                return true;
            }
            return !isBlankCsvDataRow(row);
        });
        // Schema must come from parser headers or the first non-blank data row,
        // never from a preserved leading blank row (#373 review).
        const schemaRow = filteredRows.find((row) => row && typeof row === "object" && !isBlankCsvDataRow(row));
        const schemaHeaders = (parsedHeaders && parsedHeaders.length > 0
            ? [...parsedHeaders]
            : undefined) ??
            (schemaRow ? Object.keys(schemaRow) : undefined) ??
            (filteredRows[0] ? Object.keys(filteredRows[0]) : []) ??
            [];
        // #378: opt-in — remap each row's keys from original → sanitized identifiers.
        // Always project onto schemaHeaders first so preserved blank rows still
        // carry the real column keys for Markdown/JSON (#373 review).
        let structuredColumnNameMapping;
        const sanitizedToOriginal = new Map();
        let columnNames = schemaHeaders;
        let nonEmptyRows = schemaHeaders.length === 0
            ? filteredRows
            : filteredRows.map((row) => {
                const out = Object.create(null);
                for (const h of schemaHeaders) {
                    out[h] = typeof row[h] === "string" ? row[h] : "";
                }
                return out;
            });
        if (sanitizeColumnNames && schemaHeaders.length > 0) {
            const origHeaders = schemaHeaders;
            const { sanitized, mapping } = buildColumnNameMapping(origHeaders, columnNameCase);
            origHeaders.forEach((original, i) => {
                if (original !== sanitized[i]) {
                    sanitizedToOriginal.set(sanitized[i], original);
                }
            });
            nonEmptyRows = nonEmptyRows.map((row) => {
                // Defense-in-depth: a null-prototype target means an attacker-chosen
                // header literally named "__proto__"/"constructor"/"prototype" can
                // only ever create a harmless own property, never touch
                // Object.prototype (#1199).
                const out = Object.create(null);
                origHeaders.forEach((h, i) => {
                    out[sanitized[i]] = row[h] ?? "";
                });
                return out;
            });
            structuredColumnNameMapping = mapping.length > 0 ? mapping : undefined;
            columnNames = sanitized;
        }
        // Extract metadata from parsed results
        const rowCount = nonEmptyRows.length;
        const columnCount = columnNames.length;
        const hasEmptyColumns = columnNames.some((col) => !col || col.trim() === "");
        // Sample / analysis should prefer non-blank rows so a preserved leading
        // blank does not poison type detection or formatSampleData.
        const rowsForAnalysis = nonEmptyRows.filter((row) => !isBlankCsvDataRow(row));
        const sampleRows = (rowsForAnalysis.length > 0 ? rowsForAnalysis : nonEmptyRows).slice(0, 3);
        const sampleData = this.formatSampleData(sampleRows, sampleDataFormat, includeHeaders, columnNames);
        if (hasEmptyColumns) {
            logger.warn("[CSVProcessor] CSV contains empty or blank column headers", {
                columnNames,
            });
        }
        if (rowCount === 0) {
            logger.warn("[CSVProcessor] CSV file contains no data rows");
        }
        // Perform enhanced column analysis on non-blank rows so preserved
        // leading blanks do not yield an empty schema (#373 review).
        const { columnMetadata, dataQualityWarnings, dataQualityScore } = analyzeColumns(rowsForAnalysis.length > 0 ? rowsForAnalysis : nonEmptyRows);
        // #378: carry the pre-sanitization header on each renamed column.
        if (sanitizedToOriginal.size > 0) {
            for (const col of columnMetadata) {
                const original = sanitizedToOriginal.get(col.name);
                if (original) {
                    col.originalName = original;
                }
            }
        }
        // Log data quality summary
        if (dataQualityWarnings.length > 0) {
            logger.debug("[CSVProcessor] Data quality warnings detected", {
                warningCount: dataQualityWarnings.length,
                score: dataQualityScore,
            });
        }
        // Format parsed data
        logger.debug(`[CSVProcessor] Converting ${rowCount} rows to ${formatStyle} format`);
        const formatted = this.formatForLLM(nonEmptyRows, formatStyle, includeHeaders, columnNames);
        logger.info("[CSVProcessor] ✅ Processed CSV file", {
            formatStyle,
            rowCount,
            columnCount,
            outputLength: formatted.length,
            hasEmptyColumns,
            dataQualityScore,
        });
        return {
            type: "csv",
            content: formatted,
            mimeType: "text/csv",
            metadata: {
                confidence: csvResultConfidence(dataQualityScore),
                size: content.length,
                rowCount,
                columnCount,
                columnNames,
                sampleData,
                hasEmptyColumns,
                extension,
                columnMetadata,
                dataQualityWarnings,
                dataQualityScore,
                hasHeaders: detectHasHeaders(columnNames, rowsForAnalysis.length > 0 ? rowsForAnalysis : nonEmptyRows),
                detectedDelimiter: delimiter,
                detectedEncoding,
                encodingConfidence,
                ...(structuredColumnNameMapping
                    ? { columnNameMapping: structuredColumnNameMapping }
                    : {}),
                ...(structuredTimedOut ? { parseTimedOut: true } : {}),
            },
        };
    }
    /**
     * Detect the delimiter of DSV content (comma/tab/semicolon/pipe), respecting
     * RFC-4180 quoting. An optional extension hint (`tsv` → tab, `psv` → pipe)
     * breaks ambiguity. Used by callers (e.g. the RAG CSV loader) that parse
     * DSV content themselves and need the same detection (#361). A leading
     * Excel `sep=` metadata line is stripped before sampling so it can't skew
     * detection, and an explicit `sep=<char>` value wins outright.
     */
    static detectDelimiter(csvString, extensionHint) {
        const { dataLines, explicitDelimiter } = stripMetadataLine(splitCsvLines(stripBom(csvString)));
        return explicitDelimiter ?? detectDelimiterUtil(dataLines, extensionHint);
    }
    /**
     * Parse CSV file from disk using streaming (memory efficient)
     *
     * @param filePath - Path to CSV file
     * @param maxRows - Maximum rows to parse (default: 1000)
     * @returns Array of row objects
     */
    static async parseCSVFile(filePath, maxRows = 1000, timeoutMs = DEFAULT_CSV_FILE_PARSE_TIMEOUT_MS, openOptions) {
        const { rows } = await this.parseCSVFileWithMeta(filePath, maxRows, timeoutMs, undefined, openOptions);
        return rows;
    }
    /**
     * File-parse variant that also reports whether the parse timed out, used by
     * `process()` to surface `metadata.parseTimedOut`.
     */
    static async parseCSVFileWithMeta(filePath, maxRows = 1000, timeoutMs = DEFAULT_CSV_FILE_PARSE_TIMEOUT_MS, encoding, openOptions) {
        if (typeof filePath !== "string" || filePath.trim().length === 0) {
            throw ErrorFactory.csvInvalidInput("CSVProcessor.parseCSVFile: filePath must be a non-empty string");
        }
        const clampedMaxRows = Math.max(1, Math.min(10000, maxRows));
        const fs = await import("fs");
        // #1199: a single wall-clock deadline covers access() + prepareFileSource()
        // + streamParse() together, not just the streamParse phase — otherwise a
        // stalled disk/network filesystem during the open/peek steps defeats the
        // #379 timeout guard entirely.
        const startTime = Date.now();
        const remaining = () => Math.max(0, timeoutMs - (Date.now() - startTime));
        logger.debug("[CSVProcessor] Starting file parsing", {
            filePath,
            maxRows: clampedMaxRows,
        });
        // #375: a bad path (ENOENT/EACCES) should fail fast with clear context
        // instead of a raw Node error surfacing from the stream. access() is a
        // permission check, not a content read, so #368's single-read contract
        // (one createReadStream, no second full pass) is preserved. Metadata-line
        // and delimiter (#361) detection happen inside prepareFileSource() below,
        // from the same peeked bytes used for encoding detection — not a second
        // read of the file.
        try {
            await fs.promises.access(filePath, fs.constants.R_OK);
        }
        catch (error) {
            throw ErrorFactory.csvFileAccessFailed(`[CSVProcessor] Failed to open CSV file (${filePath}): ${error instanceof Error ? error.message : String(error)}`, filePath, error instanceof Error ? error : undefined);
        }
        // The handle is opened through fs.promises because its `flags` accepts a
        // number; the stream then owns it (autoClose), so destroy() on the error
        // paths below still releases the descriptor. Three flags do the work:
        //
        // - `followSymlinks: false` adds O_NOFOLLOW (0 on Windows, so a no-op
        //   there): the sandboxed caller has already checked the real path, and a
        //   link that appears at the final component afterwards must fail with
        //   ELOOP rather than redirect the read.
        // - O_NONBLOCK (0 where undefined) so a FIFO with no writer returns from
        //   open() at once instead of parking the call in the threadpool for ever,
        //   beyond the reach of any deadline. It is ignored for regular files.
        // - O_RDONLY, and then a stat on the opened handle: a CSV is a regular
        //   file, so a FIFO, a device or a directory is refused before the read
        //   phase, which is also what stops a device read from never ending.
        //
        // The open itself runs under the same wall-clock budget as the phases
        // after it (#1199). A handle that arrives after the deadline is closed
        // when it does, so the timed-out open cannot leak a descriptor.
        const noFollow = openOptions?.followSymlinks === false
            ? (fs.constants.O_NOFOLLOW ?? 0)
            : 0;
        const openFlags = fs.constants.O_RDONLY | noFollow | (fs.constants.O_NONBLOCK ?? 0);
        const openRegularFile = async () => {
            const opened = await fs.promises.open(filePath, openFlags);
            try {
                if (!(await opened.stat()).isFile()) {
                    throw new Error("not a regular file");
                }
            }
            catch (error) {
                await opened.close();
                throw error;
            }
            return opened;
        };
        const opening = openRegularFile();
        let handle;
        try {
            handle = await withTimeout(opening, remaining(), "[CSVProcessor] Timed out opening CSV file");
        }
        catch (error) {
            if (error instanceof TimeoutError) {
                void opening.then((late) => late.close(), () => undefined);
                logger.warn(`[CSVProcessor] Opening ${filePath} exceeded the ${timeoutMs}ms parse budget; returning an empty partial result`);
                return { rows: [], timedOut: true };
            }
            throw ErrorFactory.csvFileAccessFailed(`[CSVProcessor] Failed to open CSV file (${filePath}): ${error instanceof Error ? error.message : String(error)}`, filePath, error instanceof Error ? error : undefined);
        }
        // #368: a SINGLE read stream. We peek its leading bytes to sniff the
        // encoding (#362) and detect a metadata line + delimiter (#361), then
        // replay those bytes back onto the same stream — no second disk read.
        const rawSource = handle.createReadStream();
        let prepared;
        try {
            prepared = await withTimeout(this.prepareFileSource(rawSource, encoding, extname(filePath)), remaining(), "[CSVProcessor] Timed out preparing CSV file source");
        }
        catch (error) {
            rawSource.destroy();
            if (error instanceof TimeoutError) {
                // Same contract as streamParse's own timeout path (#379): report a
                // clean partial result instead of throwing, so the whole call never
                // hangs OR surfaces a timeout as an exception.
                logger.warn(`[CSVProcessor] Prepare phase exceeded the ${timeoutMs}ms parse budget for ${filePath}; returning an empty partial result`);
                return { rows: [], timedOut: true };
            }
            throw ErrorFactory.csvParseFailed(buildCsvParseErrorMessage({
                stage: "read",
                count: 0,
                location: filePath,
                cause: error instanceof Error ? error.message : String(error),
            }), { filePath }, error instanceof Error ? error : undefined);
        }
        return this.streamParse(prepared.source, rawSource, {
            maxRows: clampedMaxRows,
            skipLines: prepared.skipLines,
            timeoutMs: remaining(),
            location: filePath,
            delimiter: prepared.delimiter,
        });
    }
    /**
     * Peek the head of a raw file stream to resolve encoding, metadata-line
     * skipping, and the delimiter (#361), then return a decoded text stream
     * that still delivers the whole file from byte 0 (via `unshift`) — a
     * single disk read (#368/#362).
     */
    static prepareFileSource(rawSource, encodingOverride, extensionHint) {
        return new Promise((resolve, reject) => {
            const PEEK_LIMIT = 64 * 1024;
            const chunks = [];
            let total = 0;
            let settled = false;
            const cleanup = () => {
                rawSource.removeListener("data", onData);
                rawSource.removeListener("end", onEnd);
                rawSource.removeListener("error", onError);
            };
            // #361: resolve the metadata-line skip count and the delimiter
            // together from the same peeked/decoded text — an explicit `sep=`
            // line wins outright, otherwise fall back to frequency-based
            // detection (+ the file extension as a tie-breaker) over the
            // post-metadata lines.
            const resolveMeta = (text) => {
                const { dataLines, hasMetadataLine, explicitDelimiter } = stripMetadataLine(splitCsvLines(text));
                const delimiter = explicitDelimiter ?? detectDelimiterUtil(dataLines, extensionHint);
                return { hasMetadataLine, dataLines, delimiter };
            };
            const onData = (chunk) => {
                chunks.push(chunk);
                total += chunk.length;
                const head = Buffer.concat(chunks);
                // isCompleteBuffer=false: `head` is only a peek, not necessarily the
                // whole file (#1199) — see the confidence note on decodeBuffer.
                const { text, encoding } = decodeBuffer(head, encodingOverride, false);
                if (splitCsvLines(text).length >= 2 || total >= PEEK_LIMIT) {
                    if (settled) {
                        return;
                    }
                    settled = true;
                    cleanup();
                    const { hasMetadataLine, delimiter } = resolveMeta(text);
                    // Replay the consumed head bytes, then decode the full raw stream.
                    // NOTE (#1282): the encoding is committed from the peeked head to keep
                    // the parse streaming (which the parse-timeout guard #379 relies on).
                    // A file whose head is pure ASCII but which switches to a legacy
                    // encoding only later would be read as UTF-8; pass `encoding`
                    // explicitly for such files.
                    rawSource.pause();
                    rawSource.unshift(head);
                    const decodeStream = iconv.decodeStream(encoding);
                    rawSource.on("error", (e) => decodeStream.destroy(e));
                    resolve({
                        source: rawSource.pipe(decodeStream),
                        skipLines: hasMetadataLine ? 1 : 0,
                        delimiter,
                    });
                }
            };
            const onEnd = () => {
                if (settled) {
                    return;
                }
                settled = true;
                cleanup();
                // Whole (small) file already buffered — decode it directly. unshift on
                // an ended stream throws, so replay via an in-memory Readable instead.
                const head = Buffer.concat(chunks);
                const { text } = decodeBuffer(head, encodingOverride);
                const { hasMetadataLine, dataLines, delimiter } = resolveMeta(text);
                const data = hasMetadataLine ? dataLines.join("\n") : text;
                resolve({ source: Readable.from([data]), skipLines: 0, delimiter });
            };
            const onError = (error) => {
                if (settled) {
                    return;
                }
                settled = true;
                cleanup();
                reject(error);
            };
            rawSource.on("data", onData);
            rawSource.on("end", onEnd);
            rawSource.on("error", onError);
        });
    }
    /**
     * Parse CSV string to array of row objects
     * Exposed for use by tools that need direct CSV parsing
     *
     * @param csvString - CSV data as string
     * @param maxRows - Maximum rows to parse (default: 1000)
     * @param timeoutMs - Wall-clock parse cap; returns partial rows on timeout
     * @returns Array of row objects
     */
    static async parseCSVString(csvString, maxRows = 1000, timeoutMs = DEFAULT_CSV_STRING_PARSE_TIMEOUT_MS) {
        const { rows } = await this.parseCSVStringWithMeta(csvString, maxRows, timeoutMs);
        return rows;
    }
    /**
     * String-parse variant that also reports whether the parse timed out, used by
     * `process()` to surface `metadata.parseTimedOut`.
     */
    static async parseCSVStringWithMeta(csvString, maxRows = 1000, timeoutMs = DEFAULT_CSV_STRING_PARSE_TIMEOUT_MS) {
        if (typeof csvString !== "string" || csvString.trim().length === 0) {
            throw ErrorFactory.csvInvalidInput("CSVProcessor.parseCSVString: csvString must be a non-empty string");
        }
        // Strip a leading UTF-8 BOM (common in Excel exports) so it does not glue
        // onto the first column name and break downstream key lookups.
        const normalized = stripBom(csvString);
        const clampedMaxRows = Math.max(1, Math.min(10000, maxRows));
        logger.debug("[CSVProcessor] Starting string parsing", {
            inputLength: normalized.length,
            maxRows: clampedMaxRows,
        });
        // Detect and skip a leading metadata line before detecting the
        // delimiter, so a `sep=` preamble can't skew detection (#361). No
        // caller currently needs to override the delimiter here (#1192's own
        // tests rely on self-detection), so it's always auto-detected.
        const lines = splitCsvLines(normalized);
        const { dataLines, hasMetadataLine, explicitDelimiter } = stripMetadataLine(lines);
        const effectiveDelimiter = explicitDelimiter ?? detectDelimiterUtil(dataLines);
        if (hasMetadataLine) {
            logger.debug("[CSVProcessor] Detected metadata line in string, skipping");
        }
        const csvData = dataLines.join("\n");
        if (csvData.trim().length === 0) {
            throw ErrorFactory.csvInvalidInput("CSVProcessor.parseCSVString: csvString must be a non-empty string");
        }
        return this.streamParse(Readable.from([csvData]), undefined, {
            maxRows: clampedMaxRows,
            skipLines: 0,
            timeoutMs,
            delimiter: effectiveDelimiter,
        });
    }
    /**
     * Shared streaming consumer for both parse methods. Guarantees:
     *  - source, rawSource, and parser are all destroyed on ANY settle path
     *    (#371) — `rawSource` matters for the file-parse path, where `source`
     *    is a decode Transform piped FROM `rawSource`; `.pipe()` does not
     *    propagate `.destroy()` upstream, so without this the underlying
     *    `fs.createReadStream` fd would leak on abort/timeout/error,
     *  - a wall-clock timeout returns partial rows instead of hanging (#379),
     *  - every row is validated at the boundary (#384),
     *  - reject messages carry columns/last-row-shape/format context (#375),
     *  - the delimiter (#361, comma/tab/semicolon/pipe) detected by the caller
     *    is honored, instead of csv-parser's hard-coded comma default.
     *
     * @param rawSource - The original file stream `source` was piped from, when
     *   applicable (file-parse path only; `undefined` for string parsing).
     */
    static streamParse(source, rawSource, opts) {
        return new Promise((resolve, reject) => {
            const rows = [];
            let count = 0;
            let settled = false;
            let capturedHeaders;
            let lastRowColumnCount;
            const startTime = Date.now();
            // maxRowBytes bounds a single-row allocation (#371) — without it a
            // pathological unbroken row grows memory unbounded and never errors.
            // skipLines drops any pre-header metadata line (e.g. Excel's `sep=,`)
            // BEFORE csv-parser reads the header, so the real header is used.
            // separator (#361) defaults to comma when the caller has no better
            // signal, matching csv-parser's own default.
            const parser = csvParser({
                separator: opts.delimiter ?? ",",
                maxRowBytes: CSV_MAX_ROW_BYTES,
                ...(opts.skipLines > 0 ? { skipLines: opts.skipLines } : {}),
            });
            const abort = () => {
                source.destroy();
                rawSource?.destroy();
                parser.destroy();
            };
            const finish = (fn) => {
                if (settled) {
                    return;
                }
                settled = true;
                clearTimeout(timer);
                fn();
            };
            const timer = setTimeout(() => {
                finish(() => {
                    logger.warn(`[CSVProcessor] Parse timed out after ${Date.now() - startTime}ms with ${rows.length} partial row(s)`);
                    abort();
                    resolve({ rows, timedOut: true, headers: capturedHeaders });
                });
            }, opts.timeoutMs);
            if (typeof timer.unref === "function") {
                timer.unref();
            }
            parser.on("headers", (h) => {
                capturedHeaders = h;
            });
            // .pipe() does not forward source errors (disk/permission failures) to
            // the destination, so listen on the source directly or it throws.
            source.on("error", (error) => {
                finish(() => {
                    abort();
                    reject(ErrorFactory.csvParseFailed(buildCsvParseErrorMessage({
                        stage: "read",
                        count,
                        headerCount: capturedHeaders?.length,
                        lastRowColumnCount,
                        location: opts.location,
                        cause: error.message,
                    }), { location: opts.location, rowCount: count }, error));
                });
            });
            source
                .pipe(parser)
                .on("data", (row) => {
                try {
                    assertValidCsvRow(row, count + 1);
                }
                catch (err) {
                    finish(() => {
                        abort();
                        reject(err);
                    });
                    return;
                }
                // #373: blank rows must not consume maxRows when skipping empties.
                if (opts.skipEmptyRows && isBlankCsvDataRow(row)) {
                    return;
                }
                lastRowColumnCount = Object.keys(row).length;
                rows.push(row);
                count++;
                if (count >= opts.maxRows) {
                    logger.debug(`[CSVProcessor] Reached row limit ${opts.maxRows}, stopping parse`);
                    finish(() => {
                        abort();
                        resolve({ rows, timedOut: false, headers: capturedHeaders });
                    });
                }
            })
                .on("end", () => {
                finish(() => {
                    logger.debug(`[CSVProcessor] Parsing complete: ${rows.length} rows parsed`);
                    resolve({ rows, timedOut: false, headers: capturedHeaders });
                });
            })
                .on("error", (error) => {
                // #371: destroy the source too — .pipe() does NOT auto-destroy the
                // upstream when the parser Transform errors, leaking the fd/stream.
                finish(() => {
                    abort();
                    logger.error("[CSVProcessor] Parsing failed:", error);
                    reject(ErrorFactory.csvParseFailed(buildCsvParseErrorMessage({
                        stage: "parse",
                        count,
                        headerCount: capturedHeaders?.length,
                        lastRowColumnCount,
                        location: opts.location,
                        cause: error.message,
                    }), { location: opts.location, rowCount: count }, error));
                });
            });
        });
    }
    /**
     * Format parsed CSV data for LLM consumption
     * Only used for JSON and Markdown formats (raw format handled separately)
     *
     * @param columnNames - Optional schema headers (#373). When preserved blank
     *   rows lead `rows`, Markdown must still use the real column names.
     */
    static formatForLLM(rows, formatStyle, includeHeaders, columnNames) {
        if (rows.length === 0) {
            return "CSV file is empty or contains no data.";
        }
        if (formatStyle === "json") {
            return JSON.stringify(rows, null, 2);
        }
        return this.toMarkdownTable(rows, includeHeaders, columnNames);
    }
    /**
     * Format as markdown table
     * Best for small datasets (<100 rows)
     *
     * @param columnNames - Optional explicit headers (#373). Falls back to
     *   `Object.keys(rows[0])` when omitted.
     */
    static toMarkdownTable(rows, includeHeaders, columnNames) {
        if (rows.length === 0) {
            return "CSV file is empty or contains no data.";
        }
        const headers = columnNames && columnNames.length > 0
            ? columnNames
            : Object.keys(rows[0] ?? {});
        if (headers.length === 0) {
            return "CSV file is empty or contains no data.";
        }
        // Escape backslashes, pipes, and sanitize newlines to keep rows intact
        const escapePipe = (str) => str
            .replace(/\\/g, "\\\\")
            .replace(/\|/g, "\\|")
            .replace(/\r\n|\n|\r/g, " ");
        let markdown = "";
        if (includeHeaders) {
            markdown = "| " + headers.map(escapePipe).join(" | ") + " |\n";
            markdown += "|" + headers.map(() => " --- ").join("|") + "|\n";
        }
        rows.forEach((row) => {
            markdown +=
                "| " +
                    headers.map((h) => escapePipe(String(row[h] || ""))).join(" | ") +
                    " |\n";
        });
        return markdown;
    }
    /**
     * Format sample data according to the specified format
     *
     * @param sampleRows - Array of sample row objects
     * @param format - Output format for sample data
     * @param includeHeaders - Whether to include headers in CSV/markdown formats
     * @param columnNames - Optional schema headers for csv/markdown (#373)
     * @returns Formatted sample data as string or array
     */
    static formatSampleData(sampleRows, format, includeHeaders, columnNames) {
        if (sampleRows.length === 0) {
            return format === "object" ? [] : "No data rows";
        }
        switch (format) {
            case "object":
                return sampleRows;
            case "json":
                return JSON.stringify(sampleRows, null, 2);
            case "csv":
                return this.toCSVString(sampleRows, includeHeaders, columnNames);
            case "markdown":
                return this.toMarkdownTable(sampleRows, includeHeaders, columnNames);
            default:
                return sampleRows;
        }
    }
    /**
     * Convert row objects to CSV string format
     *
     * @param rows - Array of row objects
     * @param includeHeaders - Whether to include header row
     * @param columnNames - Optional explicit headers (#373)
     * @returns CSV formatted string
     */
    static toCSVString(rows, includeHeaders, columnNames) {
        if (rows.length === 0) {
            return "";
        }
        const headers = columnNames && columnNames.length > 0
            ? columnNames
            : Object.keys(rows[0] ?? {});
        if (headers.length === 0) {
            return "";
        }
        // Escape CSV values (wrap in quotes if contains comma, quote, or newline)
        const escapeCSV = (value) => {
            if (value.includes(",") ||
                value.includes('"') ||
                /\r\n|\n|\r/.test(value)) {
                return `"${value.replace(/"/g, '""')}"`;
            }
            return value;
        };
        const lines = [];
        if (includeHeaders) {
            lines.push(headers.map(escapeCSV).join(","));
        }
        rows.forEach((row) => {
            const values = headers.map((h) => escapeCSV(String(row[h] ?? "")));
            lines.push(values.join(","));
        });
        return lines.join("\n");
    }
}