blockpatch 0.1.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 (60) hide show
  1. package/CONTRIBUTING.md +26 -0
  2. package/README.md +131 -74
  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 +2908 -244
  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 +33 -13
  54. package/dist/cli.d.ts +0 -2
  55. package/dist/engine.d.ts +0 -19
  56. package/dist/errors.d.ts +0 -5
  57. package/dist/index.d.ts +0 -4
  58. package/dist/index.js +0 -263
  59. package/dist/parser.d.ts +0 -2
  60. package/dist/types.d.ts +0 -20
@@ -0,0 +1,26 @@
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 run typecheck
16
+ bun test
17
+ bun run build
18
+ npm run smoke:dist
19
+ npm run pack:dry
20
+ ```
21
+
22
+ Before publishing an unpublished version, also run:
23
+
24
+ ```sh
25
+ npm run publish:dry
26
+ ```
package/README.md CHANGED
@@ -1,118 +1,175 @@
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
+ [![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: locate an exact source payload by surrounding anchors, remove those exact original bytes, and insert those same bytes at an independently located target anchor.
6
+ Cut/paste for agents: hash-verified block moves that read like unified diffs.
7
+
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.
6
9
 
7
10
  ## Install
8
11
 
9
12
  ```sh
10
- npx blockpatch check patch.blockpatch
11
- npx blockpatch apply patch.blockpatch
12
- bunx blockpatch apply patch.blockpatch --dry-run
13
13
  npm install -g blockpatch
14
+ blockpatch --help
14
15
  ```
15
16
 
16
- For local development:
17
+ Or run without installing:
17
18
 
18
19
  ```sh
