@oh-my-pi/hashline 17.1.7 → 17.2.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.
- package/CHANGELOG.md +21 -0
- package/README.md +2 -1
- package/dist/types/apply.d.ts +22 -2
- package/dist/types/block.d.ts +4 -7
- package/dist/types/clipboard.d.ts +22 -0
- package/dist/types/format.d.ts +10 -6
- package/dist/types/index.d.ts +1 -0
- package/dist/types/input.d.ts +15 -4
- package/dist/types/messages.d.ts +44 -36
- package/dist/types/patcher.d.ts +21 -6
- package/dist/types/recovery.d.ts +3 -1
- package/dist/types/tokenizer.d.ts +12 -6
- package/dist/types/types.d.ts +57 -30
- package/package.json +2 -2
- package/src/apply.ts +26 -5
- package/src/block.ts +52 -24
- package/src/clipboard.ts +110 -0
- package/src/format.ts +11 -7
- package/src/grammar.lark +12 -15
- package/src/index.ts +1 -0
- package/src/input.ts +44 -7
- package/src/messages.ts +113 -59
- package/src/parser.ts +96 -34
- package/src/patcher.ts +89 -15
- package/src/prompt.md +51 -89
- package/src/recovery.ts +28 -4
- package/src/tokenizer.ts +85 -38
- package/src/types.ts +60 -30
package/src/patcher.ts
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
import * as path from "node:path";
|
|
26
26
|
import { applyEdits } from "./apply";
|
|
27
27
|
import { hasBlockEdit, resolveBlockEdits } from "./block";
|
|
28
|
+
import { commitClipboard, forkClipboard, validateClipboardSequence } from "./clipboard";
|
|
28
29
|
import { computeFileHash, formatHashlineHeader } from "./format";
|
|
29
30
|
import type { Filesystem, WriteResult } from "./fs";
|
|
30
31
|
import { isNotFound } from "./fs";
|
|
@@ -35,13 +36,14 @@ import {
|
|
|
35
36
|
pathRecoveredFromTagMessage,
|
|
36
37
|
type RevealedLine,
|
|
37
38
|
unseenLinesMessage,
|
|
39
|
+
writeDriftWarning,
|
|
38
40
|
} from "./messages";
|
|
39
41
|
import { MismatchError } from "./mismatch";
|
|
40
42
|
import { detectLineEnding, type LineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize";
|
|
41
43
|
import { InvalidAbsoluteRangeError } from "./parser";
|
|
42
44
|
import { Recovery, type RecoveryResult } from "./recovery";
|
|
43
45
|
import type { Snapshot, SnapshotStore } from "./snapshots";
|
|
44
|
-
import type { ApplyResult, BlockResolution, BlockResolver, BlockSpan, Edit, FileOp } from "./types";
|
|
46
|
+
import type { ApplyResult, BlockResolution, BlockResolver, BlockSpan, Clipboard, Edit, FileOp } from "./types";
|
|
45
47
|
|
|
46
48
|
/**
|
|
47
49
|
* Upper bound on the number of unseen anchor lines whose actual file content
|
|
@@ -80,6 +82,12 @@ export interface PatcherOptions {
|
|
|
80
82
|
* validate on content hash alone and any anchor into the tagged content applies.
|
|
81
83
|
*/
|
|
82
84
|
enforceSeenLines?: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* Host-owned clipboard register shared across batches, so `CUT` content
|
|
87
|
+
* can be `PASTE`d by a later {@link Patcher.apply} call. Each batch works
|
|
88
|
+
* on a fork and publishes it back only after writes land.
|
|
89
|
+
*/
|
|
90
|
+
clipboard?: Clipboard;
|
|
83
91
|
}
|
|
84
92
|
|
|
85
93
|
/** Per-section result returned by {@link Patcher.apply} / {@link Patcher.commit}. */
|
|
@@ -98,7 +106,13 @@ export interface PatchSectionResult {
|
|
|
98
106
|
persisted: string;
|
|
99
107
|
/** Final text that the {@link Filesystem} actually wrote (may differ if the FS transformed it). */
|
|
100
108
|
written: string;
|
|
101
|
-
/**
|
|
109
|
+
/**
|
|
110
|
+
* 4-hex content-hash tag. Hashes the content the {@link Filesystem}
|
|
111
|
+
* reports actually landed on disk (see `written`), which normally equals
|
|
112
|
+
* `after` but can diverge when the write path transforms content (e.g. an
|
|
113
|
+
* ACP-bridge write reformatted by the client's format-on-save). Use to
|
|
114
|
+
* anchor follow-up edits.
|
|
115
|
+
*/
|
|
102
116
|
fileHash: string;
|
|
103
117
|
/** Hashline section header (`[path#tag]`) of the post-edit content. */
|
|
104
118
|
header: string;
|
|
@@ -109,9 +123,8 @@ export interface PatchSectionResult {
|
|
|
109
123
|
/** Destination path when this section includes `MV DEST`. */
|
|
110
124
|
moveDest?: string;
|
|
111
125
|
/**
|
|
112
|
-
* Resolved spans for
|
|
113
|
-
*
|
|
114
|
-
* (and for resolutions routed through drift recovery, where numbers shift).
|
|
126
|
+
* Resolved spans for block ops, present when the apply matched the tagged
|
|
127
|
+
* content. Undefined for patches with no block ops and for drift recovery.
|
|
115
128
|
*/
|
|
116
129
|
blockResolutions?: BlockResolution[];
|
|
117
130
|
}
|
|
@@ -151,6 +164,8 @@ function hasAnchorScopedEdit(edits: readonly Edit[]): boolean {
|
|
|
151
164
|
if (edit.kind === "delete") return true;
|
|
152
165
|
// A `replace_block N:` edit anchors to concrete content on line N.
|
|
153
166
|
if (edit.kind === "block") return true;
|
|
167
|
+
// A `CUT` range reads concrete content.
|
|
168
|
+
if (edit.kind === "cut") return true;
|
|
154
169
|
return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
|
|
155
170
|
});
|
|
156
171
|
}
|
|
@@ -204,6 +219,7 @@ export class Patcher {
|
|
|
204
219
|
readonly snapshots: SnapshotStore;
|
|
205
220
|
readonly recovery: Recovery;
|
|
206
221
|
readonly blockResolver: BlockResolver | undefined;
|
|
222
|
+
readonly clipboard: Clipboard | undefined;
|
|
207
223
|
readonly #enforceSeenLines: boolean;
|
|
208
224
|
|
|
209
225
|
constructor(options: PatcherOptions) {
|
|
@@ -214,6 +230,7 @@ export class Patcher {
|
|
|
214
230
|
this.snapshots = options.snapshots;
|
|
215
231
|
this.recovery = new Recovery(options.snapshots);
|
|
216
232
|
this.blockResolver = options.blockResolver;
|
|
233
|
+
this.clipboard = options.clipboard;
|
|
217
234
|
this.#enforceSeenLines = options.enforceSeenLines ?? true;
|
|
218
235
|
}
|
|
219
236
|
|
|
@@ -224,16 +241,34 @@ export class Patcher {
|
|
|
224
241
|
* {@link PatchSectionResult} per section in the original patch order.
|
|
225
242
|
*/
|
|
226
243
|
async apply(patch: Patch): Promise<PatcherApplyResult> {
|
|
244
|
+
// One register per batch: `CUT` in one section feeds `PASTE` in a later
|
|
245
|
+
// one, so content can move across files. A host-owned register
|
|
246
|
+
// (see PatcherOptions.clipboard) additionally persists across batches:
|
|
247
|
+
// work on a fork and publish it per landed section, so a failed batch
|
|
248
|
+
// never poisons the persistent register and a mid-batch failure still
|
|
249
|
+
// preserves content already cut from disk.
|
|
250
|
+
const clipboard = forkClipboard(this.clipboard);
|
|
251
|
+
|
|
227
252
|
// Single-section fast path.
|
|
228
253
|
if (patch.sections.length === 1) {
|
|
229
|
-
const prepared = await this.prepare(patch.sections[0]);
|
|
230
|
-
|
|
254
|
+
const prepared = await this.prepare(patch.sections[0], clipboard);
|
|
255
|
+
const result = await this.commit(prepared);
|
|
256
|
+
if (this.clipboard !== undefined) commitClipboard(clipboard, this.clipboard);
|
|
257
|
+
return { sections: [result] };
|
|
231
258
|
}
|
|
232
259
|
|
|
233
260
|
// Prepare every section first so any failure (stale hash, missing
|
|
234
261
|
// file, parse error, in-memory no-op) surfaces before any write.
|
|
235
262
|
const prepared: PreparedSection[] = [];
|
|
236
|
-
|
|
263
|
+
// Register state after each section's prepare. Commits are non-atomic:
|
|
264
|
+
// when a later write fails, the sections before it are already on disk,
|
|
265
|
+
// so the host register must reflect exactly the landed prefix — content
|
|
266
|
+
// a landed CUT deleted would otherwise be lost.
|
|
267
|
+
const sectionStates: Clipboard[] = [];
|
|
268
|
+
for (const section of patch.sections) {
|
|
269
|
+
prepared.push(await this.prepare(section, clipboard));
|
|
270
|
+
sectionStates.push(forkClipboard(clipboard));
|
|
271
|
+
}
|
|
237
272
|
assertUniqueCanonicalPaths(prepared);
|
|
238
273
|
for (const entry of prepared) {
|
|
239
274
|
if (entry.isNoop) {
|
|
@@ -259,6 +294,7 @@ export class Patcher {
|
|
|
259
294
|
{ cause: error },
|
|
260
295
|
);
|
|
261
296
|
}
|
|
297
|
+
if (this.clipboard !== undefined) commitClipboard(sectionStates[index], this.clipboard);
|
|
262
298
|
}
|
|
263
299
|
return { sections: results };
|
|
264
300
|
}
|
|
@@ -268,8 +304,10 @@ export class Patcher {
|
|
|
268
304
|
* No writes hit the filesystem. Use for CI checks and dry runs.
|
|
269
305
|
*/
|
|
270
306
|
async preflight(patch: Patch): Promise<void> {
|
|
307
|
+
// Dry run: fork the register and never publish it back.
|
|
308
|
+
const clipboard = forkClipboard(this.clipboard);
|
|
271
309
|
const prepared: PreparedSection[] = [];
|
|
272
|
-
for (const section of patch.sections) prepared.push(await this.prepare(section));
|
|
310
|
+
for (const section of patch.sections) prepared.push(await this.prepare(section, clipboard));
|
|
273
311
|
assertUniqueCanonicalPaths(prepared);
|
|
274
312
|
for (const entry of prepared) {
|
|
275
313
|
if (entry.isNoop) {
|
|
@@ -303,10 +341,13 @@ export class Patcher {
|
|
|
303
341
|
* {@link PreparedSection} which can be fed to {@link commit} to land
|
|
304
342
|
* the result on the filesystem.
|
|
305
343
|
*
|
|
344
|
+
* `clipboard` is the register shared by `CUT`/`PASTE` ops. Pass the batch
|
|
345
|
+
* register when preparing several sections so content can move across files.
|
|
346
|
+
*
|
|
306
347
|
* Throws on parse error, missing-file-for-anchored-edit, or unrecovered
|
|
307
348
|
* tag mismatch ({@link MismatchError}).
|
|
308
349
|
*/
|
|
309
|
-
async prepare(section: PatchSection): Promise<PreparedSection> {
|
|
350
|
+
async prepare(section: PatchSection, clipboard?: Clipboard): Promise<PreparedSection> {
|
|
310
351
|
const parsed = await this.#parseWithRangeDiagnostics(section);
|
|
311
352
|
const parseWarnings = [...parsed.warnings];
|
|
312
353
|
const fileOp = parsed.fileOp;
|
|
@@ -353,6 +394,7 @@ export class Patcher {
|
|
|
353
394
|
const lineEnding = detectLineEnding(text);
|
|
354
395
|
const normalized = normalizeToLF(text);
|
|
355
396
|
|
|
397
|
+
const register = clipboard ?? {};
|
|
356
398
|
const applyResult =
|
|
357
399
|
fileOp?.kind === "rem"
|
|
358
400
|
? this.#applyWithRecovery({
|
|
@@ -361,6 +403,7 @@ export class Patcher {
|
|
|
361
403
|
exists: read.exists,
|
|
362
404
|
normalized,
|
|
363
405
|
edits: [],
|
|
406
|
+
clipboard: register,
|
|
364
407
|
})
|
|
365
408
|
: this.#applyWithRecovery({
|
|
366
409
|
section: target,
|
|
@@ -368,6 +411,7 @@ export class Patcher {
|
|
|
368
411
|
exists: read.exists,
|
|
369
412
|
normalized,
|
|
370
413
|
edits: parsed.edits,
|
|
414
|
+
clipboard: register,
|
|
371
415
|
});
|
|
372
416
|
|
|
373
417
|
return new PreparedSection(
|
|
@@ -488,9 +532,32 @@ export class Patcher {
|
|
|
488
532
|
}
|
|
489
533
|
|
|
490
534
|
const write: WriteResult = await this.fs.writeText(section.path, persisted);
|
|
491
|
-
const fileHash = this.#recordFullSnapshot(canonicalPath, after);
|
|
492
535
|
const op = exists ? "update" : "create";
|
|
493
536
|
|
|
537
|
+
// `write.text` is the FS adapter's report of what actually landed on
|
|
538
|
+
// disk (see `WriteResult`), which for an ACP-bridge write can diverge
|
|
539
|
+
// from `after` when the client transforms content on save (e.g.
|
|
540
|
+
// format-on-save reformatting indentation the tool never touched).
|
|
541
|
+
// Keying the snapshot on `after` unconditionally would record a hash
|
|
542
|
+
// for content that no longer exists on disk: the next `read` sees the
|
|
543
|
+
// drifted file, tag validation misses, and hunk resolution proceeds
|
|
544
|
+
// against a baseline the file has already left — the mechanism behind
|
|
545
|
+
// "single-line edit reformats the whole file". Re-derive the recorded
|
|
546
|
+
// text from what was actually persisted and hash THAT.
|
|
547
|
+
//
|
|
548
|
+
// Deliberately does NOT touch `after` (or the diff/`newText` derived
|
|
549
|
+
// from it downstream): `after` stays the content this section asked
|
|
550
|
+
// for, so the model-visible diff stays scoped to the intended hunk
|
|
551
|
+
// instead of ballooning to a whole-file diff against a formatter's
|
|
552
|
+
// output on every drifted write. The drift itself is a warning, not a
|
|
553
|
+
// diff — an O(1) signal instead of an O(file-size) one. Comparing the
|
|
554
|
+
// normalized forms (rather than raw `write.text`/`persisted`) avoids a
|
|
555
|
+
// false "drift" purely from BOM/line-ending restoration asymmetry.
|
|
556
|
+
const recorded = normalizeToLF(stripBom(write.text).text);
|
|
557
|
+
const driftedOnWrite = recorded !== after;
|
|
558
|
+
const fileHash = this.#recordFullSnapshot(canonicalPath, recorded);
|
|
559
|
+
const allWarnings = driftedOnWrite ? [...warnings, writeDriftWarning(section.path)] : warnings;
|
|
560
|
+
|
|
494
561
|
return {
|
|
495
562
|
path: section.path,
|
|
496
563
|
canonicalPath,
|
|
@@ -503,7 +570,7 @@ export class Patcher {
|
|
|
503
570
|
header: formatHashlineHeader(section.path, fileHash),
|
|
504
571
|
firstChangedLine: applyResult.firstChangedLine,
|
|
505
572
|
blockResolutions: applyResult.blockResolutions,
|
|
506
|
-
warnings,
|
|
573
|
+
warnings: allWarnings,
|
|
507
574
|
};
|
|
508
575
|
}
|
|
509
576
|
|
|
@@ -609,8 +676,9 @@ export class Patcher {
|
|
|
609
676
|
exists: boolean;
|
|
610
677
|
normalized: string;
|
|
611
678
|
edits: readonly Edit[];
|
|
679
|
+
clipboard: Clipboard;
|
|
612
680
|
}): ApplyResult {
|
|
613
|
-
const { section, canonicalPath, exists, normalized, edits } = args;
|
|
681
|
+
const { section, canonicalPath, exists, normalized, edits, clipboard } = args;
|
|
614
682
|
const expected = exists ? section.fileHash : undefined;
|
|
615
683
|
// The 4-hex tag is content-derived: when the live text hashes to it,
|
|
616
684
|
// trust the match and apply directly. `storedSnapshotForTag` feeds the
|
|
@@ -643,6 +711,11 @@ export class Patcher {
|
|
|
643
711
|
onWarning: warning => resolveWarnings.push(warning),
|
|
644
712
|
});
|
|
645
713
|
}
|
|
714
|
+
// Surface clipboard sequencing mistakes (`PASTE` before any capture,
|
|
715
|
+
// capturing over un-pasted `CUT` content) with their targeted message
|
|
716
|
+
// before the recovery path below, which swallows apply failures and
|
|
717
|
+
// re-surfaces them as tag-mismatch errors.
|
|
718
|
+
validateClipboardSequence(resolved, clipboard);
|
|
646
719
|
const withResolveWarnings = (result: ApplyResult): ApplyResult =>
|
|
647
720
|
resolveWarnings.length === 0
|
|
648
721
|
? result
|
|
@@ -659,7 +732,7 @@ export class Patcher {
|
|
|
659
732
|
if (expected !== undefined && this.#enforceSeenLines) {
|
|
660
733
|
this.#assertSeenLines(section, expected, matchedSnapshot);
|
|
661
734
|
}
|
|
662
|
-
const result = applyEdits(normalized, resolved);
|
|
735
|
+
const result = applyEdits(normalized, resolved, { clipboard });
|
|
663
736
|
return withResolveWarnings(blockResolutions.length > 0 ? { ...result, blockResolutions } : result);
|
|
664
737
|
}
|
|
665
738
|
// Head/tail-only inserts are position-stable: "start"/"end" cannot move
|
|
@@ -667,7 +740,7 @@ export class Patcher {
|
|
|
667
740
|
// content and warn instead of hard-failing — unlike an anchored
|
|
668
741
|
// mismatch, which cannot be safely relocated and must reject.
|
|
669
742
|
if (!hasAnchorScopedEdit(resolved)) {
|
|
670
|
-
const result = applyEdits(normalized, resolved);
|
|
743
|
+
const result = applyEdits(normalized, resolved, { clipboard });
|
|
671
744
|
return withResolveWarnings({ ...result, warnings: [HEADTAIL_DRIFT_WARNING, ...(result.warnings ?? [])] });
|
|
672
745
|
}
|
|
673
746
|
// File drifted: map every anchor from the tagged snapshot to unchanged
|
|
@@ -677,6 +750,7 @@ export class Patcher {
|
|
|
677
750
|
currentText: normalized,
|
|
678
751
|
fileHash: expected,
|
|
679
752
|
edits: resolved,
|
|
753
|
+
clipboard,
|
|
680
754
|
});
|
|
681
755
|
if (recovered) return withResolveWarnings(recoveryToApplyResult(recovered));
|
|
682
756
|
const hashRecognized = this.snapshots.byHash(canonicalPath, expected) !== null;
|
package/src/prompt.md
CHANGED
|
@@ -1,49 +1,43 @@
|
|
|
1
|
-
|
|
1
|
+
Line-anchored patch language: name original lines to replace, cut, or insert at, then list new content. A header ending in `:` takes `+` body rows; `CUT`, `PASTE`, `REM`, `MV` take none.
|
|
2
2
|
|
|
3
3
|
<headers>
|
|
4
|
-
Every file section starts
|
|
4
|
+
Every file section starts `[PATH#TAG]`. `TAG` = 4-hex snapshot tag from your latest `read`/`search` — REQUIRED on every section. Create new files with `write`; hashline only edits existing files.
|
|
5
5
|
</headers>
|
|
6
6
|
|
|
7
7
|
<ops>
|
|
8
|
-
`SWAP N.=M:` — replace original lines N.=M
|
|
9
|
-
`SWAP.BLK N:` — replace the whole syntactic block
|
|
10
|
-
`
|
|
11
|
-
`
|
|
12
|
-
`INS.
|
|
13
|
-
`INS.
|
|
14
|
-
`
|
|
15
|
-
`
|
|
16
|
-
|
|
17
|
-
`MV DEST` — move/rename the section file to `DEST` (a path, quoted when it contains spaces). Line edits above `MV` land on the source first, then the final content is written at `DEST`.
|
|
18
|
-
Single line: `SWAP N.=N:` / `DEL N`. The range is the ORIGINAL lines you touch; body length is irrelevant (replacing 1 line with 10 is still `SWAP N.=N:`).
|
|
8
|
+
`SWAP N.=M:` — replace original lines N.=M (INCLUSIVE).
|
|
9
|
+
`SWAP.BLK N:` — replace the whole syntactic block BEGINNING on line N; its closing line is resolved for you.
|
|
10
|
+
`CUT N.=M` / `CUT.BLK N` — delete lines N.=M / the block beginning at N, and capture them for `PASTE`.
|
|
11
|
+
`INS.PRE N:` / `INS.POST N:` — insert immediately before / after line N.
|
|
12
|
+
`INS.BLK.POST N:` — insert after the END of the block beginning at N, outside it at sibling depth. Append inside a block → `INS.POST`.
|
|
13
|
+
`INS.HEAD:` / `INS.TAIL:` — insert at the very start / end of the file.
|
|
14
|
+
`PASTE.PRE N` / `PASTE.POST N` / `PASTE.HEAD` / `PASTE.TAIL` / `PASTE.BLK.POST N` — insert the clipboard at the position (the clipboard IS the body).
|
|
15
|
+
`REM` — delete the whole section file. `MV DEST` — move/rename to `DEST` (quote paths with spaces); edits above `MV` land on the source first, final content written at `DEST`.
|
|
16
|
+
Single line: `SWAP N.=N:` / `CUT N`. Range = ORIGINAL lines touched; body length irrelevant (1 line → 10 is still `SWAP N.=N:`).
|
|
19
17
|
</ops>
|
|
20
18
|
|
|
21
19
|
<body-rows>
|
|
22
|
-
|
|
20
|
+
Only under a `:` header. Every row is `+TEXT`, verbatim (leading whitespace kept); `+` alone = blank line. NEVER `-old` or bare/context rows — the range deletes; the body is only the final content. Keep a line: leave it out of every range. Literal leading `-`/`+` keeps the prefix: `- item` → `+- item`, `+ item` → `++ item`.
|
|
23
21
|
</body-rows>
|
|
24
22
|
|
|
25
23
|
<rules>
|
|
26
|
-
- Line numbers +
|
|
27
|
-
-
|
|
28
|
-
-
|
|
29
|
-
-
|
|
30
|
-
-
|
|
31
|
-
-
|
|
32
|
-
-
|
|
33
|
-
-
|
|
34
|
-
-
|
|
35
|
-
-
|
|
36
|
-
-
|
|
37
|
-
- `
|
|
38
|
-
-
|
|
39
|
-
- Markdown: a heading line IS a block opener — `SWAP.BLK`/`DEL.BLK`/`INS.BLK.POST` on a `##`/`###` heading resolves its WHOLE section (heading through every nested deeper heading, up to the next same-or-higher heading). So `DEL.BLK` drops the section, `SWAP.BLK` rewrites it, `INS.BLK.POST` lands after it (end the inserted body with a blank line to keep the next heading separated).
|
|
40
|
-
- Non-adjacent changes = separate hunks; untouched lines stay out of every range.
|
|
41
|
-
- Pure additions use `INS.PRE` / `INS.POST` / `INS.HEAD` / `INS.TAIL`, never a widened `SWAP` — retyped keepers are exactly what gets dropped. (A multi-line `SWAP` whose body restates the line just past the range is auto-dropped as an off-by-one keeper with a warning — issue the payload for the range only; never lean on the repair.)
|
|
42
|
-
- NEVER format/restyle code with this tool; run the project formatter instead.
|
|
24
|
+
- Line numbers + `#TAG` come from your latest `read`/`search` (`LINE:TEXT` rows); numbers name ORIGINAL lines, never shifted by applied hunks.
|
|
25
|
+
- Applied edits renumber the file and change the `#TAG` — take the next edit's numbers from the edit response or a fresh `read`.
|
|
26
|
+
- Touch only displayed lines — hunks on undisplayed lines are REJECTED. Far from your read window? Re-`read`; confirm numbers map to the intended construct.
|
|
27
|
+
- Elided regions are UNSEEN (`…`/`..` markers, collapsed `N-M:` summary rows) — NEVER place or span a hunk inside one; `read` the range first.
|
|
28
|
+
- NEVER start or end a range mid-expression or mid-block.
|
|
29
|
+
- Ranges cover ONLY changed lines — never widen over keepers. Non-adjacent changes = separate hunks.
|
|
30
|
+
- Whole construct → `SWAP.BLK N`; lines inside one → `SWAP N.=M`.
|
|
31
|
+
- `SWAP.BLK` resolves EXACTLY the node at N: leading decorators/attributes/doc-comments are separate nodes — point N at the FIRST decorator to sweep both; standalone line-comments are never swept (use `SWAP N.=M`).
|
|
32
|
+
- Block ops anchor the OPENING line of a MULTI-LINE construct — never the closer, last line, or a bare inner statement; one statement → plain op (`SWAP N.=N:` / `CUT N` / `INS.POST N:`). Saw the closer? `INS.POST M:`.
|
|
33
|
+
- Markdown: a heading IS a block opener — block ops on `##`/`###` resolve the WHOLE section (through deeper nested headings, up to the next same-or-higher heading). `INS.BLK.POST` after a section: end the body with a blank line to keep the next heading separated.
|
|
34
|
+
- Pure additions → `INS.PRE`/`INS.POST`/`INS.HEAD`/`INS.TAIL`, never a widened `SWAP`.
|
|
35
|
+
- Move code with `CUT`+`PASTE`, never retype. Clipboard: top-to-bottom across the whole patch (cross-file moves), persists across edit calls, latest `CUT` wins, and `PASTE` repeats freely. Pasted indentation is verbatim; re-indent via `SWAP`.
|
|
36
|
+
- NEVER format/restyle code with this tool; run the project formatter.
|
|
43
37
|
</rules>
|
|
44
38
|
|
|
45
39
|
<example>
|
|
46
|
-
|
|
40
|
+
`read` output shape:
|
|
47
41
|
```
|
|
48
42
|
[greet.py#A1B2]
|
|
49
43
|
1:def greet(name):
|
|
@@ -52,40 +46,7 @@ Original (the exact shape `read` returns):
|
|
|
52
46
|
4:greet("world")
|
|
53
47
|
```
|
|
54
48
|
|
|
55
|
-
|
|
56
|
-
```
|
|
57
|
-
[greet.py#A1B2]
|
|
58
|
-
INS.POST 1:
|
|
59
|
-
+ if not name: name = "stranger"
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
Replace line 2 with two lines:
|
|
63
|
-
```
|
|
64
|
-
[greet.py#A1B2]
|
|
65
|
-
SWAP 2.=2:
|
|
66
|
-
+ greeting = "Hi"
|
|
67
|
-
+ msg = f"{greeting}, {name}"
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
Delete line 3:
|
|
71
|
-
```
|
|
72
|
-
[greet.py#A1B2]
|
|
73
|
-
DEL 3
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
Delete the whole file:
|
|
77
|
-
```
|
|
78
|
-
[greet.py#A1B2]
|
|
79
|
-
REM
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
Rename or move the file:
|
|
83
|
-
```
|
|
84
|
-
[greet.py#A1B2]
|
|
85
|
-
MV greet_v2.py
|
|
86
|
-
```
|
|
87
|
-
|
|
88
|
-
Move after editing:
|
|
49
|
+
Edit, then move:
|
|
89
50
|
```
|
|
90
51
|
[greet.py#A1B2]
|
|
91
52
|
SWAP 1.=3:
|
|
@@ -94,16 +55,7 @@ SWAP 1.=3:
|
|
|
94
55
|
MV lib/greet.py
|
|
95
56
|
```
|
|
96
57
|
|
|
97
|
-
|
|
98
|
-
```
|
|
99
|
-
[greet.py#A1B2]
|
|
100
|
-
INS.HEAD:
|
|
101
|
-
+# generated header
|
|
102
|
-
INS.TAIL:
|
|
103
|
-
+greet("everyone")
|
|
104
|
-
```
|
|
105
|
-
|
|
106
|
-
Insert Markdown bullets — the leading `+` is the body-row marker; the file receives `- task`:
|
|
58
|
+
Markdown bullets — the file receives `- task`:
|
|
107
59
|
```
|
|
108
60
|
[PLAN.md#A1B2]
|
|
109
61
|
INS.POST 2:
|
|
@@ -111,7 +63,15 @@ INS.POST 2:
|
|
|
111
63
|
+ - nested task
|
|
112
64
|
```
|
|
113
65
|
|
|
114
|
-
|
|
66
|
+
Move `greet` to a sibling file — clipboard flows across sections:
|
|
67
|
+
```
|
|
68
|
+
[greet.py#A1B2]
|
|
69
|
+
CUT.BLK 1
|
|
70
|
+
[other.py#3C4D]
|
|
71
|
+
PASTE.HEAD
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`SWAP.BLK 1:` resolves lines 1–3 (`def` header through `print(msg)`); line 4 is a separate statement and stays:
|
|
115
75
|
```
|
|
116
76
|
[greet.py#A1B2]
|
|
117
77
|
SWAP.BLK 1:
|
|
@@ -119,7 +79,7 @@ SWAP.BLK 1:
|
|
|
119
79
|
+ print(f"Hello, {name}")
|
|
120
80
|
```
|
|
121
81
|
|
|
122
|
-
|
|
82
|
+
Decorator/doc-comment = SEPARATE block — point N at the decorator to take both; anchoring the `def` (line 2) would orphan `@cache`:
|
|
123
83
|
```
|
|
124
84
|
[svc.py#C3D4]
|
|
125
85
|
SWAP.BLK 1:
|
|
@@ -130,14 +90,14 @@ SWAP.BLK 1:
|
|
|
130
90
|
</example>
|
|
131
91
|
|
|
132
92
|
<anti-patterns>
|
|
133
|
-
# WRONG — empty `SWAP` to delete. RIGHT:
|
|
93
|
+
# WRONG — empty `SWAP` to delete. RIGHT: CUT 4
|
|
134
94
|
SWAP 4.=4:
|
|
135
95
|
|
|
136
|
-
# WRONG — range
|
|
96
|
+
# WRONG — range sized to the post-edit content. RIGHT: SWAP 1.=1: (body length irrelevant)
|
|
137
97
|
SWAP 1.=2:
|
|
138
98
|
+def greet(name):
|
|
139
99
|
|
|
140
|
-
# WRONG — `-` rows / bare context lines do not exist
|
|
100
|
+
# WRONG — `-` rows / bare context lines do not exist; the range deletes, the body is only new content.
|
|
141
101
|
SWAP 3.=3:
|
|
142
102
|
msg = "Hello, " + name
|
|
143
103
|
- print(msg)
|
|
@@ -146,27 +106,29 @@ SWAP 3.=3:
|
|
|
146
106
|
SWAP 3.=3:
|
|
147
107
|
+ return msg
|
|
148
108
|
|
|
149
|
-
# WRONG —
|
|
150
|
-
# but you replace 2.=4, retype the keepers, and drop one (here line 4, `greet("world")`).
|
|
109
|
+
# WRONG — pure insertion as a widened `SWAP`: retyped keepers get dropped (here line 4).
|
|
151
110
|
SWAP 2.=4:
|
|
152
111
|
+ msg = "Hello, " + name
|
|
153
112
|
+ extra = compute(name)
|
|
154
113
|
+ print(msg)
|
|
155
|
-
# RIGHT — touch nothing you keep
|
|
114
|
+
# RIGHT — touch nothing you keep.
|
|
156
115
|
INS.POST 2:
|
|
157
116
|
+ extra = compute(name)
|
|
158
117
|
|
|
159
|
-
# WRONG — `INS.BLK.POST
|
|
118
|
+
# WRONG — `INS.BLK.POST` anchored on the closing delimiter / last visible line. RIGHT: plain `INS.POST M:`
|
|
160
119
|
INS.BLK.POST 3:
|
|
161
120
|
+after()
|
|
162
121
|
# RIGHT
|
|
163
122
|
INS.POST 3:
|
|
164
123
|
+after()
|
|
124
|
+
|
|
125
|
+
# WRONG — body rows under PASTE; the clipboard is the body. RIGHT: capture first, then a bodyless `PASTE.POST 20`.
|
|
126
|
+
PASTE.POST 20:
|
|
127
|
+
+function f() {}
|
|
165
128
|
</anti-patterns>
|
|
166
129
|
|
|
167
130
|
<critical>
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
3. THE BODY IS THE FINAL CONTENT. Every body row starts with `+`; Markdown bullets use `+- item`, not `- item`.
|
|
131
|
+
1. RE-GROUND AFTER EVERY EDIT — applied edits renumber the file and change the `#TAG`; take next numbers from the edit response or a fresh `read`. Stale tag or surprise? STOP, re-`read`.
|
|
132
|
+
2. RANGES ARE TIGHT — cover only lines that change. Whole construct → `SWAP.BLK N`.
|
|
133
|
+
3. BODY = FINAL CONTENT — every body row starts with `+`; Markdown bullets use `+- item`, not `- item`.
|
|
172
134
|
</critical>
|
package/src/recovery.ts
CHANGED
|
@@ -10,13 +10,15 @@ import { diffLineRuns } from "@oh-my-pi/pi-natives";
|
|
|
10
10
|
import { applyEdits } from "./apply";
|
|
11
11
|
import { RECOVERY_EXTERNAL_WARNING, RECOVERY_LINE_REMAP_WARNING, RECOVERY_SESSION_CHAIN_WARNING } from "./messages";
|
|
12
12
|
import type { SnapshotStore } from "./snapshots";
|
|
13
|
-
import type { Anchor, ApplyResult, Edit } from "./types";
|
|
13
|
+
import type { Anchor, ApplyResult, Clipboard, Edit } from "./types";
|
|
14
14
|
|
|
15
15
|
export interface RecoveryArgs {
|
|
16
16
|
path: string;
|
|
17
17
|
currentText: string;
|
|
18
18
|
fileHash: string;
|
|
19
19
|
edits: readonly Edit[];
|
|
20
|
+
/** Shared clipboard register for `cut`/`paste` edits, threaded into the replay apply. */
|
|
21
|
+
clipboard?: Clipboard;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export interface RecoveryResult {
|
|
@@ -41,6 +43,12 @@ function getEditAnchors(edit: Edit): Anchor[] {
|
|
|
41
43
|
// Recovery only ever receives already-resolved edits (no `block`); this arm
|
|
42
44
|
// exists for type-exhaustiveness over the full `Edit` union.
|
|
43
45
|
if (edit.kind === "block") return [edit.anchor];
|
|
46
|
+
if (edit.kind === "cut") {
|
|
47
|
+
// Every captured line is an anchor: changed interior content is unsafe.
|
|
48
|
+
const anchors: Anchor[] = [];
|
|
49
|
+
for (let line = edit.range.start.line; line <= edit.range.end.line; line++) anchors.push({ line });
|
|
50
|
+
return anchors;
|
|
51
|
+
}
|
|
44
52
|
return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor" ? [edit.cursor.anchor] : [];
|
|
45
53
|
}
|
|
46
54
|
|
|
@@ -213,6 +221,21 @@ function remapEditsToCurrent(previousText: string, currentText: string, edits: r
|
|
|
213
221
|
remapped.push({ ...edit, anchor });
|
|
214
222
|
continue;
|
|
215
223
|
}
|
|
224
|
+
if (edit.kind === "cut") {
|
|
225
|
+
// Map every captured line; an unmapped interior line means the
|
|
226
|
+
// content drifted and cannot be moved safely. Uniform offsets keep
|
|
227
|
+
// the mapped range contiguous.
|
|
228
|
+
const start = mapLine(edit.range.start.line);
|
|
229
|
+
if (start === null) return null;
|
|
230
|
+
let end = start;
|
|
231
|
+
for (let line = edit.range.start.line + 1; line <= edit.range.end.line; line++) {
|
|
232
|
+
const mapped = mapLine(line);
|
|
233
|
+
if (mapped === null) return null;
|
|
234
|
+
end = mapped;
|
|
235
|
+
}
|
|
236
|
+
remapped.push({ ...edit, range: { start: { line: start }, end: { line: end } } });
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
216
239
|
|
|
217
240
|
let blockStart = edit.blockStart;
|
|
218
241
|
if (blockStart !== undefined) {
|
|
@@ -243,12 +266,13 @@ function replayRemappedAnchorsOnCurrent(
|
|
|
243
266
|
currentText: string,
|
|
244
267
|
edits: readonly Edit[],
|
|
245
268
|
recoveryWarning: string,
|
|
269
|
+
clipboard: Clipboard | undefined,
|
|
246
270
|
): RecoveryResult | null {
|
|
247
271
|
const remapped = remapEditsToCurrent(previousText, currentText, edits);
|
|
248
272
|
if (remapped === null) return null;
|
|
249
273
|
let applied: ApplyResult;
|
|
250
274
|
try {
|
|
251
|
-
applied = applyEdits(currentText, remapped.edits);
|
|
275
|
+
applied = applyEdits(currentText, remapped.edits, clipboard === undefined ? {} : { clipboard });
|
|
252
276
|
} catch {
|
|
253
277
|
return null;
|
|
254
278
|
}
|
|
@@ -276,13 +300,13 @@ export class Recovery {
|
|
|
276
300
|
* caller should then surface a {@link MismatchError}.
|
|
277
301
|
*/
|
|
278
302
|
tryRecover(args: RecoveryArgs): RecoveryResult | null {
|
|
279
|
-
const { path, currentText, fileHash, edits } = args;
|
|
303
|
+
const { path, currentText, fileHash, edits, clipboard } = args;
|
|
280
304
|
// When retained texts collide on the 16-bit tag, use the latest one.
|
|
281
305
|
// Recovery still requires its anchors and context to map unambiguously.
|
|
282
306
|
const snapshot = this.store.byHash(path, fileHash);
|
|
283
307
|
if (!snapshot) return null;
|
|
284
308
|
const recoveryWarning =
|
|
285
309
|
this.store.head(path) === snapshot ? RECOVERY_EXTERNAL_WARNING : RECOVERY_SESSION_CHAIN_WARNING;
|
|
286
|
-
return replayRemappedAnchorsOnCurrent(snapshot.text, currentText, edits, recoveryWarning);
|
|
310
|
+
return replayRemappedAnchorsOnCurrent(snapshot.text, currentText, edits, recoveryWarning, clipboard);
|
|
287
311
|
}
|
|
288
312
|
}
|