jeopi-hashline 16.4.2 → 16.4.3

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,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.4.3] - 2026-07-22
6
+
7
+ ### Fixed
8
+
9
+ - Rejected ambiguous swaps that risk silent deletion of range boundaries: a one-sided boundary echo (payload restates the keeper line just outside the range) is only auto-repaired when the payload is long enough to be the widened range's full content — a shorter payload could instead mean the range itself shifted by the echo, which keeps the far boundary line(s) the repair would otherwise silently delete. The edit is now rejected with a message telling the author to re-issue it. Ported from oh-my-pi (upstream `e45796908`).
10
+ - Prevented ambiguous auto-repairing of structural closing lines when payload placement is unclear: sparing a deleted closer re-inserts it after the payload, which claims the payload belongs inside the block the closer terminates. That claim now requires evidence — the payload carries the closer's unmatched opener, or its indentation sits deeper than the closer — otherwise the edit is rejected instead of guessing whether the new content belongs before or after the closer. Ported from oh-my-pi (upstream `e45796908`).
11
+
5
12
  ## [16.2.14] - 2026-07-02
6
13
 
7
14
  ### Changed
@@ -49,6 +49,23 @@ export declare function insertAfterBlockCloserLoweredWarning(line: number): stri
49
49
  * applying with a warning beats failing the patch.
50
50
  */
51
51
  export declare function insertAfterBlockUnresolvedLoweredWarning(line: number): string;
52
+ /**
53
+ * A one-sided boundary echo whose payload is too short to be the widened
54
+ * range's full content: dropping the echo deletes range line(s) the payload
55
+ * never restates (the "widened range" reading), while the "range shifted by
56
+ * the echo" reading keeps them. The readings produce different files, so the
57
+ * edit is rejected instead of repaired.
58
+ */
59
+ export declare function ambiguousBoundaryEchoMessage(startLine: number, endLine: number, side: "leading" | "trailing", count: number): string;
60
+ /**
61
+ * A replacement range deletes trailing structural closer(s) the payload never
62
+ * restates, and nothing anchors the payload inside the block those closers
63
+ * terminate: the payload has no unmatched opener for them and its indentation
64
+ * is not deeper than the closer. Sparing the closer would have to guess
65
+ * whether the payload belongs before it (inside the block) or after it (a
66
+ * sibling), so the edit is rejected instead of repaired.
67
+ */
68
+ export declare function ambiguousCloserSpareMessage(startLine: number, endLine: number, closerLine: number, count: number): string;
52
69
  /**
53
70
  * Internal invariant: `applyEdits` received an unresolved `replace_block N:`
54
71
  * edit; `resolveBlockEdits` must run first. Wiring bug, not authored input.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "jeopi-hashline",
4
- "version": "16.4.2",
4
+ "version": "16.4.3",
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://github.com/akillness/jeopi",
7
7
  "author": "Can Boluk",
package/src/apply.ts CHANGED
@@ -7,7 +7,13 @@
7
7
  * which absorbs common model mistakes where a payload restates unchanged range
8
8
  * boundaries or duplicates/drops structural closers.
9
9
  */
10
- import { afterInsertLandingShiftWarning, blockInsertLandingShiftWarning, UNRESOLVED_BLOCK_INTERNAL } from "./messages";
10
+ import {
11
+ afterInsertLandingShiftWarning,
12
+ ambiguousBoundaryEchoMessage,
13
+ ambiguousCloserSpareMessage,
14
+ blockInsertLandingShiftWarning,
15
+ UNRESOLVED_BLOCK_INTERNAL,
16
+ } from "./messages";
11
17
  import { cloneCursor } from "./tokenizer";
12
18
  import type { Anchor, ApplyResult, Cursor, Edit } from "./types";
13
19
 
@@ -702,6 +708,12 @@ function describeBoundaryRepair(group: ReplacementGroup, action: string): string
702
708
  * carries no delimiter-balance signal itself, such as a JSX `</section>` close.
703
709
  * The dropped lines must keep the already-balanced result balanced, and must
704
710
  * not consume the whole payload.
711
+ *
712
+ * A detected echo is only *repairable* when the payload is long enough to be
713
+ * the widened range's full content (`payload ≥ range + echo`). Shorter
714
+ * payloads are ambiguous — the echo may instead mean the range itself was
715
+ * shifted by the echo, which keeps the far boundary line(s) the repair would
716
+ * delete — and the caller rejects the edit instead of guessing.
705
717
  */
