@oh-my-pi/hashline 15.5.12 → 15.5.13
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/dist/types/format.d.ts +37 -23
- package/dist/types/input.d.ts +3 -3
- package/dist/types/messages.d.ts +14 -34
- package/dist/types/parser.d.ts +0 -53
- package/dist/types/recovery.d.ts +11 -13
- package/dist/types/snapshots.d.ts +36 -114
- package/dist/types/tokenizer.d.ts +10 -53
- package/dist/types/types.d.ts +7 -11
- package/package.json +3 -2
- package/src/apply.ts +334 -53
- package/src/format.ts +64 -28
- package/src/grammar.lark +10 -10
- package/src/input.ts +10 -13
- package/src/messages.ts +17 -36
- package/src/mismatch.ts +3 -4
- package/src/parser.ts +71 -329
- package/src/patcher.ts +21 -43
- package/src/prompt.md +43 -44
- package/src/recovery.ts +22 -72
- package/src/snapshots.ts +84 -390
- package/src/tokenizer.ts +102 -155
- package/src/types.ts +9 -13
package/src/prompt.md
CHANGED
|
@@ -1,40 +1,34 @@
|
|
|
1
|
-
Your patch language
|
|
1
|
+
Your patch language names lines to replace, delete, or insert at, then lists the new content. Rule of thumb: a header ending in `:` is followed by `+` body rows; `delete` has no body.
|
|
2
2
|
|
|
3
|
-
<
|
|
4
|
-
Every
|
|
5
|
-
|
|
6
|
-
&A..B copy lines A..B from snapshot
|
|
7
|
-
</body-rows>
|
|
8
|
-
|
|
9
|
-
<anchors>
|
|
10
|
-
```
|
|
11
|
-
A B select lines A..B; the body rows below describe their new content
|
|
12
|
-
(empty body = delete the range). Always TWO numbers — single
|
|
13
|
-
lines are spelled `A A`.
|
|
14
|
-
BOF virtual position before line 1; body rows insert there
|
|
15
|
-
EOF virtual position after the last line; body rows insert there
|
|
16
|
-
```
|
|
3
|
+
<headers>
|
|
4
|
+
Every file section starts with `¶PATH#TAG`. `TAG` is the 3-char snapshot tag from your latest `read`/`search`. REQUIRED for any hunk that names line numbers. Hashless `¶PATH` is allowed only for new-file creation or a patch that is purely `insert head:` / `insert tail:`.
|
|
5
|
+
</headers>
|
|
17
6
|
|
|
18
|
-
|
|
19
|
-
|
|
7
|
+
<ops>
|
|
8
|
+
replace N..M: replace original lines N..M with the body rows below.
|
|
9
|
+
delete N..M delete original lines N..M. No body.
|
|
10
|
+
insert before N: insert the body rows immediately before line N.
|
|
11
|
+
insert after N: insert the body rows immediately after line N.
|
|
12
|
+
insert head: insert the body rows at the very start of the file.
|
|
13
|
+
insert tail: insert the body rows at the very end of the file.
|
|
14
|
+
Single line: `replace N..N:` / `delete N`. The range is the ORIGINAL lines you touch; body length is irrelevant (replacing 1 line with 10 is still `replace N..N:`).
|
|
15
|
+
</ops>
|
|
20
16
|
|
|
21
|
-
<
|
|
22
|
-
|
|
23
|
-
|
|
17
|
+
<body-rows>
|
|
18
|
+
Body rows appear only under a `:` header. Every body row is:
|
|
19
|
+
+TEXT add a new literal line `TEXT`, verbatim (leading whitespace kept). `+` alone adds a blank line.
|
|
20
|
+
There is NO other body row kind. NEVER write `-old` or a bare/context line. To keep a line, leave it out of every range. To insert a literal line starting with `-` or `+`, prefix it: `+-x`, `++x`.
|
|
21
|
+
</body-rows>
|
|
24
22
|
|
|
25
23
|
<rules>
|
|
26
|
-
-
|
|
27
|
-
-
|
|
28
|
-
-
|
|
29
|
-
-
|
|
30
|
-
- Line numbers refer to the ORIGINAL file and stay valid for the whole patch — they do not shift as your hunks land.
|
|
31
|
-
- An empty body **deletes** the selected range entirely. To replace lines A..B with completely new content, list the new content under the hunk header (do not write `&A..B` for the lines you are replacing).
|
|
32
|
-
- `@@` is NOT a hashline construct. Do not wrap headers in `@@ ... @@` — write the anchor bare.
|
|
24
|
+
- Line numbers come from `read`/`search` (`LINE:TEXT`). Copy the `¶PATH#TAG` header; use the bare LINE numbers.
|
|
25
|
+
- Numbers refer to the ORIGINAL file and stay valid for the whole patch — they do not shift as hunks apply.
|
|
26
|
+
- One hunk per range; the body is the final content, never an old/new pair.
|
|
27
|
+
- To change lines 2 and 5 while keeping 3–4, issue two hunks (`replace 2..2:` and `replace 5..5:`). Untouched lines are simply absent from every range.
|
|
33
28
|
</rules>
|
|
34
29
|
|
|
35
|
-
|
|
36
30
|
<example>
|
|
37
|
-
|
|
31
|
+
Original (the exact shape `read` returns):
|
|
38
32
|
```
|
|
39
33
|
¶greet.py#A1
|
|
40
34
|
1:def greet(name):
|
|
@@ -43,46 +37,51 @@ This is the original file (the exact shape `read` returns):
|
|
|
43
37
|
4:greet("world")
|
|
44
38
|
```
|
|
45
39
|
|
|
46
|
-
|
|
40
|
+
Insert a guard after line 1:
|
|
47
41
|
```
|
|
48
42
|
¶greet.py#A1
|
|
49
|
-
|
|
50
|
-
&1
|
|
43
|
+
insert after 1:
|
|
51
44
|
+ if not name: name = "stranger"
|
|
52
45
|
```
|
|
53
46
|
|
|
54
|
-
|
|
47
|
+
Replace line 2 with two lines:
|
|
55
48
|
```
|
|
56
|
-
|
|
49
|
+
¶greet.py#A1
|
|
50
|
+
replace 2..2:
|
|
57
51
|
+ greeting = "Hi"
|
|
58
52
|
+ msg = f"{greeting}, {name}"
|
|
59
53
|
```
|
|
60
54
|
|
|
61
|
-
|
|
55
|
+
Delete line 3:
|
|
62
56
|
```
|
|
63
57
|
¶greet.py#A1
|
|
64
|
-
|
|
58
|
+
delete 3
|
|
65
59
|
```
|
|
66
60
|
|
|
67
|
-
|
|
61
|
+
Add a header and trailer:
|
|
68
62
|
```
|
|
69
63
|
¶greet.py#A1
|
|
70
|
-
|
|
64
|
+
insert head:
|
|
71
65
|
+# generated header
|
|
72
|
-
|
|
66
|
+
insert tail:
|
|
73
67
|
+greet("everyone")
|
|
74
68
|
```
|
|
75
69
|
</example>
|
|
76
70
|
|
|
77
71
|
<anti-patterns>
|
|
78
|
-
# WRONG —
|
|
79
|
-
|
|
72
|
+
# WRONG — empty `replace` to delete. RIGHT: delete 4
|
|
73
|
+
replace 4..4:
|
|
74
|
+
|
|
75
|
+
# WRONG — range describes post-edit size. RIGHT: replace 1..1: (body length is irrelevant)
|
|
76
|
+
replace 1..2:
|
|
80
77
|
+def greet(name):
|
|
81
|
-
+ """Greet a user by name."""
|
|
82
78
|
|
|
83
|
-
# WRONG —
|
|
84
|
-
|
|
79
|
+
# WRONG — `-` rows / bare context lines do not exist. The range deletes; the body is only the new content.
|
|
80
|
+
replace 3..3:
|
|
85
81
|
msg = "Hello, " + name
|
|
86
82
|
- print(msg)
|
|
87
83
|
+ return msg
|
|
84
|
+
# RIGHT
|
|
85
|
+
replace 3..3:
|
|
86
|
+
+ return msg
|
|
88
87
|
</anti-patterns>
|
package/src/recovery.ts
CHANGED
|
@@ -70,14 +70,7 @@ function collectAnchorLines(edits: readonly Edit[]): number[] {
|
|
|
70
70
|
|
|
71
71
|
function getEditAnchors(edit: Edit): Anchor[] {
|
|
72
72
|
if (edit.kind === "delete") return [edit.anchor];
|
|
73
|
-
|
|
74
|
-
if (edit.kind === "insert") return cursorAnchors;
|
|
75
|
-
|
|
76
|
-
const repeatAnchors: Anchor[] = [];
|
|
77
|
-
for (let line = edit.range.start.line; line <= edit.range.end.line; line++) {
|
|
78
|
-
repeatAnchors.push({ line });
|
|
79
|
-
}
|
|
80
|
-
return cursorAnchors.concat(repeatAnchors);
|
|
73
|
+
return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor" ? [edit.cursor.anchor] : [];
|
|
81
74
|
}
|
|
82
75
|
|
|
83
76
|
/**
|
|
@@ -135,35 +128,6 @@ function replaySessionChainOnCurrent(
|
|
|
135
128
|
};
|
|
136
129
|
}
|
|
137
130
|
|
|
138
|
-
function snapshotHasEntries(snapshot: Snapshot): boolean {
|
|
139
|
-
for (const _entry of snapshot.entries()) return true;
|
|
140
|
-
return false;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function buildSparseOverlayText(currentText: string, snapshot: Snapshot): string {
|
|
144
|
-
const overlaid = currentText.split("\n");
|
|
145
|
-
let maxCachedLine = 0;
|
|
146
|
-
for (const [lineNum] of snapshot.entries()) {
|
|
147
|
-
if (lineNum > maxCachedLine) maxCachedLine = lineNum;
|
|
148
|
-
}
|
|
149
|
-
while (overlaid.length < maxCachedLine) overlaid.push("");
|
|
150
|
-
for (const [lineNum, content] of snapshot.entries()) {
|
|
151
|
-
overlaid[lineNum - 1] = content;
|
|
152
|
-
}
|
|
153
|
-
return overlaid.join("\n");
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function sparseSnapshotCoversAnchors(snapshot: Snapshot, edits: readonly Edit[]): boolean {
|
|
157
|
-
for (const lineNumber of collectAnchorLines(edits)) {
|
|
158
|
-
if (snapshot.get(lineNumber) === undefined) return false;
|
|
159
|
-
}
|
|
160
|
-
return true;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function sparseSnapshotMatchesCurrent(currentText: string, snapshot: Snapshot): boolean {
|
|
164
|
-
return snapshot.matchesLiveFile(currentText.split("\n"));
|
|
165
|
-
}
|
|
166
|
-
|
|
167
131
|
/** First 1-indexed line at which `a` and `b` diverge, or `undefined` if equal. */
|
|
168
132
|
function findFirstChangedLine(a: string, b: string): number | undefined {
|
|
169
133
|
if (a === b) return undefined;
|
|
@@ -182,52 +146,38 @@ function isHeadSnapshot(head: Snapshot | null, snapshot: Snapshot): boolean {
|
|
|
182
146
|
|
|
183
147
|
/**
|
|
184
148
|
* Stateless recovery driver over a {@link SnapshotStore}. Construct once and
|
|
185
|
-
* call {@link Recovery.tryRecover} per stale-
|
|
186
|
-
* implementation tries
|
|
149
|
+
* call {@link Recovery.tryRecover} per stale-tag incident. The default
|
|
150
|
+
* implementation tries two strategies in order:
|
|
187
151
|
*
|
|
188
|
-
* 1. Apply on the
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
* Sparse snapshots that still match the live file are direct-apply cases
|
|
198
|
-
* owned by the patcher, so recovery declines them.
|
|
152
|
+
* 1. Apply the edits on the full-file version the tag names, then 3-way-merge
|
|
153
|
+
* the resulting patch onto the live content (handles external writes).
|
|
154
|
+
* 2. (Session chain) If that version wasn't the head, replay the edits onto
|
|
155
|
+
* the live content directly when line counts match AND every edit's anchor
|
|
156
|
+
* line content is unchanged between version and current — a prior in-session
|
|
157
|
+
* edit advanced the tag and the model's anchors still name the same logical
|
|
158
|
+
* rows. Emits a dedicated {@link RECOVERY_SESSION_REPLAY_WARNING} because
|
|
159
|
+
* even with both guards a coincidental insert+delete pair on duplicate rows
|
|
160
|
+
* can still land the edit on the wrong row; see {@link replaySessionChainOnCurrent}.
|
|
199
161
|
*/
|
|
200
162
|
export class Recovery {
|
|
201
163
|
constructor(readonly store: SnapshotStore) {}
|
|
202
|
-
|
|
203
164
|
/**
|
|
204
165
|
* Attempt recovery. Returns `null` when no path forward is found — the
|
|
205
166
|
* caller should then surface a {@link MismatchError}.
|
|
206
167
|
*/
|
|
207
168
|
tryRecover(args: RecoveryArgs): RecoveryResult | null {
|
|
208
169
|
const { path, currentText, fileHash, edits } = args;
|
|
209
|
-
const head = this.store.head(path);
|
|
210
170
|
const snapshot = this.store.byHash(path, fileHash);
|
|
211
|
-
if (!snapshot
|
|
212
|
-
|
|
213
|
-
const isHead = isHeadSnapshot(head, snapshot);
|
|
171
|
+
if (!snapshot) return null;
|
|
172
|
+
const isHead = isHeadSnapshot(this.store.head(path), snapshot);
|
|
214
173
|
const recoveryWarning = isHead ? RECOVERY_EXTERNAL_WARNING : RECOVERY_SESSION_CHAIN_WARNING;
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
// for why both guards together still don't fully prove correctness.
|
|
224
|
-
if (isSessionChain) return replaySessionChainOnCurrent(snapshot.fullText, currentText, edits);
|
|
225
|
-
return null;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
if (!sparseSnapshotCoversAnchors(snapshot, edits)) return null;
|
|
229
|
-
if (sparseSnapshotMatchesCurrent(currentText, snapshot)) return null;
|
|
230
|
-
const overlayText = buildSparseOverlayText(currentText, snapshot);
|
|
231
|
-
return applyEditsToSnapshot(overlayText, currentText, edits, recoveryWarning);
|
|
174
|
+
const merged = applyEditsToSnapshot(snapshot.text, currentText, edits, recoveryWarning);
|
|
175
|
+
if (merged !== null) return merged;
|
|
176
|
+
// Session-chain fallback: the 3-way merge on the version refused.
|
|
177
|
+
// Replay onto current is gated by line-count equality AND
|
|
178
|
+
// anchor-content alignment — see `replaySessionChainOnCurrent`
|
|
179
|
+
// for why both guards together still don't fully prove correctness.
|
|
180
|
+
if (!isHead) return replaySessionChainOnCurrent(snapshot.text, currentText, edits);
|
|
181
|
+
return null;
|
|
232
182
|
}
|
|
233
183
|
}
|