19
- bun install
20
- bun test
21
- bun run build
22
- bun run publish:dry
20
+ npx blockpatch --help
23
21
  ```
24
22
 
25
- ## Commands
23
+ ## Usage
26
24
 
27
- ```sh
28
- blockpatch check patch.blockpatch
29
- blockpatch apply patch.blockpatch
30
- blockpatch apply patch.blockpatch --dry-run
31
- ```
25
+ `blockpatch` is a deterministic move planner/apply layer for coding agents:
32
26
 
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.
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.
34
31
 
35
- ## V0 Format
32
+ Example: move `movedThing` from `src/foo.ts` into `src/bar.ts`, starting from this tree:
36
33
 
37
- ```text
38
- *** Begin BlockPatch
39
- *** Move File: src/example.ts
40
- *** Source Before
41
- function alpha() {
34
+ ```ts
35
+ // before: src/foo.ts
36
+ export function keepThing() {
37
+ return 7;
42
38
  }
43
39
 
44
- *** Source Payload
45
- function movedThing() {
46
- console.log("keep me exact");
40
+ export function movedThing() {
41
+ return 42;
47
42
  }
48
43
 
49
- *** Source After
50
- function omega() {
51
- }
44
+ // before: src/bar.ts
45
+ export const target = "here";
46
+ ```
47
+
48
+ The JSON request selects the source bytes and target anchors. Ask `blockpatch` to plan the byte-exact move and emit the reviewable patch:
52
49
 
53
- *** Target After
54
- constructor() {
50
+ ```sh
51
+ blockpatch move --json - --diff <<'JSON' > patch.blockpatch
52
+ {
53
+ "src": "src/foo.ts",
54
+ "src_start": "\nexport function movedThing() {\n",
55
+ "src_end": "}\n",
56
+ "dst": "src/bar.ts",
57
+ "target_before": "export const target = \"here\";\n"
55
58
  }
59
+ JSON
60
+ ```
56
61
 
57
- *** End BlockPatch
62
+ `patch.blockpatch` now carries the move as a unified-diff-shaped document:
63
+
64
+ ```diff
65
+ diff --blockpatch a/src/foo.ts b/src/foo.ts
66
+ blockpatch version 1
67
+ blockpatch move id=move-1 role=source payload-sha256=bb03c42613e9289c043d2fced7ce2d8c87410cdb15fa48341ce79fa409d45303
68
+ --- a/src/foo.ts
69
+ +++ b/src/foo.ts
70
+
71
+ @@ -3,5 +3,1 @@ blockpatch-source id=move-1
72
+ }
73
+ -
74
+ -export function movedThing() {
75
+ - return 42;
76
+ -}
77
+
78
+ diff --blockpatch a/src/bar.ts b/src/bar.ts
79
+ blockpatch version 1
80
+ blockpatch move id=move-1 role=target payload-sha256=bb03c42613e9289c043d2fced7ce2d8c87410cdb15fa48341ce79fa409d45303
81
+ --- a/src/bar.ts
82
+ +++ b/src/bar.ts
83
+
84
+ @@ -1,1 +1,5 @@ blockpatch-target id=move-1
85
+ export const target = "here";
86
+ +
87
+ +export function movedThing() {
88
+ + return 42;
89
+ +}
58
90
  ```
59
91
 
60
- `*** Target Before` is also supported:
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.
93
+
94
+ Review the patch, then apply it:
61
95
 
62
- ```text
63
- *** Target Before
64
- function omega() {
96
+ ```sh
97
+ blockpatch apply patch.blockpatch
98
+ ```
99
+
100
+ ```
101
+ changed src/foo.ts
102
+ changed src/bar.ts
103
+ ```
104
+
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:
106
+
107
+ ```ts
108
+ // after: src/foo.ts
109
+ export function keepThing() {
110
+ return 7;
111
+ }
112
+
113
+ // after: src/bar.ts
114
+ export const target = "here";
115
+
116
+ export function movedThing() {
117
+ return 42;
65
118
  }
66
119
  ```
67
120
 
68
- Only one target marker is allowed.
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.
69
122
 
70
- ## Section 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.
71
124
 
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.
125
+ ## Common Commands
73
126
 
74
- That means:
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 -
133
+ ```
134
+
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.
136
+
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.
138
+
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.
75
140
 
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.
141
+ ## Docs
79
142
 
80
- ## Semantics
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.
81
147
 
82
- For one patch:
148
+ ## Examples
83
149
 
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.
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/)
93
158
 
94
- ## Failure Rules
159
+ Each example has a `patch.blockpatch`, a runnable `work/` directory, and `expected/` output for successful cases.
95
160
 
96
- `blockpatch` exits non-zero and does not modify the file when:
161
+ ## Conformance
162
+
163
+ Run the published conformance cases against a blockpatch-compatible CLI:
164
+
165
+ ```sh
166
+ npx -p blockpatch blockpatch-conformance ./my-implementation
167
+ ```
97
168
 
98
- - the patch file is malformed
99
- - source anchors are missing
100
- - source anchors or full source are ambiguous
101
- - the located source payload does not exactly match `Source Payload`
102
- - the target anchor is missing
103
- - the target anchor is ambiguous
104
- - the target anchor overlaps the source payload
105
- - file I/O fails before the atomic rename
169
+ The runner checks apply/check behavior, retry idempotence, reverse application, byte preservation, and expected structured failures.
106
170
 
107
- ## Intentionally Out Of Scope
171
+ ## Scope
108
172
 
109
- V0 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.
110
174
 
111
- - fuzzy matching
112
- - AST parsing
113
- - code formatting
114
- - multi-file patches
115
- - copy operations
116
- - generated diffs
117
- - line numbers
118
- - 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.
@@ -0,0 +1,29 @@
1
+ # blockpatch conformance
2
+
3
+ These cases exercise the public `.blockpatch` apply/check contract for independent implementations.
4
+
5
+ Run against an installed implementation:
6
+
7
+ ```sh
8
+ npx -p blockpatch blockpatch-conformance ./my-implementation
9
+ ```
10
+
11
+ The shorter `npx blockpatch-conformance ./my-implementation` spelling would require publishing a separate npm package named `blockpatch-conformance`; this repository currently ships the runner as a `blockpatch` package binary.
12
+
13
+ Run against this repository after building:
14
+
15
+ ```sh
16
+ npm run build
17
+ node conformance/runner.mjs node dist/cli.js
18
+ ```
19
+
20
+ The runner invokes the implementation as:
21
+
22
+ ```sh
23
+ <implementation> check <patch.blockpatch> --cwd <work> --json-output
24
+ <implementation> apply <patch.blockpatch> --cwd <work> --json-output
25
+ ```
26
+
27
+ Successful cases also verify retry idempotence and `apply --reverse`. Failure cases verify the expected JSON error code and that the work tree remains byte-identical.
28
+
29
+ Set `BLOCKPATCH_CONFORMANCE_KEEP=1` to keep the temporary work directory after a failure.
@@ -0,0 +1,8 @@
1
+ {
2
+ "expect": "fail",
3
+ "error_code": "source_ambiguous",
4
+ "patch": "diff --blockpatch a/file.txt b/file.txt\nblockpatch version 1\nblockpatch move id=move-1 payload-sha256=f721166071c491fd38ac82a8432ecc349f39f537a969054ab2c8d3175c731e7e\n--- a/file.txt\n+++ b/file.txt\n\n@@ -1,3 +1,2 @@ blockpatch-source id=move-1\n alpha\n-move me\n omega\n\n@@ -4,1 +4,2 @@ blockpatch-target id=move-1\n target\n+move me\n",
5
+ "files": {
6
+ "file.txt": "alpha\nmove me\nomega\ntarget\nalpha\nmove me\nomega\n"
7
+ }
8
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "expect": "fail",
3
+ "error_code": "target_ambiguous",
4
+ "patch": "diff --blockpatch a/file.txt b/file.txt\nblockpatch version 1\nblockpatch move id=move-1 payload-sha256=f721166071c491fd38ac82a8432ecc349f39f537a969054ab2c8d3175c731e7e\n--- a/file.txt\n+++ b/file.txt\n\n@@ -1,3 +1,2 @@ blockpatch-source id=move-1\n alpha\n-move me\n omega\n\n@@ -4,1 +4,2 @@ blockpatch-target id=move-1\n target\n+move me\n",
5
+ "files": {
6
+ "file.txt": "alpha\nmove me\nomega\ntarget\ntarget\n"
7
+ }
8
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "expect": "pass",
3
+ "patch": "diff --blockpatch /dev/null b/file.txt\nblockpatch version 1\nblockpatch move id=move-1 payload-sha256=072a398e62ad50e40613b98e57f3e3236a471dcbd7c2b9f190440c22adc16a0f\n--- /dev/null\n+++ b/file.txt\n\n@@ -0,0 +1,1 @@ blockpatch-target id=move-1\n+created file\n",
4
+ "files": {},
5
+ "expected_files": {
6
+ "file.txt": "created file\n"
7
+ }
8
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "expect": "pass",
3
+ "patch": "diff --blockpatch a/file.txt b/file.txt\nblockpatch version 1\nblockpatch move id=move-1 payload-sha256=10d316fe0179a4ccaa97a509f75294785f941d7f9b0656684844edcdf1b5a01a\n--- a/file.txt\n+++ b/file.txt\n\n@@ -1,3 +1,2 @@ blockpatch-source id=move-1\n alpha\r\n-move me\r\n omega\r\n\n@@ -4,1 +4,2 @@ blockpatch-target id=move-1\n target\r\n+move me\r\n",
4
+ "files": {
5
+ "file.txt": "alpha\r\nmove me\r\nomega\r\ntarget\r\n"
6
+ },
7
+ "expected_files": {
8
+ "file.txt": "alpha\r\nomega\r\ntarget\r\nmove me\r\n"
9
+ }
10
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "expect": "pass",
3
+ "patch": "diff --blockpatch a/source.txt b/source.txt\nblockpatch version 1\nblockpatch move id=move-1 role=source payload-sha256=a990c0da5571138b0e2363af883a399fe214a137ad809c67f6530c618967a4e6\n--- a/source.txt\n+++ b/source.txt\n\n@@ -1,5 +1,2 @@ blockpatch-source id=move-1\n alpha()\n-function movedThing() {\n- return 42;\n-}\n omega()\n\ndiff --blockpatch a/target.txt b/target.txt\nblockpatch version 1\nblockpatch move id=move-1 role=target payload-sha256=a990c0da5571138b0e2363af883a399fe214a137ad809c67f6530c618967a4e6\n--- a/target.txt\n+++ b/target.txt\n\n@@ -1,3 +1,6 @@ blockpatch-target id=move-1\n class Target {\n method() {}\n+function movedThing() {\n+ return 42;\n+}\n }\n",
4
+ "files": {
5
+ "source.txt": "alpha()\nfunction movedThing() {\n return 42;\n}\nomega()\n",
6
+ "target.txt": "class Target {\n method() {}\n}\n"
7
+ },
8
+ "expected_files": {
9
+ "source.txt": "alpha()\nomega()\n",
10
+ "target.txt": "class Target {\n method() {}\nfunction movedThing() {\n return 42;\n}\n}\n"
11
+ }
12
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "expect": "pass",
3
+ "patch": "diff --blockpatch a/file.txt b/file.txt\nblockpatch version 1\nblockpatch move id=move-1 payload-sha256=5dbe6af8d3a2a31f6696ebdae9e590977a19b827ec8e0a22ea1e3decf1a79b77\n--- a/file.txt\n+++ b/file.txt\n\n@@ -1,3 +1,2 @@ blockpatch-source id=move-1\n alpha\n-remove me\n omega\n",
4
+ "files": {
5
+ "file.txt": "alpha\nremove me\nomega\n"
6
+ },
7
+ "expected_files": {
8
+ "file.txt": "alpha\nomega\n"
9
+ }
10
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "expect": "pass",
3
+ "patch": "diff --blockpatch a/file.txt b/file.txt\nblockpatch version 1\nblockpatch move id=move-1 payload-sha256=cbfeef0cd60ab2c01e036d23082a1f19ea3f5153d9cffde8790ad0152a3830f5\n--- a/file.txt\n+++ b/file.txt\n\n@@ -1,2 +1,3 @@ blockpatch-target id=move-1\n alpha\n+inserted line\n omega\n",
4
+ "files": {
5
+ "file.txt": "alpha\nomega\n"
6
+ },
7
+ "expected_files": {
8
+ "file.txt": "alpha\ninserted line\nomega\n"
9
+ }
10
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "expect": "pass",
3
+ "patch": "diff --blockpatch a/file.txt b/file.txt\nblockpatch version 1\nblockpatch move id=move-1 payload-sha256=f721166071c491fd38ac82a8432ecc349f39f537a969054ab2c8d3175c731e7e\n--- a/file.txt\n+++ b/file.txt\n\n@@ -1,3 +1,2 @@ blockpatch-source id=move-1\n alpha\n-move me\n omega\n\n@@ -4,1 +4,2 @@ blockpatch-target id=move-1\n+move me\n target\n\\n",
4
+ "files": {
5
+ "file.txt": "alpha\nmove me\nomega\ntarget"
6
+ },
7
+ "expected_files": {
8
+ "file.txt": "alpha\nomega\nmove me\ntarget"
9
+ }
10
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "expect": "pass",
3
+ "patch": "diff --blockpatch a/file.txt /dev/null\nblockpatch version 1\nblockpatch move id=move-1 payload-sha256=4da5a83256114db88b111676cf3b2f4a04f202d3d7fc99417cac1ba5145713e2\n--- a/file.txt\n+++ /dev/null\n\n@@ -1,1 +0,0 @@ blockpatch-source id=move-1\n-obsolete file\n",
4
+ "files": {
5
+ "file.txt": "obsolete file\n"
6
+ },
7
+ "expected_files": {},
8
+ "expected_absent": ["file.txt"]
9
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "expect": "pass",
3
+ "patch": "diff --blockpatch a/file.txt b/file.txt\nblockpatch version 1\nblockpatch move id=move-1 payload-sha256=f721166071c491fd38ac82a8432ecc349f39f537a969054ab2c8d3175c731e7e\n--- a/file.txt\n+++ b/file.txt\n\n@@ -1,3 +1,2 @@ blockpatch-source id=move-1\n alpha\n-move me\n omega\n\n@@ -4,1 +4,2 @@ blockpatch-target id=move-1\n target\n+move me\n",
4
+ "files": {
5
+ "file.txt": "alpha\nmove me\nomega\ntarget\n"
6
+ },
7
+ "expected_files": {
8
+ "file.txt": "alpha\nomega\ntarget\nmove me\n"
9
+ }
10
+ }
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import { constants } from "node:fs";
4
+ import { access, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { dirname, isAbsolute, join, resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const conformanceRoot = dirname(fileURLToPath(import.meta.url));
10
+ const casesRoot = join(conformanceRoot, "cases");
11
+ const invocationCwd = process.cwd();
12
+ const keepTemp = process.env.BLOCKPATCH_CONFORMANCE_KEEP === "1";
13
+ const implementationArgv = process.argv.slice(2);
14
+
15
+ if (implementationArgv.length === 0 || implementationArgv[0] === "--help" || implementationArgv[0] === "-h") {
16
+ printUsage();
17
+ process.exit(implementationArgv.length === 0 ? 2 : 0);
18
+ }
19
+
20
+ const implementation = normalizeImplementation(implementationArgv);
21
+ const tempRoot = await mkdtemp(join(tmpdir(), "blockpatch-conformance-"));
22
+
23
+ try {
24
+ const caseNames = await readCaseNames();
25
+ for (const caseName of caseNames) {
26
+ await runCase(caseName);
27
+ console.log(`ok ${caseName}`);
28
+ }
29
+ console.log(`ok ${caseNames.length} conformance cases`);
30
+ } catch (error) {
31
+ console.error(error instanceof Error ? error.message : String(error));
32
+ if (keepTemp) {
33
+ console.error(`kept temp directory: ${tempRoot}`);
34
+ }
35
+ process.exitCode = 1;
36
+ } finally {
37
+ if (!keepTemp) {
38
+ await rm(tempRoot, { recursive: true, force: true });
39
+ }
40
+ }
41
+
42
+ function printUsage() {
43
+ console.error("Usage: blockpatch-conformance <blockpatch-command> [args...]");
44
+ console.error("");
45
+ console.error("Examples:");
46
+ console.error(" blockpatch-conformance blockpatch");
47
+ console.error(" blockpatch-conformance node dist/cli.js");
48
+ }
49
+
50
+ function normalizeImplementation(argv) {
51
+ const [command, ...args] = argv;
52
+ const resolvedCommand = looksPathLike(command) ? resolve(invocationCwd, command) : command;
53
+ return { command: resolvedCommand, args };
54
+ }
55
+
56
+ function looksPathLike(command) {
57
+ return command.startsWith(".") || command.includes("/") || command.includes("\\") || isAbsolute(command);
58
+ }
59
+
60
+ async function readCaseNames() {
61
+ const entries = await readdir(casesRoot, { withFileTypes: true });
62
+ return entries
63
+ .filter((entry) => entry.isDirectory())
64
+ .map((entry) => entry.name)
65
+ .sort();
66
+ }
67
+
68
+ async function runCase(caseName) {
69
+ const caseDefinition = JSON.parse(await readFile(join(casesRoot, caseName, "case.json"), "utf8"));
70
+ const caseRoot = join(tempRoot, caseName);
71
+ const workRoot = join(caseRoot, "work");
72
+ const patchPath = join(caseRoot, "patch.blockpatch");
73
+ await mkdir(workRoot, { recursive: true });
74
+ await writeCaseFiles(workRoot, caseDefinition.files ?? {});
75
+ await writeFile(patchPath, Buffer.from(requiredString(caseDefinition.patch, caseName, "patch"), "utf8"));
76
+
77
+ const initialFiles = caseDefinition.files ?? {};
78
+ const expectedFiles = caseDefinition.expected_files ?? initialFiles;
79
+ const expectedAbsent = caseDefinition.expected_absent ?? [];
80
+ const expect = caseDefinition.expect ?? "pass";
81
+
82
+ if (expect === "pass") {
83
+ await expectSuccess(caseName, "check", [patchPath, "--cwd", workRoot]);
84
+ await expectTree(caseName, workRoot, initialFiles, []);
85
+ await expectSuccess(caseName, "apply", [patchPath, "--cwd", workRoot]);
86
+ await expectTree(caseName, workRoot, expectedFiles, expectedAbsent);
87
+ await expectSuccess(caseName, "retry", [patchPath, "--cwd", workRoot]);
88
+ await expectTree(caseName, workRoot, expectedFiles, expectedAbsent);
89
+ await expectSuccess(caseName, "reverse", [patchPath, "--cwd", workRoot, "--reverse"]);
90
+ await expectTree(caseName, workRoot, initialFiles, []);
91
+ return;
92
+ }
93
+
94
+ if (expect === "fail") {
95
+ await expectFailure(caseName, "check", [patchPath, "--cwd", workRoot], caseDefinition.error_code);
96
+ await expectTree(caseName, workRoot, initialFiles, []);
97
+ await expectFailure(caseName, "apply", [patchPath, "--cwd", workRoot], caseDefinition.error_code);
98
+ await expectTree(caseName, workRoot, initialFiles, []);
99
+ return;
100
+ }
101
+
102
+ throw new Error(`${caseName}: unknown expect value ${JSON.stringify(expect)}`);
103
+ }
104
+
105
+ async function expectSuccess(caseName, label, args) {
106
+ const result = runBlockpatch(commandArgs(label, args));
107
+ if (result.status !== 0) {
108
+ throw commandError(caseName, label, result);
109
+ }
110
+ const json = parseJson(caseName, label, result.stdout);
111
+ if (json.ok !== true) {
112
+ throw new Error(`${caseName}: ${label} returned non-ok JSON ${JSON.stringify(json)}`);
113
+ }
114
+ }
115
+
116
+ async function expectFailure(caseName, label, args, errorCode) {
117
+ const result = runBlockpatch(commandArgs(label, args));
118
+ if (result.status === 0) {
119
+ throw new Error(`${caseName}: ${label} succeeded but expected failure`);
120
+ }
121
+ const json = parseJson(caseName, label, result.stderr || result.stdout);
122
+ if (json.ok !== false) {
123
+ throw new Error(`${caseName}: ${label} failure returned non-error JSON ${JSON.stringify(json)}`);
124
+ }
125
+ if (errorCode !== undefined && json.error?.code !== errorCode) {
126
+ throw new Error(`${caseName}: ${label} expected error ${errorCode}, got ${String(json.error?.code)}`);
127
+ }
128
+ }
129
+
130
+ function commandArgs(label, args) {
131
+ return [label === "check" ? "check" : "apply", ...args];
132
+ }
133
+
134
+ function runBlockpatch(args) {
135
+ return spawnSync(implementation.command, [...implementation.args, ...args, "--json-output"], {
136
+ cwd: invocationCwd,
137
+ encoding: "utf8",
138
+ maxBuffer: 1024 * 1024,
139
+ shell: process.platform === "win32"
140
+ });
141
+ }
142
+
143
+ function commandError(caseName, label, result) {
144
+ if (result.error !== undefined) {
145
+ return result.error;
146
+ }
147
+ return new Error(
148
+ `${caseName}: ${label} failed\n` +
149
+ `exit: ${String(result.status)}\n` +
150
+ `stdout:\n${result.stdout}\n` +
151
+ `stderr:\n${result.stderr}`
152
+ );
153
+ }
154
+
155
+ function parseJson(caseName, label, text) {
156
+ try {
157
+ return JSON.parse(text);
158
+ } catch (error) {
159
+ throw new Error(`${caseName}: ${label} did not return JSON: ${text.trim()}\n${String(error)}`);
160
+ }
161
+ }
162
+
163
+ async function writeCaseFiles(root, files) {
164
+ for (const [path, text] of Object.entries(files)) {
165
+ const absolute = join(root, path);
166
+ await mkdir(dirname(absolute), { recursive: true });
167
+ await writeFile(absolute, Buffer.from(requiredString(text, path, "file text"), "utf8"));
168
+ }
169
+ }
170
+
171
+ async function expectTree(caseName, root, expectedFiles, expectedAbsent) {
172
+ const actualPaths = await listFiles(root);
173
+ const expectedPaths = Object.keys(expectedFiles).sort();
174
+ assertEqual(caseName, "file list", actualPaths, expectedPaths);
175
+
176
+ for (const [path, text] of Object.entries(expectedFiles)) {
177
+ const expected = Buffer.from(requiredString(text, path, "expected file text"), "utf8");
178
+ const actual = await readFile(join(root, path));
179
+ if (!actual.equals(expected)) {
180
+ throw new Error(`${caseName}: ${path} bytes differ`);
181
+ }
182
+ }
183
+
184
+ for (const path of expectedAbsent) {
185
+ try {
186
+ await access(join(root, path), constants.F_OK);
187
+ } catch {
188
+ continue;
189
+ }
190
+ throw new Error(`${caseName}: expected ${path} to be absent`);
191
+ }
192
+ }
193
+
194
+ async function listFiles(root, prefix = "") {
195
+ const entries = await readdir(join(root, prefix), { withFileTypes: true });
196
+ const paths = [];
197
+ for (const entry of entries) {
198
+ const path = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
199
+ if (entry.isDirectory()) {
200
+ paths.push(...await listFiles(root, path));
201
+ } else if (entry.isFile()) {
202
+ paths.push(path);
203
+ }
204
+ }
205
+ return paths.sort();
206
+ }
207
+
208
+ function requiredString(value, label, field) {
209
+ if (typeof value !== "string") {
210
+ throw new Error(`${label}: ${field} must be a string`);
211
+ }
212
+ return value;
213
+ }
214
+
215
+ function assertEqual(caseName, label, actual, expected) {
216
+ const actualJson = JSON.stringify(actual);
217
+ const expectedJson = JSON.stringify(expected);
218
+ if (actualJson !== expectedJson) {
219
+ throw new Error(`${caseName}: ${label} expected ${expectedJson}, got ${actualJson}`);
220
+ }
221
+ }