blockpatch 0.1.0 → 1.0.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.
@@ -0,0 +1,18 @@
1
+ # Contributing
2
+
3
+ `blockpatch` is intentionally small. The core invariant is: no fuzz, no AST parsing, and no regeneration of moved bytes.
4
+
5
+ Changes should preserve these rules:
6
+
7
+ - context and payload matching stay byte-exact
8
+ - moved bytes come from the source file, not from formatted or regenerated text
9
+ - line numbers are review hints, not match authority
10
+ - agent-facing output stays deterministic and structured
11
+
12
+ Before sending changes, run:
13
+
14
+ ```sh
15
+ bun test
16
+ bun run build
17
+ npm publish --dry-run
18
+ ```
package/README.md CHANGED
@@ -1,14 +1,20 @@
1
1
  # blockpatch
2
2
 
3
- `blockpatch` v0 applies one anchored text move patch. It is not a generic diff tool, an AST refactor tool, a formatter, or a fuzzy matcher.
3
+ `blockpatch` applies anchored text block relocation patches that look like unified diffs. It is not a generic diff tool, an AST refactor tool, a formatter, or a fuzzy matcher.
4
4
 
5
- The core invariant is simple: locate an exact source payload by surrounding anchors, remove those exact original bytes, and insert those same bytes at an independently located target anchor.
5
+ The core invariant is simple: a move transfers one exact, hash-verified payload between endpoints. In a paired move, the source hunk removes exact bytes and the target hunk adds the same exact bytes, so `blockpatch` moves the original source bytes instead of regenerating them.
6
+
7
+ A move may also be one-sided. A source-only hunk removes the verified payload from an existing file. A target-only hunk materializes the patch-carried payload into an existing file. File path absence is represented separately with `/dev/null` in file headers: `/dev/null -> file` creates a file, and `file -> /dev/null` removes a file.
8
+
9
+ `blockpatch` emits reviewable unified-diff-shaped patches that are intended to be compatible with `patch --fuzz=0` where possible. `blockpatch apply` accepts a strict, hash-verified subset of those patches and deliberately rejects fuzzy, heuristic, or ambiguous application.
6
10
 
7
11
  ## Install
8
12
 
9
13
  ```sh
10
14
  npx blockpatch check patch.blockpatch
11
15
  npx blockpatch apply patch.blockpatch
16
+ npx blockpatch plan --json -
17
+ npx blockpatch move --json -
12
18
  bunx blockpatch apply patch.blockpatch --dry-run
13
19
  npm install -g blockpatch
14
20
  ```
@@ -19,7 +25,8 @@ For local development:
19
25
  bun install
20
26
  bun test
21
27
  bun run build
22
- bun run publish:dry
28
+ npm run publish:dry
29
+ node dist/cli.js version
23
30
  ```
24
31
 
25
32
  ## Commands
@@ -28,91 +35,509 @@ bun run publish:dry
28
35
  blockpatch check patch.blockpatch
29
36
  blockpatch apply patch.blockpatch
30
37
  blockpatch apply patch.blockpatch --dry-run
38
+ blockpatch apply patch.blockpatch --reverse
39
+ blockpatch check patch.blockpatch -R
40
+ blockpatch apply - < patch.blockpatch
41
+ blockpatch apply < patch.blockpatch
42
+ blockpatch apply -i patch.blockpatch
43
+ blockpatch apply -d repo-root -p1 patch.blockpatch
44
+ blockpatch check -p1 < patch.blockpatch
45
+ blockpatch plan --json -
46
+ blockpatch move --json -
47
+ blockpatch move --json move.json
48
+ blockpatch move --src src/foo.ts --src-start $'\nfunction movedThing() {' --src-end $'\n}\n' --target-before $'class Target {\n'
49
+ blockpatch move --src /dev/null --dst src/foo.ts --payload $'inserted bytes\n' --target-before $'context before\n'
50
+ blockpatch move --src src/foo.ts --src-start $'\nfunction removeMe() {' --src-end $'\n}\n' --dst /dev/null
31
51
  ```
32
52
 
