@oh-my-pi/hashline 15.7.5 → 15.8.0

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/apply.ts +97 -22
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/hashline",
4
- "version": "15.7.5",
4
+ "version": "15.8.0",
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
@@ -3,9 +3,9 @@
3
3
  * post-edit lines plus any diagnostic warnings. Pure function: no FS, no
4
4
  * mutation of the input.
5
5
  *
6
- * Replacement groups are first normalized by {@link repairBoundaryBalance},
7
- * which fixes the common model mistake of a payload that duplicates or drops
8
- * the closing delimiter bordering the range (balance-validated; see below).
6
+ * Replacement groups are first normalized by {@link repairReplacementBoundaries},
7
+ * which absorbs common model mistakes where a payload restates unchanged range
8
+ * boundaries or duplicates/drops structural closers.
9
9
  */
10
10
  import { UNRESOLVED_BLOCK_INTERNAL } from "./messages";
11
11
  import { cloneCursor } from "./tokenizer";
@@ -98,22 +98,19 @@ function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[
98
98
  }
99
99
 
100
100
  // ═══════════════════════════════════════════════════════════════════════════
101
- // Boundary-balance repair
101
+ // Replacement-boundary repair
102
102
  //
103
- // Models routinely miscount a replacement range's edges. The payload either
104
- // re-states a closing delimiter that still lives just outside the range
105
- // (producing a DUPLICATE `}` / `);` / `]`) or the range deletes a closer the
106
- // payload never restates (DROPPING it). Both are the same defect — a
107
- // replacement whose payload does not preserve the deleted region's delimiter
108
- // balance — and both leave the file syntactically broken.
103
+ // Models routinely miscount a replacement range's edges. Sometimes the payload
104
+ // re-states unchanged lines that still live on both sides of the range
105
+ // (duplicating a function header and final statement); sometimes it only
106
+ // re-states or omits a structural closer, which leaves delimiter balance broken.
109
107
  //
110
- // A repair fires only when (a) the group's payload balance differs from the
111
- // deleted region's balance and (b) one boundary operation drives that
112
- // difference to exactly zero while leaving the surrounding text byte-identical.
113
- // The operation only ever drops an exact multi-line boundary echo or a single
114
- // pure structural-closer line, or spares a deleted pure structural-closer line,
115
- // so content lines are never moved or lost. Balance-preserving edits are left
116
- // strictly alone.
108
+ // A balance-neutral boundary-echo repair fires only when both the leading and
109
+ // trailing payload edges are exact copies of the surviving lines outside the
110
+ // range. One-sided content echoes are left alone unless delimiter-balance repair
111
+ // proves they are duplicated structural boundaries. This preserves intended
112
+ // duplicate statements while absorbing the common "body includes the unchanged
113
+ // wrapper" mistake.
117
114
 
118
115
  /** A line that is nothing but closing delimiters: `}`, `)`, `];`, `})`, `},`. */
119
116
  const STRUCTURAL_CLOSER_RE = /^\s*[)\]}]+[;,]?\s*$/;
@@ -322,6 +319,77 @@ function findDroppedSuffixClosers(
322
319
  return 0;
323
320
  }
324
321
 
