blockpatch 1.0.0 → 1.1.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.
Files changed (53) hide show
  1. package/CONTRIBUTING.md +9 -1
  2. package/README.md +100 -468
  3. package/conformance/README.md +29 -0
  4. package/conformance/cases/ambiguous-source/case.json +8 -0
  5. package/conformance/cases/ambiguous-target/case.json +8 -0
  6. package/conformance/cases/create-file/case.json +8 -0
  7. package/conformance/cases/crlf/case.json +10 -0
  8. package/conformance/cases/cross-file-relocation/case.json +12 -0
  9. package/conformance/cases/delete-existing-file/case.json +10 -0
  10. package/conformance/cases/insert-existing-file/case.json +10 -0
  11. package/conformance/cases/no-trailing-newline/case.json +10 -0
  12. package/conformance/cases/remove-file/case.json +9 -0
  13. package/conformance/cases/same-file-relocation/case.json +10 -0
  14. package/conformance/runner.mjs +221 -0
  15. package/dist/cli.js +967 -523
  16. package/docs/behavior.md +207 -0
  17. package/docs/commands.md +84 -0
  18. package/docs/spec.md +356 -0
  19. package/examples/README.md +15 -0
  20. package/examples/create-file/README.md +9 -0
  21. package/examples/create-file/expected/file.txt +1 -0
  22. package/examples/create-file/patch.blockpatch +8 -0
  23. package/examples/create-file/work/.gitkeep +1 -0
  24. package/examples/cross-file-relocation/README.md +10 -0
  25. package/examples/cross-file-relocation/expected/source.txt +2 -0
  26. package/examples/cross-file-relocation/expected/target.txt +6 -0
  27. package/examples/cross-file-relocation/patch.blockpatch +26 -0
  28. package/examples/cross-file-relocation/work/source.txt +5 -0
  29. package/examples/cross-file-relocation/work/target.txt +3 -0
  30. package/examples/delete-existing-file/README.md +9 -0
  31. package/examples/delete-existing-file/expected/file.txt +2 -0
  32. package/examples/delete-existing-file/patch.blockpatch +10 -0
  33. package/examples/delete-existing-file/work/file.txt +3 -0
  34. package/examples/failure-ambiguous-target/README.md +9 -0
  35. package/examples/failure-ambiguous-target/patch.blockpatch +14 -0
  36. package/examples/failure-ambiguous-target/work/file.txt +5 -0
  37. package/examples/insert-existing-file/README.md +9 -0
  38. package/examples/insert-existing-file/expected/file.txt +3 -0
  39. package/examples/insert-existing-file/patch.blockpatch +10 -0
  40. package/examples/insert-existing-file/work/file.txt +2 -0
  41. package/examples/remove-file/README.md +9 -0
  42. package/examples/remove-file/expected/.gitkeep +1 -0
  43. package/examples/remove-file/patch.blockpatch +8 -0
  44. package/examples/remove-file/work/file.txt +1 -0
  45. package/examples/reverse/README.md +9 -0
  46. package/examples/reverse/expected/file.txt +4 -0
  47. package/examples/reverse/patch.blockpatch +14 -0
  48. package/examples/reverse/work/file.txt +4 -0
  49. package/examples/same-file-relocation/README.md +9 -0
  50. package/examples/same-file-relocation/expected/file.txt +4 -0
  51. package/examples/same-file-relocation/patch.blockpatch +14 -0
  52. package/examples/same-file-relocation/work/file.txt +4 -0
  53. package/package.json +13 -5
package/README.md CHANGED
@@ -1,543 +1,175 @@
1
1
  # blockpatch