33
- `check` parses the patch and verifies it against the target file without writing. `apply --dry-run` does the same validation through the apply path without writing.
53
+ `check` parses the patch and verifies it against the target file without writing. `apply --dry-run` does the same validation through the apply path without writing. `-R`/`--reverse` moves the verified payload back from the target location to the source location; it works with both `check` and `apply`.
34
54
 
35
- ## V0 Format
55
+ Patch-declared source and destination paths, and move JSON `src`/`dst` paths, must be relative, non-empty, and resolve inside `--cwd`. Absolute operation paths, `..` escapes, and operation paths containing symlink components are rejected. Existing regular files are also realpath-checked; if the real path escapes `--cwd`, the operation is rejected. `-d`/`--directory` is an alias for `--cwd`. Patch files and move JSON files may be read from any path; use `--cwd` to choose the directory the operation is allowed to modify.
36
56
 
37
- ```text
38
- *** Begin BlockPatch
39
- *** Move File: src/example.ts
40
- *** Source Before
41
- function alpha() {
57
+ `apply` and `check` read the patch from stdin when no patch path is supplied. `-i`/`--input` names the patch file explicitly. `-pN`/`--strip N` strips leading path components from patch-declared file paths. Unlike GNU patch, `blockpatch` defaults to git-style `-p1` path stripping because patch headers require `a/` and `b/` prefixes.
58
+
59
+ `move` is the plug-and-play agent interface. JSON over stdin is the most reliable form because it avoids shell quoting problems:
60
+
61
+ ```sh
62
+ blockpatch move --json - <<'JSON'
63
+ {
64
+ "src": "src/foo.ts",
65
+ "src_start": "\nexport function movedThing(",
66
+ "src_end": "\n}\n",
67
+ "dst": "src/bar.ts",
68
+ "target_before": "export class Target {\n",
69
+ "target_after": "}\n"
42
70
  }
71
+ JSON
72
+ ```
73
+
74
+ Insertion occurs between `target_before` and `target_after`. Either side may be omitted, but not both. With only `target_before`, the block is inserted after that context. With only `target_after`, the block is inserted before that context.
75
+
76
+ Matching and insertion are byte-exact: the moved bytes are cut at the source and inserted directly at the anchor boundary, with no newline handling. Keep delimiters and anchors on line boundaries or the result will splice mid-line. Include the surrounding newlines you want moved in `src_start` and `src_end`.
77
+
78
+ The move JSON and `--diff` planner interfaces are UTF-8 text interfaces. They are intended for source text, not arbitrary binary payloads or invalid UTF-8 byte sequences.
79
+
80
+ The same shape can be loaded from a file:
81
+
82
+ ```sh
83
+ blockpatch move --json move.blockpatch.json
84
+ ```
43
85
 
44
- *** Source Payload
45
- function movedThing() {
46
- console.log("keep me exact");
86
+ Human-friendly flags are also supported:
87
+
88
+ ```sh
89
+ blockpatch move \
90
+ --src src/foo.ts \
91
+ --src-start $'\nexport function movedThing(' \
92
+ --src-end $'\n}\n' \
93
+ --dst src/bar.ts \
94
+ --target-before $'export class Target {\n' \
95
+ --target-after $'}\n' \
96
+ --expected-payload-sha256 <sha256>
97
+ ```
98
+
99
+ Use `--dry-run` to validate without writing, `--diff` to print a reviewable `.blockpatch` document (`--diff` implies dry-run and never writes), and `--json-output` for machine-readable success or error output. Before `move --diff` returns success, it parses and checks its own emitted patch against the current tree in memory. `--explain` is a dry-run JSON alias for `--dry-run --json-output`; it reuses the existing `moves` byte ranges and payload hash fields.
100
+
101
+ For agents, the canonical planning handshake is:
102
+
103
+ ```sh
104
+ blockpatch plan --json -
105
+ ```
106
+
107
+ That command is a thin alias for `blockpatch move --json - --diff --json-output`: it validates the provided source delimiters and/or target anchors, computes byte ranges, hashes the selected or supplied payload, renders the exact reviewable `.blockpatch`, self-checks that patch through the same in-memory `check` path, lists affected files, and returns the patch in the JSON `patch` field without mutating the working tree. The explicit `move --json - --diff --json-output` form remains supported.
108
+
109
+ `move --json --diff` is a planner for the current tree. For relocation and deletion, the JSON request selects the payload from the current source file; if that source block is already gone, the JSON request often cannot prove the final state because it does not carry the moved bytes. The generated `.blockpatch` is the retry/idempotence artifact because it carries the payload and can report `already_applied` from the final state. Target-only insertion JSON is the exception because it includes `payload` directly.
110
+
111
+ A typical flow is: propose a move as JSON, let `blockpatch` validate and render the exact patch, show that patch to the user, then apply the `.blockpatch` in a second explicit step. Retry the `.blockpatch`, not the source-selected JSON plan.
112
+
113
+ ## Move JSON
114
+
115
+ ```ts
116
+ type MoveBlockArgs = {
117
+ src: string
118
+ src_start?: string
119
+ src_end?: string
120
+ dst?: string
121
+ payload?: string
122
+ target_before?: string
123
+ target_after?: string
124
+ expected_payload_sha256?: string
125
+ dry_run?: boolean
47
126
  }