322
+ interface BoundaryEcho {
323
+ leading: number;
324
+ trailing: number;
325
+ }
326
+
327
+ function hasNonWhitespace(text: string): boolean {
328
+ for (let i = 0; i < text.length; i++) {
329
+ const code = text.charCodeAt(i);
330
+ if (code !== 9 && code !== 10 && code !== 11 && code !== 12 && code !== 13 && code !== 32) return true;
331
+ }
332
+ return false;
333
+ }
334
+
335
+ function countDuplicateLeadingBoundaryLines(group: ReplacementGroup, fileLines: readonly string[]): number {
336
+ const { payload, startLine } = group;
337
+ const max = Math.min(payload.length, startLine - 1);
338
+ for (let count = max; count >= 1; count--) {
339
+ let matches = true;
340
+ let hasContent = false;
341
+ for (let offset = 0; offset < count; offset++) {
342
+ const line = payload[offset];
343
+ if (line !== fileLines[startLine - 1 - count + offset]) {
344
+ matches = false;
345
+ break;
346
+ }
347
+ hasContent ||= hasNonWhitespace(line);
348
+ }
349
+ if (matches && hasContent) return count;
350
+ }
351
+ return 0;
352
+ }
353
+
354
+ function countDuplicateTrailingBoundaryLines(group: ReplacementGroup, fileLines: readonly string[]): number {
355
+ const { payload, endLine } = group;
356
+ const max = Math.min(payload.length, fileLines.length - endLine);
357
+ for (let count = max; count >= 1; count--) {
358
+ let matches = true;
359
+ let hasContent = false;
360
+ for (let offset = 0; offset < count; offset++) {
361
+ const line = payload[payload.length - count + offset];
362
+ if (line !== fileLines[endLine + offset]) {
363
+ matches = false;
364
+ break;
365
+ }
366
+ hasContent ||= hasNonWhitespace(line);
367
+ }
368
+ if (matches && hasContent) return count;
369
+ }
370
+ return 0;
371
+ }
372
+
373
+ function findBoundaryEcho(group: ReplacementGroup, fileLines: readonly string[]): BoundaryEcho | undefined {
374
+ const leadingMax = countDuplicateLeadingBoundaryLines(group, fileLines);
375
+ if (leadingMax === 0) return undefined;
376
+ const trailingMax = countDuplicateTrailingBoundaryLines(group, fileLines);
377
+ if (trailingMax === 0) return undefined;
378
+ // Bail when every payload line could be claimed by a boundary echo: any
379
+ // repair would strip explicit replacement content with no signal that the
380
+ // payload was a mistake rather than an intentional duplication.
381
+ if (leadingMax + trailingMax >= group.payload.length) return undefined;
382
+ return { leading: leadingMax, trailing: trailingMax };
383
+ }
384
+
385
+ function describeBoundaryEchoRepair(group: ReplacementGroup, echo: BoundaryEcho): string {
386
+ return (
387
+ `Auto-repaired a replacement boundary echo at line ${group.startLine}: ` +
388
+ `dropped ${echo.leading} leading and ${echo.trailing} trailing payload line(s) already present outside the range. ` +
389
+ `Issue the payload as the final desired content for the selected range only — never restate unchanged lines bordering the range.`
390
+ );
391
+ }
392
+
325
393
  function describeBoundaryRepair(group: ReplacementGroup, action: string): string {
326
394
  return (
327
395
  `Auto-repaired a delimiter-balance mismatch in the replacement at line ${group.startLine}: ${action}. ` +
@@ -330,11 +398,11 @@ function describeBoundaryRepair(group: ReplacementGroup, action: string): string
330
398
  }
331
399
 
332
400
  /**
333
- * Normalize each replacement group so its payload preserves the deleted
334
- * region's delimiter balance. See the section header for the contract. Returns
335
- * the (possibly trimmed) edit list plus one warning per repaired group.
401
+ * Normalize replacement groups so common off-by-one boundaries do not duplicate
402
+ * unchanged surrounding lines or structural closers. Returns the repaired edit
403
+ * list plus one warning per repaired group.
336
404
  */
337
- function repairBoundaryBalance(
405
+ function repairReplacementBoundaries(
338
406
  edits: readonly AppliedEdit[],
339
407
  fileLines: readonly string[],
340
408
  ): {
@@ -355,6 +423,13 @@ function repairBoundaryBalance(
355
423
  const deletes = group.deleteIndices.map(idx => edits[idx]);
356
424
  i = group.deleteIndices[group.deleteIndices.length - 1] + 1;
357
425
 
426
+ const boundaryEcho = findBoundaryEcho(group, fileLines);
427
+ if (boundaryEcho) {
428
+ warnings.push(describeBoundaryEchoRepair(group, boundaryEcho));
429
+ out.push(...inserts.slice(boundaryEcho.leading, inserts.length - boundaryEcho.trailing), ...deletes);
430
+ continue;
431
+ }
432
+
358
433
  const delta = balanceDelta(
359
434
  computeDelimiterBalance(group.payload),
360
435
  computeDelimiterBalance(fileLines.slice(group.startLine - 1, group.endLine)),
@@ -429,7 +504,7 @@ export function applyEdits(text: string, edits: readonly Edit[]): ApplyResult {
429
504
 
430
505
  const targetEdits = appliedEdits.map((edit, index) => cloneAppliedEdit(edit, index));
431
506
  validateLineBounds(targetEdits, fileLines);
432
- const { edits: repaired, warnings } = repairBoundaryBalance(targetEdits, fileLines);
507
+ const { edits: repaired, warnings } = repairReplacementBoundaries(targetEdits, fileLines);
433
508
 
434
509
  // Partition edits into bof, eof, and anchor-targeted buckets.
435
510
  const bofLines: string[] = [];