2
2
 
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.
3
+ [![CI](https://github.com/cheese-melted/blockpatch/actions/workflows/ci.yml/badge.svg)](https://github.com/cheese-melted/blockpatch/actions/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/blockpatch.svg)](https://www.npmjs.com/package/blockpatch)
4
5
 
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
+ Cut/paste for agents: hash-verified block moves that read like unified diffs.
6
7
 
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.
8
+ `blockpatch` emits reviewable unified-diff-shaped patches for exact, hash-verified payload moves. 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. Generated patches are intended to be compatible with `patch --fuzz=0` where possible, while `blockpatch apply` accepts a strict subset and deliberately rejects fuzzy, heuristic, or ambiguous application.
10
9
 
11
10
  ## Install
12
11
 
13
12
  ```sh
14
- npx blockpatch check patch.blockpatch
15
- npx blockpatch apply patch.blockpatch
16
- npx blockpatch plan --json -
17
- npx blockpatch move --json -
18
- bunx blockpatch apply patch.blockpatch --dry-run
19
13
  npm install -g blockpatch
14
+ blockpatch --help
20
15
  ```
21
16
 
22
- For local development:
17
+ Or run without installing:
23
18
 
24
19
  ```sh
25
- bun install
26
- bun test
27
- bun run build
28
- npm run publish:dry
29
- node dist/cli.js version
20
+ npx blockpatch --help
30
21
  ```
31
22
 
32
- ## Commands
33
-
34
- ```sh
35
- blockpatch check patch.blockpatch
36
- blockpatch apply patch.blockpatch
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
51
- ```
23
+ ## Usage
52
24
 
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`.
25
+ `blockpatch` is a deterministic move planner/apply layer for coding agents:
54
26
 
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.
27
+ 1. Send a JSON move request to `blockpatch move --json - --diff`.
28
+ 2. Show the emitted `.blockpatch` to the user for review.
29
+ 3. Apply the reviewed patch with `blockpatch apply`.
30
+ 4. Retry the `.blockpatch`, not the original JSON request.
56
31
 
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"
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
- ```
85
-
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
32
+ Example: move `movedThing` from `src/foo.ts` into `src/bar.ts`, starting from this tree:
114
33
 
115
34
  ```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
35
+ // before: src/foo.ts
36
+ export function keepThing() {
37
+ return 7;
126
38
  }
127
- ```
128
39
 
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"
40
+ export function movedThing() {
41
+ return 42;
155
42
  }
43
+
44
+ // before: src/bar.ts
45
+ export const target = "here";
156
46
  ```
157
47
 
158
- Deletion:
48
+ The JSON request selects the source bytes and target anchors. Ask `blockpatch` to plan the byte-exact move and emit the reviewable patch:
159
49
 
160
- ```json
50
+ ```sh
51
+ blockpatch move --json - --diff <<'JSON' > patch.blockpatch
161
52
  {
162
53
  "src": "src/foo.ts",
163
- "src_start": "function removeMe() {\n",
54
+ "src_start": "\nexport function movedThing() {\n",
164
55
  "src_end": "}\n",
165
- "dst": "/dev/null"
166
- }
167
- ```
168
-
169
- ## JSON Output
170
-
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
- }
194
- ```
195
-
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>`.
197
-
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
- }
56
+ "dst": "src/bar.ts",
57
+ "target_before": "export const target = \"here\";\n"
214
58
  }
59
+ JSON
215
60
  ```
216
61
 
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
62
+ `patch.blockpatch` now carries the move as a unified-diff-shaped document:
255
63
 
256
64
  ```diff
257
- diff --blockpatch a/src/example.ts b/src/example.ts
65
+ diff --blockpatch a/src/foo.ts b/src/foo.ts
258
66
  blockpatch version 1
259
- blockpatch move id=move-1 payload-sha256=bc8a95d6eb2b44aa564dbae1040ba8ff2273988ea43f0f3b0c47228f9dba6b3d
260
- --- a/src/example.ts
261
- +++ b/src/example.ts
67
+ blockpatch move id=move-1 role=source payload-sha256=bb03c42613e9289c043d2fced7ce2d8c87410cdb15fa48341ce79fa409d45303
68
+ --- a/src/foo.ts
69
+ +++ b/src/foo.ts
262
70
 
263
- @@ -1,8 +1,4 @@ blockpatch-source id=move-1 function movedThing
264
- function alpha() {
71
+ @@ -3,5 +3,1 @@ blockpatch-source id=move-1
265
72
  }
266
73
  -
267
- -function movedThing() {
268
- - console.log("keep me exact");
74
+ -export function movedThing() {
75
+ - return 42;
269
76
  -}
270
- function omega() {
271
- }
272
77
 
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
78
+ diff --blockpatch a/src/bar.ts b/src/bar.ts
293
79
  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:
80
+ blockpatch move id=move-1 role=target payload-sha256=bb03c42613e9289c043d2fced7ce2d8c87410cdb15fa48341ce79fa409d45303
81
+ --- a/src/bar.ts
82
+ +++ b/src/bar.ts
305
83
 
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
312
-
313
- @@ -1,3 +1,2 @@ blockpatch-source id=move-1
314
- context before
315
- -doomed line
316
- context after
84
+ @@ -1,1 +1,5 @@ blockpatch-target id=move-1
85
+ export const target = "here";
86
+ +
87
+ +export function movedThing() {
88
+ + return 42;
89
+ +}
317
90
  ```
318
91
 
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.
92
+ `move --diff` validates the source delimiters and target anchors, hashes the selected payload, self-checks the rendered patch against the current tree in memory, and prints it without writing to the tree.
320
93
 
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
94
+ Review the patch, then apply it:
329
95
 
330
- @@ -0,0 +1,2 @@ blockpatch-target id=move-1
331
- +first line
332
- +second line
96
+ ```sh
97
+ blockpatch apply patch.blockpatch
333
98
  ```
334
99
 
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
100
  ```
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:
408
-
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> ]
101
+ changed src/foo.ts
102
+ changed src/bar.ts
425
103
  ```
426
104
 
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:
105
+ `apply` checks the payload hash, requires the source block and target anchors to each match exactly once in the current tree, and replaces each changed file atomically. The tree now matches the requested final state:
430
106
 
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>
107
+ ```ts
108
+ // after: src/foo.ts
109
+ export function keepThing() {
110
+ return 7;
111
+ }
437
112
 
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> ]
113
+ // after: src/bar.ts
114
+ export const target = "here";
442
115
 
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> ]
116
+ export function movedThing() {
117
+ return 42;
118
+ }
453
119
  ```
454
120
 
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.
121
+ Retrying the same `patch.blockpatch` against this tree reports `already_applied`: the patch carries the payload and hash, so a retry validates the final state instead of reselecting bytes from a changed tree.
458
122
 
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.
123
+ The same handshake covers whole-file creation and removal via `mode: "create_file"` and `mode: "remove_file"`; those patches use strict `/dev/null` file headers.
460
124
 
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`.
125
+ ## Common Commands
462
126
 
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
127
+ ```sh
128
+ blockpatch check patch.blockpatch
129
+ blockpatch apply patch.blockpatch --dry-run
130
+ blockpatch apply patch.blockpatch
131
+ blockpatch plan --json -
132
+ blockpatch move --json -
470
133
  ```