127
+ ```
48
128
 
49
- *** Source After
50
- function omega() {
129
+ Rules:
130
+
131
+ - In `move --json`, `/dev/null` denotes the absent source or target hunk endpoint for in-file insertion/deletion; `move --diff` renders those as normal same-file one-sided patch sections. Whole-file path creation/removal is expressed directly as `.blockpatch` documents with `/dev/null` file headers.
132
+ - For relocation, `src_start` and `src_end` are inclusive source delimiters; `dst` defaults to `src`.
133
+ - For deletion, set `dst` to `/dev/null`; `src_start` and `src_end` select the removed payload.
134
+ - For insertion, set `src` to `/dev/null`; `dst`, `payload`, and target context are required.
135
+ - `target_before`, `target_after`, or both are required for relocation and insertion.
136
+ - insertion is between the before and after contexts, and their concatenation must match exactly once.
137
+ - if only `target_before` is supplied, insertion is immediately after that context.
138
+ - if only `target_after` is supplied, insertion is immediately before that context.
139
+ - either target side may be empty when both are supplied, but not both may be empty.
140
+ - `payload` is only valid when `src` is `/dev/null`; it must be non-empty for in-file insertion.
141
+ - `expected_payload_sha256` is optional; in flag mode, pass `--expected-payload-sha256`; when supplied, the moved or materialized payload bytes must hash to that value before any write happens.
142
+ - source delimiter match must be unique.
143
+ - target anchor match must be unique.
144
+ - moved bytes are extracted from the source file, not regenerated from args.
145
+ - same-file source and target overlap is a hard failure.
146
+
147
+ Insertion:
148
+
149
+ ```json
150
+ {
151
+ "src": "/dev/null",
152
+ "dst": "src/foo.ts",
153
+ "payload": "inserted bytes\n",
154
+ "target_before": "context before\n"
51
155
  }
156
+ ```
52
157
 
53
- *** Target After
54
- constructor() {
158
+ Deletion:
159
+
160
+ ```json
161
+ {
162
+ "src": "src/foo.ts",
163
+ "src_start": "function removeMe() {\n",
164
+ "src_end": "}\n",
165
+ "dst": "/dev/null"
55
166
  }
