@oh-my-pi/hashline 15.5.13 → 15.6.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/dist/types/messages.d.ts +15 -0
- package/package.json +2 -2
- package/src/grammar.lark +1 -1
- package/src/messages.ts +22 -0
- package/src/patcher.ts +16 -9
- package/src/prompt.md +9 -6
package/dist/types/messages.d.ts
CHANGED
|
@@ -44,3 +44,18 @@ export declare const RECOVERY_SESSION_CHAIN_WARNING = "Recovered from a stale fi
|
|
|
44
44
|
* model verifies before continuing.
|
|
45
45
|
*/
|
|
46
46
|
export declare const RECOVERY_SESSION_REPLAY_WARNING = "Recovered by replaying your edits onto the current file content \u2014 your previous edit in this session changed line(s) you re-targeted with a stale hash. Verify the diff matches your intent before continuing.";
|
|
47
|
+
/**
|
|
48
|
+
* Warning emitted when an `insert head:` / `insert tail:` edit is applied to an
|
|
49
|
+
* existing file whose snapshot tag is stale (the file drifted since the read).
|
|
50
|
+
* Head/tail insert position is content-independent — "start"/"end" cannot move
|
|
51
|
+
* with drift — so this is non-fatal: the edit applies onto the live content and
|
|
52
|
+
* we surface the drift instead of hard-failing (unlike an anchored mismatch).
|
|
53
|
+
*/
|
|
54
|
+
export declare const HEADTAIL_DRIFT_WARNING = "Applied an `insert head:`/`insert tail:` edit onto the current file content even though the snapshot tag was stale (the file changed since your read). Head/tail position is content-independent, so the insert was not rejected \u2014 but re-read if the drift was unexpected.";
|
|
55
|
+
/**
|
|
56
|
+
* Error text emitted when a hashline section omits the mandatory snapshot tag.
|
|
57
|
+
* The tag is REQUIRED on every section, enforced identically by the apply path
|
|
58
|
+
* ({@link Patcher.prepare}) and the preview/diff path, so both surfaces reuse
|
|
59
|
+
* this single builder to stay in lockstep.
|
|
60
|
+
*/
|
|
61
|
+
export declare function missingSnapshotTagMessage(sectionPath: string): string;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/hashline",
|
|
4
|
-
"version": "15.
|
|
4
|
+
"version": "15.6.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",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"diff": "^9.0.0",
|
|
37
|
-
"lru-cache": "11.
|
|
37
|
+
"lru-cache": "11.5.1"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@types/bun": "^1.3.14"
|
package/src/grammar.lark
CHANGED
package/src/messages.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* them.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./format";
|
|
9
|
+
|
|
8
10
|
/** Lines of context shown either side of a hash mismatch. */
|
|
9
11
|
export const MISMATCH_CONTEXT = 2;
|
|
10
12
|
|
|
@@ -65,3 +67,23 @@ export const RECOVERY_SESSION_CHAIN_WARNING =
|
|
|
65
67
|
*/
|
|
66
68
|
export const RECOVERY_SESSION_REPLAY_WARNING =
|
|
67
69
|
"Recovered by replaying your edits onto the current file content — your previous edit in this session changed line(s) you re-targeted with a stale hash. Verify the diff matches your intent before continuing.";
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Warning emitted when an `insert head:` / `insert tail:` edit is applied to an
|
|
73
|
+
* existing file whose snapshot tag is stale (the file drifted since the read).
|
|
74
|
+
* Head/tail insert position is content-independent — "start"/"end" cannot move
|
|
75
|
+
* with drift — so this is non-fatal: the edit applies onto the live content and
|
|
76
|
+
* we surface the drift instead of hard-failing (unlike an anchored mismatch).
|
|
77
|
+
*/
|
|
78
|
+
export const HEADTAIL_DRIFT_WARNING =
|
|
79
|
+
"Applied an `insert head:`/`insert tail:` edit onto the current file content even though the snapshot tag was stale (the file changed since your read). Head/tail position is content-independent, so the insert was not rejected — but re-read if the drift was unexpected.";
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Error text emitted when a hashline section omits the mandatory snapshot tag.
|
|
83
|
+
* The tag is REQUIRED on every section, enforced identically by the apply path
|
|
84
|
+
* ({@link Patcher.prepare}) and the preview/diff path, so both surfaces reuse
|
|
85
|
+
* this single builder to stay in lockstep.
|
|
86
|
+
*/
|
|
87
|
+
export function missingSnapshotTagMessage(sectionPath: string): string {
|
|
88
|
+
return `Missing hashline snapshot tag for edit to ${sectionPath}; use \`${HL_FILE_PREFIX}${sectionPath}${HL_FILE_HASH_SEP}tag\` from your latest read/search output. To create a new file, use the write tool.`;
|
|
89
|
+
}
|
package/src/patcher.ts
CHANGED
|
@@ -23,10 +23,11 @@
|
|
|
23
23
|
* filesystem configuration.
|
|
24
24
|
*/
|
|
25
25
|
import { applyEdits } from "./apply";
|
|
26
|
-
import { computeFileHash, formatHashlineHeader
|
|
26
|
+
import { computeFileHash, formatHashlineHeader } from "./format";
|
|
27
27
|
import type { Filesystem, WriteResult } from "./fs";
|
|
28
28
|
import { isNotFound } from "./fs";
|
|
29
29
|
import type { Patch, PatchSection } from "./input";
|
|
30
|
+
import { HEADTAIL_DRIFT_WARNING, missingSnapshotTagMessage } from "./messages";
|
|
30
31
|
import { MismatchError } from "./mismatch";
|
|
31
32
|
import { detectLineEnding, type LineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize";
|
|
32
33
|
import { Recovery, type RecoveryResult } from "./recovery";
|
|
@@ -102,11 +103,9 @@ function hasAnchorScopedEdit(edits: readonly Edit[]): boolean {
|
|
|
102
103
|
});
|
|
103
104
|
}
|
|
104
105
|
|
|
105
|
-
function
|
|
106
|
-
if (fileHash !== undefined
|
|
107
|
-
throw new Error(
|
|
108
|
-
`Missing hashline snapshot tag for anchored edit to ${sectionPath}; use \`${HL_FILE_PREFIX}${sectionPath}${HL_FILE_HASH_SEP}tag\` from your latest read/search output.`,
|
|
109
|
-
);
|
|
106
|
+
function assertSectionHashPresent(sectionPath: string, fileHash: string | undefined): void {
|
|
107
|
+
if (fileHash !== undefined) return;
|
|
108
|
+
throw new Error(missingSnapshotTagMessage(sectionPath));
|
|
110
109
|
}
|
|
111
110
|
|
|
112
111
|
function recoveryToApplyResult(result: RecoveryResult): ApplyResult {
|
|
@@ -213,13 +212,13 @@ export class Patcher {
|
|
|
213
212
|
*/
|
|
214
213
|
async prepare(section: PatchSection): Promise<PreparedSection> {
|
|
215
214
|
const { edits, warnings: parseWarnings } = section.parse();
|
|
216
|
-
|
|
215
|
+
assertSectionHashPresent(section.path, section.fileHash);
|
|
217
216
|
|
|
218
217
|
const canonicalPath = this.fs.canonicalPath(section.path);
|
|
219
218
|
await this.fs.preflightWrite(section.path);
|
|
220
219
|
const { exists, rawContent } = await this.#tryRead(section.path);
|
|
221
|
-
if (!exists
|
|
222
|
-
throw new Error(`File not found: ${section.path}
|
|
220
|
+
if (!exists) {
|
|
221
|
+
throw new Error(`File not found: ${section.path}. Use the write tool to create new files.`);
|
|
223
222
|
}
|
|
224
223
|
|
|
225
224
|
const { bom, text } = stripBom(rawContent);
|
|
@@ -320,6 +319,14 @@ export class Patcher {
|
|
|
320
319
|
// Whole-file unchanged → the tag still names the live content, so an
|
|
321
320
|
// edit anchored at ANY line (displayed or not) is safe to apply.
|
|
322
321
|
if (computeFileHash(normalized) === expected) return applyEdits(normalized, [...edits]);
|
|
322
|
+
// Head/tail-only inserts are position-stable: "start"/"end" cannot move
|
|
323
|
+
// with content drift, so a stale tag is non-fatal. Apply onto the live
|
|
324
|
+
// content and warn instead of hard-failing — unlike an anchored
|
|
325
|
+
// mismatch, which cannot be safely relocated and must reject.
|
|
326
|
+
if (!hasAnchorScopedEdit(edits)) {
|
|
327
|
+
const result = applyEdits(normalized, [...edits]);
|
|
328
|
+
return { ...result, warnings: [HEADTAIL_DRIFT_WARNING, ...(result.warnings ?? [])] };
|
|
329
|
+
}
|
|
323
330
|
// File drifted: try to replay the edit against the version the tag
|
|
324
331
|
// names and 3-way-merge it onto the live content.
|
|
325
332
|
const recovered = this.recovery.tryRecover({
|
package/src/prompt.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
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
3
|
<headers>
|
|
4
|
-
Every file section starts with `¶PATH#TAG`. `TAG` is the
|
|
4
|
+
Every file section starts with `¶PATH#TAG`. `TAG` is the 4-hex snapshot tag from your latest `read`/`search`, and is REQUIRED on every section — there is no hashless form. To create a new file, use the `write` tool; hashline only edits files that already exist.
|
|
5
5
|
</headers>
|
|
6
6
|
|
|
7
7
|
<ops>
|
|
@@ -23,6 +23,9 @@ There is NO other body row kind. NEVER write `-old` or a bare/context line. To k
|
|
|
23
23
|
<rules>
|
|
24
24
|
- Line numbers come from `read`/`search` (`LINE:TEXT`). Copy the `¶PATH#TAG` header; use the bare LINE numbers.
|
|
25
25
|
- Numbers refer to the ORIGINAL file and stay valid for the whole patch — they do not shift as hunks apply.
|
|
26
|
+
- Across calls they do NOT survive: each applied edit mints a fresh `#TAG` and renumbers the file, so the tag and line numbers you just used are dead. Anchor the next edit on the `¶PATH#TAG` and lines from the edit response (or re-`read`), never on pre-edit numbers.
|
|
27
|
+
- A line number is an offset, not a structural boundary: never `insert after N` into a construct you have not read, and never start or end a `replace`/`delete` range mid-expression or mid-block. If unsure what is on those lines, `read` them first.
|
|
28
|
+
- On a stale-tag rejection — or any result you cannot fully account for — STOP and re-`read`. Never stack more line-numbered edits onto output you have not re-grounded; that compounds corruption.
|
|
26
29
|
- One hunk per range; the body is the final content, never an old/new pair.
|
|
27
30
|
- 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.
|
|
28
31
|
</rules>
|
|
@@ -30,7 +33,7 @@ There is NO other body row kind. NEVER write `-old` or a bare/context line. To k
|
|
|
30
33
|
<example>
|
|
31
34
|
Original (the exact shape `read` returns):
|
|
32
35
|
```
|
|
33
|
-
¶greet.py#
|
|
36
|
+
¶greet.py#A1B2
|
|
34
37
|
1:def greet(name):
|
|
35
38
|
2: msg = "Hello, " + name
|
|
36
39
|
3: print(msg)
|
|
@@ -39,14 +42,14 @@ Original (the exact shape `read` returns):
|
|
|
39
42
|
|
|
40
43
|
Insert a guard after line 1:
|
|
41
44
|
```
|
|
42
|
-
¶greet.py#
|
|
45
|
+
¶greet.py#A1B2
|
|
43
46
|
insert after 1:
|
|
44
47
|
+ if not name: name = "stranger"
|
|
45
48
|
```
|
|
46
49
|
|
|
47
50
|
Replace line 2 with two lines:
|
|
48
51
|
```
|
|
49
|
-
¶greet.py#
|
|
52
|
+
¶greet.py#A1B2
|
|
50
53
|
replace 2..2:
|
|
51
54
|
+ greeting = "Hi"
|
|
52
55
|
+ msg = f"{greeting}, {name}"
|
|
@@ -54,13 +57,13 @@ replace 2..2:
|
|
|
54
57
|
|
|
55
58
|
Delete line 3:
|
|
56
59
|
```
|
|
57
|
-
¶greet.py#
|
|
60
|
+
¶greet.py#A1B2
|
|
58
61
|
delete 3
|
|
59
62
|
```
|
|
60
63
|
|
|
61
64
|
Add a header and trailer:
|
|
62
65
|
```
|
|
63
|
-
¶greet.py#
|
|
66
|
+
¶greet.py#A1B2
|
|
64
67
|
insert head:
|
|
65
68
|
+# generated header
|
|
66
69
|
insert tail:
|