@oh-my-pi/hashline 15.5.15 → 15.7.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/apply.d.ts +1 -1
- package/dist/types/block.d.ts +24 -0
- package/dist/types/format.d.ts +2 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/input.d.ts +10 -3
- package/dist/types/messages.d.ts +39 -0
- package/dist/types/patcher.d.ts +8 -1
- package/dist/types/tokenizer.d.ts +6 -0
- package/dist/types/types.d.ts +42 -0
- package/package.json +2 -2
- package/src/apply.ts +12 -3
- package/src/block.ts +84 -0
- package/src/format.ts +2 -0
- package/src/grammar.lark +5 -3
- package/src/index.ts +1 -0
- package/src/input.ts +21 -5
- package/src/messages.ts +61 -0
- package/src/parser.ts +27 -1
- package/src/patcher.ts +71 -22
- package/src/prompt.md +27 -6
- package/src/recovery.ts +3 -0
- package/src/tokenizer.ts +26 -0
- package/src/types.ts +47 -1
package/dist/types/apply.d.ts
CHANGED
|
@@ -5,4 +5,4 @@ import type { ApplyResult, Edit } from "./types";
|
|
|
5
5
|
* Returns the post-edit text and the first changed line number (1-indexed).
|
|
6
6
|
* Throws if an anchor is out of bounds.
|
|
7
7
|
*/
|
|
8
|
-
export declare function applyEdits(text: string, edits: Edit[]): ApplyResult;
|
|
8
|
+
export declare function applyEdits(text: string, edits: readonly Edit[]): ApplyResult;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { BlockResolver, Edit } from "./types";
|
|
2
|
+
export interface ResolveBlockEditsOptions {
|
|
3
|
+
/**
|
|
4
|
+
* How to handle a block edit that cannot be resolved (missing resolver or a
|
|
5
|
+
* `null` span). `"throw"` (default) raises a `blockUnresolvedMessage` error —
|
|
6
|
+
* used by the authoritative apply + final preview paths. `"drop"` silently
|
|
7
|
+
* skips the edit — used by the streaming preview, where a half-written file
|
|
8
|
+
* or transient parse error must not throw.
|
|
9
|
+
*/
|
|
10
|
+
onUnresolved?: "throw" | "drop";
|
|
11
|
+
}
|
|
12
|
+
/** True when at least one edit is an unresolved `replace block N:` edit. */
|
|
13
|
+
export declare function hasBlockEdit(edits: readonly Edit[]): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Resolve every `replace block N:` edit in `edits` against `text` (parsed as
|
|
16
|
+
* the language inferred from `path`). Non-block edits pass through untouched.
|
|
17
|
+
* Returns a fresh edit list with no `block` variants. The fast path returns the
|
|
18
|
+
* input unchanged when there is nothing to resolve.
|
|
19
|
+
*
|
|
20
|
+
* Synthesized inserts/deletes carry sequential `index` values for readability
|
|
21
|
+
* only — {@link applyEdits} re-derives every edit's index from array order, so
|
|
22
|
+
* the passthrough edits keeping their original indices is harmless.
|
|
23
|
+
*/
|
|
24
|
+
export declare function resolveBlockEdits(edits: readonly Edit[], text: string, path: string, resolver: BlockResolver | undefined, options?: ResolveBlockEditsOptions): readonly Edit[];
|
package/dist/types/format.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ export declare const HL_FILE_PREFIX = "\u00B6";
|
|
|
10
10
|
export declare const HL_PAYLOAD_REPLACE = "+";
|
|
11
11
|
/** Hunk-header keyword for concrete line replacement. */
|
|
12
12
|
export declare const HL_REPLACE_KEYWORD = "replace";
|
|
13
|
+
/** Hunk-header sub-keyword: `replace block N:` resolves N to a tree-sitter block range. */
|
|
14
|
+
export declare const HL_BLOCK_KEYWORD = "block";
|
|
13
15
|
/** Hunk-header keyword for concrete line deletion. */
|
|
14
16
|
export declare const HL_DELETE_KEYWORD = "delete";
|
|
15
17
|
/** Hunk-header keyword for insertion operations. */
|
package/dist/types/index.d.ts
CHANGED
package/dist/types/input.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ApplyResult, Edit, SplitOptions } from "./types";
|
|
1
|
+
import type { ApplyResult, BlockResolver, Edit, SplitOptions } from "./types";
|
|
2
2
|
interface RawSection {
|
|
3
3
|
path: string;
|
|
4
4
|
fileHash?: string;
|
|
@@ -49,16 +49,23 @@ export declare class PatchSection {
|
|
|
49
49
|
* {@link Patcher} owns tag validation and recovery; reach for this
|
|
50
50
|
* method directly when you've already validated the file content and
|
|
51
51
|
* just want the result.
|
|
52
|
+
*
|
|
53
|
+
* `blockResolver` resolves any `replace block N:` edits against `text`; an
|
|
54
|
+
* unresolvable block throws (this is the final, authoritative preview path).
|
|
52
55
|
*/
|
|
53
|
-
applyTo(text: string): ApplyResult;
|
|
56
|
+
applyTo(text: string, blockResolver?: BlockResolver): ApplyResult;
|
|
54
57
|
/**
|
|
55
58
|
* Streaming-tolerant counterpart to {@link applyTo}. Uses
|
|
56
59
|
* {@link parsePatchStreaming} so a trailing in-flight op (no payload yet,
|
|
57
60
|
* or a per-token parse error mid-stream) does not throw or emit a phantom
|
|
58
61
|
* empty-payload edit. Intended for incremental diff previews; the writer
|
|
59
62
|
* path should always use {@link applyTo}.
|
|
63
|
+
*
|
|
64
|
+
* `blockResolver` resolves any `replace block N:` edits against `text`; an
|
|
65
|
+
* unresolvable block is silently dropped so a half-written file does not
|
|
66
|
+
* throw mid-stream.
|
|
60
67
|
*/
|
|
61
|
-
applyPartialTo(text: string): ApplyResult;
|
|
68
|
+
applyPartialTo(text: string, blockResolver?: BlockResolver): ApplyResult;
|
|
62
69
|
}
|
|
63
70
|
/**
|
|
64
71
|
* A parsed hashline patch — zero or more {@link PatchSection}s, each rooted
|
package/dist/types/messages.d.ts
CHANGED
|
@@ -26,8 +26,32 @@ export declare const BARE_BODY_AUTO_PIPED_WARNING = "Auto-prefixed bare body row
|
|
|
26
26
|
export declare const MINUS_ROW_REJECTED = "`-` rows are not valid; hashline ranges already name the lines being changed. To insert a literal line starting with `-`, write `+-\u2026`.";
|
|
27
27
|
/** Error text emitted when a replace hunk has no body. */
|
|
28
28
|
export declare const EMPTY_REPLACE = "`replace N..M:` needs at least one `+TEXT` body row. To delete lines, use `delete N..M`.";
|
|
29
|
+
/** Error text emitted when a `replace block N:` hunk has no body. */
|
|
30
|
+
export declare const EMPTY_BLOCK = "`replace block N:` needs at least one `+TEXT` body row. To delete a block, use `delete N..M` with the block's line range.";
|
|
31
|
+
/**
|
|
32
|
+
* Error text emitted when a `replace block N:` anchor cannot be resolved to a
|
|
33
|
+
* syntactic block (unrecognized language, blank/out-of-range line, no node
|
|
34
|
+
* begins on line N such as a lone closing delimiter, or the resolved block has
|
|
35
|
+
* a syntax error). Names the offending line and steers back to an explicit
|
|
36
|
+
* `replace N..M:` range.
|
|
37
|
+
*/
|
|
38
|
+
export declare function blockUnresolvedMessage(line: number): string;
|
|
39
|
+
/**
|
|
40
|
+
* Error text emitted when a `replace block N:` edit reaches a code path that
|
|
41
|
+
* has no {@link BlockResolver} wired in. Indicates a host-configuration bug
|
|
42
|
+
* rather than authored-input error.
|
|
43
|
+
*/
|
|
44
|
+
export declare const BLOCK_RESOLVER_UNAVAILABLE = "`replace block N:` is not available here (no tree-sitter block resolver is configured). Use `replace N..M:` with an explicit range.";
|
|
45
|
+
/**
|
|
46
|
+
* Internal invariant error: `applyEdits` received an unresolved `replace block
|
|
47
|
+
* N:` edit. Block edits must be expanded by `resolveBlockEdits` before reaching
|
|
48
|
+
* the applier; hitting this is a wiring bug, not authored-input error.
|
|
49
|
+
*/
|
|
50
|
+
export declare const UNRESOLVED_BLOCK_INTERNAL = "internal error: unresolved `replace block` edit reached the applier (resolveBlockEdits was not run).";
|
|
29
51
|
/** Error text emitted when a delete hunk receives a body row. */
|
|
30
52
|
export declare const DELETE_TAKES_NO_BODY = "`delete N..M` does not take body rows. Remove the body, or use `replace N..M:`.";
|
|
53
|
+
/** Error text emitted when a `delete block N` hunk receives a body row. */
|
|
54
|
+
export declare const DELETE_BLOCK_TAKES_NO_BODY = "`delete block N` does not take body rows. Remove the body, or use `replace block N:` to replace the block.";
|
|
31
55
|
/** Error text emitted when an insert hunk has no body. */
|
|
32
56
|
export declare const EMPTY_INSERT = "`insert` needs at least one `+TEXT` body row.";
|
|
33
57
|
/** Warning text emitted by `Recovery` when an external write fits a cached snapshot. */
|
|
@@ -44,3 +68,18 @@ export declare const RECOVERY_SESSION_CHAIN_WARNING = "Recovered from a stale fi
|
|
|
44
68
|
* model verifies before continuing.
|
|
45
69
|
*/
|
|
46
70
|
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.";
|
|
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 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.";
|
|
79
|
+
/**
|
|
80
|
+
* Error text emitted when a hashline section omits the mandatory snapshot tag.
|
|
81
|
+
* The tag is REQUIRED on every section, enforced identically by the apply path
|
|
82
|
+
* ({@link Patcher.prepare}) and the preview/diff path, so both surfaces reuse
|
|
83
|
+
* this single builder to stay in lockstep.
|
|
84
|
+
*/
|
|
85
|
+
export declare function missingSnapshotTagMessage(sectionPath: string): string;
|
package/dist/types/patcher.d.ts
CHANGED
|
@@ -3,12 +3,18 @@ import type { Patch, PatchSection } from "./input";
|
|
|
3
3
|
import { type LineEnding } from "./normalize";
|
|
4
4
|
import { Recovery } from "./recovery";
|
|
5
5
|
import type { SnapshotStore } from "./snapshots";
|
|
6
|
-
import type { ApplyResult } from "./types";
|
|
6
|
+
import type { ApplyResult, BlockResolver } from "./types";
|
|
7
7
|
export interface PatcherOptions {
|
|
8
8
|
/** Storage backend used for all reads and writes. */
|
|
9
9
|
fs: Filesystem;
|
|
10
10
|
/** Snapshot store that minted and resolves hashline section tags. Required. */
|
|
11
11
|
snapshots: SnapshotStore;
|
|
12
|
+
/**
|
|
13
|
+
* Resolves `replace block N:` anchors to concrete line spans via tree-sitter.
|
|
14
|
+
* Optional: when omitted, any `replace block N:` edit throws on apply (the
|
|
15
|
+
* host did not wire a resolver). Plain line-range ops never need it.
|
|
16
|
+
*/
|
|
17
|
+
blockResolver?: BlockResolver;
|
|
12
18
|
}
|
|
13
19
|
/** Per-section result returned by {@link Patcher.apply} / {@link Patcher.commit}. */
|
|
14
20
|
export interface PatchSectionResult {
|
|
@@ -69,6 +75,7 @@ export declare class Patcher {
|
|
|
69
75
|
readonly fs: Filesystem;
|
|
70
76
|
readonly snapshots: SnapshotStore;
|
|
71
77
|
readonly recovery: Recovery;
|
|
78
|
+
readonly blockResolver: BlockResolver | undefined;
|
|
72
79
|
constructor(options: PatcherOptions);
|
|
73
80
|
/**
|
|
74
81
|
* Apply every section in `patch`. `prepare` runs the full apply for each
|
|
@@ -6,9 +6,15 @@ export declare function parseLid(raw: string, lineNum: number): Anchor;
|
|
|
6
6
|
export type BlockTarget = {
|
|
7
7
|
kind: "replace";
|
|
8
8
|
range: ParsedRange;
|
|
9
|
+
} | {
|
|
10
|
+
kind: "block";
|
|
11
|
+
anchor: Anchor;
|
|
9
12
|
} | {
|
|
10
13
|
kind: "delete";
|
|
11
14
|
range: ParsedRange;
|
|
15
|
+
} | {
|
|
16
|
+
kind: "delete_block";
|
|
17
|
+
anchor: Anchor;
|
|
12
18
|
} | {
|
|
13
19
|
kind: "insert_before";
|
|
14
20
|
anchor: Anchor;
|
package/dist/types/types.d.ts
CHANGED
|
@@ -39,6 +39,22 @@ export type Edit = {
|
|
|
39
39
|
lineNum: number;
|
|
40
40
|
index: number;
|
|
41
41
|
oldAssertion?: string;
|
|
42
|
+
} | {
|
|
43
|
+
/**
|
|
44
|
+
* Deferred block edit (`replace block N:` / `delete block N`). The exact
|
|
45
|
+
* line span is unknown at parse time — it is computed by
|
|
46
|
+
* {@link resolveBlockEdits} once file text + path (→ language) are
|
|
47
|
+
* available, then expanded into concrete edits: a non-empty `payloads`
|
|
48
|
+
* (from `replace block`) becomes the same `replacement` inserts + deletes
|
|
49
|
+
* that `replace start..end:` produces; an empty `payloads` (from `delete
|
|
50
|
+
* block`) becomes a pure range deletion. `applyEdits` never sees this
|
|
51
|
+
* variant.
|
|
52
|
+
*/
|
|
53
|
+
kind: "block";
|
|
54
|
+
anchor: Anchor;
|
|
55
|
+
payloads: string[];
|
|
56
|
+
lineNum: number;
|
|
57
|
+
index: number;
|
|
42
58
|
};
|
|
43
59
|
/** Result of applying a parsed set of edits to a text body. */
|
|
44
60
|
export interface ApplyResult {
|
|
@@ -85,3 +101,29 @@ export interface CompactDiffOptions {
|
|
|
85
101
|
/** Maximum entries kept on each side of an unchanged-context truncation (default 2). */
|
|
86
102
|
maxUnchangedRun?: number;
|
|
87
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* Resolved 1-indexed inclusive line span of a `replace block N:` target.
|
|
106
|
+
*/
|
|
107
|
+
export interface BlockSpan {
|
|
108
|
+
/** First line of the block (1-indexed, inclusive). */
|
|
109
|
+
start: number;
|
|
110
|
+
/** Last line of the block (1-indexed, inclusive). */
|
|
111
|
+
end: number;
|
|
112
|
+
}
|
|
113
|
+
/** Request handed to a {@link BlockResolver} to resolve one `replace block N:` anchor. */
|
|
114
|
+
export interface BlockResolverRequest {
|
|
115
|
+
/** Target file path (used to infer language by extension). */
|
|
116
|
+
path: string;
|
|
117
|
+
/** Full text the block must be resolved against (the snapshot the tag names). */
|
|
118
|
+
text: string;
|
|
119
|
+
/** 1-indexed line the block must begin on. */
|
|
120
|
+
line: number;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Resolves a `replace block N:` anchor to the line span of the syntactic block
|
|
124
|
+
* that begins on line N. Returns `null` when no block can be resolved
|
|
125
|
+
* (unrecognized language, blank/out-of-range line, no node begins there, or the
|
|
126
|
+
* resolved subtree has a syntax error). Pure seam: the hashline core declares
|
|
127
|
+
* the contract; the host injects a tree-sitter-backed implementation.
|
|
128
|
+
*/
|
|
129
|
+
export type BlockResolver = (request: BlockResolverRequest) => BlockSpan | null;
|
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.7.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/apply.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* which fixes the common model mistake of a payload that duplicates or drops
|
|
8
8
|
* the closing delimiter bordering the range (balance-validated; see below).
|
|
9
9
|
*/
|
|
10
|
+
import { UNRESOLVED_BLOCK_INTERNAL } from "./messages";
|
|
10
11
|
import { cloneCursor } from "./tokenizer";
|
|
11
12
|
import type { Anchor, ApplyResult, Cursor, Edit } from "./types";
|
|
12
13
|
|
|
@@ -29,7 +30,7 @@ function getCursorAnchors(cursor: Cursor): Anchor[] {
|
|
|
29
30
|
return cursor.kind === "before_anchor" || cursor.kind === "after_anchor" ? [cursor.anchor] : [];
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
function getEditAnchors(edit:
|
|
33
|
+
function getEditAnchors(edit: AppliedEdit): Anchor[] {
|
|
33
34
|
if (edit.kind === "delete") return [edit.anchor];
|
|
34
35
|
return getCursorAnchors(edit.cursor);
|
|
35
36
|
}
|
|
@@ -407,9 +408,17 @@ function repairBoundaryBalance(
|
|
|
407
408
|
* Returns the post-edit text and the first changed line number (1-indexed).
|
|
408
409
|
* Throws if an anchor is out of bounds.
|
|
409
410
|
*/
|
|
410
|
-
export function applyEdits(text: string, edits: Edit[]): ApplyResult {
|
|
411
|
+
export function applyEdits(text: string, edits: readonly Edit[]): ApplyResult {
|
|
411
412
|
if (edits.length === 0) return { text, firstChangedLine: undefined };
|
|
412
413
|
|
|
414
|
+
// Block edits are deferred until `resolveBlockEdits` expands them into
|
|
415
|
+
// concrete inserts + deletes. Reaching the applier with one still present
|
|
416
|
+
// is an internal wiring bug, not authored-input error.
|
|
417
|
+
for (const edit of edits) {
|
|
418
|
+
if (edit.kind === "block") throw new Error(UNRESOLVED_BLOCK_INTERNAL);
|
|
419
|
+
}
|
|
420
|
+
const appliedEdits = edits as readonly AppliedEdit[];
|
|
421
|
+
|
|
413
422
|
const fileLines = text.split("\n");
|
|
414
423
|
const lineOrigins: LineOrigin[] = fileLines.map(() => "original");
|
|
415
424
|
|
|
@@ -418,7 +427,7 @@ export function applyEdits(text: string, edits: Edit[]): ApplyResult {
|
|
|
418
427
|
if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line;
|
|
419
428
|
};
|
|
420
429
|
|
|
421
|
-
const targetEdits =
|
|
430
|
+
const targetEdits = appliedEdits.map((edit, index) => cloneAppliedEdit(edit, index));
|
|
422
431
|
validateLineBounds(targetEdits, fileLines);
|
|
423
432
|
const { edits: repaired, warnings } = repairBoundaryBalance(targetEdits, fileLines);
|
|
424
433
|
|
package/src/block.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expand deferred `replace block N:` edits into concrete inserts + deletes.
|
|
3
|
+
*
|
|
4
|
+
* The hashline parser cannot expand a block edit on its own — the line span is
|
|
5
|
+
* unknown until file text + path (→ language) are available. This transform
|
|
6
|
+
* runs at every apply/preview boundary that has text: it calls the injected
|
|
7
|
+
* {@link BlockResolver} to resolve each block's `[start, end]` span, then emits
|
|
8
|
+
* the exact same `before_anchor` replacement inserts + range deletes that
|
|
9
|
+
* `replace start..end:` produces in the parser. After it runs, no `block` edits
|
|
10
|
+
* remain, so {@link applyEdits} (and recovery) only ever see resolved edits.
|
|
11
|
+
*/
|
|
12
|
+
import { BLOCK_RESOLVER_UNAVAILABLE, blockUnresolvedMessage } from "./messages";
|
|
13
|
+
import type { BlockResolver, Cursor, Edit } from "./types";
|
|
14
|
+
|
|
15
|
+
export interface ResolveBlockEditsOptions {
|
|
16
|
+
/**
|
|
17
|
+
* How to handle a block edit that cannot be resolved (missing resolver or a
|
|
18
|
+
* `null` span). `"throw"` (default) raises a `blockUnresolvedMessage` error —
|
|
19
|
+
* used by the authoritative apply + final preview paths. `"drop"` silently
|
|
20
|
+
* skips the edit — used by the streaming preview, where a half-written file
|
|
21
|
+
* or transient parse error must not throw.
|
|
22
|
+
*/
|
|
23
|
+
onUnresolved?: "throw" | "drop";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** True when at least one edit is an unresolved `replace block N:` edit. */
|
|
27
|
+
export function hasBlockEdit(edits: readonly Edit[]): boolean {
|
|
28
|
+
return edits.some(edit => edit.kind === "block");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve every `replace block N:` edit in `edits` against `text` (parsed as
|
|
33
|
+
* the language inferred from `path`). Non-block edits pass through untouched.
|
|
34
|
+
* Returns a fresh edit list with no `block` variants. The fast path returns the
|
|
35
|
+
* input unchanged when there is nothing to resolve.
|
|
36
|
+
*
|
|
37
|
+
* Synthesized inserts/deletes carry sequential `index` values for readability
|
|
38
|
+
* only — {@link applyEdits} re-derives every edit's index from array order, so
|
|
39
|
+
* the passthrough edits keeping their original indices is harmless.
|
|
40
|
+
*/
|
|
41
|
+
export function resolveBlockEdits(
|
|
42
|
+
edits: readonly Edit[],
|
|
43
|
+
text: string,
|
|
44
|
+
path: string,
|
|
45
|
+
resolver: BlockResolver | undefined,
|
|
46
|
+
options: ResolveBlockEditsOptions = {},
|
|
47
|
+
): readonly Edit[] {
|
|
48
|
+
if (!hasBlockEdit(edits)) return edits;
|
|
49
|
+
const onUnresolved = options.onUnresolved ?? "throw";
|
|
50
|
+
const resolved: Edit[] = [];
|
|
51
|
+
let synthIndex = 0;
|
|
52
|
+
for (const edit of edits) {
|
|
53
|
+
if (edit.kind !== "block") {
|
|
54
|
+
resolved.push(edit);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const span = resolver ? resolver({ path, text, line: edit.anchor.line }) : null;
|
|
58
|
+
if (span === null) {
|
|
59
|
+
if (onUnresolved === "drop") continue;
|
|
60
|
+
throw new Error(
|
|
61
|
+
`line ${edit.lineNum}: ${resolver ? blockUnresolvedMessage(edit.anchor.line) : BLOCK_RESOLVER_UNAVAILABLE}`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
// Mirror the parser's `replace start..end:` expansion exactly: one
|
|
65
|
+
// `before_anchor` replacement insert per payload row at `span.start`,
|
|
66
|
+
// then one delete per line across `[span.start, span.end]`. An empty
|
|
67
|
+
// `payloads` (from `delete block N`) emits no inserts — a pure deletion.
|
|
68
|
+
for (const payload of edit.payloads) {
|
|
69
|
+
const cursor: Cursor = { kind: "before_anchor", anchor: { line: span.start } };
|
|
70
|
+
resolved.push({
|
|
71
|
+
kind: "insert",
|
|
72
|
+
cursor,
|
|
73
|
+
text: payload,
|
|
74
|
+
lineNum: edit.lineNum,
|
|
75
|
+
index: synthIndex++,
|
|
76
|
+
mode: "replacement",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
for (let line = span.start; line <= span.end; line++) {
|
|
80
|
+
resolved.push({ kind: "delete", anchor: { line }, lineNum: edit.lineNum, index: synthIndex++ });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return resolved;
|
|
84
|
+
}
|
package/src/format.ts
CHANGED
|
@@ -14,6 +14,8 @@ export const HL_PAYLOAD_REPLACE = "+";
|
|
|
14
14
|
|
|
15
15
|
/** Hunk-header keyword for concrete line replacement. */
|
|
16
16
|
export const HL_REPLACE_KEYWORD = "replace";
|
|
17
|
+
/** Hunk-header sub-keyword: `replace block N:` resolves N to a tree-sitter block range. */
|
|
18
|
+
export const HL_BLOCK_KEYWORD = "block";
|
|
17
19
|
/** Hunk-header keyword for concrete line deletion. */
|
|
18
20
|
export const HL_DELETE_KEYWORD = "delete";
|
|
19
21
|
/** Hunk-header keyword for insertion operations. */
|
package/src/grammar.lark
CHANGED
|
@@ -3,15 +3,17 @@ begin_patch: "*** Begin Patch" LF
|
|
|
3
3
|
end_patch: "*** End Patch" LF?
|
|
4
4
|
|
|
5
5
|
file_patch: file_header hunk+
|
|
6
|
-
file_header: "¶" filename
|
|
6
|
+
file_header: "¶" filename "#" file_hash LF
|
|
7
7
|
file_hash: /[0-9A-F]{4}/
|
|
8
8
|
filename: /[^\s#]+/
|
|
9
9
|
|
|
10
|
-
hunk: body_hunk | delete_hunk
|
|
10
|
+
hunk: body_hunk | delete_hunk | delete_block_hunk
|
|
11
11
|
body_hunk: body_header emit_op+
|
|
12
12
|
delete_hunk: "delete " header_range LF
|
|
13
|
-
|
|
13
|
+
delete_block_hunk: "delete block " LID LF
|
|
14
|
+
body_header: (replace_anchor | replace_block_anchor | insert_anchor) LF
|
|
14
15
|
replace_anchor: "replace " header_range ":"
|
|
16
|
+
replace_block_anchor: "replace block " LID ":"
|
|
15
17
|
insert_anchor: "insert " insert_pos ":"
|
|
16
18
|
insert_pos: "before " LID | "after " LID | "head" | "tail"
|
|
17
19
|
emit_op: "+" /(.*)/ LF
|
package/src/index.ts
CHANGED
package/src/input.ts
CHANGED
|
@@ -9,10 +9,11 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import * as path from "node:path";
|
|
11
11
|
import { applyEdits } from "./apply";
|
|
12
|
+
import { resolveBlockEdits } from "./block";
|
|
12
13
|
import { HL_FILE_HASH_LENGTH, HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./format";
|
|
13
14
|
import { parsePatch, parsePatchStreaming } from "./parser";
|
|
14
15
|
import { Tokenizer } from "./tokenizer";
|
|
15
|
-
import type { ApplyResult, Edit, SplitOptions } from "./types";
|
|
16
|
+
import type { ApplyResult, BlockResolver, Edit, SplitOptions } from "./types";
|
|
16
17
|
|
|
17
18
|
// Pure classification — single shared tokenizer is safe.
|
|
18
19
|
const TOKENIZER = new Tokenizer();
|
|
@@ -251,6 +252,8 @@ export class PatchSection {
|
|
|
251
252
|
get hasAnchorScopedEdit(): boolean {
|
|
252
253
|
return this.edits.some(edit => {
|
|
253
254
|
if (edit.kind === "delete") return true;
|
|
255
|
+
// A `replace block N:` edit is anchored to concrete content on line N.
|
|
256
|
+
if (edit.kind === "block") return true;
|
|
254
257
|
return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
|
|
255
258
|
});
|
|
256
259
|
}
|
|
@@ -263,6 +266,10 @@ export class PatchSection {
|
|
|
263
266
|
lines.add(edit.anchor.line);
|
|
264
267
|
continue;
|
|
265
268
|
}
|
|
269
|
+
if (edit.kind === "block") {
|
|
270
|
+
lines.add(edit.anchor.line);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
266
273
|
if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor") {
|
|
267
274
|
lines.add(edit.cursor.anchor.line);
|
|
268
275
|
}
|
|
@@ -276,10 +283,14 @@ export class PatchSection {
|
|
|
276
283
|
* {@link Patcher} owns tag validation and recovery; reach for this
|
|
277
284
|
* method directly when you've already validated the file content and
|
|
278
285
|
* just want the result.
|
|
286
|
+
*
|
|
287
|
+
* `blockResolver` resolves any `replace block N:` edits against `text`; an
|
|
288
|
+
* unresolvable block throws (this is the final, authoritative preview path).
|
|
279
289
|
*/
|
|
280
|
-
applyTo(text: string): ApplyResult {
|
|
290
|
+
applyTo(text: string, blockResolver?: BlockResolver): ApplyResult {
|
|
281
291
|
const { edits, warnings } = this.parse();
|
|
282
|
-
const
|
|
292
|
+
const resolved = resolveBlockEdits(edits, text, this.path, blockResolver, { onUnresolved: "throw" });
|
|
293
|
+
const result = applyEdits(text, resolved);
|
|
283
294
|
// Preserve parse warnings so consumers don't need to call `parse()`
|
|
284
295
|
// separately.
|
|
285
296
|
const merged = warnings.length === 0 ? result.warnings : [...warnings, ...(result.warnings ?? [])];
|
|
@@ -294,10 +305,15 @@ export class PatchSection {
|
|
|
294
305
|
* or a per-token parse error mid-stream) does not throw or emit a phantom
|
|
295
306
|
* empty-payload edit. Intended for incremental diff previews; the writer
|
|
296
307
|
* path should always use {@link applyTo}.
|
|
308
|
+
*
|
|
309
|
+
* `blockResolver` resolves any `replace block N:` edits against `text`; an
|
|
310
|
+
* unresolvable block is silently dropped so a half-written file does not
|
|
311
|
+
* throw mid-stream.
|
|
297
312
|
*/
|
|
298
|
-
applyPartialTo(text: string): ApplyResult {
|
|
313
|
+
applyPartialTo(text: string, blockResolver?: BlockResolver): ApplyResult {
|
|
299
314
|
const { edits, warnings } = parsePatchStreaming(this.diff);
|
|
300
|
-
const
|
|
315
|
+
const resolved = resolveBlockEdits(edits, text, this.path, blockResolver, { onUnresolved: "drop" });
|
|
316
|
+
const result = applyEdits(text, resolved);
|
|
301
317
|
const merged = warnings.length === 0 ? result.warnings : [...warnings, ...(result.warnings ?? [])];
|
|
302
318
|
return merged && merged.length > 0
|
|
303
319
|
? { ...result, warnings: merged }
|
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
|
|
|
@@ -40,9 +42,48 @@ export const MINUS_ROW_REJECTED =
|
|
|
40
42
|
/** Error text emitted when a replace hunk has no body. */
|
|
41
43
|
export const EMPTY_REPLACE = "`replace N..M:` needs at least one `+TEXT` body row. To delete lines, use `delete N..M`.";
|
|
42
44
|
|
|
45
|
+
/** Error text emitted when a `replace block N:` hunk has no body. */
|
|
46
|
+
export const EMPTY_BLOCK =
|
|
47
|
+
"`replace block N:` needs at least one `+TEXT` body row. To delete a block, use `delete N..M` with the block's line range.";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Error text emitted when a `replace block N:` anchor cannot be resolved to a
|
|
51
|
+
* syntactic block (unrecognized language, blank/out-of-range line, no node
|
|
52
|
+
* begins on line N such as a lone closing delimiter, or the resolved block has
|
|
53
|
+
* a syntax error). Names the offending line and steers back to an explicit
|
|
54
|
+
* `replace N..M:` range.
|
|
55
|
+
*/
|
|
56
|
+
export function blockUnresolvedMessage(line: number): string {
|
|
57
|
+
return (
|
|
58
|
+
`\`replace block ${line}:\` could not resolve a syntactic block beginning on line ${line}. ` +
|
|
59
|
+
`The language may be unsupported, the line may be blank or a closing delimiter, or the block may not parse. ` +
|
|
60
|
+
`Use \`replace ${line}..M:\` with the block's explicit end line instead.`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Error text emitted when a `replace block N:` edit reaches a code path that
|
|
66
|
+
* has no {@link BlockResolver} wired in. Indicates a host-configuration bug
|
|
67
|
+
* rather than authored-input error.
|
|
68
|
+
*/
|
|
69
|
+
export const BLOCK_RESOLVER_UNAVAILABLE =
|
|
70
|
+
"`replace block N:` is not available here (no tree-sitter block resolver is configured). Use `replace N..M:` with an explicit range.";
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Internal invariant error: `applyEdits` received an unresolved `replace block
|
|
74
|
+
* N:` edit. Block edits must be expanded by `resolveBlockEdits` before reaching
|
|
75
|
+
* the applier; hitting this is a wiring bug, not authored-input error.
|
|
76
|
+
*/
|
|
77
|
+
export const UNRESOLVED_BLOCK_INTERNAL =
|
|
78
|
+
"internal error: unresolved `replace block` edit reached the applier (resolveBlockEdits was not run).";
|
|
79
|
+
|
|
43
80
|
/** Error text emitted when a delete hunk receives a body row. */
|
|
44
81
|
export const DELETE_TAKES_NO_BODY = "`delete N..M` does not take body rows. Remove the body, or use `replace N..M:`.";
|
|
45
82
|
|
|
83
|
+
/** Error text emitted when a `delete block N` hunk receives a body row. */
|
|
84
|
+
export const DELETE_BLOCK_TAKES_NO_BODY =
|
|
85
|
+
"`delete block N` does not take body rows. Remove the body, or use `replace block N:` to replace the block.";
|
|
86
|
+
|
|
46
87
|
/** Error text emitted when an insert hunk has no body. */
|
|
47
88
|
export const EMPTY_INSERT = "`insert` needs at least one `+TEXT` body row.";
|
|
48
89
|
|
|
@@ -65,3 +106,23 @@ export const RECOVERY_SESSION_CHAIN_WARNING =
|
|
|
65
106
|
*/
|
|
66
107
|
export const RECOVERY_SESSION_REPLAY_WARNING =
|
|
67
108
|
"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.";
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Warning emitted when an `insert head:` / `insert tail:` edit is applied to an
|
|
112
|
+
* existing file whose snapshot tag is stale (the file drifted since the read).
|
|
113
|
+
* Head/tail insert position is content-independent — "start"/"end" cannot move
|
|
114
|
+
* with drift — so this is non-fatal: the edit applies onto the live content and
|
|
115
|
+
* we surface the drift instead of hard-failing (unlike an anchored mismatch).
|
|
116
|
+
*/
|
|
117
|
+
export const HEADTAIL_DRIFT_WARNING =
|
|
118
|
+
"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.";
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Error text emitted when a hashline section omits the mandatory snapshot tag.
|
|
122
|
+
* The tag is REQUIRED on every section, enforced identically by the apply path
|
|
123
|
+
* ({@link Patcher.prepare}) and the preview/diff path, so both surfaces reuse
|
|
124
|
+
* this single builder to stay in lockstep.
|
|
125
|
+
*/
|
|
126
|
+
export function missingSnapshotTagMessage(sectionPath: string): string {
|
|
127
|
+
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.`;
|
|
128
|
+
}
|
package/src/parser.ts
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
import { HL_PAYLOAD_REPLACE } from "./format";
|
|
7
7
|
import {
|
|
8
8
|
BARE_BODY_AUTO_PIPED_WARNING,
|
|
9
|
+
DELETE_BLOCK_TAKES_NO_BODY,
|
|
9
10
|
DELETE_TAKES_NO_BODY,
|
|
11
|
+
EMPTY_BLOCK,
|
|
10
12
|
EMPTY_INSERT,
|
|
11
13
|
EMPTY_REPLACE,
|
|
12
14
|
MINUS_ROW_REJECTED,
|
|
@@ -159,7 +161,8 @@ export class Executor {
|
|
|
159
161
|
endStreaming(): { edits: Edit[]; warnings: string[] } {
|
|
160
162
|
this.#consumePendingSkippableComments();
|
|
161
163
|
if (this.#pending && this.#pending.payloads.length > 0) this.#flushPending();
|
|
162
|
-
else if (this.#pending?.target.kind === "delete"
|
|
164
|
+
else if (this.#pending?.target.kind === "delete" || this.#pending?.target.kind === "delete_block")
|
|
165
|
+
this.#flushPending();
|
|
163
166
|
else this.#pending = undefined;
|
|
164
167
|
this.#validateNoOverlappingDeletes();
|
|
165
168
|
return { edits: this.#edits, warnings: this.#warnings };
|
|
@@ -204,6 +207,7 @@ export class Executor {
|
|
|
204
207
|
);
|
|
205
208
|
}
|
|
206
209
|
if (pending.target.kind === "delete") throw new Error(`line ${lineNum}: ${DELETE_TAKES_NO_BODY}`);
|
|
210
|
+
if (pending.target.kind === "delete_block") throw new Error(`line ${lineNum}: ${DELETE_BLOCK_TAKES_NO_BODY}`);
|
|
207
211
|
pending.payloads.push({ kind: "literal", text, lineNum });
|
|
208
212
|
}
|
|
209
213
|
|
|
@@ -213,6 +217,8 @@ export class Executor {
|
|
|
213
217
|
if (this.#pending) {
|
|
214
218
|
if (text.trim().length === 0) return;
|
|
215
219
|
if (this.#pending.target.kind === "delete") throw new Error(`line ${lineNum}: ${DELETE_TAKES_NO_BODY}`);
|
|
220
|
+
if (this.#pending.target.kind === "delete_block")
|
|
221
|
+
throw new Error(`line ${lineNum}: ${DELETE_BLOCK_TAKES_NO_BODY}`);
|
|
216
222
|
if (text.trimStart().charCodeAt(0) === 45 /* - */) throw new Error(`line ${lineNum}: ${MINUS_ROW_REJECTED}`);
|
|
217
223
|
if (!this.#warnings.includes(BARE_BODY_AUTO_PIPED_WARNING)) this.#warnings.push(BARE_BODY_AUTO_PIPED_WARNING);
|
|
218
224
|
this.#pending.payloads.push({ kind: "literal", text, lineNum });
|
|
@@ -240,6 +246,16 @@ export class Executor {
|
|
|
240
246
|
this.#edits.push({ kind: "delete", anchor: { ...anchor }, lineNum, index: this.#editIndex++ });
|
|
241
247
|
}
|
|
242
248
|
|
|
249
|
+
#pushBlock(anchor: Anchor, payloads: readonly PayloadRow[], lineNum: number): void {
|
|
250
|
+
this.#edits.push({
|
|
251
|
+
kind: "block",
|
|
252
|
+
anchor: { ...anchor },
|
|
253
|
+
payloads: payloads.map(payload => payload.text),
|
|
254
|
+
lineNum,
|
|
255
|
+
index: this.#editIndex++,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
243
259
|
#emitPayloadRows(cursor: Cursor, payloads: readonly PayloadRow[], lineNum: number, mode?: "replacement"): void {
|
|
244
260
|
for (const payload of payloads) this.#pushInsert(cursor, payload.text, lineNum, mode);
|
|
245
261
|
}
|
|
@@ -253,6 +269,16 @@ export class Executor {
|
|
|
253
269
|
for (const anchor of expandRange(target.range)) this.#pushDelete(anchor, lineNum);
|
|
254
270
|
return;
|
|
255
271
|
}
|
|
272
|
+
if (target.kind === "delete_block") {
|
|
273
|
+
// A block edit with no payloads resolves to a pure block deletion.
|
|
274
|
+
this.#pushBlock(target.anchor, [], lineNum);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (target.kind === "block") {
|
|
278
|
+
if (payloads.length === 0) throw new Error(`line ${lineNum}: ${EMPTY_BLOCK}`);
|
|
279
|
+
this.#pushBlock(target.anchor, payloads, lineNum);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
256
282
|
if (payloads.length === 0) {
|
|
257
283
|
if (target.kind === "replace") throw new Error(`line ${lineNum}: ${EMPTY_REPLACE}`);
|
|
258
284
|
throw new Error(`line ${lineNum}: ${EMPTY_INSERT}`);
|
package/src/patcher.ts
CHANGED
|
@@ -23,21 +23,29 @@
|
|
|
23
23
|
* filesystem configuration.
|
|
24
24
|
*/
|
|
25
25
|
import { applyEdits } from "./apply";
|
|
26
|
-
import {
|
|
26
|
+
import { hasBlockEdit, resolveBlockEdits } from "./block";
|
|
27
|
+
import { computeFileHash, formatHashlineHeader } from "./format";
|
|
27
28
|
import type { Filesystem, WriteResult } from "./fs";
|
|
28
29
|
import { isNotFound } from "./fs";
|
|
29
30
|
import type { Patch, PatchSection } from "./input";
|
|
31
|
+
import { HEADTAIL_DRIFT_WARNING, missingSnapshotTagMessage } from "./messages";
|
|
30
32
|
import { MismatchError } from "./mismatch";
|
|
31
33
|
import { detectLineEnding, type LineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize";
|
|
32
34
|
import { Recovery, type RecoveryResult } from "./recovery";
|
|
33
35
|
import type { SnapshotStore } from "./snapshots";
|
|
34
|
-
import type { ApplyResult, Edit } from "./types";
|
|
36
|
+
import type { ApplyResult, BlockResolver, Edit } from "./types";
|
|
35
37
|
|
|
36
38
|
export interface PatcherOptions {
|
|
37
39
|
/** Storage backend used for all reads and writes. */
|
|
38
40
|
fs: Filesystem;
|
|
39
41
|
/** Snapshot store that minted and resolves hashline section tags. Required. */
|
|
40
42
|
snapshots: SnapshotStore;
|
|
43
|
+
/**
|
|
44
|
+
* Resolves `replace block N:` anchors to concrete line spans via tree-sitter.
|
|
45
|
+
* Optional: when omitted, any `replace block N:` edit throws on apply (the
|
|
46
|
+
* host did not wire a resolver). Plain line-range ops never need it.
|
|
47
|
+
*/
|
|
48
|
+
blockResolver?: BlockResolver;
|
|
41
49
|
}
|
|
42
50
|
|
|
43
51
|
/** Per-section result returned by {@link Patcher.apply} / {@link Patcher.commit}. */
|
|
@@ -98,15 +106,15 @@ export class PreparedSection {
|
|
|
98
106
|
function hasAnchorScopedEdit(edits: readonly Edit[]): boolean {
|
|
99
107
|
return edits.some(edit => {
|
|
100
108
|
if (edit.kind === "delete") return true;
|
|
109
|
+
// A `replace block N:` edit anchors to concrete content on line N.
|
|
110
|
+
if (edit.kind === "block") return true;
|
|
101
111
|
return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
|
|
102
112
|
});
|
|
103
113
|
}
|
|
104
114
|
|
|
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
|
-
);
|
|
115
|
+
function assertSectionHashPresent(sectionPath: string, fileHash: string | undefined): void {
|
|
116
|
+
if (fileHash !== undefined) return;
|
|
117
|
+
throw new Error(missingSnapshotTagMessage(sectionPath));
|
|
110
118
|
}
|
|
111
119
|
|
|
112
120
|
function recoveryToApplyResult(result: RecoveryResult): ApplyResult {
|
|
@@ -148,6 +156,7 @@ export class Patcher {
|
|
|
148
156
|
readonly fs: Filesystem;
|
|
149
157
|
readonly snapshots: SnapshotStore;
|
|
150
158
|
readonly recovery: Recovery;
|
|
159
|
+
readonly blockResolver: BlockResolver | undefined;
|
|
151
160
|
|
|
152
161
|
constructor(options: PatcherOptions) {
|
|
153
162
|
if (!options.snapshots) {
|
|
@@ -156,6 +165,7 @@ export class Patcher {
|
|
|
156
165
|
this.fs = options.fs;
|
|
157
166
|
this.snapshots = options.snapshots;
|
|
158
167
|
this.recovery = new Recovery(options.snapshots);
|
|
168
|
+
this.blockResolver = options.blockResolver;
|
|
159
169
|
}
|
|
160
170
|
|
|
161
171
|
/**
|
|
@@ -213,13 +223,13 @@ export class Patcher {
|
|
|
213
223
|
*/
|
|
214
224
|
async prepare(section: PatchSection): Promise<PreparedSection> {
|
|
215
225
|
const { edits, warnings: parseWarnings } = section.parse();
|
|
216
|
-
|
|
226
|
+
assertSectionHashPresent(section.path, section.fileHash);
|
|
217
227
|
|
|
218
228
|
const canonicalPath = this.fs.canonicalPath(section.path);
|
|
219
229
|
await this.fs.preflightWrite(section.path);
|
|
220
230
|
const { exists, rawContent } = await this.#tryRead(section.path);
|
|
221
|
-
if (!exists
|
|
222
|
-
throw new Error(`File not found: ${section.path}
|
|
231
|
+
if (!exists) {
|
|
232
|
+
throw new Error(`File not found: ${section.path}. Use the write tool to create new files.`);
|
|
223
233
|
}
|
|
224
234
|
|
|
225
235
|
const { bom, text } = stripBom(rawContent);
|
|
@@ -307,6 +317,24 @@ export class Patcher {
|
|
|
307
317
|
#recordFullSnapshot(canonicalPath: string, normalized: string): string {
|
|
308
318
|
return this.snapshots.record(canonicalPath, normalized);
|
|
309
319
|
}
|
|
320
|
+
#mismatchError(
|
|
321
|
+
section: PatchSection,
|
|
322
|
+
canonicalPath: string,
|
|
323
|
+
normalized: string,
|
|
324
|
+
expected: string,
|
|
325
|
+
hashRecognized: boolean,
|
|
326
|
+
): MismatchError {
|
|
327
|
+
const actualFileHash = this.#recordFullSnapshot(canonicalPath, normalized);
|
|
328
|
+
return new MismatchError({
|
|
329
|
+
path: section.path,
|
|
330
|
+
expectedFileHash: expected,
|
|
331
|
+
actualFileHash,
|
|
332
|
+
fileLines: normalized.split("\n"),
|
|
333
|
+
anchorLines: section.collectAnchorLines(),
|
|
334
|
+
hashRecognized,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
310
338
|
#applyWithRecovery(args: {
|
|
311
339
|
section: PatchSection;
|
|
312
340
|
canonicalPath: string;
|
|
@@ -316,28 +344,49 @@ export class Patcher {
|
|
|
316
344
|
}): ApplyResult {
|
|
317
345
|
const { section, canonicalPath, exists, normalized, edits } = args;
|
|
318
346
|
const expected = exists ? section.fileHash : undefined;
|
|
319
|
-
|
|
347
|
+
const liveMatches = expected !== undefined && computeFileHash(normalized) === expected;
|
|
348
|
+
|
|
349
|
+
// Resolve `replace block N:` edits to concrete ranges before recovery
|
|
350
|
+
// runs. Block anchors are expressed against the snapshot the section tag
|
|
351
|
+
// names, so resolve against that exact text:
|
|
352
|
+
// - live content matches the tag (or there is no tag) → resolve against
|
|
353
|
+
// the live, normalized content;
|
|
354
|
+
// - the file drifted → resolve against the tagged snapshot's text so the
|
|
355
|
+
// resulting ranges flow through the 3-way-merge recovery below.
|
|
356
|
+
// When a block edit needs the tagged snapshot but it is unavailable, the
|
|
357
|
+
// range cannot be placed safely — reject with a MismatchError (re-read).
|
|
358
|
+
let resolved: readonly Edit[] = edits;
|
|
359
|
+
if (hasBlockEdit(edits)) {
|
|
360
|
+
const baseText =
|
|
361
|
+
expected === undefined || liveMatches ? normalized : this.snapshots.byHash(canonicalPath, expected)?.text;
|
|
362
|
+
if (baseText === undefined) {
|
|
363
|
+
throw this.#mismatchError(section, canonicalPath, normalized, expected ?? "", false);
|
|
364
|
+
}
|
|
365
|
+
resolved = resolveBlockEdits(edits, baseText, section.path, this.blockResolver, { onUnresolved: "throw" });
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (expected === undefined) return applyEdits(normalized, resolved);
|
|
320
369
|
// Whole-file unchanged → the tag still names the live content, so an
|
|
321
370
|
// edit anchored at ANY line (displayed or not) is safe to apply.
|
|
322
|
-
if (
|
|
371
|
+
if (liveMatches) return applyEdits(normalized, resolved);
|
|
372
|
+
// Head/tail-only inserts are position-stable: "start"/"end" cannot move
|
|
373
|
+
// with content drift, so a stale tag is non-fatal. Apply onto the live
|
|
374
|
+
// content and warn instead of hard-failing — unlike an anchored
|
|
375
|
+
// mismatch, which cannot be safely relocated and must reject.
|
|
376
|
+
if (!hasAnchorScopedEdit(resolved)) {
|
|
377
|
+
const result = applyEdits(normalized, resolved);
|
|
378
|
+
return { ...result, warnings: [HEADTAIL_DRIFT_WARNING, ...(result.warnings ?? [])] };
|
|
379
|
+
}
|
|
323
380
|
// File drifted: try to replay the edit against the version the tag
|
|
324
381
|
// names and 3-way-merge it onto the live content.
|
|
325
382
|
const recovered = this.recovery.tryRecover({
|
|
326
383
|
path: canonicalPath,
|
|
327
384
|
currentText: normalized,
|
|
328
385
|
fileHash: expected,
|
|
329
|
-
edits,
|
|
386
|
+
edits: resolved,
|
|
330
387
|
});
|
|
331
388
|
if (recovered) return recoveryToApplyResult(recovered);
|
|
332
389
|
const hashRecognized = this.snapshots.byHash(canonicalPath, expected) !== null;
|
|
333
|
-
|
|
334
|
-
throw new MismatchError({
|
|
335
|
-
path: section.path,
|
|
336
|
-
expectedFileHash: expected,
|
|
337
|
-
actualFileHash,
|
|
338
|
-
fileLines: normalized.split("\n"),
|
|
339
|
-
anchorLines: section.collectAnchorLines(),
|
|
340
|
-
hashRecognized,
|
|
341
|
-
});
|
|
390
|
+
throw this.#mismatchError(section, canonicalPath, normalized, expected, hashRecognized);
|
|
342
391
|
}
|
|
343
392
|
}
|
package/src/prompt.md
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
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>
|
|
8
8
|
replace N..M: replace original lines N..M with the body rows below.
|
|
9
|
+
replace block N: replace the whole syntactic block that BEGINS on line N — its header line through its closing line — resolved with tree-sitter. Body rows below. Point N at the line that OPENS the construct (the `if`/`function`/`def`/`{`-bearing line), not a closing `}` or a blank line.
|
|
9
10
|
delete N..M delete original lines N..M. No body.
|
|
11
|
+
delete block N delete the whole syntactic block that BEGINS on line N.
|
|
10
12
|
insert before N: insert the body rows immediately before line N.
|
|
11
13
|
insert after N: insert the body rows immediately after line N.
|
|
12
14
|
insert head: insert the body rows at the very start of the file.
|
|
@@ -23,14 +25,18 @@ There is NO other body row kind. NEVER write `-old` or a bare/context line. To k
|
|
|
23
25
|
<rules>
|
|
24
26
|
- Line numbers come from `read`/`search` (`LINE:TEXT`). Copy the `¶PATH#TAG` header; use the bare LINE numbers.
|
|
25
27
|
- Numbers refer to the ORIGINAL file and stay valid for the whole patch — they do not shift as hunks apply.
|
|
28
|
+
- 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.
|
|
29
|
+
- 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.
|
|
30
|
+
- 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
31
|
- One hunk per range; the body is the final content, never an old/new pair.
|
|
32
|
+
- Keep every range as tight as the change: a range must cover ONLY lines whose content actually changes. Never widen it to swallow an unchanged signature, brace, or neighboring statement just to rewrite a few lines inside — change one line with `replace N..N`, not the whole block around it. (A range where every line genuinely changes is correctly long; tightness is about excluding unchanged lines, not about being short.) This bounds the blast radius if a number is off: a stale single-line replace corrupts one line, while a stale block replace shreds the whole block and its structure.
|
|
27
33
|
- 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
34
|
</rules>
|
|
29
35
|
|
|
30
36
|
<example>
|
|
31
37
|
Original (the exact shape `read` returns):
|
|
32
38
|
```
|
|
33
|
-
¶greet.py#
|
|
39
|
+
¶greet.py#A1B2
|
|
34
40
|
1:def greet(name):
|
|
35
41
|
2: msg = "Hello, " + name
|
|
36
42
|
3: print(msg)
|
|
@@ -39,14 +45,14 @@ Original (the exact shape `read` returns):
|
|
|
39
45
|
|
|
40
46
|
Insert a guard after line 1:
|
|
41
47
|
```
|
|
42
|
-
¶greet.py#
|
|
48
|
+
¶greet.py#A1B2
|
|
43
49
|
insert after 1:
|
|
44
50
|
+ if not name: name = "stranger"
|
|
45
51
|
```
|
|
46
52
|
|
|
47
53
|
Replace line 2 with two lines:
|
|
48
54
|
```
|
|
49
|
-
¶greet.py#
|
|
55
|
+
¶greet.py#A1B2
|
|
50
56
|
replace 2..2:
|
|
51
57
|
+ greeting = "Hi"
|
|
52
58
|
+ msg = f"{greeting}, {name}"
|
|
@@ -54,18 +60,26 @@ replace 2..2:
|
|
|
54
60
|
|
|
55
61
|
Delete line 3:
|
|
56
62
|
```
|
|
57
|
-
¶greet.py#
|
|
63
|
+
¶greet.py#A1B2
|
|
58
64
|
delete 3
|
|
59
65
|
```
|
|
60
66
|
|
|
61
67
|
Add a header and trailer:
|
|
62
68
|
```
|
|
63
|
-
¶greet.py#
|
|
69
|
+
¶greet.py#A1B2
|
|
64
70
|
insert head:
|
|
65
71
|
+# generated header
|
|
66
72
|
insert tail:
|
|
67
73
|
+greet("everyone")
|
|
68
74
|
```
|
|
75
|
+
|
|
76
|
+
Replace the whole `greet` function block — `replace block 1:` resolves lines 1–3 (the `def` header through `print(msg)`); line 4 is a separate statement and stays:
|
|
77
|
+
```
|
|
78
|
+
¶greet.py#A1B2
|
|
79
|
+
replace block 1:
|
|
80
|
+
+def greet(name):
|
|
81
|
+
+ print(f"Hello, {name}")
|
|
82
|
+
```
|
|
69
83
|
</example>
|
|
70
84
|
|
|
71
85
|
<anti-patterns>
|
|
@@ -85,3 +99,10 @@ replace 3..3:
|
|
|
85
99
|
replace 3..3:
|
|
86
100
|
+ return msg
|
|
87
101
|
</anti-patterns>
|
|
102
|
+
|
|
103
|
+
<critical>
|
|
104
|
+
If you remember nothing else:
|
|
105
|
+
1. RE-GROUND AFTER EVERY EDIT. Each applied edit mints a fresh `#TAG` and renumbers the file — the tag and line numbers you just used are now dead. Take the next edit's numbers from the edit response or a fresh `read`, never from pre-edit memory. On a stale-tag rejection or any unexpected result, STOP and re-`read`.
|
|
106
|
+
2. RANGES ARE TIGHT AND IN-BOUNDS. Cover only lines whose content actually changes; never widen a range to swallow an unchanged signature, brace, or statement, and never start or end a range mid-expression or mid-block. A stale single-line replace corrupts one line; a stale block replace shreds the whole block.
|
|
107
|
+
3. THE BODY IS THE FINAL CONTENT. Only `+TEXT` rows under a `:` header — never `-old`/bare context lines, never an old/new pair. The range does the deleting.
|
|
108
|
+
</critical>
|
package/src/recovery.ts
CHANGED
|
@@ -70,6 +70,9 @@ 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
|
+
// Recovery only ever receives already-resolved edits (no `block`); this arm
|
|
74
|
+
// exists for type-exhaustiveness over the full `Edit` union.
|
|
75
|
+
if (edit.kind === "block") return [edit.anchor];
|
|
73
76
|
return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor" ? [edit.cursor.anchor] : [];
|
|
74
77
|
}
|
|
75
78
|
|
package/src/tokenizer.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import {
|
|
12
12
|
describeAnchorExamples,
|
|
13
|
+
HL_BLOCK_KEYWORD,
|
|
13
14
|
HL_DELETE_KEYWORD,
|
|
14
15
|
HL_FILE_HASH_LENGTH,
|
|
15
16
|
HL_FILE_HASH_SEP,
|
|
@@ -196,7 +197,9 @@ function scanHeaderRange(line: string, index = 0, end = trimEndIndex(line), allo
|
|
|
196
197
|
|
|
197
198
|
export type BlockTarget =
|
|
198
199
|
| { kind: "replace"; range: ParsedRange }
|
|
200
|
+
| { kind: "block"; anchor: Anchor }
|
|
199
201
|
| { kind: "delete"; range: ParsedRange }
|
|
202
|
+
| { kind: "delete_block"; anchor: Anchor }
|
|
200
203
|
| { kind: "insert_before"; anchor: Anchor }
|
|
201
204
|
| { kind: "insert_after"; anchor: Anchor }
|
|
202
205
|
| { kind: "bof" }
|
|
@@ -249,6 +252,18 @@ function scanHunkAnchor(line: string, start: number, end: number): TargetScan |
|
|
|
249
252
|
const cursor = skipWhitespace(line, start, end);
|
|
250
253
|
const replaceEnd = scanKeyword(line, cursor, end, HL_REPLACE_KEYWORD);
|
|
251
254
|
if (replaceEnd !== null) {
|
|
255
|
+
// `replace block N:` — resolve N to a tree-sitter block range at apply
|
|
256
|
+
// time. Try the `block` sub-keyword before falling back to a literal
|
|
257
|
+
// `replace N..M:` range.
|
|
258
|
+
const blockEnd = scanKeyword(line, skipWhitespace(line, replaceEnd, end), end, HL_BLOCK_KEYWORD);
|
|
259
|
+
if (blockEnd !== null) {
|
|
260
|
+
const anchor = scanLineNumber(line, skipWhitespace(line, blockEnd, end), end);
|
|
261
|
+
if (anchor === null) return null;
|
|
262
|
+
return {
|
|
263
|
+
target: { kind: "block", anchor: { line: anchor.line } },
|
|
264
|
+
nextIndex: consumeOptionalColon(line, anchor.nextIndex, end),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
252
267
|
const range = scanHeaderRange(line, replaceEnd, end, true);
|
|
253
268
|
if (range === null) return null;
|
|
254
269
|
return {
|
|
@@ -258,6 +273,17 @@ function scanHunkAnchor(line: string, start: number, end: number): TargetScan |
|
|
|
258
273
|
}
|
|
259
274
|
const deleteEnd = scanKeyword(line, cursor, end, HL_DELETE_KEYWORD);
|
|
260
275
|
if (deleteEnd !== null) {
|
|
276
|
+
// `delete block N` — resolve N to a tree-sitter block range at apply
|
|
277
|
+
// time and delete its whole span. Like `delete N..M`, it takes no body
|
|
278
|
+
// and no trailing colon.
|
|
279
|
+
const blockEnd = scanKeyword(line, skipWhitespace(line, deleteEnd, end), end, HL_BLOCK_KEYWORD);
|
|
280
|
+
if (blockEnd !== null) {
|
|
281
|
+
const anchor = scanLineNumber(line, skipWhitespace(line, blockEnd, end), end);
|
|
282
|
+
if (anchor === null) return null;
|
|
283
|
+
const next = skipWhitespace(line, anchor.nextIndex, end);
|
|
284
|
+
if (next < end && line.charCodeAt(next) === CHAR_COLON) return null;
|
|
285
|
+
return { target: { kind: "delete_block", anchor: { line: anchor.line } }, nextIndex: next };
|
|
286
|
+
}
|
|
261
287
|
const range = scanHeaderRange(line, deleteEnd, end, true);
|
|
262
288
|
if (range === null) return null;
|
|
263
289
|
const next = skipWhitespace(line, range.nextIndex, end);
|
package/src/types.ts
CHANGED
|
@@ -32,7 +32,24 @@ export type Edit =
|
|
|
32
32
|
index: number;
|
|
33
33
|
mode?: "replacement";
|
|
34
34
|
}
|
|
35
|
-
| { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string }
|
|
35
|
+
| { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string }
|
|
36
|
+
| {
|
|
37
|
+
/**
|
|
38
|
+
* Deferred block edit (`replace block N:` / `delete block N`). The exact
|
|
39
|
+
* line span is unknown at parse time — it is computed by
|
|
40
|
+
* {@link resolveBlockEdits} once file text + path (→ language) are
|
|
41
|
+
* available, then expanded into concrete edits: a non-empty `payloads`
|
|
42
|
+
* (from `replace block`) becomes the same `replacement` inserts + deletes
|
|
43
|
+
* that `replace start..end:` produces; an empty `payloads` (from `delete
|
|
44
|
+
* block`) becomes a pure range deletion. `applyEdits` never sees this
|
|
45
|
+
* variant.
|
|
46
|
+
*/
|
|
47
|
+
kind: "block";
|
|
48
|
+
anchor: Anchor;
|
|
49
|
+
payloads: string[];
|
|
50
|
+
lineNum: number;
|
|
51
|
+
index: number;
|
|
52
|
+
};
|
|
36
53
|
|
|
37
54
|
/** Result of applying a parsed set of edits to a text body. */
|
|
38
55
|
export interface ApplyResult {
|
|
@@ -84,3 +101,32 @@ export interface CompactDiffOptions {
|
|
|
84
101
|
/** Maximum entries kept on each side of an unchanged-context truncation (default 2). */
|
|
85
102
|
maxUnchangedRun?: number;
|
|
86
103
|
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Resolved 1-indexed inclusive line span of a `replace block N:` target.
|
|
107
|
+
*/
|
|
108
|
+
export interface BlockSpan {
|
|
109
|
+
/** First line of the block (1-indexed, inclusive). */
|
|
110
|
+
start: number;
|
|
111
|
+
/** Last line of the block (1-indexed, inclusive). */
|
|
112
|
+
end: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Request handed to a {@link BlockResolver} to resolve one `replace block N:` anchor. */
|
|
116
|
+
export interface BlockResolverRequest {
|
|
117
|
+
/** Target file path (used to infer language by extension). */
|
|
118
|
+
path: string;
|
|
119
|
+
/** Full text the block must be resolved against (the snapshot the tag names). */
|
|
120
|
+
text: string;
|
|
121
|
+
/** 1-indexed line the block must begin on. */
|
|
122
|
+
line: number;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Resolves a `replace block N:` anchor to the line span of the syntactic block
|
|
127
|
+
* that begins on line N. Returns `null` when no block can be resolved
|
|
128
|
+
* (unrecognized language, blank/out-of-range line, no node begins there, or the
|
|
129
|
+
* resolved subtree has a syntax error). Pure seam: the hashline core declares
|
|
130
|
+
* the contract; the host injects a tree-sitter-backed implementation.
|
|
131
|
+
*/
|
|
132
|
+
export type BlockResolver = (request: BlockResolverRequest) => BlockSpan | null;
|