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
@@ -0,0 +1,207 @@
1
+ # blockpatch Behavior
2
+
3
+ `blockpatch` is intentionally strict. It prefers refusing a patch over guessing, regenerating bytes, or applying a fuzzy edit.
4
+
5
+ For CLI forms, see [Commands](commands.md). For the `.blockpatch` artifact format and JSON contracts, see [Patch spec](spec.md).
6
+
7
+ ## Core Invariant
8
+
9
+ 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.
10
+
11
+ The payload hash is checked before any write happens. For paired moves, the source `-` payload and target `+` payload must be byte-identical.
12
+
13
+ ## Planning And Retry Flow
14
+
15
+ `plan --json -` is the canonical planning handshake. It 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.
16
+
17
+ `move --json --diff` is a planner for the current tree. For relocation, in-file deletion, and whole-file removal, the JSON request selects the payload from the current source file; if that source block or file 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 and `create_file` JSON are the exceptions because they include `payload` directly.
18
+
19
+ 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.
20
+
21
+ ## Path Containment
22
+
23
+ Patch-declared source and destination paths, and move JSON `src`/`dst` paths, must be relative, non-empty, and resolve inside `--cwd`.
24
+
25
+ Rejected operation paths include:
26
+
27
+ - absolute paths
28
+ - `.` or `..` path segments
29
+ - backslashes; operation paths use POSIX-style `/` separators in `.blockpatch` artifacts and move JSON
30
+ - non-printing control characters
31
+ - paths containing symlink components
32
+ - existing regular files whose real path escapes `--cwd`
33
+
34
+ Patch files and move JSON files may be read from any path; use `--cwd` to choose the directory the operation is allowed to modify.
35
+
36
+ Relative patch file paths and relative move JSON file paths resolve from the shell working directory, not from `--cwd`. Operation paths declared inside a patch or move JSON request resolve inside `--cwd`.
37
+
38
+ `/dev/null` is reserved for path absence and is never resolved, opened, or checked against `--cwd`. 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.
39
+
40
+ ## Exact Matching
41
+
42
+ Source and target anchors are byte-exact. There is no fuzzy matching, regex matching, AST parsing, or formatting.
43
+
44
+ For source hunks, `blockpatch` locates exactly one source match for:
45
+
46
+ ```text
47
+ source context before + payload + source context after
48
+ ```
49
+
50
+ For target hunks in existing files, `blockpatch` locates exactly one target match for:
51
+
52
+ ```text
53
+ target context before + target context after
54
+ ```
55
+
56
+ For target hunks in existing files, insertion occurs between target-before and target-after context:
57
+
58
+ ```diff
59
+ @@ -40,2 +40,3 @@ blockpatch-target id=move-1
60
+ context before
61
+ +moved payload
62
+ context after
63
+ ```
64
+
65
+ The moved bytes are extracted from the source file, not regenerated from arguments. `apply` and `check` preserve parsed hunk body bytes exactly, including CRLF and no-trailing-newline cases.
66
+
67
+ ## Move JSON Behavior
68
+
69
+ 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`.
70
+
71
+ For source selection, each `src_start` match pairs with the first `src_end` occurrence after it. The resulting source delimiter match must be unique.
72
+
73
+ For target placement:
74
+
75
+ - insertion is between the before and after contexts, and their concatenation must match exactly once.
76
+ - if only `target_before` is supplied, insertion is immediately after that context.
77
+ - if only `target_after` is supplied, insertion is immediately before that context.
78
+ - either target side may be empty when both are supplied, but not both may be empty.
79
+
80
+ When `expected_payload_sha256` is supplied, the moved or materialized payload bytes must hash to that value before any write happens.
81
+
82
+ Same-file source and target overlap is a hard failure.
83
+
84
+ `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.
85
+
86
+ ## Patch Evaluation
87
+
88
+ For one patch, `blockpatch`:
89
+
90
+ 1. Parses one same-file section with source+target, source-only, or target-only hunks; or parses two cross-file relocation sections tied by `role=source` and `role=target`.
91
+ 2. Verifies hunk ids match `blockpatch move id=<id>`.
92
+ 3. Extracts source payload from contiguous `-` lines when a source hunk exists.
93
+ 4. Extracts target payload from contiguous `+` lines when a target hunk exists.
94
+ 5. For paired moves, verifies target payload exactly equals source payload.
95
+ 6. Verifies `payload-sha256` matches the exact moved or materialized payload bytes.
96
+ 7. For source hunks, locates exactly one source match for `source context before + payload + source context after`.
97
+ 8. For target hunks in existing files, locates exactly one target match for `target context before + target context after`.
98
+ 9. Fails if a same-file target context range overlaps the source payload bytes.
99
+ 10. Applies the hunk transition: remove source payload, insert target payload, or both.
100
+ 11. Applies any path-state transition from `/dev/null`: create the missing destination path or remove the source path.
101
+ 12. Writes changed files with temp-file-and-rename replacement.
102
+
103
+ Line-number ranges are review hints only. `blockpatch` validates hunk body line counts, but it locates changes by exact context and payload bytes.
104
+
105
+ ## One-Sided And Null-Endpoint Behavior
106
+
107
+ Rules for target-only insertion into an existing file:
108
+
109
+ - the section contains exactly one `blockpatch-target` hunk and no source hunk.
110
+ - the payload comes from the `+` lines and must match `payload-sha256`.
111
+ - the file must exist; missing files are not treated as empty files.
112
+ - `target context before + target context after` must match exactly once; the payload is inserted at the boundary.
113
+ - at least one side of target context is required, so arbitrary zero-byte in-file insertions are not valid.
114
+ - if `target before + payload + target after` is already present exactly once, the result is `already_applied`.
115
+
116
+ Rules for source-only deletion from an existing file:
117
+
118
+ - the section contains exactly one `blockpatch-source` hunk and no target hunk.
119
+ - the file must exist; missing files are not treated as empty files.
120
+ - `source context before + payload + source context after` must match exactly once; the payload bytes are removed.
121
+ - if removal leaves zero bytes, the file remains as an empty file.
122
+ - retries are idempotent when both source anchors are adjacent, when the remaining after anchor is at the start of the file, when the remaining before anchor is at the end of the file, or when an anchorless payload is absent.
123
+
124
+ Rules for `/dev/null -> file` creation:
125
+
126
+ - the section contains exactly one `blockpatch-target` hunk and no source hunk.
127
+ - the target hunk must be whole-file payload: only contiguous `+` payload lines, with no context lines.
128
+ - zero-byte payload is valid and creates an empty file.
129
+ - a missing destination is created, including parent directories; new files are created with mode 0644.
130
+ - if the destination already exists with exactly the requested bytes, the result is `already_applied`; if it exists with different bytes, the patch fails.
131
+
132
+ Rules for `file -> /dev/null` removal:
133
+
134
+ - the section contains exactly one `blockpatch-source` hunk and no target hunk.
135
+ - the source hunk must be whole-file payload: only contiguous `-` payload lines, with no context lines.
136
+ - zero-byte payload is valid and removes an empty file.
137
+ - the existing file bytes must exactly equal the source payload; then the path is removed.
138
+ - if the source path is already missing, the result is `already_applied`.
139
+
140
+ ## Idempotence
141
+
142
+ If the requested final state is already present, `blockpatch` reports `already_applied`.
143
+
144
+ For paired moves, that means the source full match is absent and:
145
+
146
+ ```text
147
+ target context before + payload + target context after
148
+ ```
149
+
150
+ is present exactly once.
151
+
152
+ For one-sided and null-endpoint shapes, the `already_applied` conditions are listed per shape in [One-Sided And Null-Endpoint Behavior](#one-sided-and-null-endpoint-behavior).
153
+
154
+ This is strict retry idempotence. It does not search fuzzily or infer moved bytes.
155
+
156
+ ## Reverse Application
157
+
158
+ `-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.
159
+
160
+ 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.
161
+
162
+ ## Write Behavior
163
+
164
+ 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.
165
+
166
+ Before renaming or removing a path, `blockpatch` re-checks that every existing file still matches the bytes and stat captured during planning. For path creation, it re-checks that the destination is still absent or already contains the exact expected bytes. If the live path no longer matches the verified input state, the operation fails with `concurrent_modification`.
167
+
168
+ Once renames begin, a two-file operation is 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.
169
+
170
+ If a retry sees that a cross-file source payload is still present while the destination already contains the exact final target state, `blockpatch` fails with `partial_applied_duplicate` instead of guessing. The error includes the source range, target range, payload hash, and `suggested_action: "review_then_remove_source"`.
171
+
172
+ ## Failure Rules
173
+
174
+ `blockpatch` exits non-zero and does not modify files when:
175
+
176
+ - the patch file is malformed
177
+ - both endpoints are `/dev/null`
178
+ - a patch-declared or move-declared source/destination path is absolute, invalid, or escapes `--cwd`
179
+ - a referenced file is missing, unless the patch explicitly says `/dev/null` where creation or removal would make that legal
180
+ - a referenced path is not a regular file, unreadable, unwritable, or otherwise hits a filesystem error
181
+ - a target-only existing-file insertion has no target context
182
+ - a `/dev/null -> file` creation targets an existing file with different bytes
183
+ - a `file -> /dev/null` removal hunk does not match the whole file
184
+ - source anchors are missing
185
+ - source anchors or full source are ambiguous
186
+ - a cross-file retry finds the source payload still present and the destination already patched
187
+ - the located source payload does not exactly match the source hunk payload
188
+ - target added payload does not exactly match source removed payload
189
+ - `payload-sha256` does not match the moved payload
190
+ - `move --diff` would need to render invalid UTF-8 bytes
191
+ - the target anchor is missing
192
+ - the target anchor is ambiguous
193
+ - the target anchor overlaps the source payload
194
+ - a file changes after `blockpatch` verifies it but before the planned write/remove
195
+ - file I/O fails before a write completes
196
+
197
+ ## Intentionally Out Of Scope
198
+
199
+ `blockpatch` does not implement:
200
+
201
+ - multiple independent moves in one patch document
202
+ - arbitrary generated diffs from before/after file snapshots
203
+ - fuzzy matching
204
+ - AST parsing
205
+ - code formatting
206
+ - copy operations
207
+ - regex anchors
@@ -0,0 +1,84 @@
1
+ # blockpatch Commands
2
+
3
+ This document lists the supported CLI forms. For `.blockpatch`, move JSON, and JSON output contracts, see [Patch spec](spec.md). For planning, matching, idempotence, and write behavior, see [Behavior](behavior.md).
4
+
5
+ ## Common Commands
6
+
7
+ ```sh
8
+ blockpatch check patch.blockpatch
9
+ blockpatch apply patch.blockpatch --dry-run
10
+ blockpatch apply patch.blockpatch
11
+ blockpatch plan --json -
12
+ blockpatch move --json -
13
+ ```
14
+
15
+ `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. `plan` returns a reviewable `.blockpatch` for a move JSON request without writing; `move` applies the request directly.
16
+
17
+ ## Patch Input
18
+
19
+ ```sh
20
+ blockpatch apply - < patch.blockpatch
21
+ blockpatch apply < patch.blockpatch
22
+ blockpatch apply -i patch.blockpatch
23
+ ```
24
+
25
+ `apply` and `check` read the patch from stdin when no patch path is supplied. `-i`/`--input` names the patch file explicitly.
26
+
27
+ ## Paths And Stripping
28
+
29
+ ```sh
30
+ blockpatch apply -d repo-root -p1 patch.blockpatch
31
+ blockpatch check --cwd repo-root --strip 1 < patch.blockpatch
32
+ ```
33
+
34
+ `-d`/`--directory` is an alias for `--cwd`. `-pN`/`--strip N` strips leading path components from patch-declared file paths. Unlike GNU `patch`, the default is `-p1`, matching the git-style `a/`/`b/` prefixes; use `-p0` only if your working tree contains literal `a/` and `b/` directories.
35
+
36
+ ## Reverse
37
+
38
+ ```sh
39
+ blockpatch apply patch.blockpatch --reverse
40
+ blockpatch check patch.blockpatch -R
41
+ ```
42
+
43
+ `-R`/`--reverse` works with both `check` and `apply`.
44
+
45
+ ## Move JSON Input
46
+
47
+ ```sh
48
+ blockpatch plan --json -
49
+ blockpatch move --json -
50
+ blockpatch move --json - --diff --json-output
51
+ blockpatch move --json move.json
52
+ ```
53
+
54
+ `--json -` reads the move request from stdin; `--json move.json` reads the same shape from a file. `plan` is shorthand for `move --json - --diff --json-output`: it validates the request and returns the rendered `.blockpatch` in the JSON `patch` field without writing. On `move`, `--dry-run` validates without writing, and `--diff` never writes: it prints the rendered patch, or returns it in the JSON `patch` field with `--json-output`.
55
+
56
+ ## Plan Envelope
57
+
58
+ ```sh
59
+ blockpatch plan --json - < move.json > plan.json
60
+ jq -r .patch plan.json > patch.blockpatch
61
+ blockpatch apply patch.blockpatch
62
+ ```
63
+
64
+ `plan` writes a single-line JSON envelope to stdout: `ok`, `changed`, `affected`, `status`, per-move byte ranges, and the rendered patch in the `patch` field, per the contract in [Patch spec](spec.md#json-output). Extract the `patch` field to save the reviewable artifact; `apply` accepts only the `.blockpatch`, never the envelope.
65
+
66
+ ## Move Flags
67
+
68
+ ```sh
69
+ blockpatch move --src src/foo.ts --src-start $'\nfunction movedThing() {' --src-end $'\n}\n' --target-before $'class Target {\n'
70
+ blockpatch move --src /dev/null --dst src/foo.ts --payload $'inserted bytes\n' --target-before $'context before\n'
71
+ blockpatch move --src src/foo.ts --src-start $'\nfunction removeMe() {' --src-end $'\n}\n' --dst /dev/null
72
+ ```
73
+
74
+ Each flag sets the move JSON field of the same name and cannot be combined with `--json`. `mode` has no flag form, so whole-file `create_file`/`remove_file` requests are JSON-only. JSON avoids shell quoting problems, so it is usually the more reliable form for agents.
75
+
76
+ ## Output
77
+
78
+ ```sh
79
+ blockpatch apply patch.blockpatch --json-output
80
+ blockpatch apply patch.blockpatch --explain
81
+ blockpatch version --json-output
82
+ ```
83
+
84
+ Without `--json-output`, successful commands print `changed <path>`, `would change <path>`, or `unchanged <path>`. `--json-output` switches success and error reporting to the JSON contract in [Patch spec](spec.md#json-output); `plan` always uses it. `--explain` implies `--json-output` plus, for `apply`, `move`, and `plan`, `--dry-run`, so it always reports without writing. `version` prints the CLI version.
package/docs/spec.md ADDED
@@ -0,0 +1,356 @@
1
+ # blockpatch Spec
2
+
3
+ This document defines the `.blockpatch` artifact format, move JSON request contract, and JSON output contract. The public API is the CLI and its JSON output; there is no library API.
4
+
5
+ For command forms, see [Commands](commands.md). For planning, matching, idempotence, and write behavior, see [Behavior](behavior.md).
6
+
7
+ ## Format
8
+
9
+ ```diff
10
+ diff --blockpatch a/src/example.ts b/src/example.ts
11
+ blockpatch version 1
12
+ blockpatch move id=move-1 payload-sha256=bc8a95d6eb2b44aa564dbae1040ba8ff2273988ea43f0f3b0c47228f9dba6b3d
13
+ --- a/src/example.ts
14
+ +++ b/src/example.ts
15
+
16
+ @@ -1,8 +1,4 @@ blockpatch-source id=move-1 function movedThing
17
+ function alpha() {
18
+ }
19
+ -
20
+ -function movedThing() {
21
+ - console.log("keep me exact");
22
+ -}
23
+ function omega() {
24
+ }
25
+
26
+ @@ -40,3 +36,7 @@ blockpatch-target id=move-1 constructor
27
+ constructor() {
28
+ }
29
+ +
30
+ +function movedThing() {
31
+ + console.log("keep me exact");
32
+ +}
33
+ methodAfter() {
34
+ ```
35
+
36
+ Same-file moves, insertions, and deletions use one file section. In that shape, the `--- a/<path>` and `+++ b/<path>` headers must name the same file after normal path cleanup.
37
+
38
+ ```text
39
+ diff --blockpatch a/<path> b/<path>
40
+ blockpatch version 1
41
+ blockpatch move id=<id> payload-sha256=<sha256>
42
+ --- a/<path>
43
+ +++ b/<path>
44
+
45
+ @@ -<old-start>,<old-count> +<new-start>,<new-count> @@ blockpatch-source id=<id> optional label
46
+ [ <source context before> ]
47
+ -<moved payload>
48
+ [ <source context after> ]
49
+
50
+ @@ -<old-start>,<old-count> +<new-start>,<new-count> @@ blockpatch-target id=<id> optional label
51
+ [ <target context before> ]
52
+ +<same moved payload>
53
+ [ <target context after> ]
54
+ ```
55
+
56
+ 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.
57
+
58
+ 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.
59
+
60
+ ```text
61
+ diff --blockpatch a/<source-path> b/<source-path>
62
+ blockpatch version 1
63
+ blockpatch move id=<id> role=source payload-sha256=<sha256>
64
+ --- a/<source-path>
65
+ +++ b/<source-path>
66
+
67
+ @@ -<old-start>,<old-count> +<new-start>,<new-count> @@ blockpatch-source id=<id> optional label
68
+ [ <source context before> ]
69
+ -<moved payload>
70
+ [ <source context after> ]
71
+
72
+ diff --blockpatch a/<target-path> b/<target-path>
73
+ blockpatch version 1
74
+ blockpatch move id=<id> role=target payload-sha256=<sha256>
75
+ --- a/<target-path>
76
+ +++ b/<target-path>
77
+
78
+ @@ -<old-start>,<old-count> +<new-start>,<new-count> @@ blockpatch-target id=<id> optional label
79
+ [ <target context before> ]
80
+ +<same moved payload>
81
+ [ <target context after> ]
82
+ ```
83
+
84
+ Format constraints:
85
+
86
+ - Every file section must declare `blockpatch version 1` on the line after `diff --blockpatch`.
87
+ - 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. The prefixes are consumed by the default `-p1` path stripping ([Commands](commands.md#paths-and-stripping)).
88
+ - Patch-declared paths use POSIX-style `/` separators on every platform. Backslashes, `.`/`..` path segments, and non-printing control characters are rejected instead of normalized or escaped.
89
+ - `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.
90
+ - Source context before and after may each be empty.
91
+ - Target hunks for existing files must include context on at least one side; either side may be empty, but not both.
92
+ - Whole-file creation and removal hunks (a `/dev/null` endpoint) contain only contiguous payload lines, with no context lines.
93
+ - The `-<old-start>,<old-count> +<new-start>,<new-count>` ranges must match the hunk body line counts, but the range values are line-number hints for review.
94
+
95
+ ## One-Sided Hunks And Null Endpoints
96
+
97
+ One-sided hunks are for in-file insertion and deletion when the file exists both before and after the patch.
98
+
99
+ Target-only insertion into an existing file:
100
+
101
+ ```diff
102
+ diff --blockpatch a/src/example.ts b/src/example.ts
103
+ blockpatch version 1
104
+ blockpatch move id=move-1 payload-sha256=<sha256 of the added payload>
105
+ --- a/src/example.ts
106
+ +++ b/src/example.ts
107
+
108
+ @@ -1,2 +1,3 @@ blockpatch-target id=move-1
109
+ context before
110
+ +inserted line
111
+ context after
112
+ ```
113
+
114
+ Source-only deletion from an existing file:
115
+
116
+ ```diff
117
+ diff --blockpatch a/src/example.ts b/src/example.ts
118
+ blockpatch version 1
119
+ blockpatch move id=move-1 payload-sha256=<sha256 of the removed payload>
120
+ --- a/src/example.ts
121
+ +++ b/src/example.ts
122
+
123
+ @@ -1,3 +1,2 @@ blockpatch-source id=move-1
124
+ context before
125
+ -doomed line
126
+ context after
127
+ ```
128
+
129
+ `/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`).
130
+
131
+ Use `/dev/null` only when the file path itself is absent before or after the patch. A `/dev/null -> file` section is the whole-file creation shape:
132
+
133
+ ```diff
134
+ diff --blockpatch /dev/null b/src/new.txt
135
+ blockpatch version 1
136
+ blockpatch move id=move-1 payload-sha256=<sha256 of the file payload>
137
+ --- /dev/null
138
+ +++ b/src/new.txt
139
+
140
+ @@ -0,0 +1,2 @@ blockpatch-target id=move-1
141
+ +first line
142
+ +second line
143
+ ```
144
+
145
+ A `file -> /dev/null` section is the whole-file removal shape:
146
+
147
+ ```diff
148
+ diff --blockpatch a/src/old.txt /dev/null
149
+ blockpatch version 1
150
+ blockpatch move id=move-1 payload-sha256=<sha256 of the file payload>
151
+ --- a/src/old.txt
152
+ +++ /dev/null
153
+
154
+ @@ -1,2 +0,0 @@ blockpatch-source id=move-1
155
+ -first line
156
+ -second line
157
+ ```
158
+
159
+ The section shapes and their meaning:
160
+
161
+ | Shape | Meaning |
162
+ | --- | --- |
163
+ | `file -> file`, source + target hunks | relocation |
164
+ | `file -> file`, target hunk only | insert payload into an existing file |
165
+ | `file -> file`, source hunk only | delete payload from an existing file |
166
+ | `/dev/null -> file`, target hunk only | create a file |
167
+ | `file -> /dev/null`, source hunk only | remove a file |
168
+ | `/dev/null -> /dev/null` | invalid |
169
+
170
+ Matching rules, idempotence, reverse application, and write behavior are documented in [Behavior](behavior.md).
171
+
172
+ ## Byte Rules
173
+
174
+ Hunk body lines use unified-diff prefixes:
175
+
176
+ - space for context
177
+ - `-` for source payload
178
+ - `+` for target payload
179
+
180
+ 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.
181
+
182
+ 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.
183
+
184
+ ## Move JSON
185
+
186
+ Move JSON is the request contract accepted by `blockpatch plan --json` and `blockpatch move --json`.
187
+
188
+ ```ts
189
+ type MoveBlockArgs = {
190
+ src: string
191
+ src_start?: string
192
+ src_end?: string
193
+ dst?: string
194
+ payload?: string
195
+ target_before?: string
196
+ target_after?: string
197
+ expected_payload_sha256?: string
198
+ mode?: "create_file" | "remove_file"
199
+ dry_run?: boolean
200
+ }
201
+ ```
202
+
203
+ Request shapes:
204
+
205
+ - For relocation, `src_start` and `src_end` are inclusive source delimiters, and `dst` defaults to `src`.
206
+ - For deletion, set `dst` to `/dev/null`; `src_start` and `src_end` select the removed payload.
207
+ - For insertion, set `src` to `/dev/null`; `dst`, `payload`, and target context are required.
208
+ - For file creation, set `src` to `/dev/null`, set `mode` to `create_file`, and provide `dst` plus `payload`. Empty payload is valid and creates an empty file.
209
+ - For file removal, set `dst` to `/dev/null` and set `mode` to `remove_file`. The whole source file is selected as the payload.
210
+ - `mode` selects the whole-file path shapes; without it, `/dev/null` endpoints denote in-file insertion/deletion. `move --diff` renders the in-file shapes as same-file one-sided sections and the `mode` shapes with `/dev/null` file headers.
211
+ - `target_before`, `target_after`, or both are required for relocation and insertion.
212
+ - `payload` is only valid when `src` is `/dev/null`; it must be non-empty for in-file insertion.
213
+ - `expected_payload_sha256` is optional.
214
+
215
+ Insertion:
216
+
217
+ ```json
218
+ {
219
+ "src": "/dev/null",
220
+ "dst": "src/foo.ts",
221
+ "payload": "inserted bytes\n",
222
+ "target_before": "context before\n"
223
+ }
224
+ ```
225
+
226
+ Deletion:
227
+
228
+ ```json
229
+ {
230
+ "src": "src/foo.ts",
231
+ "src_start": "function removeMe() {\n",
232
+ "src_end": "}\n",
233
+ "dst": "/dev/null"
234
+ }
235
+ ```
236
+
237
+ File creation:
238
+
239
+ ```json
240
+ {
241
+ "src": "/dev/null",
242
+ "dst": "src/new.ts",
243
+ "payload": "export const x = 1;\n",
244
+ "mode": "create_file"
245
+ }
246
+ ```
247
+
248
+ File removal:
249
+
250
+ ```json
251
+ {
252
+ "src": "src/old.ts",
253
+ "dst": "/dev/null",
254
+ "mode": "remove_file",
255
+ "expected_payload_sha256": "<sha256>"
256
+ }
257
+ ```
258
+
259
+ Matching behavior for move JSON requests is documented in [Behavior](behavior.md#move-json-behavior).
260
+
261
+ ## JSON Output
262
+
263
+ With `--json-output`, successful commands print:
264
+
265
+ ```ts
266
+ type ApplyResult = {
267
+ ok: true
268
+ changed: string[]
269
+ affected: string[]
270
+ written: boolean
271
+ noop: boolean
272
+ status: "applied" | "noop" | "already_applied"
273
+ strip_components?: number
274
+ patch?: string
275
+ moves: Array<{
276
+ id: string
277
+ src: string
278
+ dst: string
279
+ payload_sha256: string
280
+ payload_bytes: number
281
+ source_range: { start: number; end: number } | null
282
+ target_range: { start: number; end: number } | null
283
+ insert_index: number | null
284
+ }>
285
+ }
286
+ ```
287
+
288
+ `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.
289
+
290
+ `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`.
291
+
292
+ `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`.
293
+
294
+ With `--json-output`, errors print:
295
+
296
+ ```ts
297
+ type BlockPatchJsonError = {
298
+ ok: false
299
+ error: {
300
+ code: BlockPatchErrorCode
301
+ message: string
302
+ field?: string
303
+ path?: string
304
+ phase?: string
305
+ anchor?: string
306
+ matches?: number
307
+ matches_truncated?: boolean
308
+ ranges?: Array<{ start: number; end: number }>
309
+ line_ranges?: Array<{ start: number; end: number }>
310
+ source_range?: { start: number; end: number }
311
+ target_range?: { start: number; end: number }
312
+ payload_sha256?: string
313
+ suggested_action?: string
314
+ }
315
+ }
316
+ ```
317
+
318
+ 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. When `matches_truncated` is true, `matches` is a lower bound rather than an exact count. They do not include source snippets, fuzzy suggestions, or repair guidance.
319
+
320
+ `partial_applied_duplicate` reports an interrupted cross-file apply state where the source payload is still present and the destination already contains the exact final target state. It includes `source_range`, `target_range`, `payload_sha256`, and `suggested_action: "review_then_remove_source"`. `blockpatch` does not auto-repair this state.
321
+
322
+ Error codes are the agent-facing branch contract: branch on `error.code`, not on human-readable messages. Removing a code or changing its meaning is semver-major.
323
+
324
+ ```ts
325
+ type BlockPatchErrorCode =
326
+ | "parse_error"
327
+ | "invalid_path"
328
+ | "path_outside_cwd"
329
+ | "symlink_path"
330
+ | "file_not_found"
331
+ | "not_regular_file"
332
+ | "permission_denied"
333
+ | "io_error"
334
+ | "source_not_found"
335
+ | "source_ambiguous"
336
+ | "target_not_found"
337
+ | "target_ambiguous"
338
+ | "destination_exists"
339
+ | "concurrent_modification"
340
+ | "partial_applied_duplicate"
341
+ | "payload_mismatch"
342
+ | "hash_mismatch"
343
+ | "invalid_utf8"
344
+ | "target_overlaps_source"
345
+ | "invalid_move_args"
346
+ | "invalid_json"
347
+ | "missing_move_args"
348
+ | "unknown_command"
349
+ | "unknown_option"
350
+ | "invalid_option"
351
+ | "missing_option_value"
352
+ | "too_many_args"
353
+ | "unexpected_error"
354
+ ```
355
+
356
+ `unexpected_error` is the generic fallback for non-`BlockPatchError` failures; agents should treat it as an internal failure and avoid branching on its message.
@@ -0,0 +1,15 @@
1
+ # Examples
2
+
3
+ Each successful example contains:
4
+
5
+ - `patch.blockpatch`: the reviewed patch artifact.
6
+ - `work/`: the before-state tree used by `-d`.
7
+ - `expected/`: the after-state tree after applying the patch.
8
+
9
+ Check an example without writing:
10
+
11
+ ```sh
12
+ blockpatch check examples/same-file-relocation/patch.blockpatch -d examples/same-file-relocation/work
13
+ ```
14
+
15
+ To apply without changing the checked-in example tree, copy `work/` to a scratch directory and use that as `-d`.
@@ -0,0 +1,9 @@
1
+ # Create File
2
+
3
+ Creates `file.txt` from a whole-file `/dev/null -> file` patch.
4
+
5
+ ```sh
6
+ blockpatch check patch.blockpatch -d work
7
+ blockpatch apply patch.blockpatch -d work
8
+ diff -u expected/file.txt work/file.txt
9
+ ```
@@ -0,0 +1 @@
1
+ created file