#!/usr/bin/env node // Describe statement files without revealing their contents. // // node scripts/profile-samples.mjs [folder] (default: samples) // // Prints, for each file: its layout, header labels, and value *shapes* only. // Every letter becomes A/a and every digit 9, so "KROGER #123" reads // "AAAAAA #999" and "-54.20" reads "-99.99". Rows made only of labels (no // digits at all, such as a CSV header) are shown as they are. This is what // Claude reads to build importers; the raw files never leave this server. import { readdirSync, readFileSync, statSync } from 'node:fs'; import { basename, extname, join, relative } from 'node:path'; const root = process.argv[2] ?? 'samples'; const SKIP = new Set(['README.md', '.DS_Store']); const mask = (s) => s.replace(/[A-Z]/g, 'A').replace(/[a-z]/g, 'a').replace(/[0-9]/g, '9').replace(/[^\x20-\x7e]/g, '?'); const shape = (s) => s.trim() === '' ? '(empty)' : s.trim().replace(/[0-9]+/g, '9').replace(/[A-Z]+/g, 'A').replace(/[a-z]+/g, 'a').replace(/[^\x20-\x7e]/g, '?'); const maskName = (name) => name.replace(/[0-9]{3,}/g, (d) => '9'.repeat(d.length)); function files(dir) { return readdirSync(dir).flatMap((name) => { if (SKIP.has(name) || name.startsWith('_') || name.startsWith('.')) return []; const path = join(dir, name); return statSync(path).isDirectory() ? files(path) : [path]; }); } /** A small CSV reader that understands quoted fields and embedded commas or newlines. */ function parseCsv(text, delimiter) { const rows = []; let row = []; let field = ''; let quoted = false; for (let i = 0; i < text.length; i++) { const ch = text[i]; if (quoted) { if (ch === '"' && text[i + 1] === '"') { field += '"'; i++; } else if (ch === '"') quoted = false; else field += ch; } else if (ch === '"') quoted = true; else if (ch === delimiter) { row.push(field); field = ''; } else if (ch === '\n' || ch === '\r') { if (ch === '\r' && text[i + 1] === '\n') i++; row.push(field); rows.push(row); row = []; field = ''; } else field += ch; } if (field !== '' || row.length) { row.push(field); rows.push(row); } return rows; } const isLabelRow = (cells) => cells.filter((c) => c.trim()).length >= 2 && cells.every((c) => !/[0-9]/.test(c)); const showRow = (cells) => (isLabelRow(cells) ? cells.map((c) => c.trim()) : cells.map(mask)); const numeric = (s) => /^\s*[-+(]?\s*\$?\s*[0-9][0-9,]*(\.[0-9]+)?\s*\)?\s*(CR|DR)?\s*$/i.test(s); function profileCsv(text) { const firstLines = text.split(/\r?\n/).slice(0, 20).join('\n'); const counts = { ',': 0, '\t': 0, ';': 0, '|': 0 }; for (const ch of firstLines) if (ch in counts) counts[ch]++; const delimiter = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0]; const rows = parseCsv(text, delimiter).filter((r) => r.some((c) => c.trim() !== '')); const out = []; out.push(`- Delimiter: ${JSON.stringify(delimiter)} · rows (non-blank): ${rows.length}`); const widths = {}; for (const r of rows) widths[r.length] = (widths[r.length] ?? 0) + 1; out.push(`- Columns per row: ${Object.entries(widths).map(([w, n]) => `${w} cols × ${n}`).join(', ')}`); const headerIndex = rows.findIndex((r, i) => isLabelRow(r) && rows.slice(i + 1, i + 4).some((n) => n.length === r.length)); out.push(`- Header row: ${headerIndex >= 0 ? `row ${headerIndex + 1}` : 'none found'}`); out.push('', '```text'); rows.slice(0, 12).forEach((r, i) => out.push(`${String(i + 1).padStart(3)}: ${showRow(r).join(' | ')}`)); if (rows.length > 15) out.push(' …'); rows.slice(Math.max(12, rows.length - 3)).forEach((r, i, arr) => out.push(`${String(rows.length - arr.length + i + 1).padStart(3)}: ${showRow(r).join(' | ')}`)); out.push('```'); if (headerIndex >= 0) { const header = rows[headerIndex]; const data = rows.slice(headerIndex + 1).filter((r) => r.length === header.length); out.push('', `Columns (${data.length} data rows):`, ''); header.forEach((name, col) => { const values = data.map((r) => r[col] ?? ''); const shapes = {}; for (const v of values) shapes[shape(v)] = (shapes[shape(v)] ?? 0) + 1; const top = Object.entries(shapes) .sort((a, b) => b[1] - a[1]) .slice(0, 4) .map(([s, n]) => `\`${s}\` ×${n}`) .join(', '); let extra = ''; const nums = values.filter((v) => numeric(v)); if (nums.length >= values.length * 0.6 && nums.length > 0) { const neg = nums.filter((v) => /^\s*-|\(|DR/i.test(v)).length; extra = ` · numeric: ${nums.length - neg} positive, ${neg} negative`; } else { const distinct = new Set(values.map((v) => v.trim()).filter(Boolean)).size; extra = ` · ${distinct} distinct values`; if (/^\d{1,2}\/\d{1,2}\/\d{2,4}$/.test(values.find((v) => v.trim()) ?? '')) { const firsts = values.map((v) => Number(v.split('/')[0])).filter(Number.isFinite); const seconds = values.map((v) => Number(v.split('/')[1])).filter(Number.isFinite); extra += ` · first part max ${Math.max(...firsts)}, second part max ${Math.max(...seconds)}`; } } out.push(`- **${name.trim() || `(column ${col + 1})`}**: ${top}${extra}`); }); } return out; } function profileOfx(text) { const out = []; const headerLines = text.split(/\r?\n/).filter((l) => /^[A-Z]+:[A-Z0-9]+$/.test(l.trim())); if (headerLines.length) out.push(`- OFX header: ${headerLines.join(' ')}`); const tags = {}; const examples = {}; for (const m of text.matchAll(/<([A-Z0-9.]+)>([^<\r\n]*)/g)) { tags[m[1]] = (tags[m[1]] ?? 0) + 1; if (m[2].trim() && !examples[m[1]]) examples[m[1]] = mask(m[2].trim()); } out.push(`- Transactions (STMTTRN): ${tags.STMTTRN ?? 0}`); out.push('', 'Tags (count, masked example):', ''); for (const [tag, n] of Object.entries(tags)) out.push(`- ${tag} ×${n}${examples[tag] ? ` · \`${examples[tag]}\`` : ''}`); return out; } const list = files(root); console.log(`# Sample profiles\n\n${list.length} file(s) under ${root}/. Values are masked: letters → A/a, digits → 9.\n`); for (const path of list) { const buffer = readFileSync(path); const bom = buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf; const text = buffer.toString('utf8').replace(/^/, ''); const ext = extname(path).toLowerCase(); const rel = relative(root, path); console.log(`## ${maskName(rel)}\n`); console.log(`- Size: ${buffer.length} bytes · extension: ${ext || '(none)'} · BOM: ${bom ? 'yes' : 'no'} · line endings: ${text.includes('\r\n') ? 'CRLF' : 'LF'} · non-ASCII bytes: ${/[^\x00-\x7f]/.test(text) ? 'yes' : 'no'}`); const looksOfx = /|OFXHEADER/i.test(text.slice(0, 2000)); const lines = looksOfx ? profileOfx(text) : profileCsv(text); console.log(lines.join('\n') + '\n'); } void basename;