patchling 0.2.0 → 0.2.1
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 +53 -8
- package/package.json +6 -4
- package/src/applydiff.d.ts +69 -0
- package/src/applydiff.js +782 -0
- package/src/filter.d.ts +366 -0
- package/src/filter.js +1513 -0
- package/src/gptdiff.d.ts +28 -1
- package/src/gptdiff.js +78 -10
- package/src/index.d.ts +3 -0
- package/src/index.js +25 -0
- package/src/llm.d.ts +4 -1
- package/src/llm.js +4 -0
package/src/applydiff.js
CHANGED
|
@@ -402,3 +402,785 @@ export function applyDiff(files, diffText) {
|
|
|
402
402
|
}
|
|
403
403
|
return { changed: true, files: working };
|
|
404
404
|
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Parse the hunks of a single-file patch.
|
|
408
|
+
*
|
|
409
|
+
* Returns null when a body line is ambiguous (`++`, which is either a doubled
|
|
410
|
+
* add marker or a literal line that starts with `+`). A non-standard hunk
|
|
411
|
+
* header with no line numbers is kept and anchored at the start of the file.
|
|
412
|
+
* {@link applyPatchToFile} refuses that header instead; this parser exists so
|
|
413
|
+
* a later intent check can still see the `+`/`-` lines.
|
|
414
|
+
*
|
|
415
|
+
* @param {string} patch
|
|
416
|
+
* @returns {Array<{ hint: number, ops: Array<{ kind: string, text: string }> }> | null}
|
|
417
|
+
*/
|
|
418
|
+
export function parsePatchHunks(patch) {
|
|
419
|
+
const patchLines = splitLines(patch || '');
|
|
420
|
+
/** @type {Array<{ hint: number, ops: Array<{ kind: string, text: string }> }>} */
|
|
421
|
+
const hunks = [];
|
|
422
|
+
let i = 0;
|
|
423
|
+
while (i < patchLines.length) {
|
|
424
|
+
const line = patchLines[i];
|
|
425
|
+
if (!line.replace(/^\s+/, '').startsWith('@@')) {
|
|
426
|
+
i += 1;
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
let origStart = 1;
|
|
430
|
+
const trimmed = line.trim();
|
|
431
|
+
if (trimmed !== '@@') {
|
|
432
|
+
const m = trimmed.match(HUNK_HEADER_RE);
|
|
433
|
+
if (m && m[1] !== undefined) origStart = parseInt(m[1], 10);
|
|
434
|
+
}
|
|
435
|
+
/** @type {Array<{ kind: string, text: string }>} */
|
|
436
|
+
const ops = [];
|
|
437
|
+
i += 1;
|
|
438
|
+
while (i < patchLines.length && !patchLines[i].replace(/^\s+/, '').startsWith('@@')) {
|
|
439
|
+
const pline = patchLines[i];
|
|
440
|
+
i += 1;
|
|
441
|
+
if (pline.startsWith('\\')) continue;
|
|
442
|
+
if (pline.startsWith('++') && !pline.startsWith('+++')) return null;
|
|
443
|
+
if (pline.startsWith('-')) ops.push({ kind: 'del', text: pline.slice(1) });
|
|
444
|
+
else if (pline.startsWith('+')) ops.push({ kind: 'add', text: pline.slice(1) });
|
|
445
|
+
else if (pline.startsWith(' ')) ops.push({ kind: 'context', text: pline.slice(1) });
|
|
446
|
+
else ops.push({ kind: 'context', text: pline });
|
|
447
|
+
}
|
|
448
|
+
hunks.push({ hint: Math.max(origStart - 1, 0), ops });
|
|
449
|
+
}
|
|
450
|
+
return hunks;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* @param {string[]} lines
|
|
455
|
+
* @returns {Map<string, number>}
|
|
456
|
+
*/
|
|
457
|
+
function lineCounts(lines) {
|
|
458
|
+
/** @type {Map<string, number>} */
|
|
459
|
+
const counts = new Map();
|
|
460
|
+
for (const line of lines) counts.set(line, (counts.get(line) || 0) + 1);
|
|
461
|
+
return counts;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* @param {Map<string, number>} budget
|
|
466
|
+
* @param {string} text
|
|
467
|
+
* @returns {boolean}
|
|
468
|
+
*/
|
|
469
|
+
function takeOne(budget, text) {
|
|
470
|
+
const n = budget.get(text) || 0;
|
|
471
|
+
if (n <= 0) return false;
|
|
472
|
+
if (n === 1) budget.delete(text);
|
|
473
|
+
else budget.set(text, n - 1);
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* True when `updatedLines` is `originalLines` with only the budgeted `-`
|
|
479
|
+
* lines removed and the budgeted `+` lines inserted, in an order that keeps
|
|
480
|
+
* every surviving line in its original relative order.
|
|
481
|
+
*
|
|
482
|
+
* @param {string[]} originalLines
|
|
483
|
+
* @param {string[]} updatedLines
|
|
484
|
+
* @param {Map<string, number>} delCounts
|
|
485
|
+
* @param {Map<string, number>} addCounts
|
|
486
|
+
* @returns {boolean}
|
|
487
|
+
*/
|
|
488
|
+
function isPlusMinusEdit(originalLines, updatedLines, delCounts, addCounts) {
|
|
489
|
+
const delLeft = new Map(delCounts);
|
|
490
|
+
const addLeft = new Map(addCounts);
|
|
491
|
+
let i = 0;
|
|
492
|
+
let j = 0;
|
|
493
|
+
while (i < originalLines.length && j < updatedLines.length) {
|
|
494
|
+
if (originalLines[i] === updatedLines[j]) {
|
|
495
|
+
i += 1;
|
|
496
|
+
j += 1;
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
if (takeOne(addLeft, updatedLines[j])) {
|
|
500
|
+
j += 1;
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
if (takeOne(delLeft, originalLines[i])) {
|
|
504
|
+
i += 1;
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
return false;
|
|
508
|
+
}
|
|
509
|
+
while (i < originalLines.length) {
|
|
510
|
+
if (!takeOne(delLeft, originalLines[i])) return false;
|
|
511
|
+
i += 1;
|
|
512
|
+
}
|
|
513
|
+
while (j < updatedLines.length) {
|
|
514
|
+
if (!takeOne(addLeft, updatedLines[j])) return false;
|
|
515
|
+
j += 1;
|
|
516
|
+
}
|
|
517
|
+
return true;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* @param {string} text
|
|
522
|
+
* @returns {string}
|
|
523
|
+
*/
|
|
524
|
+
function leadingWhitespace(text) {
|
|
525
|
+
const m = /^[ \t]*/.exec(text);
|
|
526
|
+
return m ? m[0] : '';
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Move `text` by `delta` leading spaces. A negative delta only strips spaces,
|
|
531
|
+
* never tabs. Returns null when the line is not indented far enough.
|
|
532
|
+
*
|
|
533
|
+
* @param {string} text
|
|
534
|
+
* @param {number} delta
|
|
535
|
+
* @returns {string | null}
|
|
536
|
+
*/
|
|
537
|
+
function shiftIndent(text, delta) {
|
|
538
|
+
if (!delta) return text;
|
|
539
|
+
if (delta > 0) return `${' '.repeat(delta)}${text}`;
|
|
540
|
+
const remove = -delta;
|
|
541
|
+
const ws = leadingWhitespace(text);
|
|
542
|
+
if (ws.length < remove || ws.slice(0, remove).includes('\t')) return null;
|
|
543
|
+
return text.slice(remove);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Indent deltas at which a context or deletion line equals some file line.
|
|
548
|
+
* `0` is always included so an exact hunk keeps its current anchor.
|
|
549
|
+
*
|
|
550
|
+
* @param {Array<{ kind: string, text: string }>} ops
|
|
551
|
+
* @param {string[]} fileLines
|
|
552
|
+
* @returns {number[]}
|
|
553
|
+
*/
|
|
554
|
+
function candidateDeltas(ops, fileLines) {
|
|
555
|
+
/** @type {Set<number>} */
|
|
556
|
+
const deltas = new Set([0]);
|
|
557
|
+
/** @type {Map<string, string[]>} */
|
|
558
|
+
const linesByBody = new Map();
|
|
559
|
+
for (const line of fileLines) {
|
|
560
|
+
const body = line.replace(/^[ \t]*/, '');
|
|
561
|
+
if (!linesByBody.has(body)) linesByBody.set(body, []);
|
|
562
|
+
linesByBody.get(body).push(line);
|
|
563
|
+
}
|
|
564
|
+
for (const op of ops) {
|
|
565
|
+
if (op.kind === 'add') continue;
|
|
566
|
+
const body = op.text.replace(/^[ \t]*/, '');
|
|
567
|
+
const lines = linesByBody.get(body);
|
|
568
|
+
if (!lines) continue;
|
|
569
|
+
const opWs = leadingWhitespace(op.text).length;
|
|
570
|
+
for (const line of lines) {
|
|
571
|
+
const delta = leadingWhitespace(line).length - opWs;
|
|
572
|
+
if (shiftIndent(op.text, delta) === line) deltas.add(delta);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return [...deltas];
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* @param {string} content
|
|
580
|
+
* @returns {boolean}
|
|
581
|
+
*/
|
|
582
|
+
function isBlank(content) {
|
|
583
|
+
return !/\S/.test(content || '');
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const IDENT_PAIR_MIN = 0.55;
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* @param {string} text
|
|
590
|
+
* @returns {boolean}
|
|
591
|
+
*/
|
|
592
|
+
function isEllipsis(text) {
|
|
593
|
+
const trimmed = String(text || '').trim();
|
|
594
|
+
return trimmed === '...' || trimmed === '…' || trimmed === '....' || trimmed === '..';
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Identity ignoring whitespace and trailing semicolons. Case-sensitive, so
|
|
599
|
+
* `False` and `false` stay different.
|
|
600
|
+
*
|
|
601
|
+
* @param {string} line
|
|
602
|
+
* @returns {string}
|
|
603
|
+
*/
|
|
604
|
+
function trivialKey(line) {
|
|
605
|
+
let collapsed = String(line || '').replace(/\s+/g, '');
|
|
606
|
+
while (collapsed.endsWith(';')) collapsed = collapsed.slice(0, -1);
|
|
607
|
+
return collapsed;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* @param {string} a
|
|
612
|
+
* @param {string} b
|
|
613
|
+
* @returns {boolean}
|
|
614
|
+
*/
|
|
615
|
+
function sameTrivial(a, b) {
|
|
616
|
+
return a === b || trivialKey(a) === trivialKey(b);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Ratcliff/Obershelp match size, the same ratio basis as Python's
|
|
621
|
+
* difflib.SequenceMatcher for short lines (no autojunk).
|
|
622
|
+
*
|
|
623
|
+
* @param {string} a
|
|
624
|
+
* @param {string} b
|
|
625
|
+
* @returns {number}
|
|
626
|
+
*/
|
|
627
|
+
function matchingSize(a, b) {
|
|
628
|
+
if (!a.length || !b.length) return 0;
|
|
629
|
+
let bestLen = 0;
|
|
630
|
+
let bestI = 0;
|
|
631
|
+
let bestJ = 0;
|
|
632
|
+
const cols = b.length;
|
|
633
|
+
let prev = new Uint32Array(cols + 1);
|
|
634
|
+
let cur = new Uint32Array(cols + 1);
|
|
635
|
+
for (let i = 1; i <= a.length; i++) {
|
|
636
|
+
for (let j = 1; j <= cols; j++) {
|
|
637
|
+
if (a.charCodeAt(i - 1) === b.charCodeAt(j - 1)) {
|
|
638
|
+
const len = prev[j - 1] + 1;
|
|
639
|
+
cur[j] = len;
|
|
640
|
+
if (len > bestLen) {
|
|
641
|
+
bestLen = len;
|
|
642
|
+
bestI = i - len;
|
|
643
|
+
bestJ = j - len;
|
|
644
|
+
}
|
|
645
|
+
} else {
|
|
646
|
+
cur[j] = 0;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
const swap = prev;
|
|
650
|
+
prev = cur;
|
|
651
|
+
cur = swap;
|
|
652
|
+
cur.fill(0);
|
|
653
|
+
}
|
|
654
|
+
if (bestLen === 0) return 0;
|
|
655
|
+
return bestLen
|
|
656
|
+
+ matchingSize(a.slice(0, bestI), b.slice(0, bestJ))
|
|
657
|
+
+ matchingSize(a.slice(bestI + bestLen), b.slice(bestJ + bestLen));
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* @param {string} a
|
|
662
|
+
* @param {string} b
|
|
663
|
+
* @returns {number}
|
|
664
|
+
*/
|
|
665
|
+
function sequenceRatio(a, b) {
|
|
666
|
+
const total = a.length + b.length;
|
|
667
|
+
if (total === 0) return 1;
|
|
668
|
+
return (2 * matchingSize(a, b)) / total;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* @param {string} diffLine
|
|
673
|
+
* @param {string} fileLine
|
|
674
|
+
* @returns {number}
|
|
675
|
+
*/
|
|
676
|
+
function contextAnchorScore(diffLine, fileLine) {
|
|
677
|
+
if (sameTrivial(diffLine, fileLine)) return 1;
|
|
678
|
+
const left = trivialKey(diffLine);
|
|
679
|
+
const right = trivialKey(fileLine);
|
|
680
|
+
if (left.length < 4 || right.length < 4) return 0;
|
|
681
|
+
return sequenceRatio(left, right);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* @param {string} text
|
|
686
|
+
* @returns {string[]}
|
|
687
|
+
*/
|
|
688
|
+
function identifiers(text) {
|
|
689
|
+
return String(text || '').match(/[A-Za-z_][A-Za-z0-9_]*/g) || [];
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Map drifted diff identifiers onto the names the file actually uses.
|
|
694
|
+
*
|
|
695
|
+
* A context line pairs with a file line only when they are the best match
|
|
696
|
+
* near the edit and contain the same number of identifiers, so
|
|
697
|
+
* `database.get(user_id)` can teach `user_id` → `uid` without rewriting
|
|
698
|
+
* unrelated lines. Conflicting mappings are dropped.
|
|
699
|
+
*
|
|
700
|
+
* @param {string[]} lines
|
|
701
|
+
* @param {string[]} contexts
|
|
702
|
+
* @param {number} [start]
|
|
703
|
+
* @param {number} [end]
|
|
704
|
+
* @returns {Map<string, string>}
|
|
705
|
+
*/
|
|
706
|
+
function identMapForLines(lines, contexts, start = 0, end = lines.length) {
|
|
707
|
+
const from = Math.max(0, start);
|
|
708
|
+
const to = Math.min(lines.length, end);
|
|
709
|
+
/** @type {Map<string, string>} */
|
|
710
|
+
const mapping = new Map();
|
|
711
|
+
/** @type {Set<string>} */
|
|
712
|
+
const conflicts = new Set();
|
|
713
|
+
for (const text of contexts) {
|
|
714
|
+
if (isEllipsis(text)) continue;
|
|
715
|
+
let bestI = -1;
|
|
716
|
+
let bestS = IDENT_PAIR_MIN;
|
|
717
|
+
for (let i = from; i < to; i++) {
|
|
718
|
+
const score = contextAnchorScore(text, lines[i]);
|
|
719
|
+
if (score > bestS) {
|
|
720
|
+
bestS = score;
|
|
721
|
+
bestI = i;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
if (bestI < 0) continue;
|
|
725
|
+
const diffIds = identifiers(text);
|
|
726
|
+
const fileIds = identifiers(lines[bestI]);
|
|
727
|
+
if (diffIds.length !== fileIds.length) continue;
|
|
728
|
+
for (let k = 0; k < diffIds.length; k++) {
|
|
729
|
+
const src = diffIds[k];
|
|
730
|
+
const dst = fileIds[k];
|
|
731
|
+
if (src === dst) continue;
|
|
732
|
+
const prev = mapping.get(src);
|
|
733
|
+
if (prev === undefined) mapping.set(src, dst);
|
|
734
|
+
else if (prev !== dst) conflicts.add(src);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
for (const src of conflicts) mapping.delete(src);
|
|
738
|
+
return mapping;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* @param {string} text
|
|
743
|
+
* @param {Map<string, string>} mapping
|
|
744
|
+
* @returns {string}
|
|
745
|
+
*/
|
|
746
|
+
function applyIdentMap(text, mapping) {
|
|
747
|
+
if (!mapping || mapping.size === 0) return text;
|
|
748
|
+
return text.replace(/[A-Za-z_][A-Za-z0-9_]*/g, (id) => mapping.get(id) || id);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* True when `updatedLines` is not `originalLines` with only the budgeted
|
|
753
|
+
* deletions removed and the budgeted additions inserted.
|
|
754
|
+
*
|
|
755
|
+
* @param {string[]} originalLines
|
|
756
|
+
* @param {string[]} updatedLines
|
|
757
|
+
* @param {Map<string, number>} delCounts
|
|
758
|
+
* @param {Map<string, number>} addCounts
|
|
759
|
+
* @returns {boolean}
|
|
760
|
+
*/
|
|
761
|
+
function editViolates(originalLines, updatedLines, delCounts, addCounts) {
|
|
762
|
+
if (!isPlusMinusEdit(originalLines, updatedLines, delCounts, addCounts)) return true;
|
|
763
|
+
const origCounts = lineCounts(originalLines);
|
|
764
|
+
const updCounts = lineCounts(updatedLines);
|
|
765
|
+
for (const [text, n] of delCounts) {
|
|
766
|
+
if (addCounts.has(text)) continue;
|
|
767
|
+
const have = origCounts.get(text) || 0;
|
|
768
|
+
if (have === 0) continue;
|
|
769
|
+
const left = updCounts.get(text) || 0;
|
|
770
|
+
if (left > have - Math.min(have, n)) return true;
|
|
771
|
+
}
|
|
772
|
+
for (const [text, n] of addCounts) {
|
|
773
|
+
if (delCounts.has(text)) continue;
|
|
774
|
+
const have = origCounts.get(text) || 0;
|
|
775
|
+
const got = updCounts.get(text) || 0;
|
|
776
|
+
if (got < have + n) return true;
|
|
777
|
+
}
|
|
778
|
+
return false;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Best `+`/`-` alignment of one hunk against `fileLines`, starting at `start`.
|
|
783
|
+
*
|
|
784
|
+
* Context ops may be skipped when they do not equal the current file line, so
|
|
785
|
+
* a stale context line is never written. `delta` is a uniform leading-space
|
|
786
|
+
* shift: a diff line matches the file line `delta` spaces deeper (or shallower).
|
|
787
|
+
* File lines inside the matched span are only kept (matched context) or dropped
|
|
788
|
+
* (a `-` line). Added lines are emitted at that same shift.
|
|
789
|
+
*
|
|
790
|
+
* @param {Array<{ kind: string, text: string }>} ops
|
|
791
|
+
* @param {string[]} fileLines
|
|
792
|
+
* @param {number} start
|
|
793
|
+
* @param {number} delta
|
|
794
|
+
* @returns {{ matchedContext: number, end: number, replacement: string[], addFlags: boolean[], removed: string[], added: string[] } | null}
|
|
795
|
+
*/
|
|
796
|
+
function alignHunkAt(ops, fileLines, start, delta) {
|
|
797
|
+
const consumable = ops.reduce((n, op) => n + (op.kind === 'add' ? 0 : 1), 0);
|
|
798
|
+
const last = Math.min(fileLines.length, start + consumable);
|
|
799
|
+
/** @type {Map<number, { matchedContext: number, end: number, replacement: string[], addFlags: boolean[], removed: string[], added: string[] } | null>} */
|
|
800
|
+
const memo = new Map();
|
|
801
|
+
const width = fileLines.length + 1;
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* @param {number} opIdx
|
|
805
|
+
* @param {number} i
|
|
806
|
+
* @returns {{ matchedContext: number, end: number, replacement: string[], addFlags: boolean[], removed: string[], added: string[] } | null}
|
|
807
|
+
*/
|
|
808
|
+
const rec = (opIdx, i) => {
|
|
809
|
+
const key = opIdx * width + i;
|
|
810
|
+
if (memo.has(key)) return memo.get(key) ?? null;
|
|
811
|
+
/** @type {{ matchedContext: number, end: number, replacement: string[], addFlags: boolean[], removed: string[], added: string[] } | null} */
|
|
812
|
+
let result = null;
|
|
813
|
+
if (opIdx === ops.length) {
|
|
814
|
+
result = { matchedContext: 0, end: i, replacement: [], addFlags: [], removed: [], added: [] };
|
|
815
|
+
} else if (i > last) {
|
|
816
|
+
result = null;
|
|
817
|
+
} else {
|
|
818
|
+
const op = ops[opIdx];
|
|
819
|
+
if (op.kind === 'add') {
|
|
820
|
+
const addedText = shiftIndent(op.text, delta);
|
|
821
|
+
const rest = addedText === null ? null : rec(opIdx + 1, i);
|
|
822
|
+
if (rest && addedText !== null) {
|
|
823
|
+
result = {
|
|
824
|
+
matchedContext: rest.matchedContext,
|
|
825
|
+
end: rest.end,
|
|
826
|
+
replacement: [addedText, ...rest.replacement],
|
|
827
|
+
addFlags: [true, ...rest.addFlags],
|
|
828
|
+
removed: rest.removed,
|
|
829
|
+
added: [addedText, ...rest.added],
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
} else if (op.kind === 'del') {
|
|
833
|
+
const shifted = shiftIndent(op.text, delta);
|
|
834
|
+
if (shifted !== null && i < last && i < fileLines.length && fileLines[i] === shifted) {
|
|
835
|
+
const rest = rec(opIdx + 1, i + 1);
|
|
836
|
+
if (rest) {
|
|
837
|
+
result = {
|
|
838
|
+
matchedContext: rest.matchedContext,
|
|
839
|
+
end: rest.end,
|
|
840
|
+
replacement: rest.replacement,
|
|
841
|
+
addFlags: rest.addFlags,
|
|
842
|
+
removed: [fileLines[i], ...rest.removed],
|
|
843
|
+
added: rest.added,
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
} else {
|
|
848
|
+
const skipped = rec(opIdx + 1, i);
|
|
849
|
+
/** @type {{ matchedContext: number, end: number, replacement: string[], addFlags: boolean[], removed: string[], added: string[] } | null} */
|
|
850
|
+
let matched = null;
|
|
851
|
+
const shifted = shiftIndent(op.text, delta);
|
|
852
|
+
if (shifted !== null && i < last && i < fileLines.length && fileLines[i] === shifted) {
|
|
853
|
+
const rest = rec(opIdx + 1, i + 1);
|
|
854
|
+
if (rest) {
|
|
855
|
+
matched = {
|
|
856
|
+
matchedContext: rest.matchedContext + 1,
|
|
857
|
+
end: rest.end,
|
|
858
|
+
replacement: [fileLines[i], ...rest.replacement],
|
|
859
|
+
addFlags: [false, ...rest.addFlags],
|
|
860
|
+
removed: rest.removed,
|
|
861
|
+
added: rest.added,
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
if (matched && skipped) {
|
|
866
|
+
result = matched.matchedContext >= skipped.matchedContext ? matched : skipped;
|
|
867
|
+
} else {
|
|
868
|
+
result = matched || skipped;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
memo.set(key, result);
|
|
873
|
+
return result;
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
return rec(0, start);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* @param {{ matchedContext: number, start: number, delta: number }} candidate
|
|
881
|
+
* @param {{ matchedContext: number, start: number, delta: number }} best
|
|
882
|
+
* @param {number} hint
|
|
883
|
+
* @returns {boolean}
|
|
884
|
+
*/
|
|
885
|
+
function betterAlignment(candidate, best, hint) {
|
|
886
|
+
// A literal (delta 0) alignment wins outright, so an exact `-` line keeps
|
|
887
|
+
// the anchor it had before indent-shifted matches existed.
|
|
888
|
+
if ((candidate.delta === 0) !== (best.delta === 0)) return candidate.delta === 0;
|
|
889
|
+
if (candidate.matchedContext !== best.matchedContext) {
|
|
890
|
+
return candidate.matchedContext > best.matchedContext;
|
|
891
|
+
}
|
|
892
|
+
const candidateDist = Math.abs(candidate.start - hint);
|
|
893
|
+
const bestDist = Math.abs(best.start - hint);
|
|
894
|
+
if (candidateDist !== bestDist) return candidateDist < bestDist;
|
|
895
|
+
return candidate.start < best.start;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* @param {Array<{ kind: string, text: string }>} ops
|
|
900
|
+
* @param {string[]} fileLines
|
|
901
|
+
* @param {number} cursor
|
|
902
|
+
* @param {number} hint
|
|
903
|
+
* @returns {{
|
|
904
|
+
* skip: boolean,
|
|
905
|
+
* start: number,
|
|
906
|
+
* end: number,
|
|
907
|
+
* replacement: string[],
|
|
908
|
+
* removed: string[],
|
|
909
|
+
* added: string[],
|
|
910
|
+
* addFlags: boolean[],
|
|
911
|
+
* matchedContext: number,
|
|
912
|
+
* delta: number,
|
|
913
|
+
* relocated: boolean,
|
|
914
|
+
* adapted: boolean,
|
|
915
|
+
* } | null}
|
|
916
|
+
*/
|
|
917
|
+
function locateHunk(ops, fileLines, cursor, hint) {
|
|
918
|
+
const nDel = ops.reduce((n, op) => n + (op.kind === 'del' ? 1 : 0), 0);
|
|
919
|
+
const nAdd = ops.reduce((n, op) => n + (op.kind === 'add' ? 1 : 0), 0);
|
|
920
|
+
const nCtx = ops.reduce((n, op) => n + (op.kind === 'context' ? 1 : 0), 0);
|
|
921
|
+
if (nDel === 0 && nAdd === 0) {
|
|
922
|
+
return {
|
|
923
|
+
skip: true,
|
|
924
|
+
start: cursor,
|
|
925
|
+
end: cursor,
|
|
926
|
+
replacement: [],
|
|
927
|
+
removed: [],
|
|
928
|
+
added: [],
|
|
929
|
+
addFlags: [],
|
|
930
|
+
matchedContext: 0,
|
|
931
|
+
delta: 0,
|
|
932
|
+
relocated: false,
|
|
933
|
+
adapted: false,
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
// Pure insertion with no context: the hunk header is the only anchor.
|
|
937
|
+
if (nDel === 0 && nCtx === 0) {
|
|
938
|
+
const at = Math.min(Math.max(hint, cursor), fileLines.length);
|
|
939
|
+
const added = ops.filter((op) => op.kind === 'add').map((op) => op.text);
|
|
940
|
+
return {
|
|
941
|
+
skip: false,
|
|
942
|
+
start: at,
|
|
943
|
+
end: at,
|
|
944
|
+
replacement: added,
|
|
945
|
+
removed: [],
|
|
946
|
+
added,
|
|
947
|
+
addFlags: added.map(() => true),
|
|
948
|
+
matchedContext: 0,
|
|
949
|
+
delta: 0,
|
|
950
|
+
relocated: false,
|
|
951
|
+
adapted: false,
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/** @type {{ start: number, end: number, replacement: string[], addFlags: boolean[], removed: string[], added: string[], matchedContext: number, delta: number } | null} */
|
|
956
|
+
let best = null;
|
|
957
|
+
for (const delta of candidateDeltas(ops, fileLines)) {
|
|
958
|
+
/** @type {number[]} */
|
|
959
|
+
const starts = [];
|
|
960
|
+
for (let s = cursor; s < fileLines.length; s++) {
|
|
961
|
+
for (const op of ops) {
|
|
962
|
+
if (op.kind === 'add') continue;
|
|
963
|
+
const shifted = shiftIndent(op.text, delta);
|
|
964
|
+
if (shifted !== null && fileLines[s] === shifted) {
|
|
965
|
+
starts.push(s);
|
|
966
|
+
break;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
if (nAdd > 0) starts.push(fileLines.length);
|
|
971
|
+
|
|
972
|
+
for (const start of starts) {
|
|
973
|
+
const found = alignHunkAt(ops, fileLines, start, delta);
|
|
974
|
+
if (!found || found.end < start) continue;
|
|
975
|
+
const candidate = {
|
|
976
|
+
start,
|
|
977
|
+
end: found.end,
|
|
978
|
+
replacement: found.replacement,
|
|
979
|
+
addFlags: found.addFlags,
|
|
980
|
+
removed: found.removed,
|
|
981
|
+
added: found.added,
|
|
982
|
+
matchedContext: found.matchedContext,
|
|
983
|
+
delta,
|
|
984
|
+
};
|
|
985
|
+
if (!best || betterAlignment(candidate, best, hint)) best = candidate;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
if (!best) return null;
|
|
990
|
+
// A pure insertion whose context matched nothing has no safe anchor.
|
|
991
|
+
if (nDel === 0 && best.matchedContext === 0 && nCtx > 0) return null;
|
|
992
|
+
const adapted = adaptHunkAdditions(best, ops, fileLines);
|
|
993
|
+
return { skip: false, ...adapted, relocated: best.start !== hint };
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* Rewrite '+' lines when drifted context reveals a rename. File lines that
|
|
998
|
+
* were kept as context are not touched.
|
|
999
|
+
*
|
|
1000
|
+
* @param {{ start: number, end: number, replacement: string[], addFlags: boolean[], removed: string[], added: string[], matchedContext: number, delta: number }} best
|
|
1001
|
+
* @param {Array<{ kind: string, text: string }>} ops
|
|
1002
|
+
* @param {string[]} fileLines
|
|
1003
|
+
* @returns {{ start: number, end: number, replacement: string[], addFlags: boolean[], removed: string[], added: string[], matchedContext: number, delta: number, adapted: boolean }}
|
|
1004
|
+
*/
|
|
1005
|
+
function adaptHunkAdditions(best, ops, fileLines) {
|
|
1006
|
+
/** @type {string[]} */
|
|
1007
|
+
const contexts = [];
|
|
1008
|
+
for (const op of ops) {
|
|
1009
|
+
if (op.kind === 'context' && !isEllipsis(op.text)) contexts.push(op.text);
|
|
1010
|
+
}
|
|
1011
|
+
const mapping = identMapForLines(fileLines, contexts, best.start - 40, best.end + 40);
|
|
1012
|
+
if (mapping.size === 0) return { ...best, adapted: false };
|
|
1013
|
+
/** @type {string[]} */
|
|
1014
|
+
const added = [];
|
|
1015
|
+
let adapted = false;
|
|
1016
|
+
const flags = best.addFlags || [];
|
|
1017
|
+
const replacement = best.replacement.map((line, i) => {
|
|
1018
|
+
if (!flags[i]) return line;
|
|
1019
|
+
const next = applyIdentMap(line, mapping);
|
|
1020
|
+
if (next !== line) adapted = true;
|
|
1021
|
+
added.push(next);
|
|
1022
|
+
return next;
|
|
1023
|
+
});
|
|
1024
|
+
return { ...best, replacement, added, adapted };
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* Replay a patch's `+`/`-` lines, allowing one indent shift per hunk and a
|
|
1029
|
+
* match away from the hunk header.
|
|
1030
|
+
*
|
|
1031
|
+
* @param {string} originalContent
|
|
1032
|
+
* @param {string[]} originalLines
|
|
1033
|
+
* @param {Array<{ hint: number, ops: Array<{ kind: string, text: string }> }>} hunks
|
|
1034
|
+
* @returns {{ content: string, delCounts: Map<string, number>, addCounts: Map<string, number>, shifted: boolean, relocated: boolean, adapted: boolean } | null}
|
|
1035
|
+
*/
|
|
1036
|
+
function walkIntent(originalContent, originalLines, hunks) {
|
|
1037
|
+
const originalNoTrailingNewline =
|
|
1038
|
+
!!originalContent && originalContent.length > 0 && !/[\n\r]$/.test(originalContent);
|
|
1039
|
+
let endsWithoutNewline = false;
|
|
1040
|
+
/** @type {string[]} */
|
|
1041
|
+
const newLines = [];
|
|
1042
|
+
let cursor = 0;
|
|
1043
|
+
/** @type {Map<string, number>} */
|
|
1044
|
+
const delCounts = new Map();
|
|
1045
|
+
/** @type {Map<string, number>} */
|
|
1046
|
+
const addCounts = new Map();
|
|
1047
|
+
let shifted = false;
|
|
1048
|
+
let relocated = false;
|
|
1049
|
+
let adapted = false;
|
|
1050
|
+
|
|
1051
|
+
const pushOriginal = (k) => {
|
|
1052
|
+
newLines.push(originalLines[k]);
|
|
1053
|
+
endsWithoutNewline = k === originalLines.length - 1 && originalNoTrailingNewline;
|
|
1054
|
+
};
|
|
1055
|
+
const bump = (map, text) => map.set(text, (map.get(text) || 0) + 1);
|
|
1056
|
+
|
|
1057
|
+
for (const hunk of hunks) {
|
|
1058
|
+
const located = locateHunk(hunk.ops, originalLines, cursor, hunk.hint);
|
|
1059
|
+
if (!located) return null;
|
|
1060
|
+
if (located.skip) continue;
|
|
1061
|
+
if (located.delta) shifted = true;
|
|
1062
|
+
if (located.relocated) relocated = true;
|
|
1063
|
+
if (located.adapted) adapted = true;
|
|
1064
|
+
for (let k = cursor; k < located.start; k++) pushOriginal(k);
|
|
1065
|
+
for (const line of located.removed) bump(delCounts, line);
|
|
1066
|
+
for (const line of located.added) bump(addCounts, line);
|
|
1067
|
+
for (const line of located.replacement) {
|
|
1068
|
+
newLines.push(line);
|
|
1069
|
+
endsWithoutNewline = false;
|
|
1070
|
+
}
|
|
1071
|
+
cursor = located.end;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
for (let k = cursor; k < originalLines.length; k++) pushOriginal(k);
|
|
1075
|
+
|
|
1076
|
+
let content = newLines.join('\n');
|
|
1077
|
+
if (content && !endsWithoutNewline) content += '\n';
|
|
1078
|
+
return { content, delCounts, addCounts, shifted, relocated, adapted };
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* Whether `updatedContent` does something other than apply this diff's
|
|
1083
|
+
* `+`/`-` lines to `originalContent`.
|
|
1084
|
+
*
|
|
1085
|
+
* Armed when a context line does not occur anywhere in the original file —
|
|
1086
|
+
* the case where a model copies the diff's stale context in, or drops lines
|
|
1087
|
+
* the hunk never removed. A context line that differs only by a uniform
|
|
1088
|
+
* indentation shift, or a hunk that matches somewhere other than its `@@`
|
|
1089
|
+
* line, still counts as context: the edit is accepted at the file's indent.
|
|
1090
|
+
* A result that deletes any other line, or an empty rewrite of a non-empty
|
|
1091
|
+
* file, is still a violation.
|
|
1092
|
+
*
|
|
1093
|
+
* A `-` line that merely fails to match (quotes, whitespace) does not by
|
|
1094
|
+
* itself arm the check when every context line is present as written, so a
|
|
1095
|
+
* fuzzy model rewrite of that line can still stand.
|
|
1096
|
+
*
|
|
1097
|
+
* @param {string} originalContent
|
|
1098
|
+
* @param {string} updatedContent
|
|
1099
|
+
* @param {string} patch
|
|
1100
|
+
* @returns {boolean}
|
|
1101
|
+
*/
|
|
1102
|
+
export function diffOutputViolatesIntent(originalContent, updatedContent, patch) {
|
|
1103
|
+
const hunks = parsePatchHunks(patch);
|
|
1104
|
+
if (!hunks || hunks.length === 0) return false;
|
|
1105
|
+
|
|
1106
|
+
const originalLines = splitLines(originalContent || '');
|
|
1107
|
+
const updatedLines = splitLines(updatedContent || '');
|
|
1108
|
+
const present = new Set(originalLines);
|
|
1109
|
+
/** @type {Map<string, number>} */
|
|
1110
|
+
const delCounts = new Map();
|
|
1111
|
+
/** @type {Map<string, number>} */
|
|
1112
|
+
const addCounts = new Map();
|
|
1113
|
+
let badContext = false;
|
|
1114
|
+
for (const hunk of hunks) {
|
|
1115
|
+
for (const op of hunk.ops) {
|
|
1116
|
+
if (op.kind === 'context') {
|
|
1117
|
+
if (!present.has(op.text)) badContext = true;
|
|
1118
|
+
} else if (op.kind === 'del') {
|
|
1119
|
+
delCounts.set(op.text, (delCounts.get(op.text) || 0) + 1);
|
|
1120
|
+
} else if (op.kind === 'add') {
|
|
1121
|
+
addCounts.set(op.text, (addCounts.get(op.text) || 0) + 1);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
const walked = walkIntent(originalContent, originalLines, hunks);
|
|
1127
|
+
|
|
1128
|
+
// Empty output is not a successful apply unless the anchored edit itself
|
|
1129
|
+
// deletes every remaining line and adds nothing.
|
|
1130
|
+
if (isBlank(updatedContent) && !isBlank(originalContent)) {
|
|
1131
|
+
if (walked && !editViolates(originalLines, updatedLines, walked.delCounts, walked.addCounts)) {
|
|
1132
|
+
return false;
|
|
1133
|
+
}
|
|
1134
|
+
return true;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
if (!badContext) return false;
|
|
1138
|
+
|
|
1139
|
+
// Indent-shifted or relocated hunk: only the file-side +/- edit is in bounds.
|
|
1140
|
+
if (walked) return editViolates(originalLines, updatedLines, walked.delCounts, walked.addCounts);
|
|
1141
|
+
return editViolates(originalLines, updatedLines, delCounts, addCounts);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* Apply only the `+`/`-` lines of a patch, ignoring context lines that are
|
|
1146
|
+
* not actually in the file.
|
|
1147
|
+
*
|
|
1148
|
+
* A context or `-` line may match with a uniform indentation shift, and the
|
|
1149
|
+
* hunk may sit somewhere other than the hunk header. Added lines are written
|
|
1150
|
+
* at that same indent. When drifted context pairs a diff name with the
|
|
1151
|
+
* file's own name (`user_id` in the diff, `uid` in the file), `+` lines are
|
|
1152
|
+
* rewritten onto those names. Returns null when a `-` line cannot be found
|
|
1153
|
+
* even then, when a pure insertion has no matching context to anchor it, or
|
|
1154
|
+
* when a body line is ambiguous. Never writes a context line that was not
|
|
1155
|
+
* already in the original.
|
|
1156
|
+
*
|
|
1157
|
+
* @param {string} originalContent
|
|
1158
|
+
* @param {string} patch
|
|
1159
|
+
* @returns {string | null}
|
|
1160
|
+
*/
|
|
1161
|
+
export function applyIntentIgnoringBadContext(originalContent, patch) {
|
|
1162
|
+
const hunks = parsePatchHunks(patch);
|
|
1163
|
+
if (!hunks || hunks.length === 0) return null;
|
|
1164
|
+
const originalLines = splitLines(originalContent || '');
|
|
1165
|
+
const walked = walkIntent(originalContent, originalLines, hunks);
|
|
1166
|
+
if (!walked) return null;
|
|
1167
|
+
return walked.content;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/**
|
|
1171
|
+
* Deterministic +/- apply that rewrote at least one added identifier onto
|
|
1172
|
+
* a name the file already uses. Null when the hunk cannot be anchored or
|
|
1173
|
+
* when no identifier changed, so the caller can still ask the model.
|
|
1174
|
+
*
|
|
1175
|
+
* @param {string} originalContent
|
|
1176
|
+
* @param {string} patch
|
|
1177
|
+
* @returns {string | null}
|
|
1178
|
+
*/
|
|
1179
|
+
export function applyAdaptedIdentifiers(originalContent, patch) {
|
|
1180
|
+
const hunks = parsePatchHunks(patch);
|
|
1181
|
+
if (!hunks || hunks.length === 0) return null;
|
|
1182
|
+
const originalLines = splitLines(originalContent || '');
|
|
1183
|
+
const walked = walkIntent(originalContent, originalLines, hunks);
|
|
1184
|
+
if (!walked || !walked.adapted) return null;
|
|
1185
|
+
return walked.content;
|
|
1186
|
+
}
|