167
+ ```
168
+
169
+ ## JSON Output
56
170
 
57
- *** End BlockPatch
171
+ With `--json-output`, successful commands print:
172
+
173
+ ```ts
174
+ type ApplyResult = {
175
+ ok: true
176
+ changed: string[]
177
+ affected: string[]
178
+ written: boolean
179
+ noop: boolean
180
+ status: "applied" | "noop" | "already_applied"
181
+ strip_components?: number
182
+ patch?: string
183
+ moves: Array<{
184
+ id: string
185
+ src: string
186
+ dst: string
187
+ payload_sha256: string
188
+ payload_bytes: number
189
+ source_range: { start: number; end: number } | null
190
+ target_range: { start: number; end: number } | null
191
+ insert_index: number | null
192
+ }>
193
+ }
58
194
  ```
59
195
 
60
- `*** Target Before` is also supported:
196
+ `changed` lists paths whose content changed, or would change during `check`, `--dry-run`, `--diff`, and `--explain`. `written` is true only when files were actually replaced by the command; it is false for `check`, `--dry-run`, `--diff`, `--explain`, `noop`, and `already_applied`. `affected` lists paths examined by the patch. `noop` is true when the patch validated but produced identical bytes. `status` is `applied` for a normal computed move, `noop` for a computed move whose output bytes are identical, and `already_applied` when the command can prove the requested final state is already present. `strip_components` is present for `check` and `apply` JSON success output and reports the effective `-p` path-stripping count; it defaults to `1`. `.blockpatch` apply/check can prove retry idempotence because the patch carries the payload; source-selected `move --json` relocation/deletion requests generally cannot after the source block is gone. `patch` is present when `move --diff --json-output` is used. In `already_applied` relocation results, `source_range` is `null` because the source block is no longer present. For target-only insertions, `source_range` is `null`. For source-only deletions, `target_range` and `insert_index` are `null`. For path creation/removal, `src` or `dst` is the string `/dev/null`. Human text output prints `changed <path>`, `would change <path>`, or `unchanged <path>`.
61
197
 
62
- ```text
63
- *** Target Before
64
- function omega() {
198
+ Errors print:
199
+
200
+ ```ts
201
+ type BlockPatchJsonError = {
202
+ ok: false
203
+ error: {
204
+ code: BlockPatchErrorCode
205
+ message: string
206
+ field?: string
207
+ path?: string
208
+ phase?: string
209
+ anchor?: string
210
+ matches?: number
211
+ ranges?: Array<{ start: number; end: number }>
212
+ line_ranges?: Array<{ start: number; end: number }>
213
+ }
65
214
  }
66
215
  ```
67
216
 
68
- Only one target marker is allowed.
217
+ Ambiguous-match errors include up to the first 10 exact byte ranges for the matched anchors or candidate source ranges, plus matching 1-based inclusive `line_ranges` when the relevant file bytes are available. They do not include source snippets, fuzzy suggestions, or repair guidance.
218
+
219
+ Error codes are the agent-facing branch contract. Removing a code or changing its meaning is semver-major.
220
+
221
+ ```ts
222
+ type BlockPatchErrorCode =
223
+ | "parse_error"
224
+ | "invalid_path"
225
+ | "path_outside_cwd"
226
+ | "symlink_path"
227
+ | "file_not_found"
228
+ | "not_regular_file"
229
+ | "permission_denied"
230
+ | "io_error"
231
+ | "source_not_found"
232
+ | "source_ambiguous"
233
+ | "target_not_found"
234
+ | "target_ambiguous"
235
+ | "destination_exists"
236
+ | "payload_mismatch"
237
+ | "hash_mismatch"
238
+ | "invalid_utf8"
239
+ | "target_overlaps_source"
240
+ | "already_applied"
241
+ | "invalid_move_args"
242
+ | "invalid_json"
243
+ | "missing_move_args"
244
+ | "unknown_command"
245
+ | "unknown_option"
246
+ | "invalid_option"
247
+ | "missing_option_value"
248
+ | "too_many_args"
249
+ | "unexpected_error"
250
+ ```
251
+
252
+ `unexpected_error` is the generic fallback for non-`BlockPatchError` failures; agents should treat it as an internal failure and avoid branching on its message.
253
+
254
+ ## V1 Format
255
+
256
+ ```diff
257
+ diff --blockpatch a/src/example.ts b/src/example.ts
258
+ blockpatch version 1
259
+ blockpatch move id=move-1 payload-sha256=bc8a95d6eb2b44aa564dbae1040ba8ff2273988ea43f0f3b0c47228f9dba6b3d
260
+ --- a/src/example.ts
261
+ +++ b/src/example.ts
262
+
263
+ @@ -1,8 +1,4 @@ blockpatch-source id=move-1 function movedThing
264
+ function alpha() {
265
+ }
266
+ -
267
+ -function movedThing() {
268
+ - console.log("keep me exact");
269
+ -}
270
+ function omega() {
271
+ }
272
+
273
+ @@ -40,3 +36,7 @@ blockpatch-target id=move-1 constructor
274
+ constructor() {
275
+ }
276
+ +
277
+ +function movedThing() {
278
+ + console.log("keep me exact");
279
+ +}
280
+ methodAfter() {
281
+ ```
282
+
283
+ Line numbers in hunk headers are review hints only. Application uses context and exact payload verification, not line numbers.
284
+
285
+ ## One-Sided Hunks And Null Endpoints
286
+
287
+ Same-file sections may contain a source hunk and a target hunk, a source hunk only, or a target hunk only. One-sided hunks are for in-file insertion and deletion when the file exists both before and after the patch.
288
+
289
+ Target-only insertion into an existing file:
290
+
291
+ ```diff
292
+ diff --blockpatch a/src/example.ts b/src/example.ts
293
+ blockpatch version 1
294
+ blockpatch move id=move-1 payload-sha256=<sha256 of the added payload>
295
+ --- a/src/example.ts
296
+ +++ b/src/example.ts
297
+
298
+ @@ -1,2 +1,3 @@ blockpatch-target id=move-1
299
+ context before
300
+ +inserted line
301
+ context after
302
+ ```
303
+
304
+ Source-only deletion from an existing file:
69
305
 
70
- ## Section Bytes
306
+ ```diff
307
+ diff --blockpatch a/src/example.ts b/src/example.ts
308
+ blockpatch version 1
309
+ blockpatch move id=move-1 payload-sha256=<sha256 of the removed payload>
310
+ --- a/src/example.ts
311
+ +++ b/src/example.ts
71
312
 
72
- Patch markers are ASCII lines. Section bytes are the bytes between one marker line and the next marker line, excluding the line ending immediately before the next marker.
313
+ @@ -1,3 +1,2 @@ blockpatch-source id=move-1
314
+ context before
315
+ -doomed line
316
+ context after
317
+ ```
318
+
319
+ `/dev/null` is reserved for path absence. It appears bare, without an `a/` or `b/` prefix, exactly as in git diffs, so it can never collide with a real file named `dev/null` (which would appear as `a/dev/null`). The token is recognized during parsing and is never resolved, opened, or checked against `--cwd`; path validation and `-p` stripping do not apply to it.
320
+
321
+ Use `/dev/null` only when the file path itself is absent before or after the patch. A move from `/dev/null` creates a file from a whole-file target payload:
322
+
323
+ ```diff
324
+ diff --blockpatch /dev/null b/src/new.txt
325
+ blockpatch version 1
326
+ blockpatch move id=move-1 payload-sha256=<sha256 of the file payload>
327
+ --- /dev/null
328
+ +++ b/src/new.txt
329
+
330
+ @@ -0,0 +1,2 @@ blockpatch-target id=move-1
331
+ +first line
332
+ +second line
333
+ ```
334
+
335
+ A move to `/dev/null` removes a file after verifying a whole-file source payload:
336
+
337
+ ```diff
338
+ diff --blockpatch a/src/old.txt /dev/null
339
+ blockpatch version 1
340
+ blockpatch move id=move-1 payload-sha256=<sha256 of the file payload>
341
+ --- a/src/old.txt
342
+ +++ /dev/null
343
+
344
+ @@ -1,2 +0,0 @@ blockpatch-source id=move-1
345
+ -first line
346
+ -second line
347
+ ```
348
+
349
+ The section shapes and their meaning:
350
+
351
+ | Shape | Meaning |
352
+ | --- | --- |
353
+ | `file -> file`, source + target hunks | relocation |
354
+ | `file -> file`, target hunk only | insert payload into an existing file |
355
+ | `file -> file`, source hunk only | delete payload from an existing file |
356
+ | `/dev/null -> file`, target hunk only | create a file |
357
+ | `file -> /dev/null`, source hunk only | remove a file |
358
+ | `/dev/null -> /dev/null` | invalid |
359
+
360
+ The key distinction is that an empty file is a real endpoint while `/dev/null` is the null endpoint. A missing file is an error unless the patch explicitly says `/dev/null`; missing files never silently resolve as empty files.
361
+
362
+ Rules for target-only insertion into an existing file:
363
+
364
+ - the section contains exactly one `blockpatch-target` hunk and no source hunk.
365
+ - the payload comes from the `+` lines and must match `payload-sha256`.
366
+ - the file must exist; missing files are not treated as empty files.
367
+ - `target context before + target context after` must match exactly once; the payload is inserted at the boundary.
368
+ - at least one side of target context is required, so arbitrary zero-byte in-file insertions are not valid.
369
+ - if `target before + payload + target after` is already present exactly once, the result is `already_applied`.
370
+
371
+ Rules for source-only deletion from an existing file:
372
+
373
+ - the section contains exactly one `blockpatch-source` hunk and no target hunk.
374
+ - the file must exist; missing files are not treated as empty files.
375
+ - `source context before + payload + source context after` must match exactly once; the payload bytes are removed.
376
+ - if removal leaves zero bytes, the file remains as an empty file.
377
+ - retries are idempotent: adjacent source anchors, or an absent anchorless payload, report `already_applied`.
378
+
379
+ Rules for `/dev/null -> file` creation:
380
+
381
+ - the section contains exactly one `blockpatch-target` hunk and no source hunk.
382
+ - the target hunk must be whole-file payload: only contiguous `+` payload lines, with no context lines.
383
+ - zero-byte payload is valid and creates an empty file.
384
+ - a missing destination is created, including parent directories; new files are created with mode 0644.
385
+ - if the destination already exists with exactly the requested bytes, the result is `already_applied`; if it exists with different bytes, the patch fails.
386
+
387
+ Rules for `file -> /dev/null` removal:
388
+
389
+ - the section contains exactly one `blockpatch-source` hunk and no target hunk.
390
+ - the source hunk must be whole-file payload: only contiguous `-` payload lines, with no context lines.
391
+ - zero-byte payload is valid and removes an empty file.
392
+ - the existing file bytes must exactly equal the source payload; then the path is removed.
393
+ - if the source path is already missing, the result is `already_applied`.
394
+
395
+ `-R`/`--reverse` swaps source and target hunk roles: reversing a target-only insertion deletes the inserted payload, reversing a source-only deletion re-inserts the payload, reversing a file creation removes the created file, and reversing a file removal recreates it.
396
+
397
+ In JSON output, a target-only insertion has `source_range: null`, and a source-only deletion has `target_range: null` and `insert_index: null`. A null path endpoint is rendered as the string `/dev/null` in `src` or `dst`.
398
+
399
+ Same-file moves use one file section. In that shape, the `--- a/<path>` and `+++ b/<path>` headers must name the same file after normal path cleanup.
400
+
401
+ Cross-file moves use two conventional file sections tied by the same move id and payload hash: one `role=source` section for the source file and one `role=target` section for the target file. Each section's `---` and `+++` headers name the same file. This avoids the misleading patch shape where `--- a/source.ts` and `+++ b/target.ts` look like a transformation from one filename into another.
402
+
403
+ The `a/` and `b/` prefixes are required, and each `diff --blockpatch` line must name the same two raw paths as that section's file headers. Unlike GNU patch, `blockpatch` defaults to git-style `-p1` path stripping: `a/src/file.ts` and `b/src/file.ts` resolve as `src/file.ts`; use `-p0` only if your working tree contains literal `a/` and `b/` directories.
404
+
405
+ ## Grammar
406
+
407
+ Same-file move, insertion, or deletion:
73
408
 
74
- That means:
409
+ ```text
410
+ diff --blockpatch a/<path> b/<path>
411
+ blockpatch version 1
412
+ blockpatch move id=<id> payload-sha256=<sha256>
413
+ --- a/<path>
414
+ +++ b/<path>
415
+
416
+ @@ -<old-start>,<old-count> +<new-start>,<new-count> @@ blockpatch-source id=<id> optional label
417
+ [ <source context before> ]
418
+ -<moved payload>
419
+ [ <source context after> ]
420
+
421
+ @@ -<old-start>,<old-count> +<new-start>,<new-count> @@ blockpatch-target id=<id> optional label
422
+ [ <target context before> ]
423
+ +<same moved payload>
424
+ [ <target context after> ]
425
+ ```
426
+
427
+ For relocation, include both hunks and the target `+` payload must equal the source `-` payload. For source-only deletion, omit the target hunk. For target-only insertion, omit the source hunk and the payload comes from the target `+` lines.
428
+
429
+ Cross-file move:
430
+
431
+ ```text
432
+ diff --blockpatch a/<source-path> b/<source-path>
433
+ blockpatch version 1
434
+ blockpatch move id=<id> role=source payload-sha256=<sha256>
435
+ --- a/<source-path>
436
+ +++ b/<source-path>
437
+
438
+ @@ -<old-start>,<old-count> +<new-start>,<new-count> @@ blockpatch-source id=<id> optional label
439
+ [ <source context before> ]
440
+ -<moved payload>
441
+ [ <source context after> ]
442
+
443
+ diff --blockpatch a/<target-path> b/<target-path>
444
+ blockpatch version 1
445
+ blockpatch move id=<id> role=target payload-sha256=<sha256>
446
+ --- a/<target-path>
447
+ +++ b/<target-path>
448
+
449
+ @@ -<old-start>,<old-count> +<new-start>,<new-count> @@ blockpatch-target id=<id> optional label
450
+ [ <target context before> ]
451
+ +<same moved payload>
452
+ [ <target context after> ]
453
+ ```
454
+
455
+ Source context before and after are exact byte anchors. Either side may be empty, and payload-only source hunks are allowed if the payload is unique. Target hunks for existing files must include context on at least one side.
456
+
457
+ `blockpatch move` metadata keys must be unique. The recognized keys are `id`, `payload-sha256`, and `role`; unknown keys are rejected unless they use the reserved `x-` extension prefix.
75
458
 
76
- - To encode section content ending in a newline, leave a blank line before the next marker.
77
- - To encode section content without a trailing newline, put the next marker on the following line without a blank line.
78
- - Source and target matching is byte-for-byte. No regex, no trimming, no indentation handling.
459
+ The `-<old-start>,<old-count> +<new-start>,<new-count>` ranges are line-number hints for review, not match authority. `blockpatch` validates the line counts against the hunk body, but it locates changes by exact context and payload bytes.
460
+
461
+ For target hunks in existing files, `blockpatch` matches `target context before + target context after` exactly once in the destination file and inserts at `start + target context before.length`.
462
+
463
+ That means insertion occurs between target-before and target-after context:
464
+
465
+ ```diff
466
+ @@ -40,2 +40,3 @@ blockpatch-target id=move-1
467
+ context before
468
+ +moved payload
469
+ context after
470
+ ```
471
+
472
+ Either target side may be empty, but not both, unless the target hunk is a whole-file `/dev/null -> file` creation hunk.
79
473
 
80
474
  ## Semantics
81
475
 
82
476
  For one patch:
83
477
 
84
- 1. Parse the `.blockpatch` file.
85
- 2. Read `*** Move File` as bytes.
86
- 3. Locate exactly one source match for `Source Before + Source Payload + Source After`.
87
- 4. If that exact source is not found, locate a source envelope from `Source Before` to the next `Source After`; if exactly one envelope exists, fail with payload mismatch.
88
- 5. Locate exactly one target anchor from `Target Before` or `Target After`.
89
- 6. Fail if the target anchor overlaps the source payload bytes.
90
- 7. Remove the original source payload bytes.
91
- 8. Insert those exact original bytes before or after the target anchor.
92
- 9. Write atomically by writing a temp file in the same directory, then renaming it over the original.
478
+ 1. Parse one same-file section with source+target, source-only, or target-only hunks; or parse two cross-file relocation sections tied by `role=source` and `role=target`.
479
+ 2. Verify hunk ids match `blockpatch move id=<id>`.
480
+ 3. Extract source payload from contiguous `-` lines when a source hunk exists.
481
+ 4. Extract target payload from contiguous `+` lines when a target hunk exists.
482
+ 5. For paired moves, verify target payload exactly equals source payload.
483
+ 6. Verify `payload-sha256` matches the exact moved or materialized payload bytes.
484
+ 7. For source hunks, locate exactly one source match for `source context before + payload + source context after`.
485
+ 8. For target hunks in existing files, locate exactly one target match for `target context before + target context after`.
486
+ 9. Fail if a same-file target context range overlaps the source payload bytes.
487
+ 10. Apply the hunk transition: remove source payload, insert target payload, or both.
488
+ 11. Apply any path-state transition from `/dev/null`: create the missing destination path or remove the source path.
489
+ 12. Write changed files with temp-file-and-rename replacement.
490
+
491
+ If the requested final state is already present, `blockpatch` reports `already_applied`. For paired moves, that means the source full match is absent and `target context before + payload + target context after` is present exactly once. For target-only insertion, the target payload is already between the target anchors. For source-only deletion, the source anchors are already adjacent or an anchorless payload is absent. This is strict idempotence for retries; it does not search fuzzily or infer moved bytes.
492
+
493
+ With `-R`/`--reverse`, `blockpatch` swaps hunk roles and path endpoints. Reverse application is exact and non-fuzzy. A payload-only source hunk has no source-side anchor for reverse insertion, so reverse requires source context before or after unless it is a whole-file path recreation.
494
+
495
+ Same-file moves are atomic at file-replacement granularity. Cross-file moves preflight both files and stage all changed temp files before renaming any original. If staging fails, originals are left untouched. Once renames begin, the two-file operation is still not transactional; the destination is renamed before the source so an interruption can duplicate the payload, but should not delete it from both files. Atomic here means per-file replacement, not a crash-durable multi-file transaction.
93
496
 
94
497
  ## Failure Rules
95
498
 
96
- `blockpatch` exits non-zero and does not modify the file when:
499
+ `blockpatch` exits non-zero and does not modify files when:
97
500
 
98
501
  - the patch file is malformed
502
+ - both endpoints are `/dev/null`
503
+ - a patch-declared or move-declared source/destination path is absolute, invalid, or escapes `--cwd`
504
+ - a referenced file is missing (and the patch does not say `/dev/null` where creation or removal would make that legal), not a regular file, unreadable, unwritable, or otherwise hits a filesystem error
505
+ - a target-only existing-file insertion has no target context
506
+ - a `/dev/null -> file` creation targets an existing file with different bytes
507
+ - a `file -> /dev/null` removal hunk does not match the whole file
99
508
  - source anchors are missing
100
509
  - source anchors or full source are ambiguous
101
- - the located source payload does not exactly match `Source Payload`
510
+ - the located source payload does not exactly match the source hunk payload
511
+ - target added payload does not exactly match source removed payload
512
+ - `payload-sha256` does not match the moved payload
513
+ - `move --diff` would need to render invalid UTF-8 bytes
102
514
  - the target anchor is missing
103
515
  - the target anchor is ambiguous
104
516
  - the target anchor overlaps the source payload
105
- - file I/O fails before the atomic rename
517
+ - file I/O fails before a write completes
518
+
519
+ ## Byte Rules
520
+
521
+ Hunk body lines use unified-diff prefixes:
522
+
523
+ - space for context
524
+ - `-` for source payload
525
+ - `+` for target payload
526
+
527
+ The byte content after the prefix is matched exactly, including line endings. The standard `` marker is supported for a hunk body line without a trailing newline.
528
+
529
+ `.blockpatch` `apply` and `check` preserve parsed hunk body bytes exactly, including CRLF and no-trailing-newline cases. `move --json` and generated `move --diff` output render anchors and payloads as UTF-8 text, so they are not a binary-safe round trip for invalid UTF-8.
530
+
531
+ Blank lines are separators between the header and hunks. A blank line inside a hunk body is an error; encode an empty context line as a single space.
106
532
 
107
533
  ## Intentionally Out Of Scope
108
534
 
109
- V0 does not implement:
535
+ The current format does not implement:
110
536
 
537
+ - multiple independent moves in one patch document
538
+ - arbitrary generated diffs from before/after file snapshots
111
539
  - fuzzy matching
112
540
  - AST parsing
113
541
  - code formatting
114
- - multi-file patches
115
542
  - copy operations
116
- - generated diffs
117
- - line numbers
118
543
  - regex anchors