@crewhaus/tool-table 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,336 @@
1
+ /**
2
+ * Reconciling two exports, and matching records that do not share a key.
3
+ *
4
+ * "What changed between yesterday's file and today's" and "is this the same
5
+ * customer as that one" are the two questions that make a person open two
6
+ * spreadsheets side by side. Both are mechanical, and both are done badly by
7
+ * eye at any real size.
8
+ */
9
+ const keyOf = (row, columns) => JSON.stringify(columns.map((c) => row[c] ?? ""));
10
+ /**
11
+ * Reconcile two tables by key.
12
+ *
13
+ * Duplicate keys are reported rather than silently resolved. With a
14
+ * duplicated key there is no fact of the matter about which row changed into
15
+ * which, and picking one produces a diff that looks authoritative and is
16
+ * arbitrary.
17
+ */
18
+ export function diffTables(before, after, keyColumns, ignoreColumns = []) {
19
+ if (keyColumns.length === 0)
20
+ throw new Error("a diff needs at least one key column");
21
+ const beforeColumns = new Set(before.flatMap((r) => Object.keys(r)));
22
+ const afterColumns = new Set(after.flatMap((r) => Object.keys(r)));
23
+ for (const column of keyColumns) {
24
+ if (before.length > 0 && !beforeColumns.has(column)) {
25
+ throw new Error(`the key column "${column}" is not in the before table`);
26
+ }
27
+ if (after.length > 0 && !afterColumns.has(column)) {
28
+ throw new Error(`the key column "${column}" is not in the after table`);
29
+ }
30
+ }
31
+ const index = (rows, side) => {
32
+ const map = new Map();
33
+ const duplicates = [];
34
+ const counts = new Map();
35
+ for (const row of rows) {
36
+ const key = keyOf(row, keyColumns);
37
+ counts.set(key, (counts.get(key) ?? 0) + 1);
38
+ if (!map.has(key))
39
+ map.set(key, row);
40
+ }
41
+ for (const [key, count] of counts)
42
+ if (count > 1)
43
+ duplicates.push({ key, side, count });
44
+ return { map, duplicates };
45
+ };
46
+ const a = index(before, "before");
47
+ const b = index(after, "after");
48
+ const ignored = new Set([...ignoreColumns, ...keyColumns]);
49
+ const compared = [...new Set([...beforeColumns, ...afterColumns])]
50
+ .filter((c) => !ignored.has(c))
51
+ .sort();
52
+ const added = [];
53
+ const removed = [];
54
+ const changed = [];
55
+ let unchanged = 0;
56
+ for (const [key, row] of b.map) {
57
+ const prior = a.map.get(key);
58
+ if (prior === undefined) {
59
+ added.push(row);
60
+ continue;
61
+ }
62
+ const changes = compared
63
+ .filter((column) => (prior[column] ?? "") !== (row[column] ?? ""))
64
+ .map((column) => ({ column, from: prior[column] ?? "", to: row[column] ?? "" }));
65
+ if (changes.length === 0)
66
+ unchanged++;
67
+ else
68
+ changed.push({ key, changes });
69
+ }
70
+ for (const [key, row] of a.map)
71
+ if (!b.map.has(key))
72
+ removed.push(row);
73
+ return {
74
+ keyColumns,
75
+ added,
76
+ removed,
77
+ changed,
78
+ unchanged,
79
+ duplicateKeys: [...a.duplicates, ...b.duplicates],
80
+ columnsOnlyInBefore: [...beforeColumns].filter((c) => !afterColumns.has(c)).sort(),
81
+ columnsOnlyInAfter: [...afterColumns].filter((c) => !beforeColumns.has(c)).sort(),
82
+ };
83
+ }
84
+ const NAME_NOISE = new Set([
85
+ "mr",
86
+ "mrs",
87
+ "ms",
88
+ "miss",
89
+ "dr",
90
+ "prof",
91
+ "sir",
92
+ "jr",
93
+ "sr",
94
+ "ii",
95
+ "iii",
96
+ "iv",
97
+ "phd",
98
+ "md",
99
+ ]);
100
+ const COMPANY_NOISE = new Set([
101
+ "inc",
102
+ "llc",
103
+ "ltd",
104
+ "limited",
105
+ "corp",
106
+ "corporation",
107
+ "co",
108
+ "company",
109
+ "gmbh",
110
+ "sa",
111
+ "sas",
112
+ "bv",
113
+ "nv",
114
+ "plc",
115
+ "ag",
116
+ "pty",
117
+ "srl",
118
+ "spa",
119
+ "oy",
120
+ "ab",
121
+ "as",
122
+ ]);
123
+ /**
124
+ * Canonicalize a contact's identifying fields.
125
+ *
126
+ * Gmail's dots and `+tags` are folded ONLY for gmail-family domains, because
127
+ * they are a Gmail feature and not a rule of email: treating `a.b@other.com`
128
+ * as `ab@other.com` merges two different people. The original is never
129
+ * discarded — this returns a comparison key beside it, and any fold that
130
+ * happened is named in `notes`.
131
+ */
132
+ export function normalizeContact(input) {
133
+ const notes = [];
134
+ let email = (input.email ?? "").trim().toLowerCase();
135
+ let emailDomain = "";
136
+ if (email.includes("@")) {
137
+ const at = email.lastIndexOf("@");
138
+ let local = email.slice(0, at);
139
+ emailDomain = email.slice(at + 1);
140
+ const plus = local.indexOf("+");
141
+ if (plus > 0) {
142
+ local = local.slice(0, plus);
143
+ notes.push("dropped an email +tag");
144
+ }
145
+ if (["gmail.com", "googlemail.com"].includes(emailDomain)) {
146
+ const withoutDots = local.split(".").join("");
147
+ if (withoutDots !== local)
148
+ notes.push("folded gmail dots");
149
+ local = withoutDots;
150
+ if (emailDomain === "googlemail.com")
151
+ emailDomain = "gmail.com";
152
+ }
153
+ email = `${local}@${emailDomain}`;
154
+ }
155
+ else if (email !== "") {
156
+ notes.push("the email has no @, so it was left as written");
157
+ }
158
+ // Phones keep only digits, and a leading + is preserved as the sign of an
159
+ // international number. A national number is prefixed only when the caller
160
+ // said which country to assume.
161
+ const rawPhone = (input.phone ?? "").trim();
162
+ let phone = "";
163
+ if (rawPhone !== "") {
164
+ const international = rawPhone.trimStart().startsWith("+");
165
+ const digits = rawPhone.replace(/\D/g, "");
166
+ if (international)
167
+ phone = `+${digits}`;
168
+ else if (digits.startsWith("00"))
169
+ phone = `+${digits.slice(2)}`;
170
+ else if (input.defaultCountryCode !== undefined && input.defaultCountryCode !== "") {
171
+ const trimmed = digits.replace(/^0+/, "");
172
+ phone = `+${input.defaultCountryCode.replace(/\D/g, "")}${trimmed}`;
173
+ notes.push(`assumed country code ${input.defaultCountryCode}`);
174
+ }
175
+ else {
176
+ phone = digits;
177
+ notes.push("no country code, so the number is national and may not compare across regions");
178
+ }
179
+ }
180
+ const name = (input.name ?? "").trim().replace(/\s+/g, " ");
181
+ const nameKey = name
182
+ .toLowerCase()
183
+ .replace(/[^\p{L}\p{N}\s]/gu, " ")
184
+ .split(/\s+/)
185
+ .filter((part) => part !== "" && !NAME_NOISE.has(part))
186
+ .sort()
187
+ .join(" ");
188
+ const company = (input.company ?? "").trim().replace(/\s+/g, " ");
189
+ const companyKey = company
190
+ .toLowerCase()
191
+ .replace(/[^\p{L}\p{N}\s]/gu, " ")
192
+ .split(/\s+/)
193
+ .filter((part) => part !== "" && !COMPANY_NOISE.has(part))
194
+ .join(" ");
195
+ return { email, emailDomain, phone, name, nameKey, company: companyKey, notes };
196
+ }
197
+ // ---------------------------------------------------------------------------
198
+ /** Levenshtein distance, bounded so a long pair cannot become quadratic cost. */
199
+ export function editDistance(a, b, max = 64) {
200
+ if (a === b)
201
+ return 0;
202
+ if (Math.abs(a.length - b.length) > max)
203
+ return max + 1;
204
+ const s = a.length > b.length ? b : a;
205
+ const t = a.length > b.length ? a : b;
206
+ let previous = Array.from({ length: s.length + 1 }, (_, i) => i);
207
+ for (let i = 1; i <= t.length; i++) {
208
+ const current = [i];
209
+ for (let j = 1; j <= s.length; j++) {
210
+ current[j] = Math.min(current[j - 1] + 1, previous[j] + 1, previous[j - 1] + (s[j - 1] === t[i - 1] ? 0 : 1));
211
+ }
212
+ previous = current;
213
+ }
214
+ return previous[s.length];
215
+ }
216
+ /** 1 for identical, 0 for nothing in common. */
217
+ export function similarity(a, b) {
218
+ if (a === "" && b === "")
219
+ return 1;
220
+ const longest = Math.max(a.length, b.length);
221
+ if (longest === 0)
222
+ return 1;
223
+ return Math.max(0, 1 - editDistance(a, b) / longest);
224
+ }
225
+ export const FIELD_NORMALIZERS = ["none", "name", "email", "phone", "company"];
226
+ /** Apply one field normalizer, reusing the contact rules. */
227
+ export function normalizeField(value, how) {
228
+ const text = value.trim();
229
+ switch (how) {
230
+ case "name":
231
+ return normalizeContact({ name: text }).nameKey;
232
+ case "email":
233
+ return normalizeContact({ email: text }).email;
234
+ case "phone":
235
+ return normalizeContact({ phone: text }).phone;
236
+ case "company":
237
+ return normalizeContact({ company: text }).company;
238
+ default:
239
+ return text.toLowerCase();
240
+ }
241
+ }
242
+ /**
243
+ * How much of a value a fuzzy comparison looks at.
244
+ *
245
+ * Edit distance is quadratic in the length, so the real cost of a linkage
246
+ * run is comparisons times length squared — and the comparison cap alone
247
+ * bounds the wrong quantity. Thirty records against thirty, with
248
+ * three-thousand-character values, is 900 comparisons (far under any
249
+ * sensible cap) and took forty-five seconds.
250
+ *
251
+ * The fields this tool exists to match — names, companies, addresses — are
252
+ * nowhere near this long, and two values that agree for 256 characters and
253
+ * differ afterwards are the same value for matching purposes.
254
+ */
255
+ const MAX_COMPARE_CHARS = 256;
256
+ const bounded = (value) => value.length <= MAX_COMPARE_CHARS ? value : value.slice(0, MAX_COMPARE_CHARS);
257
+ /**
258
+ * Match records between two lists that share no key.
259
+ *
260
+ * Every accepted pair carries its evidence — which field matched and how
261
+ * well — because "these are the same customer" is a claim somebody will have
262
+ * to defend, and a bare score cannot be defended. Pairs between the review
263
+ * floor and the accept floor are returned separately rather than guessed at.
264
+ *
265
+ * Matching is greedy over the scored pairs, and one record is used once: a
266
+ * record matched to two others is not a match, it is a question.
267
+ */
268
+ export function linkRecords(left, right, rules, options = {}) {
269
+ if (rules.length === 0)
270
+ throw new Error("linkage needs at least one rule");
271
+ const totalWeight = rules.reduce((s, r) => s + r.weight, 0);
272
+ if (totalWeight <= 0)
273
+ throw new Error("the rule weights sum to zero, so every score would be zero");
274
+ const accept = options.accept ?? 0.85;
275
+ const review = options.review ?? 0.6;
276
+ const maxComparisons = options.maxComparisons ?? 5_000_000;
277
+ if (left.length * right.length > maxComparisons) {
278
+ throw new Error(`${left.length} x ${right.length} is ${left.length * right.length} comparisons, over the ${maxComparisons} limit — narrow the inputs first`);
279
+ }
280
+ const scored = [];
281
+ for (const [i, a] of left.entries()) {
282
+ for (const [j, b] of right.entries()) {
283
+ const evidence = [];
284
+ let score = 0;
285
+ for (const rule of rules) {
286
+ const x = bounded(normalizeField(a[rule.field] ?? "", rule.normalize));
287
+ const y = bounded(normalizeField(b[rule.field] ?? "", rule.normalize));
288
+ if (x === "" || y === "")
289
+ continue;
290
+ const sim = rule.compare === "exact" ? (x === y ? 1 : 0) : similarity(x, y);
291
+ const counted = rule.compare === "fuzzy" && sim < (rule.threshold ?? 0.8) ? 0 : sim;
292
+ if (counted > 0) {
293
+ score += counted * rule.weight;
294
+ evidence.push({
295
+ field: rule.field,
296
+ similarity: Number(sim.toFixed(4)),
297
+ weight: rule.weight,
298
+ });
299
+ }
300
+ }
301
+ const normalized = score / totalWeight;
302
+ if (normalized >= review) {
303
+ scored.push({
304
+ leftIndex: i,
305
+ rightIndex: j,
306
+ score: Number(normalized.toFixed(4)),
307
+ evidence,
308
+ });
309
+ }
310
+ }
311
+ }
312
+ scored.sort((p, q) => q.score - p.score || p.leftIndex - q.leftIndex || p.rightIndex - q.rightIndex);
313
+ const usedLeft = new Set();
314
+ const usedRight = new Set();
315
+ const matched = [];
316
+ const reviewPairs = [];
317
+ for (const pair of scored) {
318
+ if (usedLeft.has(pair.leftIndex) || usedRight.has(pair.rightIndex))
319
+ continue;
320
+ if (pair.score >= accept) {
321
+ matched.push(pair);
322
+ usedLeft.add(pair.leftIndex);
323
+ usedRight.add(pair.rightIndex);
324
+ }
325
+ else {
326
+ reviewPairs.push(pair);
327
+ }
328
+ }
329
+ return {
330
+ matched,
331
+ review: reviewPairs,
332
+ unmatchedLeft: left.map((_, i) => i).filter((i) => !usedLeft.has(i)),
333
+ unmatchedRight: right.map((_, i) => i).filter((i) => !usedRight.has(i)),
334
+ comparisons: left.length * right.length,
335
+ };
336
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Reshaping and splitting tables.
3
+ *
4
+ * Wide-to-long and long-to-wide are the two transforms SQL cannot express
5
+ * without already knowing the distinct values, and they are exactly what
6
+ * stands between an export and a usable dataset. Sharding is here because a
7
+ * file too large to process in one pass is a practical problem with a
8
+ * mechanical answer.
9
+ */
10
+ export type Row = Readonly<Record<string, string>>;
11
+ /**
12
+ * Wide to long: one row per (identifier, variable, value).
13
+ *
14
+ * A cell that is empty is dropped by default. A wide export usually has a
15
+ * column per period and most cells empty, and keeping them turns a small
16
+ * table into a huge one whose rows say nothing.
17
+ */
18
+ export declare function toLong(rows: ReadonlyArray<Row>, idColumns: ReadonlyArray<string>, valueColumns: ReadonlyArray<string>, options?: {
19
+ variableName?: string;
20
+ valueName?: string;
21
+ keepEmpty?: boolean;
22
+ }): Row[];
23
+ export type WideResult = {
24
+ readonly rows: ReadonlyArray<Row>;
25
+ readonly columns: ReadonlyArray<string>;
26
+ /** (identifier, variable) pairs that appeared more than once. */
27
+ readonly collisions: ReadonlyArray<{
28
+ readonly key: string;
29
+ readonly variable: string;
30
+ readonly count: number;
31
+ }>;
32
+ };
33
+ export declare function toWide(rows: ReadonlyArray<Row>, idColumns: ReadonlyArray<string>, variableColumn: string, valueColumn: string, options?: {
34
+ fill?: string;
35
+ maxColumns?: number;
36
+ }): WideResult;
37
+ export type Shard = {
38
+ readonly index: number;
39
+ readonly rows: number;
40
+ readonly bytes: number;
41
+ readonly firstRow: number;
42
+ };
43
+ /**
44
+ * Split rows into shards, each carrying the header.
45
+ *
46
+ * Every shard is independently readable. A split that put the header only on
47
+ * the first piece produces one usable file and N-1 that need it grafted back
48
+ * on, which is how a column ends up shifted.
49
+ */
50
+ export declare function shardRows(header: ReadonlyArray<string>, rows: ReadonlyArray<ReadonlyArray<string>>, options: {
51
+ maxRows?: number;
52
+ maxBytes?: number;
53
+ }): {
54
+ shards: Shard[];
55
+ bodies: string[];
56
+ };
57
+ export type FixedField = {
58
+ readonly name: string;
59
+ /** 1-based, inclusive, as a copybook or layout document states them. */
60
+ readonly start: number;
61
+ readonly length: number;
62
+ readonly trim?: boolean;
63
+ };
64
+ /**
65
+ * Parse a fixed-width extract.
66
+ *
67
+ * Positions are 1-based and inclusive because that is how every layout
68
+ * document, copybook and mainframe spec states them; converting in your head
69
+ * is how a field ends up one character off down the whole file.
70
+ */
71
+ export declare function parseFixedWidth(text: string, fields: ReadonlyArray<FixedField>, options?: {
72
+ skipLines?: number;
73
+ }): {
74
+ rows: Row[];
75
+ shortLines: Array<{
76
+ line: number;
77
+ length: number;
78
+ }>;
79
+ };
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Reshaping and splitting tables.
3
+ *
4
+ * Wide-to-long and long-to-wide are the two transforms SQL cannot express
5
+ * without already knowing the distinct values, and they are exactly what
6
+ * stands between an export and a usable dataset. Sharding is here because a
7
+ * file too large to process in one pass is a practical problem with a
8
+ * mechanical answer.
9
+ */
10
+ /**
11
+ * Wide to long: one row per (identifier, variable, value).
12
+ *
13
+ * A cell that is empty is dropped by default. A wide export usually has a
14
+ * column per period and most cells empty, and keeping them turns a small
15
+ * table into a huge one whose rows say nothing.
16
+ */
17
+ export function toLong(rows, idColumns, valueColumns, options = {}) {
18
+ const variableName = options.variableName ?? "variable";
19
+ const valueName = options.valueName ?? "value";
20
+ if (idColumns.includes(variableName) || idColumns.includes(valueName)) {
21
+ throw new Error(`"${variableName}" and "${valueName}" name the output columns, so they cannot also be identifier columns — rename one`);
22
+ }
23
+ const out = [];
24
+ for (const row of rows) {
25
+ const identity = {};
26
+ for (const column of idColumns)
27
+ identity[column] = row[column] ?? "";
28
+ for (const column of valueColumns) {
29
+ const value = row[column] ?? "";
30
+ if (value === "" && options.keepEmpty !== true)
31
+ continue;
32
+ out.push({ ...identity, [variableName]: column, [valueName]: value });
33
+ }
34
+ }
35
+ return out;
36
+ }
37
+ /**
38
+ * Long to wide: one column per distinct variable.
39
+ *
40
+ * A repeated (identifier, variable) pair is a collision — the shape does not
41
+ * say which value wins. The first is kept and every collision is reported,
42
+ * because silently taking the last would make a pivot that looks complete
43
+ * and is wrong in a way nothing downstream can detect.
44
+ */
45
+ /**
46
+ * How many distinct values may become columns.
47
+ *
48
+ * Pivoting on a high-cardinality column is almost always a mistake — an id
49
+ * or a timestamp produces a column per row — and the result is a table
50
+ * nobody can read rather than an error anybody notices.
51
+ */
52
+ const MAX_PIVOT_COLUMNS = 2_048;
53
+ export function toWide(rows, idColumns, variableColumn, valueColumn, options = {}) {
54
+ const fill = options.fill ?? "";
55
+ const maxColumns = options.maxColumns ?? MAX_PIVOT_COLUMNS;
56
+ const byKey = new Map();
57
+ const variables = new Set();
58
+ const collisions = [];
59
+ const counts = new Map();
60
+ for (const row of rows) {
61
+ const identity = {};
62
+ for (const column of idColumns)
63
+ identity[column] = row[column] ?? "";
64
+ const key = JSON.stringify(idColumns.map((c) => identity[c]));
65
+ const variable = row[variableColumn] ?? "";
66
+ if (variable === "")
67
+ continue;
68
+ variables.add(variable);
69
+ const pair = `${key}\t${variable}`;
70
+ counts.set(pair, (counts.get(pair) ?? 0) + 1);
71
+ let bucket = byKey.get(key);
72
+ if (bucket === undefined) {
73
+ bucket = { identity, values: new Map() };
74
+ byKey.set(key, bucket);
75
+ }
76
+ if (!bucket.values.has(variable))
77
+ bucket.values.set(variable, row[valueColumn] ?? "");
78
+ }
79
+ if (variables.size > maxColumns) {
80
+ throw new Error(`"${variableColumn}" has ${variables.size} distinct values, so pivoting on it would make ${variables.size} columns (limit ${maxColumns}) — that is usually an id or a timestamp rather than a category`);
81
+ }
82
+ for (const [pair, count] of counts) {
83
+ if (count <= 1)
84
+ continue;
85
+ const [key = "", variable = ""] = pair.split("\t");
86
+ collisions.push({ key, variable, count });
87
+ }
88
+ const ordered = [...variables].sort();
89
+ const out = [];
90
+ for (const bucket of byKey.values()) {
91
+ const row = { ...bucket.identity };
92
+ for (const variable of ordered)
93
+ row[variable] = bucket.values.get(variable) ?? fill;
94
+ out.push(row);
95
+ }
96
+ return { rows: out, columns: [...idColumns, ...ordered], collisions };
97
+ }
98
+ /**
99
+ * Split rows into shards, each carrying the header.
100
+ *
101
+ * Every shard is independently readable. A split that put the header only on
102
+ * the first piece produces one usable file and N-1 that need it grafted back
103
+ * on, which is how a column ends up shifted.
104
+ */
105
+ export function shardRows(header, rows, options) {
106
+ const maxRows = options.maxRows ?? Number.POSITIVE_INFINITY;
107
+ const maxBytes = options.maxBytes ?? Number.POSITIVE_INFINITY;
108
+ if (maxRows === Number.POSITIVE_INFINITY && maxBytes === Number.POSITIVE_INFINITY) {
109
+ throw new Error("give maxRows or maxBytes, or every row lands in one shard");
110
+ }
111
+ const quote = (cell) => /[",\n\r]/.test(cell) ? `"${cell.split('"').join('""')}"` : cell;
112
+ const line = (cells) => cells.map(quote).join(",");
113
+ const headerLine = `${line(header)}\n`;
114
+ const shards = [];
115
+ const bodies = [];
116
+ let current = [];
117
+ let bytes = Buffer.byteLength(headerLine);
118
+ let firstRow = 1;
119
+ const flush = () => {
120
+ if (current.length === 0)
121
+ return;
122
+ shards.push({ index: shards.length, rows: current.length, bytes, firstRow });
123
+ bodies.push(headerLine + current.join(""));
124
+ current = [];
125
+ bytes = Buffer.byteLength(headerLine);
126
+ };
127
+ for (const [i, row] of rows.entries()) {
128
+ const text = `${line(row)}\n`;
129
+ const size = Buffer.byteLength(text);
130
+ if (current.length > 0 && (current.length + 1 > maxRows || bytes + size > maxBytes)) {
131
+ flush();
132
+ firstRow = i + 1;
133
+ }
134
+ current.push(text);
135
+ bytes += size;
136
+ }
137
+ flush();
138
+ return { shards, bodies };
139
+ }
140
+ /**
141
+ * Parse a fixed-width extract.
142
+ *
143
+ * Positions are 1-based and inclusive because that is how every layout
144
+ * document, copybook and mainframe spec states them; converting in your head
145
+ * is how a field ends up one character off down the whole file.
146
+ */
147
+ export function parseFixedWidth(text, fields, options = {}) {
148
+ if (fields.length === 0)
149
+ throw new Error("a fixed-width layout needs at least one field");
150
+ for (const field of fields) {
151
+ if (field.start < 1)
152
+ throw new Error(`field "${field.name}" starts at ${field.start}; positions are 1-based`);
153
+ if (field.length < 1)
154
+ throw new Error(`field "${field.name}" has length ${field.length}`);
155
+ }
156
+ const needed = Math.max(...fields.map((f) => f.start + f.length - 1));
157
+ const rows = [];
158
+ const shortLines = [];
159
+ const lines = text.split(/\r?\n/).slice(options.skipLines ?? 0);
160
+ for (const [i, raw] of lines.entries()) {
161
+ if (raw.trim() === "")
162
+ continue;
163
+ // A short line is reported rather than padded: silently padding turns a
164
+ // truncated record into one with empty trailing fields, which reads as
165
+ // real data.
166
+ if (raw.length < needed)
167
+ shortLines.push({ line: i + 1 + (options.skipLines ?? 0), length: raw.length });
168
+ const row = {};
169
+ for (const field of fields) {
170
+ const value = raw.slice(field.start - 1, field.start - 1 + field.length);
171
+ row[field.name] = field.trim === false ? value : value.trim();
172
+ }
173
+ rows.push(row);
174
+ }
175
+ return { rows, shortLines };
176
+ }
@@ -0,0 +1,42 @@
1
+ import { CrewhausError } from "@crewhaus/errors";
2
+ /** Raised when a caller-supplied path resolves outside the workspace root. */
3
+ export declare class ToolPermissionError extends CrewhausError {
4
+ readonly name = "ToolPermissionError";
5
+ readonly toolName: string;
6
+ readonly path: string;
7
+ constructor(toolName: string, attemptedPath: string);
8
+ }
9
+ /**
10
+ * A validated path, in both forms the tools need.
11
+ *
12
+ * `real` has every in-workspace symlink already resolved and is what I/O
13
+ * should use — operating on it closes most of the check-to-use window.
14
+ * `abs` is the lexical resolution, which is what a tool must use when the
15
+ * question is about the link ITSELF (`Stat` reporting a symlink target,
16
+ * `RemovePath` deleting a link rather than its destination).
17
+ */
18
+ export type SafePath = {
19
+ readonly abs: string;
20
+ readonly real: string;
21
+ /** The path as the caller wrote it, for echoing back in results. */
22
+ readonly given: string;
23
+ /** Slash-separated path relative to the workspace root. */
24
+ readonly rel: string;
25
+ };
26
+ /** The workspace root, resolved. Every path is contained within it. */
27
+ export declare function workspaceRoot(): string;
28
+ export declare function resolveSafe(toolName: string, rel: string, root?: string): SafePath;
29
+ /** A path relative to the workspace root, with `/` separators on every OS. */
30
+ export declare function toPosix(p: string): string;
31
+ /**
32
+ * True when `candidate` is `parent` or lives underneath it. Both must already
33
+ * be absolute. Used to keep an archive entry, or a symlink inside an
34
+ * extracted tree, from reaching outside its destination.
35
+ */
36
+ export declare function isInside(parent: string, candidate: string): boolean;
37
+ /**
38
+ * Reject an archive member name that would escape its destination: an
39
+ * absolute path, a Windows drive prefix, or any `..` segment. Checked on the
40
+ * NAME, before extraction, because after extraction the damage is done.
41
+ */
42
+ export declare function archiveEntryEscapes(name: string): boolean;