@crewhaus/tool-data 0.7.0

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.
@@ -0,0 +1,80 @@
1
+ /**
2
+ * CSV reading and writing to RFC 4180, plus the two departures from it that
3
+ * real files require: a configurable delimiter (tab, semicolon, pipe) and
4
+ * tolerance of bare LF line endings as well as CRLF.
5
+ *
6
+ * What is handled: quoted fields, delimiters and newlines inside quotes,
7
+ * doubled quotes as an escaped quote, a final row with no trailing newline,
8
+ * a row that is a single quoted empty field (`""`, which is a row and not a
9
+ * blank line), and ragged rows (reported, not silently padded, unless
10
+ * asked).
11
+ *
12
+ * What is not: a byte-order mark is stripped but other encodings are the
13
+ * caller's problem, and there is no support for backslash escapes, which
14
+ * RFC 4180 does not define.
15
+ */
16
+ export type CsvParseOptions = {
17
+ delimiter: string;
18
+ quote: string;
19
+ /** Drop lines that are entirely empty, rather than yielding a one-empty-field row. */
20
+ skipEmptyLines: boolean;
21
+ /** Trim surrounding whitespace from every unquoted field. Quoted fields are never trimmed. */
22
+ trim: boolean;
23
+ /** Stop after this many rows (header included) and report the truncation. */
24
+ maxRows: number;
25
+ };
26
+ export type CsvParseResult = {
27
+ rows: string[][];
28
+ truncated: boolean;
29
+ };
30
+ export declare class CsvError extends Error {
31
+ readonly line: number;
32
+ constructor(message: string, line: number);
33
+ }
34
+ /** Split CSV text into rows of raw string fields. Throws `CsvError` on an unterminated quote. */
35
+ export declare function parseCsv(text: string, options: CsvParseOptions): CsvParseResult;
36
+ /**
37
+ * Best-effort scalar typing of a CSV cell: integers and decimals become
38
+ * numbers, `true`/`false` (any case) become booleans, an empty cell and the
39
+ * configured null tokens become null, everything else stays a string.
40
+ *
41
+ * Leading zeros (`007`), leading `+`, and values outside the safe integer
42
+ * range stay strings, because turning a zip code or an account number into a
43
+ * number loses data.
44
+ */
45
+ export declare function inferScalar(raw: string, nullTokens: ReadonlyArray<string>): unknown;
46
+ /** Turn rows into records using the first row as the header. */
47
+ export declare function rowsToRecords(rows: ReadonlyArray<ReadonlyArray<string>>, header: ReadonlyArray<string>, infer: boolean, nullTokens: ReadonlyArray<string>): {
48
+ records: Record<string, unknown>[];
49
+ ragged: number[];
50
+ };
51
+ /**
52
+ * Make header names unique and non-empty, so records never lose a column.
53
+ *
54
+ * The suffix is bumped until the name is genuinely unused, not merely until
55
+ * the base name's counter advances: `a, a, a_2` has to come back as
56
+ * `a, a_2, a_3`, because emitting `a_2` twice would collapse two columns
57
+ * into one when the rows are turned into records.
58
+ */
59
+ export declare function normalizeHeader(raw: ReadonlyArray<string>): string[];
60
+ export type CsvWriteOptions = {
61
+ delimiter: string;
62
+ quote: string;
63
+ /** "\n" or "\r\n"; RFC 4180 specifies CRLF, but LF is what most tools now emit. */
64
+ newline: string;
65
+ /** Quote every field, not only the ones that need it. */
66
+ quoteAll: boolean;
67
+ header: string[] | null;
68
+ };
69
+ /** Quote a single field if it contains the delimiter, a quote, or a line break. */
70
+ export declare function escapeCsvField(value: string, options: CsvWriteOptions): string;
71
+ /**
72
+ * Render a cell value. `null` and `undefined` become the empty field;
73
+ * objects and arrays become their compact JSON, because silently emitting
74
+ * `[object Object]` is how a pipeline loses data without noticing.
75
+ */
76
+ export declare function cellToString(value: unknown): string;
77
+ /** Serialize rows of already-stringified cells. */
78
+ export declare function writeCsvRows(rows: ReadonlyArray<ReadonlyArray<unknown>>, options: CsvWriteOptions): string;
79
+ /** Column order for a set of records: keys in first-seen order across every record. */
80
+ export declare function unionKeys(records: ReadonlyArray<Record<string, unknown>>): string[];
@@ -0,0 +1,240 @@
1
+ /**
2
+ * CSV reading and writing to RFC 4180, plus the two departures from it that
3
+ * real files require: a configurable delimiter (tab, semicolon, pipe) and
4
+ * tolerance of bare LF line endings as well as CRLF.
5
+ *
6
+ * What is handled: quoted fields, delimiters and newlines inside quotes,
7
+ * doubled quotes as an escaped quote, a final row with no trailing newline,
8
+ * a row that is a single quoted empty field (`""`, which is a row and not a
9
+ * blank line), and ragged rows (reported, not silently padded, unless
10
+ * asked).
11
+ *
12
+ * What is not: a byte-order mark is stripped but other encodings are the
13
+ * caller's problem, and there is no support for backslash escapes, which
14
+ * RFC 4180 does not define.
15
+ */
16
+ export class CsvError extends Error {
17
+ line;
18
+ constructor(message, line) {
19
+ super(message);
20
+ this.line = line;
21
+ }
22
+ }
23
+ /** Split CSV text into rows of raw string fields. Throws `CsvError` on an unterminated quote. */
24
+ export function parseCsv(text, options) {
25
+ const { delimiter, quote } = options;
26
+ if (delimiter.length !== 1)
27
+ throw new CsvError("delimiter must be a single character", 0);
28
+ if (quote.length !== 1)
29
+ throw new CsvError("quote must be a single character", 0);
30
+ if (delimiter === quote)
31
+ throw new CsvError("delimiter and quote must differ", 0);
32
+ const src = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
33
+ const rows = [];
34
+ let row = [];
35
+ let field = "";
36
+ let quoted = false;
37
+ let fieldWasQuoted = false;
38
+ // Whether anything in the current row was written with quotes. A row that
39
+ // is a single *quoted* empty field (`""`) is a real one-field row and must
40
+ // not be mistaken for a blank line.
41
+ let rowHadQuote = false;
42
+ let line = 1;
43
+ // Where the currently-open quote started, so an unterminated one points at
44
+ // the opening quote rather than at the end of the file.
45
+ let quoteStartLine = 1;
46
+ let truncated = false;
47
+ const endField = () => {
48
+ row.push(options.trim && !fieldWasQuoted ? field.trim() : field);
49
+ field = "";
50
+ fieldWasQuoted = false;
51
+ };
52
+ const endRow = () => {
53
+ endField();
54
+ const blank = row.length === 1 && row[0] === "" && !rowHadQuote;
55
+ if (!(options.skipEmptyLines && blank))
56
+ rows.push(row);
57
+ row = [];
58
+ rowHadQuote = false;
59
+ if (rows.length >= options.maxRows) {
60
+ truncated = true;
61
+ return false;
62
+ }
63
+ return true;
64
+ };
65
+ for (let i = 0; i < src.length; i++) {
66
+ const c = src[i];
67
+ if (quoted) {
68
+ if (c === quote) {
69
+ if (src[i + 1] === quote) {
70
+ field += quote;
71
+ i += 1;
72
+ continue;
73
+ }
74
+ quoted = false;
75
+ continue;
76
+ }
77
+ if (c === "\n")
78
+ line += 1;
79
+ field += c;
80
+ continue;
81
+ }
82
+ if (c === quote && field === "" && !fieldWasQuoted) {
83
+ quoted = true;
84
+ fieldWasQuoted = true;
85
+ rowHadQuote = true;
86
+ quoteStartLine = line;
87
+ continue;
88
+ }
89
+ if (c === delimiter) {
90
+ endField();
91
+ continue;
92
+ }
93
+ if (c === "\r") {
94
+ if (src[i + 1] === "\n")
95
+ i += 1;
96
+ line += 1;
97
+ if (!endRow())
98
+ return { rows, truncated };
99
+ continue;
100
+ }
101
+ if (c === "\n") {
102
+ line += 1;
103
+ if (!endRow())
104
+ return { rows, truncated };
105
+ continue;
106
+ }
107
+ field += c;
108
+ }
109
+ if (quoted) {
110
+ throw new CsvError(`unterminated quoted field — the quote opened on line ${quoteStartLine} is never closed`, quoteStartLine);
111
+ }
112
+ // A trailing newline should not manufacture an extra empty row — but a
113
+ // final `""` is a row, so the quote flag has to be part of the test.
114
+ if (field !== "" || row.length > 0 || fieldWasQuoted)
115
+ endRow();
116
+ return { rows, truncated };
117
+ }
118
+ /**
119
+ * Best-effort scalar typing of a CSV cell: integers and decimals become
120
+ * numbers, `true`/`false` (any case) become booleans, an empty cell and the
121
+ * configured null tokens become null, everything else stays a string.
122
+ *
123
+ * Leading zeros (`007`), leading `+`, and values outside the safe integer
124
+ * range stay strings, because turning a zip code or an account number into a
125
+ * number loses data.
126
+ */
127
+ export function inferScalar(raw, nullTokens) {
128
+ const s = raw.trim();
129
+ if (s === "")
130
+ return null;
131
+ if (nullTokens.includes(s))
132
+ return null;
133
+ const lower = s.toLowerCase();
134
+ if (lower === "true")
135
+ return true;
136
+ if (lower === "false")
137
+ return false;
138
+ if (/^-?(0|[1-9][0-9]*)$/.test(s)) {
139
+ const n = Number(s);
140
+ return Number.isSafeInteger(n) ? n : raw;
141
+ }
142
+ if (/^-?(0|[1-9][0-9]*)\.[0-9]+$/.test(s))
143
+ return Number(s);
144
+ return raw;
145
+ }
146
+ /** Turn rows into records using the first row as the header. */
147
+ export function rowsToRecords(rows, header, infer, nullTokens) {
148
+ const records = [];
149
+ const ragged = [];
150
+ rows.forEach((row, i) => {
151
+ if (row.length !== header.length)
152
+ ragged.push(i);
153
+ const rec = {};
154
+ header.forEach((name, j) => {
155
+ const cell = row[j];
156
+ if (cell === undefined) {
157
+ rec[name] = null;
158
+ return;
159
+ }
160
+ rec[name] = infer ? inferScalar(cell, nullTokens) : cell;
161
+ });
162
+ records.push(rec);
163
+ });
164
+ return { records, ragged };
165
+ }
166
+ /**
167
+ * Make header names unique and non-empty, so records never lose a column.
168
+ *
169
+ * The suffix is bumped until the name is genuinely unused, not merely until
170
+ * the base name's counter advances: `a, a, a_2` has to come back as
171
+ * `a, a_2, a_3`, because emitting `a_2` twice would collapse two columns
172
+ * into one when the rows are turned into records.
173
+ */
174
+ export function normalizeHeader(raw) {
175
+ const counts = new Map();
176
+ const taken = new Set();
177
+ return raw.map((name, i) => {
178
+ const base = name.trim() === "" ? `column${i + 1}` : name.trim();
179
+ let count = counts.get(base) ?? 0;
180
+ let candidate = count === 0 ? base : `${base}_${count + 1}`;
181
+ while (taken.has(candidate)) {
182
+ count += 1;
183
+ candidate = `${base}_${count + 1}`;
184
+ }
185
+ counts.set(base, count + 1);
186
+ taken.add(candidate);
187
+ return candidate;
188
+ });
189
+ }
190
+ /** Quote a single field if it contains the delimiter, a quote, or a line break. */
191
+ export function escapeCsvField(value, options) {
192
+ const needs = options.quoteAll ||
193
+ value.includes(options.delimiter) ||
194
+ value.includes(options.quote) ||
195
+ value.includes("\n") ||
196
+ value.includes("\r");
197
+ if (!needs)
198
+ return value;
199
+ const q = options.quote;
200
+ return q + value.split(q).join(q + q) + q;
201
+ }
202
+ /**
203
+ * Render a cell value. `null` and `undefined` become the empty field;
204
+ * objects and arrays become their compact JSON, because silently emitting
205
+ * `[object Object]` is how a pipeline loses data without noticing.
206
+ */
207
+ export function cellToString(value) {
208
+ if (value === null || value === undefined)
209
+ return "";
210
+ if (typeof value === "string")
211
+ return value;
212
+ if (typeof value === "number" || typeof value === "boolean")
213
+ return String(value);
214
+ return JSON.stringify(value);
215
+ }
216
+ /** Serialize rows of already-stringified cells. */
217
+ export function writeCsvRows(rows, options) {
218
+ const lines = [];
219
+ if (options.header !== null) {
220
+ lines.push(options.header.map((h) => escapeCsvField(h, options)).join(options.delimiter));
221
+ }
222
+ for (const row of rows) {
223
+ lines.push(row.map((cell) => escapeCsvField(cellToString(cell), options)).join(options.delimiter));
224
+ }
225
+ return lines.join(options.newline);
226
+ }
227
+ /** Column order for a set of records: keys in first-seen order across every record. */
228
+ export function unionKeys(records) {
229
+ const out = [];
230
+ const seen = new Set();
231
+ for (const rec of records) {
232
+ for (const k of Object.keys(rec)) {
233
+ if (!seen.has(k)) {
234
+ seen.add(k);
235
+ out.push(k);
236
+ }
237
+ }
238
+ }
239
+ return out;
240
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Deep structural diff of two JSON values.
3
+ *
4
+ * Objects are compared key by key; arrays are compared index by index, which
5
+ * means an insertion at the front reports every later index as changed. That
6
+ * is the honest answer for positional data (a config array, a tuple) and the
7
+ * wrong one for a list of records — for records, key the arrays first with
8
+ * `keyArraysBy`, which matches elements by a field instead of by position.
9
+ */
10
+ export type DiffKind = "added" | "removed" | "changed";
11
+ export type DiffEntry = {
12
+ path: string;
13
+ kind: DiffKind;
14
+ /** The type on each side, so a `1` -> `"1"` change is visible. */
15
+ from?: unknown;
16
+ to?: unknown;
17
+ fromType?: string;
18
+ toType?: string;
19
+ };
20
+ export type DiffOptions = {
21
+ /**
22
+ * Match array elements by the value of this field instead of by index.
23
+ * The keying is all-or-nothing per array: if *either* side has an element
24
+ * without the field, with a non-scalar value for it, or with a value that
25
+ * repeats, that whole array is compared positionally instead. Nested
26
+ * arrays are considered independently.
27
+ */
28
+ keyArraysBy?: string;
29
+ /** Stop descending past this depth; deeper differences report as one `changed`. */
30
+ maxDepth: number;
31
+ /** Stop after this many entries, so a total rewrite cannot fill a context window. */
32
+ maxEntries: number;
33
+ };
34
+ export type DiffResult = {
35
+ entries: DiffEntry[];
36
+ truncated: boolean;
37
+ counts: {
38
+ added: number;
39
+ removed: number;
40
+ changed: number;
41
+ };
42
+ };
43
+ /** Compare two values, returning a flat list of differences by JSON Pointer path. */
44
+ export declare function deepDiff(a: unknown, b: unknown, options: DiffOptions): DiffResult;
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Deep structural diff of two JSON values.
3
+ *
4
+ * Objects are compared key by key; arrays are compared index by index, which
5
+ * means an insertion at the front reports every later index as changed. That
6
+ * is the honest answer for positional data (a config array, a tuple) and the
7
+ * wrong one for a list of records — for records, key the arrays first with
8
+ * `keyArraysBy`, which matches elements by a field instead of by position.
9
+ */
10
+ import { deepEqual, isPlainObject, typeOf } from "./json";
11
+ import { formatPointer } from "./patch";
12
+ /** Compare two values, returning a flat list of differences by JSON Pointer path. */
13
+ export function deepDiff(a, b, options) {
14
+ const entries = [];
15
+ let truncated = false;
16
+ const push = (entry) => {
17
+ if (entries.length >= options.maxEntries) {
18
+ truncated = true;
19
+ return;
20
+ }
21
+ entries.push(entry);
22
+ };
23
+ const recurse = (left, right, path, depth) => {
24
+ if (truncated)
25
+ return;
26
+ // Equality first, then the budget: returning early on a full budget
27
+ // *before* knowing there is a difference would leave `truncated` false
28
+ // while differences went unreported, which is exactly the silence this
29
+ // tool exists to avoid.
30
+ if (deepEqual(left, right))
31
+ return;
32
+ if (entries.length >= options.maxEntries) {
33
+ truncated = true;
34
+ return;
35
+ }
36
+ const pointer = formatPointer(path) || "/";
37
+ if (depth >= options.maxDepth) {
38
+ push({
39
+ path: pointer,
40
+ kind: "changed",
41
+ from: left,
42
+ to: right,
43
+ fromType: typeOf(left),
44
+ toType: typeOf(right),
45
+ });
46
+ return;
47
+ }
48
+ if (isPlainObject(left) && isPlainObject(right)) {
49
+ for (const key of Object.keys(left)) {
50
+ if (!Object.hasOwn(right, key)) {
51
+ push({
52
+ path: formatPointer([...path, key]),
53
+ kind: "removed",
54
+ from: left[key],
55
+ fromType: typeOf(left[key]),
56
+ });
57
+ continue;
58
+ }
59
+ recurse(left[key], right[key], [...path, key], depth + 1);
60
+ }
61
+ for (const key of Object.keys(right)) {
62
+ if (!Object.hasOwn(left, key)) {
63
+ push({
64
+ path: formatPointer([...path, key]),
65
+ kind: "added",
66
+ to: right[key],
67
+ toType: typeOf(right[key]),
68
+ });
69
+ }
70
+ }
71
+ return;
72
+ }
73
+ if (Array.isArray(left) && Array.isArray(right)) {
74
+ const key = options.keyArraysBy;
75
+ if (key !== undefined && canKey(left, key) && canKey(right, key)) {
76
+ diffKeyedArrays(left, right, key, path, depth, recurse, push);
77
+ return;
78
+ }
79
+ const shared = Math.min(left.length, right.length);
80
+ for (let i = 0; i < shared; i++) {
81
+ recurse(left[i], right[i], [...path, String(i)], depth + 1);
82
+ }
83
+ for (let i = shared; i < left.length; i++) {
84
+ push({
85
+ path: formatPointer([...path, String(i)]),
86
+ kind: "removed",
87
+ from: left[i],
88
+ fromType: typeOf(left[i]),
89
+ });
90
+ }
91
+ for (let i = shared; i < right.length; i++) {
92
+ push({
93
+ path: formatPointer([...path, String(i)]),
94
+ kind: "added",
95
+ to: right[i],
96
+ toType: typeOf(right[i]),
97
+ });
98
+ }
99
+ return;
100
+ }
101
+ push({
102
+ path: pointer,
103
+ kind: "changed",
104
+ from: left,
105
+ to: right,
106
+ fromType: typeOf(left),
107
+ toType: typeOf(right),
108
+ });
109
+ };
110
+ recurse(a, b, [], 0);
111
+ const counts = { added: 0, removed: 0, changed: 0 };
112
+ for (const e of entries)
113
+ counts[e.kind] += 1;
114
+ return { entries, truncated, counts };
115
+ }
116
+ /** True when every element is an object carrying a distinct, scalar value for `key`. */
117
+ function canKey(arr, key) {
118
+ const seen = new Set();
119
+ for (const el of arr) {
120
+ if (!isPlainObject(el))
121
+ return false;
122
+ const v = el[key];
123
+ if (v === undefined || v === null || typeof v === "object")
124
+ return false;
125
+ const s = String(v);
126
+ if (seen.has(s))
127
+ return false;
128
+ seen.add(s);
129
+ }
130
+ return true;
131
+ }
132
+ function diffKeyedArrays(left, right, key, path, depth, recurse, push) {
133
+ const index = (arr) => {
134
+ const m = new Map();
135
+ arr.forEach((el, i) => {
136
+ const v = el[key];
137
+ m.set(String(v), { i, value: el });
138
+ });
139
+ return m;
140
+ };
141
+ const l = index(left);
142
+ const r = index(right);
143
+ for (const [k, entry] of l) {
144
+ const other = r.get(k);
145
+ if (other === undefined) {
146
+ push({
147
+ path: formatPointer([...path, String(entry.i)]),
148
+ kind: "removed",
149
+ from: entry.value,
150
+ fromType: typeOf(entry.value),
151
+ });
152
+ continue;
153
+ }
154
+ recurse(entry.value, other.value, [...path, String(other.i)], depth + 1);
155
+ }
156
+ for (const [k, entry] of r) {
157
+ if (!l.has(k)) {
158
+ push({
159
+ path: formatPointer([...path, String(entry.i)]),
160
+ kind: "added",
161
+ to: entry.value,
162
+ toType: typeOf(entry.value),
163
+ });
164
+ }
165
+ }
166
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The JSON primitives everything else in this package is built on: a value
3
+ * model, structural equality, a stable (key-sorted) serializer, and a
4
+ * content hash.
5
+ *
6
+ * Stability is the point. Two harness runs that produce the same data must
7
+ * produce the same bytes, or every downstream diff is noise.
8
+ */
9
+ export type JsonValue = null | boolean | number | string | JsonValue[] | {
10
+ [k: string]: JsonValue;
11
+ };
12
+ /** A plain object, as distinct from an array or a primitive. */
13
+ export declare function isPlainObject(value: unknown): value is Record<string, unknown>;
14
+ /** Structural equality over JSON values. Key order is irrelevant; NaN never appears in JSON. */
15
+ export declare function deepEqual(a: unknown, b: unknown): boolean;
16
+ /** A structured clone that only has to handle JSON shapes, so no cycles exist. */
17
+ export declare function deepClone<T>(value: T): T;
18
+ /**
19
+ * Serialize with object keys in sorted order at every depth, so that two
20
+ * equal values always produce identical bytes. Array order is data and is
21
+ * never touched.
22
+ *
23
+ * One caveat worth knowing rather than discovering: `JSON.stringify` emits
24
+ * an object's integer-like keys first, in ascending numeric order, whatever
25
+ * order they sit in. So `{"10":a,"2":b,"x":c}` serializes as `2, 10, x`, not
26
+ * the code-unit order `10, 2, x` that `sortKeysDeep` puts them in. The
27
+ * result is still canonical — two equal values still produce identical bytes
28
+ * — but it is not literally sorted for those keys.
29
+ *
30
+ * `indent` of 0 gives the compact form.
31
+ */
32
+ export declare function canonicalStringify(value: unknown, indent?: number): string;
33
+ /** Recursively rebuild objects with their keys in code-unit sorted order. */
34
+ export declare function sortKeysDeep(value: unknown): unknown;
35
+ /**
36
+ * FNV-1a, 64 bits, over the UTF-8 bytes of `text` — the algorithm exactly as
37
+ * Fowler/Noll/Vo define it, so the reference vectors hold: `""` hashes to
38
+ * `cbf29ce484222325`, `"a"` to `af63dc4c8601ec8c` and `"foobar"` to
39
+ * `85944171f73967e8`. `lib.test.ts` pins all three.
40
+ *
41
+ * It is a content fingerprint, not a cryptographic digest — never use it
42
+ * where collision resistance against an adversary matters.
43
+ */
44
+ export declare function fnv1a64(text: string): string;
45
+ /**
46
+ * A 64-bit FNV-1a hash of the canonical serialization, rendered as 16 hex
47
+ * characters. Two values that are `deepEqual` always hash the same, because
48
+ * the serialization sorts keys first.
49
+ *
50
+ * Use it as a fingerprint a caller can compare or record. Do not use it as
51
+ * an identity key inside this package: 64 bits collide, and the canonical
52
+ * string it is computed from is right there and is exact, which is what
53
+ * `totalCompare`, `aggregate`, `joinRecords` and `dedupeRecords` key on.
54
+ */
55
+ export declare function stableHash(value: unknown): string;
56
+ /** Parse JSON, converting the engine's error into a caller-readable message. */
57
+ export declare function parseJson(text: string): {
58
+ ok: true;
59
+ value: unknown;
60
+ } | {
61
+ ok: false;
62
+ error: string;
63
+ };
64
+ /** A short, human-readable type name used in diffs and error messages. */
65
+ export declare function typeOf(value: unknown): string;