471
134
 
472
- Either target side may be empty, but not both, unless the target hunk is a whole-file `/dev/null -> file` creation hunk.
473
-
474
- ## Semantics
475
-
476
- For one patch:
135
+ `check` parses a patch and verifies it against the target tree without writing. `apply --dry-run` validates through the apply path without writing. `apply` writes the verified result.
477
136
 
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.
137
+ `move --json -` applies a move request directly; with `--diff` it only prints the rendered patch. `plan --json -` runs the same planner but returns a JSON envelope with validation metadata and the patch in its `patch` field — the agent-facing form shown in [Commands](docs/commands.md). JSON over stdin is the most reliable form because it avoids shell quoting problems.
490
138
 
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.
139
+ When using `--cwd`, operation paths inside patches and move JSON are relative to `--cwd`; input patch and move JSON filenames are normal CLI paths, relative to your shell working directory unless absolute.
492
140
 
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.
141
+ ## Docs
494
142
 
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.
143
+ - [Commands](docs/commands.md): supported CLI forms and flags.
144
+ - [Patch spec](docs/spec.md): canonical `.blockpatch` artifact format, hunk syntax, `/dev/null`, byte rules, move JSON requests, JSON output, and error codes.
145
+ - [Behavior](docs/behavior.md): exact matching, idempotence, path containment, failure rules, and write behavior.
146
+ - [Conformance](conformance/): runnable `.blockpatch` cases for checking another implementation.
496
147
 
497
- ## Failure Rules
148
+ ## Examples
498
149
 
499
- `blockpatch` exits non-zero and does not modify files when:
150
+ - [same-file relocation](examples/same-file-relocation/)
151
+ - [cross-file relocation](examples/cross-file-relocation/)
152
+ - [insert existing file](examples/insert-existing-file/)
153
+ - [delete existing file](examples/delete-existing-file/)
154
+ - [create file](examples/create-file/)
155
+ - [remove file](examples/remove-file/)
156
+ - [reverse](examples/reverse/)
157
+ - [failure: ambiguous target](examples/failure-ambiguous-target/)
500
158
 
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
508
- - source anchors are missing
509
- - source anchors or full source are ambiguous
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
514
- - the target anchor is missing
515
- - the target anchor is ambiguous
516
- - the target anchor overlaps the source payload
517
- - file I/O fails before a write completes
159
+ Each example has a `patch.blockpatch`, a runnable `work/` directory, and `expected/` output for successful cases.
518
160
 
519
- ## Byte Rules
161
+ ## Conformance
520
162
 
521
- Hunk body lines use unified-diff prefixes:
163
+ Run the published conformance cases against a blockpatch-compatible CLI:
522
164
 
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.
165
+ ```sh
166
+ npx -p blockpatch blockpatch-conformance ./my-implementation
167
+ ```
530
168
 
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.
169
+ The runner checks apply/check behavior, retry idempotence, reverse application, byte preservation, and expected structured failures.
532
170
 
533
- ## Intentionally Out Of Scope
171
+ ## Scope
534
172
 
535
- The current format does not implement:
173
+ `blockpatch` intentionally does not implement fuzzy matching, AST parsing, code formatting, copy operations, regex anchors, multiple independent moves in one patch document, or arbitrary generated diffs from before/after snapshots.
536
174
 
537
- - multiple independent moves in one patch document
538
- - arbitrary generated diffs from before/after file snapshots
539
- - fuzzy matching
540
- - AST parsing
541
- - code formatting
542
- - copy operations
543
- - regex anchors
175
+ `apply` preserves patch body bytes exactly, but `plan`/`move --json` are text-oriented and intended for source files, not arbitrary binary payloads: JSON requests and rendered patches are UTF-8.