@crewhaus/tool-text 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,82 @@
1
+ export type DiffOp = {
2
+ readonly kind: "eq" | "del" | "ins";
3
+ readonly line: string;
4
+ };
5
+ /**
6
+ * Line-level diff via the classic longest-common-subsequence dynamic
7
+ * program. O(n*m) in time and memory, which is why the tool caps its input:
8
+ * diffing two 50k-line files would allocate a 2.5-billion-cell table.
9
+ * Callers with bigger inputs should diff a narrowed region instead.
10
+ */
11
+ export declare function diffLines(a: ReadonlyArray<string>, b: ReadonlyArray<string>): DiffOp[];
12
+ export type DiffStats = {
13
+ readonly added: number;
14
+ readonly removed: number;
15
+ readonly same: number;
16
+ };
17
+ /** Count each op kind — the "+12 -3" summary a reviewer actually reads. */
18
+ export declare function diffStats(ops: ReadonlyArray<DiffOp>): DiffStats;
19
+ /**
20
+ * Render ops as a unified diff keeping `context` unchanged lines around each
21
+ * change. Long runs of unchanged lines collapse, which is the entire point:
22
+ * the reader sees the change, not the file.
23
+ */
24
+ export declare function renderUnified(ops: ReadonlyArray<DiffOp>, aLabel: string, bLabel: string, context: number): string;
25
+ /** What a body line does to the file: unchanged, introduced, or taken away. */
26
+ export type DiffLineKind = "context" | "added" | "removed";
27
+ export type ParsedDiffLine = {
28
+ readonly kind: DiffLineKind;
29
+ /** 1-based line number in the NEW file; null on a removed line, which has none. */
30
+ readonly newLine: number | null;
31
+ /** 1-based line number in the OLD file; null on an added line. */
32
+ readonly oldLine: number | null;
33
+ /** The content with the marker column removed — never including the marker. */
34
+ readonly text: string;
35
+ /** A `` marker followed this line. */
36
+ readonly noNewline?: boolean;
37
+ };
38
+ export type ParsedHunk = {
39
+ readonly oldStart: number;
40
+ readonly oldCount: number;
41
+ readonly newStart: number;
42
+ readonly newCount: number;
43
+ /** What git prints after the closing `@@` — usually the enclosing function. */
44
+ readonly section: string;
45
+ readonly lines: ReadonlyArray<ParsedDiffLine>;
46
+ };
47
+ export type DiffFileStatus = "added" | "deleted" | "modified" | "renamed" | "copied";
48
+ export type ParsedDiffFile = {
49
+ /** Path in the old tree, prefix stripped; null when the file was added (`/dev/null`). */
50
+ readonly oldPath: string | null;
51
+ /** Path in the new tree, prefix stripped; null when the file was deleted. */
52
+ readonly newPath: string | null;
53
+ readonly status: DiffFileStatus;
54
+ readonly binary: boolean;
55
+ readonly renamed: boolean;
56
+ readonly added: number;
57
+ readonly removed: number;
58
+ /** The old file's last line had no terminating newline. */
59
+ readonly oldNoFinalNewline: boolean;
60
+ /** The new file's last line had no terminating newline. */
61
+ readonly newNoFinalNewline: boolean;
62
+ readonly oldMode?: string;
63
+ readonly newMode?: string;
64
+ /** Percentage from a `similarity index` line, on a rename or a copy. */
65
+ readonly similarity?: number;
66
+ /** Empty for a rename, a mode change or a binary file — those carry no body. */
67
+ readonly hunks: ReadonlyArray<ParsedHunk>;
68
+ };
69
+ export type ParsedDiff = {
70
+ readonly files: ReadonlyArray<ParsedDiffFile>;
71
+ /** Everything the parser could not account for, so a caller is never silently short. */
72
+ readonly warnings: ReadonlyArray<string>;
73
+ };
74
+ /**
75
+ * Parse a unified diff — `git diff`, `git show`, `diff -u`, a patch file —
76
+ * into files, hunks and lines, each line carrying its number in the new file.
77
+ *
78
+ * Unparseable input never throws and never guesses: whatever could not be
79
+ * accounted for comes back in `warnings`, because a caller about to edit a
80
+ * file from these numbers needs to know the parse was incomplete.
81
+ */
82
+ export declare function parseUnifiedDiff(diff: string): ParsedDiff;
@@ -0,0 +1,541 @@
1
+ /**
2
+ * Line-level diff via the classic longest-common-subsequence dynamic
3
+ * program. O(n*m) in time and memory, which is why the tool caps its input:
4
+ * diffing two 50k-line files would allocate a 2.5-billion-cell table.
5
+ * Callers with bigger inputs should diff a narrowed region instead.
6
+ */
7
+ export function diffLines(a, b) {
8
+ const n = a.length;
9
+ const m = b.length;
10
+ const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
11
+ for (let i = n - 1; i >= 0; i--) {
12
+ for (let j = m - 1; j >= 0; j--) {
13
+ const row = lcs[i];
14
+ const next = lcs[i + 1];
15
+ row[j] =
16
+ a[i] === b[j]
17
+ ? next[j + 1] + 1
18
+ : Math.max(next[j], row[j + 1]);
19
+ }
20
+ }
21
+ const ops = [];
22
+ let i = 0;
23
+ let j = 0;
24
+ while (i < n && j < m) {
25
+ if (a[i] === b[j]) {
26
+ ops.push({ kind: "eq", line: a[i] });
27
+ i++;
28
+ j++;
29
+ continue;
30
+ }
31
+ const down = lcs[i + 1][j];
32
+ const right = lcs[i][j + 1];
33
+ if (down >= right) {
34
+ ops.push({ kind: "del", line: a[i] });
35
+ i++;
36
+ }
37
+ else {
38
+ ops.push({ kind: "ins", line: b[j] });
39
+ j++;
40
+ }
41
+ }
42
+ while (i < n)
43
+ ops.push({ kind: "del", line: a[i++] });
44
+ while (j < m)
45
+ ops.push({ kind: "ins", line: b[j++] });
46
+ return ops;
47
+ }
48
+ /** Count each op kind — the "+12 -3" summary a reviewer actually reads. */
49
+ export function diffStats(ops) {
50
+ let added = 0;
51
+ let removed = 0;
52
+ let same = 0;
53
+ for (const op of ops) {
54
+ if (op.kind === "ins")
55
+ added++;
56
+ else if (op.kind === "del")
57
+ removed++;
58
+ else
59
+ same++;
60
+ }
61
+ return { added, removed, same };
62
+ }
63
+ /**
64
+ * Render ops as a unified diff keeping `context` unchanged lines around each
65
+ * change. Long runs of unchanged lines collapse, which is the entire point:
66
+ * the reader sees the change, not the file.
67
+ */
68
+ export function renderUnified(ops, aLabel, bLabel, context) {
69
+ const keep = new Array(ops.length).fill(false);
70
+ for (let i = 0; i < ops.length; i++) {
71
+ if (ops[i]?.kind === "eq")
72
+ continue;
73
+ for (let j = Math.max(0, i - context); j <= Math.min(ops.length - 1, i + context); j++) {
74
+ keep[j] = true;
75
+ }
76
+ }
77
+ const out = [`--- ${aLabel}`, `+++ ${bLabel}`];
78
+ let aLine = 1;
79
+ let bLine = 1;
80
+ let i = 0;
81
+ while (i < ops.length) {
82
+ if (!keep[i]) {
83
+ const op = ops[i];
84
+ if (op.kind !== "ins")
85
+ aLine++;
86
+ if (op.kind !== "del")
87
+ bLine++;
88
+ i++;
89
+ continue;
90
+ }
91
+ const hunkStartA = aLine;
92
+ const hunkStartB = bLine;
93
+ const body = [];
94
+ let aCount = 0;
95
+ let bCount = 0;
96
+ while (i < ops.length && keep[i]) {
97
+ const op = ops[i];
98
+ if (op.kind === "eq") {
99
+ body.push(` ${op.line}`);
100
+ aLine++;
101
+ bLine++;
102
+ aCount++;
103
+ bCount++;
104
+ }
105
+ else if (op.kind === "del") {
106
+ body.push(`-${op.line}`);
107
+ aLine++;
108
+ aCount++;
109
+ }
110
+ else {
111
+ body.push(`+${op.line}`);
112
+ bLine++;
113
+ bCount++;
114
+ }
115
+ i++;
116
+ }
117
+ out.push(`@@ -${hunkStartA},${aCount} +${hunkStartB},${bCount} @@`);
118
+ out.push(...body);
119
+ }
120
+ return out.join("\n");
121
+ }
122
+ /** Standard hunk header. A missing count means 1 — `@@ -1 +1 @@` is one line. */
123
+ const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@ ?(.*)$/;
124
+ /** A merge/combined diff (`@@@ -1,2 -1,2 +1,3 @@@`) carries one column per parent. */
125
+ const COMBINED_HEADER = /^@@@+ /;
126
+ /**
127
+ * Undo git's C-style path quoting. git quotes only when it has to — a control
128
+ * character, a quote, a backslash, or (unless `core.quotePath=false`) a
129
+ * non-ASCII byte — and it escapes per BYTE, so the octal escapes have to be
130
+ * reassembled into bytes and decoded as UTF-8. Decoding them as code points
131
+ * turns every accented filename into mojibake.
132
+ */
133
+ function dequotePath(raw) {
134
+ if (raw.length < 2 || !raw.startsWith('"') || !raw.endsWith('"'))
135
+ return raw;
136
+ const encoder = new TextEncoder();
137
+ const body = raw.slice(1, -1);
138
+ const bytes = [];
139
+ const simple = {
140
+ a: 7,
141
+ b: 8,
142
+ t: 9,
143
+ n: 10,
144
+ v: 11,
145
+ f: 12,
146
+ r: 13,
147
+ '"': 34,
148
+ "\\": 92,
149
+ };
150
+ for (let i = 0; i < body.length; i++) {
151
+ const ch = body[i];
152
+ if (ch !== "\\") {
153
+ for (const b of encoder.encode(ch))
154
+ bytes.push(b);
155
+ continue;
156
+ }
157
+ const next = body[i + 1];
158
+ if (next === undefined)
159
+ break;
160
+ i++;
161
+ const mapped = simple[next];
162
+ if (mapped !== undefined) {
163
+ bytes.push(mapped);
164
+ continue;
165
+ }
166
+ if (next >= "0" && next <= "7") {
167
+ let octal = next;
168
+ while (octal.length < 3) {
169
+ const digit = body[i + 1];
170
+ if (digit === undefined || digit < "0" || digit > "7")
171
+ break;
172
+ octal += digit;
173
+ i++;
174
+ }
175
+ bytes.push(Number.parseInt(octal, 8) & 0xff);
176
+ continue;
177
+ }
178
+ for (const b of encoder.encode(next))
179
+ bytes.push(b);
180
+ }
181
+ return new TextDecoder().decode(new Uint8Array(bytes));
182
+ }
183
+ /** Drop the `a/`/`b/` diff prefix; `/dev/null` means the file is absent on that side. */
184
+ function stripPrefix(value, prefix) {
185
+ // Deliberately no trim: a leading or trailing space is part of the filename,
186
+ // and git does not quote one. Every caller slices to an exact boundary.
187
+ const v = dequotePath(value);
188
+ if (v === "/dev/null")
189
+ return null;
190
+ return v.startsWith(prefix) ? v.slice(prefix.length) : v;
191
+ }
192
+ /**
193
+ * Read a path off a `---`/`+++` line. Plain `diff -u` appends a tab and a
194
+ * timestamp, which is not part of the name.
195
+ */
196
+ function headerPath(rest, prefix) {
197
+ const tab = rest.indexOf("\t");
198
+ return stripPrefix(tab === -1 ? rest : rest.slice(0, tab), prefix);
199
+ }
200
+ /**
201
+ * Split `a/<old> b/<new>` off a `diff --git` line. Paths may contain spaces —
202
+ * git does not quote those — so the only sound split is the one where both
203
+ * halves name the same file, which is every case except a rename or a copy.
204
+ * Those carry explicit `rename from`/`rename to` lines that overwrite this
205
+ * guess, so the fallback only has to be reasonable, not right.
206
+ */
207
+ function splitGitHeader(rest) {
208
+ // Quoting is decided per path, so a rename can quote one side only. Either
209
+ // quote is an unambiguous split point, which beats guessing at the spaces.
210
+ if (rest.startsWith('"')) {
211
+ let i = 1;
212
+ while (i < rest.length) {
213
+ if (rest[i] === "\\")
214
+ i += 2;
215
+ else if (rest[i] === '"')
216
+ break;
217
+ else
218
+ i++;
219
+ }
220
+ return {
221
+ old: stripPrefix(rest.slice(0, i + 1), "a/"),
222
+ new: stripPrefix(rest.slice(i + 2), "b/"),
223
+ };
224
+ }
225
+ const quotedNew = rest.indexOf(' "b/');
226
+ if (quotedNew !== -1) {
227
+ return {
228
+ old: stripPrefix(rest.slice(0, quotedNew), "a/"),
229
+ new: stripPrefix(rest.slice(quotedNew + 1), "b/"),
230
+ };
231
+ }
232
+ let fallback = null;
233
+ for (let at = rest.indexOf(" b/"); at !== -1; at = rest.indexOf(" b/", at + 1)) {
234
+ const left = stripPrefix(rest.slice(0, at), "a/");
235
+ const right = stripPrefix(rest.slice(at + 1), "b/");
236
+ if (left !== null && left === right)
237
+ return { old: left, new: right };
238
+ fallback ??= { old: left, new: right };
239
+ }
240
+ return fallback ?? { old: null, new: null };
241
+ }
242
+ function newFileDraft() {
243
+ return {
244
+ guessOld: null,
245
+ guessNew: null,
246
+ headerOld: null,
247
+ headerNew: null,
248
+ sawOldHeader: false,
249
+ sawNewHeader: false,
250
+ status: "modified",
251
+ binary: false,
252
+ renamed: false,
253
+ added: 0,
254
+ removed: 0,
255
+ oldNoFinalNewline: false,
256
+ newNoFinalNewline: false,
257
+ hunks: [],
258
+ };
259
+ }
260
+ function finishFile(draft) {
261
+ // `---`/`+++` win when present: they are what a patch applier reads, and
262
+ // they are the only place `/dev/null` appears. The `diff --git` guess and
263
+ // the rename stanza are the fallback, for a file with no body at all.
264
+ return {
265
+ oldPath: draft.sawOldHeader ? draft.headerOld : draft.guessOld,
266
+ newPath: draft.sawNewHeader ? draft.headerNew : draft.guessNew,
267
+ status: draft.status,
268
+ binary: draft.binary,
269
+ renamed: draft.renamed,
270
+ added: draft.added,
271
+ removed: draft.removed,
272
+ oldNoFinalNewline: draft.oldNoFinalNewline,
273
+ newNoFinalNewline: draft.newNoFinalNewline,
274
+ ...(draft.oldMode !== undefined ? { oldMode: draft.oldMode } : {}),
275
+ ...(draft.newMode !== undefined ? { newMode: draft.newMode } : {}),
276
+ ...(draft.similarity !== undefined ? { similarity: draft.similarity } : {}),
277
+ hunks: draft.hunks,
278
+ };
279
+ }
280
+ /**
281
+ * Split the diff into lines. A diff transported as CRLF has a `\r` on every
282
+ * line including its headers; a LF diff of a CRLF-content file has one only
283
+ * on the content lines, where it is real data. Deciding once, for the whole
284
+ * document, is the only way to tell those apart — per line it is undecidable.
285
+ */
286
+ function splitDiffLines(diff) {
287
+ const uniformCrlf = diff.includes("\r\n") && !/(^|[^\r])\n/.test(diff);
288
+ const lines = diff.split(uniformCrlf ? "\r\n" : "\n");
289
+ // A trailing newline ends the last line; it does not begin another one.
290
+ if (lines.length > 0 && lines[lines.length - 1] === "")
291
+ lines.pop();
292
+ return lines;
293
+ }
294
+ /** Structural lines are ASCII markers, so a stray `\r` on one is transport, not data. */
295
+ const structural = (line) => (line.endsWith("\r") ? line.slice(0, -1) : line);
296
+ /**
297
+ * Parse a unified diff — `git diff`, `git show`, `diff -u`, a patch file —
298
+ * into files, hunks and lines, each line carrying its number in the new file.
299
+ *
300
+ * Unparseable input never throws and never guesses: whatever could not be
301
+ * accounted for comes back in `warnings`, because a caller about to edit a
302
+ * file from these numbers needs to know the parse was incomplete.
303
+ */
304
+ export function parseUnifiedDiff(diff) {
305
+ const lines = splitDiffLines(diff);
306
+ const files = [];
307
+ const warnings = [];
308
+ let draft = null;
309
+ let i = 0;
310
+ const flush = () => {
311
+ if (draft !== null)
312
+ files.push(finishFile(draft));
313
+ draft = null;
314
+ };
315
+ /** Meta lines only make sense inside a stanza; tolerate a patch that omits one. */
316
+ const current = () => {
317
+ if (draft === null)
318
+ draft = newFileDraft();
319
+ return draft;
320
+ };
321
+ while (i < lines.length) {
322
+ const line = structural(lines[i]);
323
+ if (line.startsWith("diff --git ")) {
324
+ flush();
325
+ const split = splitGitHeader(line.slice("diff --git ".length));
326
+ const fresh = newFileDraft();
327
+ fresh.guessOld = split.old;
328
+ fresh.guessNew = split.new;
329
+ draft = fresh;
330
+ i++;
331
+ continue;
332
+ }
333
+ // Plain `diff -u` has no `diff --git` line, so the `---`/`+++` pair is
334
+ // what starts a file. Requiring the `+++` on the next line is what keeps
335
+ // `git format-patch`'s bare `---` separator from opening an empty stanza.
336
+ if (line.startsWith("--- ") && structural(lines[i + 1] ?? "").startsWith("+++ ")) {
337
+ if (draft === null || draft.sawOldHeader || draft.hunks.length > 0) {
338
+ flush();
339
+ draft = newFileDraft();
340
+ }
341
+ const file = current();
342
+ file.sawOldHeader = true;
343
+ file.headerOld = headerPath(line.slice(4), "a/");
344
+ if (file.headerOld === null)
345
+ file.status = "added";
346
+ i++;
347
+ continue;
348
+ }
349
+ if (line.startsWith("+++ ") && draft !== null && draft.sawOldHeader) {
350
+ draft.sawNewHeader = true;
351
+ draft.headerNew = headerPath(line.slice(4), "b/");
352
+ if (draft.headerNew === null)
353
+ draft.status = "deleted";
354
+ i++;
355
+ continue;
356
+ }
357
+ if (COMBINED_HEADER.test(line)) {
358
+ // A merge diff has one marker column per parent, so single-column line
359
+ // numbers would be wrong. Refusing beats returning a plausible lie.
360
+ warnings.push(`line ${i + 1}: combined (merge) diff hunk skipped — it has one column per parent, which this parser does not number`);
361
+ i++;
362
+ while (i < lines.length && !structural(lines[i]).startsWith("diff "))
363
+ i++;
364
+ continue;
365
+ }
366
+ if (HUNK_HEADER.test(line)) {
367
+ i = readHunk(lines, i, current(), warnings);
368
+ continue;
369
+ }
370
+ if (line.startsWith("Binary files ") || line.startsWith("Binary file ")) {
371
+ current().binary = true;
372
+ i++;
373
+ continue;
374
+ }
375
+ if (line === "GIT binary patch") {
376
+ // The payload is base85, whose lines begin with a length letter and
377
+ // would otherwise read as stray content. Skip to the next stanza.
378
+ current().binary = true;
379
+ i++;
380
+ while (i < lines.length && !structural(lines[i]).startsWith("diff "))
381
+ i++;
382
+ continue;
383
+ }
384
+ if (draft !== null) {
385
+ const file = draft;
386
+ const meta = (prefix) =>
387
+ // No trim: the rest of a `rename from` line is the path, spaces and all.
388
+ line.startsWith(prefix) ? line.slice(prefix.length) : null;
389
+ const newFileMode = meta("new file mode ");
390
+ const deletedFileMode = meta("deleted file mode ");
391
+ const oldMode = meta("old mode ");
392
+ const newMode = meta("new mode ");
393
+ const renameFrom = meta("rename from ");
394
+ const renameTo = meta("rename to ");
395
+ const copyFrom = meta("copy from ");
396
+ const copyTo = meta("copy to ");
397
+ const similarity = meta("similarity index ");
398
+ if (newFileMode !== null) {
399
+ file.status = "added";
400
+ file.newMode = newFileMode;
401
+ }
402
+ else if (deletedFileMode !== null) {
403
+ file.status = "deleted";
404
+ file.oldMode = deletedFileMode;
405
+ }
406
+ else if (oldMode !== null) {
407
+ file.oldMode = oldMode;
408
+ }
409
+ else if (newMode !== null) {
410
+ file.newMode = newMode;
411
+ }
412
+ else if (renameFrom !== null) {
413
+ file.renamed = true;
414
+ file.status = "renamed";
415
+ file.guessOld = dequotePath(renameFrom);
416
+ }
417
+ else if (renameTo !== null) {
418
+ file.renamed = true;
419
+ file.status = "renamed";
420
+ file.guessNew = dequotePath(renameTo);
421
+ }
422
+ else if (copyFrom !== null) {
423
+ file.status = "copied";
424
+ file.guessOld = dequotePath(copyFrom);
425
+ }
426
+ else if (copyTo !== null) {
427
+ file.status = "copied";
428
+ file.guessNew = dequotePath(copyTo);
429
+ }
430
+ else if (similarity !== null) {
431
+ const pct = Number.parseInt(similarity, 10);
432
+ if (Number.isFinite(pct))
433
+ file.similarity = pct;
434
+ }
435
+ else if (line.startsWith("index ")) {
436
+ // `index <old>..<new> <mode>` — the mode is present only when unchanged.
437
+ const mode = /^index [^ ]+ (\d{6})$/.exec(line)?.[1];
438
+ if (mode !== undefined) {
439
+ file.oldMode ??= mode;
440
+ file.newMode ??= mode;
441
+ }
442
+ }
443
+ }
444
+ i++;
445
+ }
446
+ flush();
447
+ return { files, warnings };
448
+ }
449
+ /**
450
+ * Consume one hunk, returning the index of the first line after it.
451
+ *
452
+ * The hunk header's counts, not the line markers, decide where the body ends.
453
+ * That matters: removing a line that itself starts with `--` renders as
454
+ * `--- foo`, and an added `+++ b/x` renders as `++++ b/x`. Inside the body
455
+ * the FIRST COLUMN is the marker and everything after it is data, so only the
456
+ * counts can say when the body is over.
457
+ */
458
+ function readHunk(lines, start, file, warnings) {
459
+ const match = HUNK_HEADER.exec(structural(lines[start]));
460
+ if (match === null)
461
+ return start + 1;
462
+ const oldStart = Number.parseInt(match[1], 10);
463
+ const oldCount = match[2] === undefined ? 1 : Number.parseInt(match[2], 10);
464
+ const newStart = Number.parseInt(match[3], 10);
465
+ const newCount = match[4] === undefined ? 1 : Number.parseInt(match[4], 10);
466
+ let oldLine = oldStart;
467
+ let newLine = newStart;
468
+ let oldLeft = oldCount;
469
+ let newLeft = newCount;
470
+ const body = [];
471
+ let i = start + 1;
472
+ /** `` describes the line above it and costs no count. */
473
+ const markNoNewline = () => {
474
+ const last = body[body.length - 1];
475
+ if (last === undefined)
476
+ return;
477
+ last.noNewline = true;
478
+ if (last.kind !== "added")
479
+ file.oldNoFinalNewline = true;
480
+ if (last.kind !== "removed")
481
+ file.newNoFinalNewline = true;
482
+ };
483
+ while (i < lines.length && (oldLeft > 0 || newLeft > 0)) {
484
+ const raw = lines[i];
485
+ if (raw.startsWith("\\")) {
486
+ markNoNewline();
487
+ i++;
488
+ continue;
489
+ }
490
+ const marker = raw.charAt(0);
491
+ // An entirely empty line is a context line whose single leading space was
492
+ // eaten in transit — mail clients and editors strip trailing whitespace
493
+ // routinely, and git itself never emits one, so this is always that.
494
+ if (marker === " " || raw === "") {
495
+ body.push({ kind: "context", oldLine: oldLine++, newLine: newLine++, text: raw.slice(1) });
496
+ oldLeft--;
497
+ newLeft--;
498
+ }
499
+ else if (marker === "+") {
500
+ body.push({ kind: "added", oldLine: null, newLine: newLine++, text: raw.slice(1) });
501
+ newLeft--;
502
+ file.added++;
503
+ }
504
+ else if (marker === "-") {
505
+ body.push({ kind: "removed", oldLine: oldLine++, newLine: null, text: raw.slice(1) });
506
+ oldLeft--;
507
+ file.removed++;
508
+ }
509
+ else {
510
+ warnings.push(`line ${i + 1}: hunk body ended early at an unrecognized marker ${JSON.stringify(marker)}`);
511
+ break;
512
+ }
513
+ i++;
514
+ }
515
+ if (i < lines.length && lines[i].startsWith("\\")) {
516
+ markNoNewline();
517
+ i++;
518
+ }
519
+ if (oldLeft > 0 || newLeft > 0) {
520
+ warnings.push(`line ${start + 1}: hunk @@ -${oldStart},${oldCount} +${newStart},${newCount} @@ is short by ${oldLeft} old and ${newLeft} new lines — the diff is truncated`);
521
+ }
522
+ else {
523
+ // The counts, not the markers, ended the body — so a count that was too
524
+ // small drops real lines silently. A bare `+` after the body can only be
525
+ // that: `+++ ` is the only stanza line starting with one. The `-` side
526
+ // gets no such check, because `-- ` is format-patch's mail signature.
527
+ const after = lines[i];
528
+ if (after?.startsWith("+") && !after.startsWith("+++ ")) {
529
+ warnings.push(`line ${i + 1}: content past the end of hunk @@ -${oldStart},${oldCount} +${newStart},${newCount} @@ — its line counts are too small and lines were dropped`);
530
+ }
531
+ }
532
+ file.hunks.push({
533
+ oldStart,
534
+ oldCount,
535
+ newStart,
536
+ newCount,
537
+ section: match[5] ?? "",
538
+ lines: body,
539
+ });
540
+ return i;
541
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Regex-only entity extraction. Explicitly NOT named-entity recognition:
3
+ * these find things that have a syntax (a URL, a UUID, a semver), not things
4
+ * that have a meaning (a company, a person). That distinction is exactly
5
+ * what keeps the tool deterministic.
6
+ */
7
+ export declare const ENTITY_PATTERNS: Readonly<Record<string, RegExp>>;
8
+ /** Every kind `extractEntities` understands, for schema + error messages. */
9
+ export declare const ENTITY_KINDS: readonly string[];
10
+ export type EntityHit = {
11
+ readonly value: string;
12
+ readonly index: number;
13
+ readonly line: number;
14
+ };
15
+ export declare function extractEntities(text: string, kinds: ReadonlyArray<string>, unique: boolean): Record<string, EntityHit[]>;
@@ -0,0 +1,56 @@
1
+ import { lineStarts, offsetToLineCol } from "./locate";
2
+ /**
3
+ * Regex-only entity extraction. Explicitly NOT named-entity recognition:
4
+ * these find things that have a syntax (a URL, a UUID, a semver), not things
5
+ * that have a meaning (a company, a person). That distinction is exactly
6
+ * what keeps the tool deterministic.
7
+ */
8
+ export const ENTITY_PATTERNS = Object.freeze({
9
+ url: /https?:\/\/[^\s<>"'`)\]]+/g,
10
+ email: /[\w.!#$%&'*+/=?^`{|}~-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+/g,
11
+ ipv4: /\b(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\b/g,
12
+ uuid: /\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/g,
13
+ semver: /\bv?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\b/g,
14
+ isoDate: /\b\d{4}-\d{2}-\d{2}\b/g,
15
+ time: /\b\d{1,2}:\d{2}(?::\d{2})?\b/g,
16
+ money: /(?:[$£€¥]\s?\d[\d,]*(?:\.\d+)?)|(?:\b\d[\d,]*(?:\.\d+)?\s?(?:USD|EUR|GBP|JPY|CAD|AUD)\b)/g,
17
+ percent: /\b\d+(?:\.\d+)?\s?%/g,
18
+ hashtag: /(?:^|\s)(#[A-Za-z][\w-]*)/g,
19
+ mention: /(?:^|\s)(@[A-Za-z][\w.-]*)/g,
20
+ ticket: /\b[A-Z][A-Z0-9]+-\d+\b/g,
21
+ jwt: /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g,
22
+ macAddress: /\b(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}\b/g,
23
+ });
24
+ /** Every kind `extractEntities` understands, for schema + error messages. */
25
+ export const ENTITY_KINDS = Object.freeze(Object.keys(ENTITY_PATTERNS));
26
+ export function extractEntities(text, kinds, unique) {
27
+ const starts = lineStarts(text);
28
+ const out = {};
29
+ for (const kind of kinds) {
30
+ const source = ENTITY_PATTERNS[kind];
31
+ if (source === undefined)
32
+ continue;
33
+ const re = new RegExp(source.source, source.flags);
34
+ const hits = [];
35
+ const seen = new Set();
36
+ for (;;) {
37
+ const m = re.exec(text);
38
+ if (m === null)
39
+ break;
40
+ const whole = m[0];
41
+ // Patterns with a leading-boundary group (hashtag, mention) report the
42
+ // capture, so the preceding space is not part of the value.
43
+ const value = (m[1] ?? whole);
44
+ const index = m.index + whole.indexOf(value);
45
+ if (whole.length === 0)
46
+ re.lastIndex += 1;
47
+ if (unique && seen.has(value))
48
+ continue;
49
+ seen.add(value);
50
+ hits.push({ value, index, line: offsetToLineCol(starts, index).line });
51
+ }
52
+ if (hits.length > 0)
53
+ out[kind] = hits;
54
+ }
55
+ return out;
56
+ }