@oh-my-pi/hashline 16.1.13 → 16.1.15

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,15 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.1.14] - 2026-06-22
6
+
7
+ ### Fixed
8
+
9
+ - Improved delimiter-balance repair to correctly identify and spare deleted structural closers
10
+ - Prevented premature deletion of structural closers when existing code below the range covers them
11
+ - Accurate tracking of inserted lines to improve boundary repair logic for surrounding code blocks
12
+ - Fixed delimiter-balance repair so deleted closer suffixes are kept only when the replacement prefix still has unmatched openers for them, avoiding duplicated trailing braces while preserving omitted outer closers.
13
+
5
14
  ## [16.1.8] - 2026-06-20
6
15
 
7
16
  ### Fixed
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.13",
4
+ "version": "16.1.15",
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
@@ -430,37 +430,167 @@ function findDuplicatePrefix(group: ReplacementGroup, fileLines: readonly string
430
430
  }
431
431
  return 0;
432
432
  }
433
+ interface DroppedSuffixClosers {
434
+ readonly startLine: number;
435
+ readonly count: number;
436
+ readonly balance: DelimiterBalance;
437
+ }
438
+
439
+ function countPayloadRestatedSuffixHead(payload: readonly string[], suffixLines: readonly string[]): number {
440
+ const maxCount = Math.min(payload.length, suffixLines.length);
441
+ for (let count = maxCount; count >= 1; count--) {
442
+ let matches = true;
443
+ for (let offset = 0; offset < count; offset++) {
444
+ if (payload[payload.length - count + offset] !== suffixLines[offset]) {
445
+ matches = false;
446
+ break;
447
+ }
448
+ }
449
+ if (matches) return count;
450
+ }
451
+ return 0;
452
+ }
453
+
454
+ function countProjectedBelowSuffixTail(
455
+ group: ReplacementGroup,
456
+ fileLines: readonly string[],
457
+ deletedLines: ReadonlySet<number>,
458
+ insertedLineMaps: InsertedLineMaps,
459
+ suffixLines: readonly string[],
460
+ ): number {
461
+ const below: string[] = [];
462
+ const appendCloserLines = (lines: readonly string[] | undefined): boolean => {
463
+ if (!lines) return true;
464
+ for (const text of lines) {
465
+ if (!STRUCTURAL_CLOSER_RE.test(text)) return false;
466
+ below.push(text);
467
+ }
468
+ return true;
469
+ };
470
+ if (!appendCloserLines(insertedLineMaps.after.get(group.endLine))) return 0;
471
+ for (let line = group.endLine + 1; line <= fileLines.length; line++) {
472
+ if (!appendCloserLines(insertedLineMaps.before.get(line))) break;
473
+ if (!deletedLines.has(line)) {
474
+ const text = fileLines[line - 1] ?? "";
475
+ if (!STRUCTURAL_CLOSER_RE.test(text)) break;
476
+ below.push(text);
477
+ }
478
+ if (!appendCloserLines(insertedLineMaps.after.get(line))) break;
479
+ }
480
+ const maxCount = Math.min(below.length, suffixLines.length);
481
+ for (let count = maxCount; count >= 1; count--) {
482
+ let matches = true;
483
+ for (let offset = 0; offset < count; offset++) {
484
+ if (below[offset] !== suffixLines[suffixLines.length - count + offset]) {
485
+ matches = false;
486
+ break;
487
+ }
488
+ }
489
+ if (matches) return count;
490
+ }
491
+ return 0;
492
+ }
433
493
 
