@design.estate/dees-catalog 13.1.0 → 13.2.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.
@@ -61,6 +61,44 @@ export type TUnifiedDiffSummary =
61
61
  | { type: 'binary' }
62
62
  | { type: 'unparseable' };
63
63
 
64
+ export type TUnifiedDiffMetadataKind =
65
+ | 'index'
66
+ | 'new-file-mode'
67
+ | 'deleted-file-mode'
68
+ | 'old-mode'
69
+ | 'new-mode'
70
+ | 'similarity-index'
71
+ | 'dissimilarity-index'
72
+ | 'rename-from'
73
+ | 'rename-to'
74
+ | 'copy-from'
75
+ | 'copy-to';
76
+
77
+ export interface IUnifiedDiffMetadata {
78
+ kind: TUnifiedDiffMetadataKind;
79
+ value: string;
80
+ }
81
+
82
+ /** One ordered file section from a unified patch. Paths never retain Git's a/ and b/ prefixes. */
83
+ export interface IUnifiedDiffFileSection {
84
+ beforePath?: string;
85
+ afterPath?: string;
86
+ displayPath?: string;
87
+ metadata: IUnifiedDiffMetadata[];
88
+ rows: IDiffRow[];
89
+ binary: boolean;
90
+ }
91
+
92
+ export type TUnifiedDiffDocumentSummary =
93
+ | { type: 'exact'; stats?: IDiffStats; binaryFiles: number }
94
+ | { type: 'partial'; stats?: IDiffStats; binaryFiles: number }
95
+ | { type: 'unparseable'; binaryFiles: number };
96
+
97
+ export interface IUnifiedDiffDocument {
98
+ files: IUnifiedDiffFileSection[];
99
+ summary: TUnifiedDiffDocumentSummary;
100
+ }
101
+
64
102
  const splitLines = (text: string): string[] => {
65
103
  if (!text) return [];
66
104
  const lines = text.split('\n');
@@ -171,61 +209,157 @@ export const computeLineDiff = (beforeText: string, afterText: string): IDiffRow
171
209
  return annotateIntraline(rows);
172
210
  };
173
211
 