706
718
  function findOneSidedBoundaryEcho(
707
719
  group: ReplacementGroup,
@@ -800,6 +812,12 @@ function slotPatchDelta(slot: RepairSlot, fileLines: readonly string[]): Delimit
800
812
  * deleted is only kept when the patch as a whole is missing it — never when
801
813
  * another hunk already removed the matching opener. Returns the repaired edits
802
814
  * plus one warning per repaired group.
815
+ *
816
+ * Repairs fire only when exactly one reading explains the mistake. When the
817
+ * evidence is ambiguous — a one-sided echo whose payload is too short for the
818
+ * widened range, or a spared closer the payload neither opens nor indents
819
+ * into — the function throws instead of guessing, so the author re-issues the
820
+ * edit rather than shipping silently corrupted content.
803
821
  */
804
822
  function repairReplacementBoundaries(
805
823
  edits: readonly AppliedEdit[],
@@ -842,6 +860,15 @@ function repairReplacementBoundaries(
842
860
  if (balanceIsZero(delta)) {
843
861
  const oneSided = findOneSidedBoundaryEcho(group, fileLines);
844
862
  if (oneSided) {
863
+ // A payload shorter than range+echo cannot be the widened
864
+ // range's full content: the repair would delete range line(s)
865
+ // the payload never restates, while the "shifted range"
866
+ // reading keeps them. Reject rather than guess.
867
+ if (group.payload.length < group.deleteIndices.length + oneSided.count) {
868
+ throw new Error(
869
+ ambiguousBoundaryEchoMessage(group.startLine, group.endLine, oneSided.side, oneSided.count),
870
+ );
871
+ }
845
872
  const trimmed =
846
873
  oneSided.side === "leading"
847
874
  ? inserts.slice(oneSided.count)
@@ -934,6 +961,29 @@ function repairReplacementBoundaries(
934
961
  insertedLineMaps,
935
962
  );
936
963
  if (droppedClosers) {
964
+ // Sparing a closer re-inserts it *after* the payload, which claims
965
+ // the payload lives inside the block the closer terminates. That
966
+ // claim needs evidence: the payload carries the closer's unmatched
967
+ // opener itself, or its indentation sits deeper than the closer.
968
+ // Without either, "before or after the closer" is a coin flip —
969
+ // reject rather than guess (e.g. a statement swapped onto a lone
970
+ // `}` at the closer's own depth belongs after the block).
971
+ const keptIndent = leadingIndent(fileLines[droppedClosers.startLine - 1] ?? "");
972
+ const payloadIndent = bodyTargetIndent(slot.group.payload);
973
+ const payloadOpens = balanceCovers(
974
+ computeDelimiterBalance(slot.group.payload),
975
+ balanceNegate(droppedClosers.balance),
976
+ );
977
+ if (!payloadOpens && !(payloadIndent !== undefined && isIndentDeeper(payloadIndent, keptIndent))) {
978
+ throw new Error(
979
+ ambiguousCloserSpareMessage(
980
+ slot.group.startLine,
981
+ slot.group.endLine,
982
+ droppedClosers.startLine,
983
+ droppedClosers.count,
984
+ ),
985
+ );
986
+ }
937
987
  warnings.push(
938
988
  describeBoundaryRepair(
939
989
  slot.group,
package/src/messages.ts CHANGED
@@ -108,6 +108,55 @@ export function insertAfterBlockCloserLoweredWarning(line: number): string {
108
108
  export function insertAfterBlockUnresolvedLoweredWarning(line: number): string {
109
109
  return `\`INS.BLK.POST ${line}:\` could not resolve a syntactic block on line ${line}, so it was applied as plain \`INS.POST ${line}:\`. Verify the landing line; anchor on a line that OPENS a construct.`;
110
110
  }
111
+ /**
112
+ * A one-sided boundary echo whose payload is too short to be the widened
113
+ * range's full content: dropping the echo deletes range line(s) the payload
114
+ * never restates (the "widened range" reading), while the "range shifted by
115
+ * the echo" reading keeps them. The readings produce different files, so the
116
+ * edit is rejected instead of repaired.
117
+ */
118
+ export function ambiguousBoundaryEchoMessage(
119
+ startLine: number,
120
+ endLine: number,
121
+ side: "leading" | "trailing",
122
+ count: number,
123
+ ): string {
124
+ const where =
125
+ side === "leading"
126
+ ? `opens by restating the ${count} line(s) just above the range`
127
+ : `ends by restating the ${count} line(s) just below the range`;
128
+ return (
129
+ `\`SWAP ${startLine}${HL_RANGE_SEP}${endLine}:\` rejected: the body ${where}, ` +
130
+ `but is too short to be the full final content of the widened range — applying it as-is or ` +
131
+ `auto-repairing would delete range line(s) the body never restates. ` +
132
+ `Re-issue with the range covering exactly the lines that change and the body as their complete ` +
133
+ `final content: drop the restated keeper from the body, or widen the range to consume it.`
134
+ );
135
+ }
136
+
137
+ /**
138
+ * A replacement range deletes trailing structural closer(s) the payload never
139
+ * restates, and nothing anchors the payload inside the block those closers
140
+ * terminate: the payload has no unmatched opener for them and its indentation
141
+ * is not deeper than the closer. Sparing the closer would have to guess
142
+ * whether the payload belongs before it (inside the block) or after it (a
143
+ * sibling), so the edit is rejected instead of repaired.
144
+ */
145
+ export function ambiguousCloserSpareMessage(
146
+ startLine: number,
147
+ endLine: number,
148
+ closerLine: number,
149
+ count: number,
150
+ ): string {
151
+ const closers = count === 1 ? `line ${closerLine}` : `lines ${closerLine}-${closerLine + count - 1}`;
152
+ return (
153
+ `\`SWAP ${startLine}${HL_RANGE_SEP}${endLine}:\` rejected: the range deletes the closing-delimiter ` +
154
+ `${closers} but the body never restates it, and the body claims no position inside that block ` +
155
+ `(no unmatched opener, indentation not deeper than the closer) — whether the new content belongs ` +
156
+ `before or after the closer is ambiguous. Restate the closer in the body at the intended position, ` +
157
+ `or use \`INS.PRE ${closerLine}:\` / \`INS.POST ${closerLine}:\` instead.`
158
+ );
159
+ }
111
160
 
112
161
  /**
113
162
  * Internal invariant: `applyEdits` received an unresolved `replace_block N:`