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/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
+ }