174
- /** Parses a unified diff/patch into rows (file headers skipped, hunks kept). */
175
- export const parseUnifiedDiff = (patchText: string): IDiffRow[] => {
176
- const rows: IDiffRow[] = [];
177
- let oldLine = 0;
178
- let newLine = 0;
179
- let remainingOld = 0;
180
- let remainingNew = 0;
181
- let inHunk = false;
182
- for (const line of splitLines(patchText)) {
183
- const hunkMatch = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/.exec(line);
184
- if (hunkMatch) {
185
- oldLine = Number(hunkMatch[1]);
186
- newLine = Number(hunkMatch[3]);
187
- remainingOld = hunkMatch[2] === undefined ? 1 : Number(hunkMatch[2]);
188
- remainingNew = hunkMatch[4] === undefined ? 1 : Number(hunkMatch[4]);
189
- inHunk = true;
190
- rows.push({ kind: 'hunk', text: line });
212
+ const decodeGitQuotedPath = (valueArg: string): string => {
213
+ if (!(valueArg.startsWith('"') && valueArg.endsWith('"'))) return valueArg;
214
+ const source = valueArg.slice(1, -1);
215
+ const bytes: number[] = [];
216
+ const encoder = new TextEncoder();
217
+ const escapeBytes: Record<string, number> = {
218
+ a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13, '"': 34, '\\': 92,
219
+ };
220
+ for (let index = 0; index < source.length; index++) {
221
+ const character = source[index]!;
222
+ if (character !== '\\') {
223
+ const codePoint = source.codePointAt(index)!;
224
+ bytes.push(...encoder.encode(String.fromCodePoint(codePoint)));
225
+ if (codePoint > 0xffff) index++;
191
226
  continue;
192
227
  }
193
- if (line.startsWith('\\ No newline')) continue;
194
- if (!inHunk) continue;
195
- if (line.startsWith('+')) {
196
- rows.push({ kind: 'add', text: line.slice(1), newLine: newLine++ });
197
- remainingNew--;
198
- } else if (line.startsWith('-')) {
199
- rows.push({ kind: 'remove', text: line.slice(1), oldLine: oldLine++ });
200
- remainingOld--;
201
- } else {
202
- rows.push({ kind: 'context', text: line.startsWith(' ') ? line.slice(1) : line, oldLine: oldLine++, newLine: newLine++ });
203
- remainingOld--;
204
- remainingNew--;
228
+ const escaped = source[++index];
229
+ if (escaped === undefined) {
230
+ bytes.push(92);
231
+ break;
205
232
  }
206
- if (remainingOld <= 0 && remainingNew <= 0) inHunk = false;
233
+ const octal = /^[0-7]{3}$/.exec(source.slice(index, index + 3));
234
+ if (octal) {
235
+ bytes.push(Number.parseInt(octal[0], 8));
236
+ index += 2;
237
+ continue;
238
+ }
239
+ bytes.push(escapeBytes[escaped] ?? escaped.charCodeAt(0));
207
240
  }
208
- return annotateIntraline(rows);
241
+ return new TextDecoder().decode(Uint8Array.from(bytes));
242
+ };
243
+
244
+ const readGitPathToken = (
245
+ sourceArg: string,
246
+ startArg = 0,
247
+ ): { value: string; end: number } | undefined => {
248
+ let start = startArg;
249
+ while (sourceArg[start] === ' ') start++;
250
+ if (start >= sourceArg.length) return undefined;
251
+ if (sourceArg[start] !== '"') return { value: sourceArg.slice(start), end: sourceArg.length };
252
+ let escaped = false;
253
+ for (let index = start + 1; index < sourceArg.length; index++) {
254
+ const character = sourceArg[index]!;
255
+ if (character === '"' && !escaped) {
256
+ return {
257
+ value: decodeGitQuotedPath(sourceArg.slice(start, index + 1)),
258
+ end: index + 1,
259
+ };
260
+ }
261
+ escaped = character === '\\' && !escaped;
262
+ if (character !== '\\') escaped = false;
263
+ }
264
+ return undefined;
265
+ };
266
+
267
+ const normalizeGitPath = (valueArg: string | undefined): string | undefined => {
268
+ if (!valueArg) return undefined;
269
+ const value = decodeGitQuotedPath(valueArg);
270
+ if (value === '/dev/null') return undefined;
271
+ return value.startsWith('a/') || value.startsWith('b/') ? value.slice(2) : value;
272
+ };
273
+
274
+ const parsePatchHeaderPath = (valueArg: string): string | undefined => {
275
+ const value = valueArg.startsWith('"')
276
+ ? readGitPathToken(valueArg)?.value
277
+ : valueArg.split('\t', 1)[0];
278
+ return normalizeGitPath(value);
279
+ };
280
+
281
+ const parseMetadataPath = (valueArg: string): string | undefined => {
282
+ const value = valueArg.startsWith('"')
283
+ ? readGitPathToken(valueArg)?.value
284
+ : valueArg;
285
+ return value === '/dev/null' ? undefined : value;
286
+ };
287
+
288
+ const parseDiffGitPaths = (lineArg: string): { beforePath?: string; afterPath?: string } => {
289
+ const value = lineArg.slice('diff --git '.length);
290
+ if (value.startsWith('"')) {
291
+ const before = readGitPathToken(value);
292
+ const after = before ? readGitPathToken(value, before.end) : undefined;
293
+ const beforePath = normalizeGitPath(before?.value);
294
+ const afterPath = normalizeGitPath(after?.value);
295
+ return {
296
+ ...(beforePath ? { beforePath } : {}),
297
+ ...(afterPath ? { afterPath } : {}),
298
+ };
299
+ }
300
+ const boundary = Math.max(value.lastIndexOf(' b/'), value.lastIndexOf(' "b/'));
301
+ if (boundary < 0) return {};
302
+ const beforePath = normalizeGitPath(value.slice(0, boundary));
303
+ const afterToken = value.slice(boundary + 1);
304
+ const afterPath = normalizeGitPath(afterToken.startsWith('"')
305
+ ? readGitPathToken(afterToken)?.value
306
+ : afterToken);
307
+ return {
308
+ ...(beforePath ? { beforePath } : {}),
309
+ ...(afterPath ? { afterPath } : {}),
310
+ };
311
+ };
312
+
313
+ const metadataLine = (lineArg: string): IUnifiedDiffMetadata | undefined => {
314
+ const kinds: Array<[TUnifiedDiffMetadataKind, string]> = [
315
+ ['new-file-mode', 'new file mode '],
316
+ ['deleted-file-mode', 'deleted file mode '],
317
+ ['old-mode', 'old mode '],
318
+ ['new-mode', 'new mode '],
319
+ ['similarity-index', 'similarity index '],
320
+ ['dissimilarity-index', 'dissimilarity index '],
321
+ ['rename-from', 'rename from '],
322
+ ['rename-to', 'rename to '],
323
+ ['copy-from', 'copy from '],
324
+ ['copy-to', 'copy to '],
325
+ ['index', 'index '],
326
+ ];
327
+ const match = kinds.find(([, prefix]) => lineArg.startsWith(prefix));
328
+ if (!match) return undefined;
329
+ const rawValue = lineArg.slice(match[1].length);
330
+ const pathMetadata = match[0] === 'rename-from'
331
+ || match[0] === 'rename-to'
332
+ || match[0] === 'copy-from'
333
+ || match[0] === 'copy-to';
334
+ return {
335
+ kind: match[0],
336
+ value: pathMetadata ? parseMetadataPath(rawValue) ?? rawValue : rawValue,
337
+ };
209
338
  };
210
339
 
211
340
  /**
212
- * Returns trustworthy change counts for a unified patch. Declared hunk sizes
213
- * are validated before their rows contribute to the result. A truncated patch
214
- * reports only the complete hunk prefix, and omits stats when none is complete.
341
+ * Parses one unified patch without discarding file boundaries or extended Git metadata.
342
+ * Only complete hunks contribute to summary counts. A producer-declared truncation therefore
343
+ * reports a trustworthy complete prefix instead of inventing totals for the missing suffix.
215
344
  */
216
- export const summarizeUnifiedDiff = (
345
+ export const parseUnifiedDiffDocument = (
217
346
  patchText: string,
218
347
  options: IUnifiedDiffSummaryOptions = {},
219
- ): TUnifiedDiffSummary => {
220
- const lines = splitLines(patchText);
221
- let sawPatchStructure = false;
222
- let sawOldHeader = false;
223
- let sawNewHeader = false;
224
- let sawHunk = false;
348
+ ): IUnifiedDiffDocument => {
349
+ const files: IUnifiedDiffFileSection[] = [];
350
+ let current: IUnifiedDiffFileSection | undefined;
351
+ let currentSawOldHeader = false;
352
+ let currentSawNewHeader = false;
353
+ let sawRecognizedContent = false;
354
+ let sawHeaderPair = false;
355
+ let unparseable = false;
356
+ let binaryPayload = false;
225
357
  let added = 0;
226
358
  let removed = 0;
227
359
  let completeHunks = 0;
228
360
  let hunk: {
361
+ oldLine: number;
362
+ newLine: number;
229
363
  expectedOld: number;
230
364
  expectedNew: number;
231
365
  observedOld: number;
@@ -234,9 +368,28 @@ export const summarizeUnifiedDiff = (
234
368
  removed: number;
235
369
  } | undefined;
236
370
 
237
- const completeCurrentHunk = (): boolean => {
238
- if (!hunk) return true;
239
- if (hunk.observedOld !== hunk.expectedOld || hunk.observedNew !== hunk.expectedNew) return false;
371
+ const createFile = (pathsArg: { beforePath?: string; afterPath?: string } = {}) => {
372
+ current = {
373
+ ...pathsArg,
374
+ displayPath: pathsArg.afterPath ?? pathsArg.beforePath,
375
+ metadata: [],
376
+ rows: [],
377
+ binary: false,
378
+ };
379
+ files.push(current);
380
+ currentSawOldHeader = false;
381
+ currentSawNewHeader = false;
382
+ binaryPayload = false;
383
+ return current;
384
+ };
385
+ const requireFile = () => current ?? createFile();
386
+ const refreshDisplayPath = (fileArg: IUnifiedDiffFileSection) => {
387
+ fileArg.displayPath = fileArg.afterPath ?? fileArg.beforePath;
388
+ };
389
+ const completeHunk = () => {
390
+ if (!hunk
391
+ || hunk.observedOld !== hunk.expectedOld
392
+ || hunk.observedNew !== hunk.expectedNew) return false;
240
393
  added += hunk.added;
241
394
  removed += hunk.removed;
242
395
  completeHunks++;
@@ -244,13 +397,23 @@ export const summarizeUnifiedDiff = (
244
397
  return true;
245
398
  };
246
399
 
247
- for (const line of lines) {
248
- const hunkMatch = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?:.*)$/.exec(line);
400
+ for (const line of splitLines(patchText)) {
401
+ if (line.startsWith('diff --git ')) {
402
+ if (hunk) {
403
+ unparseable = true;
404
+ hunk = undefined;
405
+ }
406
+ createFile(parseDiffGitPaths(line));
407
+ continue;
408
+ }
409
+
410
+ const hunkMatch = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/.exec(line);
249
411
  if (hunkMatch) {
250
- if (!completeCurrentHunk()) return { type: 'unparseable' };
251
- sawPatchStructure = true;
252
- sawHunk = true;
412
+ if (hunk) unparseable = true;
413
+ const file = requireFile();
253
414
  hunk = {
415
+ oldLine: Number(hunkMatch[1]),
416
+ newLine: Number(hunkMatch[3]),
254
417
  expectedOld: hunkMatch[2] === undefined ? 1 : Number(hunkMatch[2]),
255
418
  expectedNew: hunkMatch[4] === undefined ? 1 : Number(hunkMatch[4]),
256
419
  observedOld: 0,
@@ -258,65 +421,135 @@ export const summarizeUnifiedDiff = (
258
421
  added: 0,
259
422
  removed: 0,
260
423
  };
261
- if (hunk.expectedOld === 0 && hunk.expectedNew === 0) completeCurrentHunk();
424
+ file.rows.push({ kind: 'hunk', text: line });
425
+ sawRecognizedContent = true;
426
+ completeHunk();
262
427
  continue;
263
428
  }
264
429
 
265
- if (/^(Binary files .+ differ|GIT binary patch)$/.test(line)) return { type: 'binary' };
266
430
  if (line.startsWith('\')) continue;
267
-
268
431
  if (hunk) {
432
+ const file = requireFile();
269
433
  if (line.startsWith('+')) {
434
+ file.rows.push({ kind: 'add', text: line.slice(1), newLine: hunk.newLine++ });
270
435
  hunk.observedNew++;
271
436
  hunk.added++;
272
437
  } else if (line.startsWith('-')) {
438
+ file.rows.push({ kind: 'remove', text: line.slice(1), oldLine: hunk.oldLine++ });
273
439
  hunk.observedOld++;
274
440
  hunk.removed++;
275
441
  } else if (line.startsWith(' ')) {
442
+ file.rows.push({
443
+ kind: 'context',
444
+ text: line.slice(1),
445
+ oldLine: hunk.oldLine++,
446
+ newLine: hunk.newLine++,
447
+ });
276
448
  hunk.observedOld++;
277
449
  hunk.observedNew++;
278
450
  } else {
279
- return { type: 'unparseable' };
451
+ unparseable = true;
452
+ hunk = undefined;
453
+ continue;
280
454
  }
281
- if (hunk.observedOld > hunk.expectedOld || hunk.observedNew > hunk.expectedNew) {
282
- return { type: 'unparseable' };
283
- }
284
- if (hunk.observedOld === hunk.expectedOld && hunk.observedNew === hunk.expectedNew) {
285
- completeCurrentHunk();
455
+ if (hunk
456
+ && (hunk.observedOld > hunk.expectedOld || hunk.observedNew > hunk.expectedNew)) {
457
+ unparseable = true;
458
+ hunk = undefined;
459
+ } else {
460
+ completeHunk();
286
461
  }
287
462
  continue;
288
463
  }
289
464
 
465
+ if (binaryPayload) continue;
290
466
  if (line.startsWith('--- ')) {
291
- sawPatchStructure = true;
292
- sawOldHeader = true;
467
+ if (currentSawNewHeader) createFile();
468
+ const file = requireFile();
469
+ file.beforePath = parsePatchHeaderPath(line.slice(4));
470
+ currentSawOldHeader = true;
471
+ refreshDisplayPath(file);
293
472
  continue;
294
473
  }
295
474
  if (line.startsWith('+++ ')) {
296
- sawPatchStructure = true;
297
- sawNewHeader = true;
475
+ const file = requireFile();
476
+ file.afterPath = parsePatchHeaderPath(line.slice(4));
477
+ currentSawNewHeader = true;
478
+ sawHeaderPair ||= currentSawOldHeader;
479
+ refreshDisplayPath(file);
298
480
  continue;
299
481
  }
300
- if (
301
- line.startsWith('diff ')
302
- || line.startsWith('index ')
303
- || /^(new file mode|deleted file mode|old mode|new mode|similarity index|dissimilarity index|rename from|rename to|copy from|copy to) /.test(line)
304
- ) {
305
- sawPatchStructure = true;
482
+ const metadata = metadataLine(line);
483
+ if (metadata) {
484
+ const file = requireFile();
485
+ file.metadata.push(metadata);
486
+ if (metadata.kind === 'rename-from' || metadata.kind === 'copy-from') {
487
+ file.beforePath ??= metadata.value;
488
+ }
489
+ if (metadata.kind === 'rename-to' || metadata.kind === 'copy-to') {
490
+ file.afterPath ??= metadata.value;
491
+ }
492
+ refreshDisplayPath(file);
493
+ sawRecognizedContent = true;
306
494
  continue;
307
495
  }
308
- if (line.trim()) return { type: 'unparseable' };
496
+ const binaryMatch = /^Binary files (.+) and (.+) differ$/.exec(line);
497
+ if (binaryMatch) {
498
+ const file = requireFile();
499
+ file.beforePath ??= parsePatchHeaderPath(binaryMatch[1]!);
500
+ file.afterPath ??= parsePatchHeaderPath(binaryMatch[2]!);
501
+ refreshDisplayPath(file);
502
+ file.binary = true;
503
+ sawRecognizedContent = true;
504
+ continue;
505
+ }
506
+ if (line === 'GIT binary patch') {
507
+ requireFile().binary = true;
508
+ binaryPayload = true;
509
+ sawRecognizedContent = true;
510
+ continue;
511
+ }
512
+ if (line.trim()) unparseable = true;
309
513
  }
310
514
 
311
- const finalHunkComplete = completeCurrentHunk();
312
- if (!finalHunkComplete && !options.truncated) return { type: 'unparseable' };
313
- if (options.truncated) {
314
- return completeHunks ? { type: 'partial', stats: { added, removed } } : { type: 'partial' };
515
+ if (hunk) {
516
+ if (!options.truncated) unparseable = true;
517
+ hunk = undefined;
315
518
  }
316
- if (!sawHunk && !(sawPatchStructure && sawOldHeader && sawNewHeader)) {
317
- return { type: 'unparseable' };
519
+ for (const file of files) file.rows = annotateIntraline(file.rows);
520
+ const binaryFiles = files.filter((file) => file.binary).length;
521
+ const stats = completeHunks > 0 ? { added, removed } : undefined;
522
+ const recognized = sawRecognizedContent || sawHeaderPair || binaryFiles > 0;
523
+ const summary: TUnifiedDiffDocumentSummary = unparseable || !recognized
524
+ ? { type: 'unparseable', binaryFiles }
525
+ : options.truncated
526
+ ? { type: 'partial', ...(stats ? { stats } : {}), binaryFiles }
527
+ : { type: 'exact', ...(stats ? { stats } : {}), binaryFiles };
528
+ return { files, summary };
529
+ };
530
+
531
+ /** Compatibility row view. File boundaries and metadata are available from parseUnifiedDiffDocument. */
532
+ export const parseUnifiedDiff = (patchText: string): IDiffRow[] =>
533
+ parseUnifiedDiffDocument(patchText).files.flatMap((file) => file.rows);
534
+
535
+ /**
536
+ * Returns trustworthy change counts for a unified patch. Declared hunk sizes
537
+ * are validated before their rows contribute to the result. A truncated patch
538
+ * reports only the complete hunk prefix, and omits stats when none is complete.
539
+ */
540
+ export const summarizeUnifiedDiff = (
541
+ patchText: string,
542
+ options: IUnifiedDiffSummaryOptions = {},
543
+ ): TUnifiedDiffSummary => {
544
+ const document = parseUnifiedDiffDocument(patchText, options);
545
+ if (document.summary.binaryFiles > 0) return { type: 'binary' };
546
+ if (document.summary.type === 'unparseable') return { type: 'unparseable' };
547
+ if (document.summary.type === 'partial') {
548
+ return document.summary.stats
549
+ ? { type: 'partial', stats: document.summary.stats }
550
+ : { type: 'partial' };
318
551
  }
319
- return { type: 'exact', stats: { added, removed } };
552
+ return { type: 'exact', stats: document.summary.stats ?? { added: 0, removed: 0 } };
320
553
  };
321
554
 
322
555
  const maxIntralineChars = 400;