@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,133 @@
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
+ /** A plain object, as distinct from an array or a primitive. */
10
+ export function isPlainObject(value) {
11
+ return typeof value === "object" && value !== null && !Array.isArray(value);
12
+ }
13
+ /** Structural equality over JSON values. Key order is irrelevant; NaN never appears in JSON. */
14
+ export function deepEqual(a, b) {
15
+ if (a === b)
16
+ return true;
17
+ if (Array.isArray(a) || Array.isArray(b)) {
18
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
19
+ return false;
20
+ for (let i = 0; i < a.length; i++) {
21
+ if (!deepEqual(a[i], b[i]))
22
+ return false;
23
+ }
24
+ return true;
25
+ }
26
+ if (isPlainObject(a) && isPlainObject(b)) {
27
+ const ak = Object.keys(a);
28
+ const bk = Object.keys(b);
29
+ if (ak.length !== bk.length)
30
+ return false;
31
+ for (const k of ak) {
32
+ if (!Object.hasOwn(b, k))
33
+ return false;
34
+ if (!deepEqual(a[k], b[k]))
35
+ return false;
36
+ }
37
+ return true;
38
+ }
39
+ return false;
40
+ }
41
+ /** A structured clone that only has to handle JSON shapes, so no cycles exist. */
42
+ export function deepClone(value) {
43
+ if (Array.isArray(value))
44
+ return value.map((v) => deepClone(v));
45
+ if (isPlainObject(value)) {
46
+ const out = {};
47
+ for (const k of Object.keys(value))
48
+ out[k] = deepClone(value[k]);
49
+ return out;
50
+ }
51
+ return value;
52
+ }
53
+ /**
54
+ * Serialize with object keys in sorted order at every depth, so that two
55
+ * equal values always produce identical bytes. Array order is data and is
56
+ * never touched.
57
+ *
58
+ * One caveat worth knowing rather than discovering: `JSON.stringify` emits
59
+ * an object's integer-like keys first, in ascending numeric order, whatever
60
+ * order they sit in. So `{"10":a,"2":b,"x":c}` serializes as `2, 10, x`, not
61
+ * the code-unit order `10, 2, x` that `sortKeysDeep` puts them in. The
62
+ * result is still canonical — two equal values still produce identical bytes
63
+ * — but it is not literally sorted for those keys.
64
+ *
65
+ * `indent` of 0 gives the compact form.
66
+ */
67
+ export function canonicalStringify(value, indent = 0) {
68
+ return JSON.stringify(sortKeysDeep(value), null, indent);
69
+ }
70
+ /** Recursively rebuild objects with their keys in code-unit sorted order. */
71
+ export function sortKeysDeep(value) {
72
+ if (Array.isArray(value))
73
+ return value.map(sortKeysDeep);
74
+ if (isPlainObject(value)) {
75
+ const out = {};
76
+ for (const k of Object.keys(value).sort())
77
+ out[k] = sortKeysDeep(value[k]);
78
+ return out;
79
+ }
80
+ return value;
81
+ }
82
+ const UTF8 = new TextEncoder();
83
+ /**
84
+ * FNV-1a, 64 bits, over the UTF-8 bytes of `text` — the algorithm exactly as
85
+ * Fowler/Noll/Vo define it, so the reference vectors hold: `""` hashes to
86
+ * `cbf29ce484222325`, `"a"` to `af63dc4c8601ec8c` and `"foobar"` to
87
+ * `85944171f73967e8`. `lib.test.ts` pins all three.
88
+ *
89
+ * It is a content fingerprint, not a cryptographic digest — never use it
90
+ * where collision resistance against an adversary matters.
91
+ */
92
+ export function fnv1a64(text) {
93
+ const prime = 1099511628211n;
94
+ const mask = 0xffffffffffffffffn;
95
+ let hash = 14695981039346656037n;
96
+ // UTF-8 bytes, so the hash is over the encoding the vectors are defined on
97
+ // rather than over JavaScript's UTF-16 code units.
98
+ for (const byte of UTF8.encode(text)) {
99
+ hash ^= BigInt(byte);
100
+ hash = (hash * prime) & mask;
101
+ }
102
+ return hash.toString(16).padStart(16, "0");
103
+ }
104
+ /**
105
+ * A 64-bit FNV-1a hash of the canonical serialization, rendered as 16 hex
106
+ * characters. Two values that are `deepEqual` always hash the same, because
107
+ * the serialization sorts keys first.
108
+ *
109
+ * Use it as a fingerprint a caller can compare or record. Do not use it as
110
+ * an identity key inside this package: 64 bits collide, and the canonical
111
+ * string it is computed from is right there and is exact, which is what
112
+ * `totalCompare`, `aggregate`, `joinRecords` and `dedupeRecords` key on.
113
+ */
114
+ export function stableHash(value) {
115
+ return fnv1a64(canonicalStringify(value));
116
+ }
117
+ /** Parse JSON, converting the engine's error into a caller-readable message. */
118
+ export function parseJson(text) {
119
+ try {
120
+ return { ok: true, value: JSON.parse(text) };
121
+ }
122
+ catch (err) {
123
+ return { ok: false, error: err.message };
124
+ }
125
+ }
126
+ /** A short, human-readable type name used in diffs and error messages. */
127
+ export function typeOf(value) {
128
+ if (value === null)
129
+ return "null";
130
+ if (Array.isArray(value))
131
+ return "array";
132
+ return typeof value;
133
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Line-delimited JSON. One value per line, which is what log shippers,
3
+ * dataset files and streaming APIs emit.
4
+ *
5
+ * Reading is tolerant where tolerance is safe — a trailing newline, blank
6
+ * lines, CRLF endings, and a byte-order mark are all fine — and strict where
7
+ * it is not: a line that does not parse is reported with its 1-based line
8
+ * number and the parser's message, never dropped in silence.
9
+ */
10
+ export type JsonlRecord = {
11
+ line: number;
12
+ value: unknown;
13
+ };
14
+ export type JsonlFailure = {
15
+ line: number;
16
+ error: string;
17
+ preview: string;
18
+ };
19
+ export type JsonlParseResult = {
20
+ records: JsonlRecord[];
21
+ failures: JsonlFailure[];
22
+ truncated: boolean;
23
+ };
24
+ /**
25
+ * Parse JSONL. `maxRecords` bounds the result; `stopOnError` makes the first
26
+ * bad line end the parse, which is what a caller wants when the file is a
27
+ * transaction log rather than a best-effort sample.
28
+ */
29
+ export declare function parseJsonl(text: string, maxRecords: number, stopOnError: boolean): JsonlParseResult;
30
+ /**
31
+ * Serialize values as JSONL. A value containing a literal newline is still
32
+ * safe, because `JSON.stringify` escapes it — that is the property the
33
+ * format relies on. `undefined` entries are skipped and counted, since
34
+ * `JSON.stringify(undefined)` is not a value at all.
35
+ */
36
+ export declare function writeJsonl(values: ReadonlyArray<unknown>, trailingNewline: boolean): {
37
+ text: string;
38
+ skipped: number;
39
+ };
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Line-delimited JSON. One value per line, which is what log shippers,
3
+ * dataset files and streaming APIs emit.
4
+ *
5
+ * Reading is tolerant where tolerance is safe — a trailing newline, blank
6
+ * lines, CRLF endings, and a byte-order mark are all fine — and strict where
7
+ * it is not: a line that does not parse is reported with its 1-based line
8
+ * number and the parser's message, never dropped in silence.
9
+ */
10
+ /**
11
+ * Parse JSONL. `maxRecords` bounds the result; `stopOnError` makes the first
12
+ * bad line end the parse, which is what a caller wants when the file is a
13
+ * transaction log rather than a best-effort sample.
14
+ */
15
+ export function parseJsonl(text, maxRecords, stopOnError) {
16
+ const src = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
17
+ const lines = src.split("\n");
18
+ const records = [];
19
+ const failures = [];
20
+ let truncated = false;
21
+ for (let i = 0; i < lines.length; i++) {
22
+ const raw = lines[i].replace(/\r$/, "");
23
+ if (raw.trim() === "")
24
+ continue;
25
+ if (records.length >= maxRecords) {
26
+ truncated = true;
27
+ break;
28
+ }
29
+ try {
30
+ records.push({ line: i + 1, value: JSON.parse(raw) });
31
+ }
32
+ catch (err) {
33
+ failures.push({
34
+ line: i + 1,
35
+ error: err.message,
36
+ preview: raw.length > 120 ? `${raw.slice(0, 120)}…` : raw,
37
+ });
38
+ if (stopOnError)
39
+ break;
40
+ }
41
+ }
42
+ return { records, failures, truncated };
43
+ }
44
+ /**
45
+ * Serialize values as JSONL. A value containing a literal newline is still
46
+ * safe, because `JSON.stringify` escapes it — that is the property the
47
+ * format relies on. `undefined` entries are skipped and counted, since
48
+ * `JSON.stringify(undefined)` is not a value at all.
49
+ */
50
+ export function writeJsonl(values, trailingNewline) {
51
+ const lines = [];
52
+ let skipped = 0;
53
+ for (const value of values) {
54
+ const text = JSON.stringify(value);
55
+ if (text === undefined) {
56
+ skipped += 1;
57
+ continue;
58
+ }
59
+ lines.push(text);
60
+ }
61
+ const body = lines.join("\n");
62
+ return { text: trailingNewline && body !== "" ? `${body}\n` : body, skipped };
63
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * A small JSONPath-like query language, implemented here rather than taken
3
+ * from a dependency so its grammar is exactly what is documented and nothing
4
+ * more.
5
+ *
6
+ * ## Supported grammar
7
+ *
8
+ * ```
9
+ * path := "$"? step*
10
+ * step := "." name
11
+ * | ".." name recursive descent to every `name` at any depth
12
+ * | ".." "*" every node at any depth
13
+ * | "." "*" | "[*]" every element of an array / every value of an object
14
+ * | "[" index "]" integer, negative counts from the end
15
+ * | "[" slice "]" start:end or start:end:step (Python semantics)
16
+ * | "[" quoted "]" 'key' or "key" — for keys with dots or spaces
17
+ * | "[?(" filter ")]" keep array elements / object values matching filter
18
+ * filter := "@" ("." name)* (op literal)?
19
+ * op := == | != | < | <= | > | >= | =~
20
+ * literal := number | 'string' | "string" | true | false | null
21
+ * ```
22
+ *
23
+ * A filter with no operator is an existence test: `[?(@.email)]` keeps
24
+ * elements whose `email` is defined and neither null nor false. `=~` matches
25
+ * the string form of the field against a JavaScript regular expression
26
+ * source (no flags).
27
+ *
28
+ * ## Deliberately not supported
29
+ *
30
+ * Unions (`['a','b']`), script expressions beyond the filter grammar above,
31
+ * `length()` and other functions, parent axes, and boolean combinators
32
+ * (`&&`, `||`) inside a filter. Chain two filter steps instead of using
33
+ * `&&`. Anything unsupported raises a parse error naming the offending
34
+ * position, never a silent empty result.
35
+ */
36
+ export type PathStep = {
37
+ kind: "child";
38
+ name: string;
39
+ } | {
40
+ kind: "descend";
41
+ name: string | null;
42
+ } | {
43
+ kind: "wildcard";
44
+ } | {
45
+ kind: "index";
46
+ index: number;
47
+ } | {
48
+ kind: "slice";
49
+ start: number | null;
50
+ end: number | null;
51
+ step: number;
52
+ } | {
53
+ kind: "filter";
54
+ field: string[];
55
+ op: FilterOp | null;
56
+ literal: unknown;
57
+ };
58
+ export type FilterOp = "==" | "!=" | "<" | "<=" | ">" | ">=" | "=~";
59
+ export declare class PathError extends Error {
60
+ }
61
+ /** Parse a path expression into steps, or throw a `PathError` saying where it broke. */
62
+ export declare function parsePath(expr: string): PathStep[];
63
+ /** One result of a query: the value, plus the normalized path that reaches it. */
64
+ export type PathMatch = {
65
+ path: string;
66
+ value: unknown;
67
+ };
68
+ /**
69
+ * One step of a match's location. A number is an array index, a string is an
70
+ * object key — the distinction has to be carried, because an object may have
71
+ * the key `"0"` and rendering that as `[0]` would produce a path that reads
72
+ * back as an array index and matches nothing.
73
+ */
74
+ export type PathSegment = string | number;
75
+ /** Run a parsed path over a value, returning matches in document order. */
76
+ export declare function queryPath(root: unknown, steps: ReadonlyArray<PathStep>, limit: number): {
77
+ matches: PathMatch[];
78
+ truncated: boolean;
79
+ };
80
+ /** Python slice semantics, including negative bounds and a negative step. */
81
+ export declare function sliceIndices(length: number, start: number | null, end: number | null, step: number): number[];
82
+ /**
83
+ * Render a path as `$.a[0]["odd key"]` — readable, and re-parseable by
84
+ * `parsePath` into a path that reaches the same node in the same document.
85
+ *
86
+ * A numeric *segment* is only written as `[0]` when it really is an array
87
+ * index. An object key that happens to look numeric is written `["0"]`,
88
+ * because `[0]` only ever matches an array and the round trip would
89
+ * otherwise silently return nothing.
90
+ */
91
+ export declare function renderPath(segments: ReadonlyArray<PathSegment>): string;