434
- function payloadEndsWithDeletedSuffix(group: ReplacementGroup, fileLines: readonly string[], count: number): boolean {
435
- if (group.payload.length < count) return false;
436
- const deletedStart = group.endLine - count;
437
- const payloadStart = group.payload.length - count;
438
- for (let offset = 0; offset < count; offset++) {
439
- if (group.payload[payloadStart + offset] !== fileLines[deletedStart + offset]) return false;
494
+ interface InsertedLineMaps {
495
+ readonly before: ReadonlyMap<number, readonly string[]>;
496
+ readonly after: ReadonlyMap<number, readonly string[]>;
497
+ }
498
+
499
+ function computeProjectedPrefixBalance(
500
+ group: ReplacementGroup,
501
+ fileLines: readonly string[],
502
+ deletedLines: ReadonlySet<number>,
503
+ insertedByLine: ReadonlyMap<number, readonly string[]>,
504
+ insertedLineMaps: InsertedLineMaps,
505
+ ): DelimiterBalance {
506
+ const prefix: string[] = [];
507
+ for (let line = 1; line < group.startLine; line++) {
508
+ const inserted = insertedByLine.get(line);
509
+ if (inserted) prefix.push(...inserted);
510
+ if (!deletedLines.has(line)) prefix.push(fileLines[line - 1] ?? "");
440
511
  }
441
- return true;
512
+ const insertedAtStart = insertedLineMaps.before.get(group.startLine);
513
+ if (insertedAtStart) prefix.push(...insertedAtStart);
514
+ prefix.push(...group.payload);
515
+ return computeDelimiterBalance(prefix);
516
+ }
517
+
518
+ function prefixCanCoverSuffixClosers(
519
+ group: ReplacementGroup,
520
+ fileLines: readonly string[],
521
+ suffixBalance: DelimiterBalance,
522
+ coveredBelowBalance: DelimiterBalance,
523
+ deletedLines: ReadonlySet<number>,
524
+ insertedByLine: ReadonlyMap<number, readonly string[]>,
525
+ insertedLineMaps: InsertedLineMaps,
526
+ ): boolean {
527
+ const neededOpeners = balanceNegate(suffixBalance);
528
+ const prefixBalance = computeProjectedPrefixBalance(
529
+ group,
530
+ fileLines,
531
+ deletedLines,
532
+ insertedByLine,
533
+ insertedLineMaps,
534
+ );
535
+ const uncoveredPrefixBalance = balanceSum(prefixBalance, coveredBelowBalance);
536
+ return balanceCovers(uncoveredPrefixBalance, neededOpeners);
442
537
  }
443
538
 
444
539
  /**
445
- * Smallest `m` such that the range's last `m` deleted lines are all pure
446
- * structural closers, the payload does not already restate those same suffix
447
- * lines, and sparing them (keeping instead of deleting) zeroes `delta`. The
448
- * mirror mistake: a range that swallows a closing delimiter the payload never
449
- * restates.
540
+ * Missing segment of the range's deleted structural-closer suffix that should
541
+ * be spared. Payload lines that already restate the suffix head are not kept
542
+ * again, and projected closers immediately below the range satisfy the suffix
543
+ * tail. The remaining middle segment is kept only when backed by unmatched
544
+ * openers plus the whole-patch residual.
450
545
  */
451
546
  function findDroppedSuffixClosers(
452
547
  group: ReplacementGroup,
453
548
  fileLines: readonly string[],
454
549
  delta: DelimiterBalance,
455
- ): number {
456
- const wanted = balanceNegate(delta);
457
- const maxM = group.deleteIndices.length;
458
- for (let m = 1; m <= maxM; m++) {
459
- if (!STRUCTURAL_CLOSER_RE.test(fileLines[group.endLine - m] ?? "")) break;
460
- if (payloadEndsWithDeletedSuffix(group, fileLines, m)) continue;
461
- if (balanceEqual(computeDelimiterBalance(fileLines.slice(group.endLine - m, group.endLine)), wanted)) return m;
550
+ remainingDelta: DelimiterBalance,
551
+ deletedPrefixBalance: DelimiterBalance,
552
+ deletedLines: ReadonlySet<number>,
553
+ insertedByLine: ReadonlyMap<number, readonly string[]>,
554
+ insertedLineMaps: InsertedLineMaps,
555
+ ): DroppedSuffixClosers | undefined {
556
+ let suffixLength = 0;
557
+ while (
558
+ suffixLength < group.deleteIndices.length &&
559
+ STRUCTURAL_CLOSER_RE.test(fileLines[group.endLine - suffixLength - 1] ?? "")
560
+ ) {
561
+ suffixLength++;
462
562
  }
463
- return 0;
563
+ if (suffixLength === 0) return undefined;
564
+
565
+ const suffixStartLine = group.endLine - suffixLength + 1;
566
+ const suffixLines = fileLines.slice(group.endLine - suffixLength, group.endLine);
567
+ const restatedHead = countPayloadRestatedSuffixHead(group.payload, suffixLines);
568
+ const coveredTail = countProjectedBelowSuffixTail(group, fileLines, deletedLines, insertedLineMaps, suffixLines);
569
+ const keepStart = restatedHead;
570
+ const keepEnd = suffixLength - coveredTail;
571
+ if (keepStart >= keepEnd) return undefined;
572
+
573
+ const keptLines = suffixLines.slice(keepStart, keepEnd);
574
+ const keptBalance = computeDelimiterBalance(keptLines);
575
+ const neededOpeners = balanceNegate(keptBalance);
576
+ const coveredBelowBalance = computeDelimiterBalance(suffixLines.slice(keepEnd));
577
+ if (!balanceCovers(delta, neededOpeners)) return undefined;
578
+ if (balanceCovers(deletedPrefixBalance, neededOpeners)) return undefined;
579
+ if (!balanceCovers(remainingDelta, neededOpeners)) return undefined;
580
+ if (
581
+ !prefixCanCoverSuffixClosers(
582
+ group,
583
+ fileLines,
584
+ keptBalance,
585
+ coveredBelowBalance,
586
+ deletedLines,
587
+ insertedByLine,
588
+ insertedLineMaps,
589
+ )
590
+ ) {
591
+ return undefined;
592
+ }
593
+ return { startLine: suffixStartLine + keepStart, count: keepEnd - keepStart, balance: keptBalance };
464
594
  }
465
595
 
466
596
  interface BoundaryEcho {
@@ -754,10 +884,6 @@ function repairReplacementBoundaries(
754
884
  slots.push({ kind: "candidate", group, inserts, deletes, delta });
755
885
  }
756
886
 
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
887
  const projected: AppliedEdit[] = [];
762
888
  for (const slot of slots) {
763
889
  projected.push(...(slot.kind === "candidate" ? [...slot.inserts, ...slot.deletes] : slot.edits));
@@ -767,6 +893,10 @@ function repairReplacementBoundaries(
767
893
  if (edit.kind === "delete") deletedLines.add(edit.anchor.line);
768
894
  }
769
895
  const insertedByLine = new Map<number, string[]>();
896
+ const insertedLineMaps: { before: Map<number, string[]>; after: Map<number, string[]> } = {
897
+ before: new Map(),
898
+ after: new Map(),
899
+ };
770
900
  for (const edit of projected) {
771
901
  if (edit.kind !== "insert") continue;
772
902
  for (const anchor of getCursorAnchors(edit.cursor)) {
@@ -774,6 +904,12 @@ function repairReplacementBoundaries(
774
904
  if (lines) lines.push(edit.text);
775
905
  else insertedByLine.set(anchor.line, [edit.text]);
776
906
  }
907
+ if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor") {
908
+ const bySide = edit.cursor.kind === "before_anchor" ? insertedLineMaps.before : insertedLineMaps.after;
909
+ const lines = bySide.get(edit.cursor.anchor.line);
910
+ if (lines) lines.push(edit.text);
911
+ else bySide.set(edit.cursor.anchor.line, [edit.text]);
912
+ }
777
913
  }
778
914
  let remainingDelta: DelimiterBalance = { paren: 0, bracket: 0, brace: 0 };
779
915
  for (const slot of slots) remainingDelta = balanceSum(remainingDelta, slotPatchDelta(slot, fileLines));
@@ -786,20 +922,37 @@ function repairReplacementBoundaries(
786
922
  out.push(...slot.edits);
787
923
  continue;
788
924
  }
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;
794
- if (droppedClosers > 0) {
925
+ const deletedPrefixBalance = netDeletedPrefixBalance(slot.group, deletedLines, insertedByLine, fileLines);
926
+ const droppedClosers = findDroppedSuffixClosers(
927
+ slot.group,
928
+ fileLines,
929
+ slot.delta,
930
+ remainingDelta,
931
+ deletedPrefixBalance,
932
+ deletedLines,
933
+ insertedByLine,
934
+ insertedLineMaps,
935
+ );
936
+ if (droppedClosers) {
795
937
  warnings.push(
796
938
  describeBoundaryRepair(
797
939
  slot.group,
798
- `kept ${droppedClosers} structural closing line(s) the range deleted without restating`,
940
+ `kept ${droppedClosers.count} structural closing line(s) the range deleted without restating`,
799
941
  ),
800
942
  );
801
- out.push(...slot.inserts, ...slot.deletes.slice(0, slot.deletes.length - droppedClosers));
802
- remainingDelta = balanceDelta(remainingDelta, slot.delta);
943
+ out.push(
944
+ ...slot.inserts,
945
+ ...slot.deletes.filter(
946
+ edit =>
947
+ edit.kind !== "delete" ||
948
+ edit.anchor.line < droppedClosers.startLine ||
949
+ edit.anchor.line >= droppedClosers.startLine + droppedClosers.count,
950
+ ),
951
+ );
952
+ for (let line = droppedClosers.startLine; line < droppedClosers.startLine + droppedClosers.count; line++) {
953
+ deletedLines.delete(line);
954
+ }
955
+ remainingDelta = balanceSum(remainingDelta, droppedClosers.balance);
803
956
  continue;
804
957
  }
805
958
  out.push(...slot.inserts, ...slot.deletes);