@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.
- package/README.md +193 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +988 -0
- package/dist/lib/csv.d.ts +80 -0
- package/dist/lib/csv.js +240 -0
- package/dist/lib/diff.d.ts +44 -0
- package/dist/lib/diff.js +166 -0
- package/dist/lib/json.d.ts +65 -0
- package/dist/lib/json.js +133 -0
- package/dist/lib/jsonl.d.ts +39 -0
- package/dist/lib/jsonl.js +63 -0
- package/dist/lib/jsonpath.d.ts +91 -0
- package/dist/lib/jsonpath.js +430 -0
- package/dist/lib/patch.d.ts +72 -0
- package/dist/lib/patch.js +272 -0
- package/dist/lib/table.d.ts +156 -0
- package/dist/lib/table.js +571 -0
- package/dist/lib/toml.d.ts +59 -0
- package/dist/lib/toml.js +530 -0
- package/dist/lib/xml.d.ts +72 -0
- package/dist/lib/xml.js +344 -0
- package/dist/lib/yaml.d.ts +61 -0
- package/dist/lib/yaml.js +598 -0
- package/package.json +45 -0
|
@@ -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[];
|
package/dist/lib/csv.js
ADDED
|
@@ -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;
|
package/dist/lib/diff.js
ADDED
|
@@ -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;
|