@oh-my-pi/hashline 16.1.6 → 16.1.8

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/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.1.8] - 2026-06-20
6
+
7
+ ### Fixed
8
+
9
+ - Fixed multi-hunk delimiter-balance repair so a `SWAP` that drops a structural closer no longer keeps it when another hunk already removed the matching opener (a deliberate wrapper removal); the missing-closer repair now weighs each group against the whole patch's residual delimiter balance — summed per hunk so quote/comment state never bleeds across non-contiguous hunks — and consumes that residual per repair so a genuine missing closer elsewhere still fires. ([#3142](https://github.com/can1357/oh-my-pi/issues/3142))
10
+
5
11
  ## [16.1.2] - 2026-06-19
6
12
 
7
13
  ### Changed
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/hashline",
4
- "version": "16.1.6",
4
+ "version": "16.1.8",
5
5
  "description": "Hashline: a compact, line-anchored patch language and applier. Pluggable FS/IO so it works over disk, in-memory, or any custom backend.",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
package/src/apply.ts CHANGED
@@ -312,6 +312,23 @@ function balanceIsZero(a: DelimiterBalance): boolean {
312
312
  return a.paren === 0 && a.bracket === 0 && a.brace === 0;
313
313
  }
314
314
 
315
+ function balanceSum(a: DelimiterBalance, b: DelimiterBalance): DelimiterBalance {
316
+ return { paren: a.paren + b.paren, bracket: a.bracket + b.bracket, brace: a.brace + b.brace };
317
+ }
318
+
319
+ function balanceComponentCovers(candidate: number, target: number): boolean {
320
+ if (target === 0) return true;
321
+ return candidate > 0 === target > 0 && Math.abs(candidate) >= Math.abs(target);
322
+ }
323
+
324
+ function balanceCovers(candidate: DelimiterBalance, target: DelimiterBalance): boolean {
325
+ return (
326
+ balanceComponentCovers(candidate.paren, target.paren) &&
327
+ balanceComponentCovers(candidate.bracket, target.bracket) &&
328
+ balanceComponentCovers(candidate.brace, target.brace)
329
+ );
330
+ }
331
+
315
332
  interface ReplacementGroup {
316
333
  /** Positions in the edit array of the payload inserts, in payload order. */
317
334
  insertIndices: number[];
@@ -587,10 +604,72 @@ function describeOneSidedEchoRepair(group: ReplacementGroup, side: "leading" | "
587
604
  );
588
605
  }
589
606
 
607
+ /**
608
+ * One pass-1 outcome per source position: resolved edits (with an optional
609
+ * warning) or a deferred missing-closer candidate, resolved against the
610
+ * whole-patch residual in pass 2.
611
+ */
612
+ type RepairSlot =
613
+ | { kind: "edits"; edits: AppliedEdit[]; warning?: string }
614
+ | {
615
+ kind: "candidate";
616
+ group: ReplacementGroup;
617
+ inserts: AppliedEdit[];
618
+ deletes: AppliedEdit[];
619
+ delta: DelimiterBalance;
620
+ };
621
+
622
+ /**
623
+ * Delimiter balance of the lines immediately above a group's range that are
624
+ * themselves deleted by other hunks, netted against any payload inserted at
625
+ * those lines. When this covers the group's own delta the matching opener was
626
+ * deleted (or replaced by an opener of the same shape) just above — a deliberate
627
+ * wrapper removal — so the range's deleted closer must stay deleted, not be
628
+ * "kept". Scanned over its own contiguous lines so quote/comment state never
629
+ * bleeds in from elsewhere in the patch.
630
+ */
631
+ function netDeletedPrefixBalance(
632
+ group: ReplacementGroup,
633
+ deletedLines: ReadonlySet<number>,
634
+ insertedByLine: ReadonlyMap<number, readonly string[]>,
635
+ fileLines: readonly string[],
636
+ ): DelimiterBalance {
637
+ const deleted: string[] = [];
638
+ const inserted: string[] = [];
639
+ for (let line = group.startLine - 1; line >= 1 && deletedLines.has(line); line--) {
640
+ deleted.unshift(fileLines[line - 1] ?? "");
641
+ const insertedAtLine = insertedByLine.get(line);
642
+ if (insertedAtLine) inserted.unshift(...insertedAtLine);
643
+ }
644
+ return balanceDelta(computeDelimiterBalance(deleted), computeDelimiterBalance(inserted));
645
+ }
646
+
647
+ /**
648
+ * Net delimiter balance a slot contributes, computed over the slot's own
649
+ * contiguous insert/delete lines only. Summing these per-slot deltas — never one
650
+ * concatenated scan across non-adjacent hunks — keeps backtick/block-comment
651
+ * state local, so an unterminated quote in one hunk cannot mask a real delimiter
652
+ * in another.
653
+ */
654
+ function slotPatchDelta(slot: RepairSlot, fileLines: readonly string[]): DelimiterBalance {
655
+ if (slot.kind === "candidate") return slot.delta;
656
+ const inserted: string[] = [];
657
+ const deleted: string[] = [];
658
+ for (const edit of slot.edits) {
659
+ if (edit.kind === "insert") inserted.push(edit.text);
660
+ else deleted.push(fileLines[edit.anchor.line - 1] ?? "");
661
+ }
662
+ return balanceDelta(computeDelimiterBalance(inserted), computeDelimiterBalance(deleted));
663
+ }
664
+
590
665
  /**
591
666
  * Normalize replacement groups so common off-by-one boundaries do not duplicate
592
- * unchanged surrounding lines or structural closers. Returns the repaired edit
593
- * list plus one warning per repaired group.
667
+ * unchanged surrounding lines or wrongly drop/keep structural closers. Local
668
+ * repairs run in pass 1; the missing-closer repair is deferred to pass 2 and
669
+ * weighed against the whole-patch delimiter residual, so a closer the range
670
+ * deleted is only kept when the patch as a whole is missing it — never when
671
+ * another hunk already removed the matching opener. Returns the repaired edits
672
+ * plus one warning per repaired group.
594
673
  */
595
674
  function repairReplacementBoundaries(
596
675
  edits: readonly AppliedEdit[],
@@ -599,13 +678,16 @@ function repairReplacementBoundaries(
599
678
  edits: AppliedEdit[];
600
679
  warnings: string[];
601
680
  } {
602
- const out: AppliedEdit[] = [];
603
- const warnings: string[] = [];
681
+ // Pass 1: apply every repair whose correctness is local to one group
682
+ // (boundary echo, duplicate prefix/suffix). Defer the missing-closer repair:
683
+ // it must weigh a group's imbalance against the whole patch, which is only
684
+ // known once the local repairs above have settled.
685
+ const slots: RepairSlot[] = [];
604
686
  let i = 0;
605
687
  while (i < edits.length) {
606
688
  const group = findReplacementGroup(edits, i);
607
689
  if (!group) {
608
- out.push(edits[i]);
690
+ slots.push({ kind: "edits", edits: [edits[i]] });
609
691
  i++;
610
692
  continue;
611
693
  }
@@ -615,8 +697,11 @@ function repairReplacementBoundaries(
615
697
 
616
698
  const boundaryEcho = findBoundaryEcho(group, fileLines);
617
699
  if (boundaryEcho) {
618
- warnings.push(describeBoundaryEchoRepair(group, boundaryEcho));
619
- out.push(...inserts.slice(boundaryEcho.leading, inserts.length - boundaryEcho.trailing), ...deletes);
700
+ slots.push({
701
+ kind: "edits",
702
+ edits: [...inserts.slice(boundaryEcho.leading, inserts.length - boundaryEcho.trailing), ...deletes],
703
+ warning: describeBoundaryEchoRepair(group, boundaryEcho),
704
+ });
620
705
  continue;
621
706
  }
622
707
 
@@ -627,52 +712,97 @@ function repairReplacementBoundaries(
627
712
  if (balanceIsZero(delta)) {
628
713
  const oneSided = findOneSidedBoundaryEcho(group, fileLines);
629
714
  if (oneSided) {
630
- warnings.push(describeOneSidedEchoRepair(group, oneSided.side, oneSided.count));
631
715
  const trimmed =
632
716
  oneSided.side === "leading"
633
717
  ? inserts.slice(oneSided.count)
634
718
  : inserts.slice(0, inserts.length - oneSided.count);
635
- out.push(...trimmed, ...deletes);
719
+ slots.push({
720
+ kind: "edits",
721
+ edits: [...trimmed, ...deletes],
722
+ warning: describeOneSidedEchoRepair(group, oneSided.side, oneSided.count),
723
+ });
636
724
  continue;
637
725
  }
638
- out.push(...inserts, ...deletes);
726
+ slots.push({ kind: "edits", edits: [...inserts, ...deletes] });
639
727
  continue;
640
728
  }
641
729
 
642
730
  const dupSuffix = findDuplicateSuffix(group, fileLines, delta);
643
731
  if (dupSuffix > 0) {
644
- warnings.push(
645
- describeBoundaryRepair(
732
+ slots.push({
733
+ kind: "edits",
734
+ edits: [...inserts.slice(0, inserts.length - dupSuffix), ...deletes],
735
+ warning: describeBoundaryRepair(
646
736
  group,
647
737
  `dropped ${dupSuffix} duplicated trailing payload line(s) already present below the range`,
648
738
  ),
649
- );
650
- out.push(...inserts.slice(0, inserts.length - dupSuffix), ...deletes);
739
+ });
651
740
  continue;
652
741
  }
653
742
  const dupPrefix = findDuplicatePrefix(group, fileLines, delta);
654
743
  if (dupPrefix > 0) {
655
- warnings.push(
656
- describeBoundaryRepair(
744
+ slots.push({
745
+ kind: "edits",
746
+ edits: [...inserts.slice(dupPrefix), ...deletes],
747
+ warning: describeBoundaryRepair(
657
748
  group,
658
749
  `dropped ${dupPrefix} duplicated leading payload line(s) already present above the range`,
659
750
  ),
660
- );
661
- out.push(...inserts.slice(dupPrefix), ...deletes);
751
+ });
662
752
  continue;
663
753
  }
664
- const droppedClosers = findDroppedSuffixClosers(group, fileLines, delta);
754
+ slots.push({ kind: "candidate", group, inserts, deletes, delta });
755
+ }
756
+
757
+ // Pass 2: project the post-pass-1 stream to learn which lines are deleted and
758
+ // what is inserted where, sum the whole-patch residual balance bleed-free,
759
+ // then resolve each deferred missing-closer candidate against that residual,
760
+ // consuming it per repair so one missing closer is never spent twice.
761
+ const projected: AppliedEdit[] = [];
762
+ for (const slot of slots) {
763
+ projected.push(...(slot.kind === "candidate" ? [...slot.inserts, ...slot.deletes] : slot.edits));
764
+ }
765
+ const deletedLines = new Set<number>();
766
+ for (const edit of projected) {
767
+ if (edit.kind === "delete") deletedLines.add(edit.anchor.line);
768
+ }
769
+ const insertedByLine = new Map<number, string[]>();
770
+ for (const edit of projected) {
771
+ if (edit.kind !== "insert") continue;
772
+ for (const anchor of getCursorAnchors(edit.cursor)) {
773
+ const lines = insertedByLine.get(anchor.line);
774
+ if (lines) lines.push(edit.text);
775
+ else insertedByLine.set(anchor.line, [edit.text]);
776
+ }
777
+ }
778
+ let remainingDelta: DelimiterBalance = { paren: 0, bracket: 0, brace: 0 };
779
+ for (const slot of slots) remainingDelta = balanceSum(remainingDelta, slotPatchDelta(slot, fileLines));
780
+
781
+ const out: AppliedEdit[] = [];
782
+ const warnings: string[] = [];
783
+ for (const slot of slots) {
784
+ if (slot.kind !== "candidate") {
785
+ if (slot.warning !== undefined) warnings.push(slot.warning);
786
+ out.push(...slot.edits);
787
+ continue;
788
+ }
789
+ const prefixBalance = netDeletedPrefixBalance(slot.group, deletedLines, insertedByLine, fileLines);
790
+ const droppedClosers =
791
+ !balanceCovers(prefixBalance, slot.delta) && balanceCovers(remainingDelta, slot.delta)
792
+ ? findDroppedSuffixClosers(slot.group, fileLines, slot.delta)
793
+ : 0;
665
794
  if (droppedClosers > 0) {
666
795
  warnings.push(
667
796
  describeBoundaryRepair(
668
- group,
797
+ slot.group,
669
798
  `kept ${droppedClosers} structural closing line(s) the range deleted without restating`,
670
799
  ),
671
800
  );
672
- out.push(...inserts, ...deletes.slice(0, deletes.length - droppedClosers));
801
+ out.push(...slot.inserts, ...slot.deletes.slice(0, slot.deletes.length - droppedClosers));
802
+ remainingDelta = balanceDelta(remainingDelta, slot.delta);
673
803
  continue;
674
804
  }
675
- out.push(...inserts, ...deletes);
805
+ out.push(...slot.inserts, ...slot.deletes);
676
806
  }
677
807
  return { edits: out, warnings };
678
808
  }