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.
- package/CONTRIBUTING.md +26 -0
- package/README.md +131 -74
- package/conformance/README.md +29 -0
- package/conformance/cases/ambiguous-source/case.json +8 -0
- package/conformance/cases/ambiguous-target/case.json +8 -0
- package/conformance/cases/create-file/case.json +8 -0
- package/conformance/cases/crlf/case.json +10 -0
- package/conformance/cases/cross-file-relocation/case.json +12 -0
- package/conformance/cases/delete-existing-file/case.json +10 -0
- package/conformance/cases/insert-existing-file/case.json +10 -0
- package/conformance/cases/no-trailing-newline/case.json +10 -0
- package/conformance/cases/remove-file/case.json +9 -0
- package/conformance/cases/same-file-relocation/case.json +10 -0
- package/conformance/runner.mjs +221 -0
- package/dist/cli.js +2908 -244
- package/docs/behavior.md +207 -0
- package/docs/commands.md +84 -0
- package/docs/spec.md +356 -0
- package/examples/README.md +15 -0
- package/examples/create-file/README.md +9 -0
- package/examples/create-file/expected/file.txt +1 -0
- package/examples/create-file/patch.blockpatch +8 -0
- package/examples/create-file/work/.gitkeep +1 -0
- package/examples/cross-file-relocation/README.md +10 -0
- package/examples/cross-file-relocation/expected/source.txt +2 -0
- package/examples/cross-file-relocation/expected/target.txt +6 -0
- package/examples/cross-file-relocation/patch.blockpatch +26 -0
- package/examples/cross-file-relocation/work/source.txt +5 -0
- package/examples/cross-file-relocation/work/target.txt +3 -0
- package/examples/delete-existing-file/README.md +9 -0
- package/examples/delete-existing-file/expected/file.txt +2 -0
- package/examples/delete-existing-file/patch.blockpatch +10 -0
- package/examples/delete-existing-file/work/file.txt +3 -0
- package/examples/failure-ambiguous-target/README.md +9 -0
- package/examples/failure-ambiguous-target/patch.blockpatch +14 -0
- package/examples/failure-ambiguous-target/work/file.txt +5 -0
- package/examples/insert-existing-file/README.md +9 -0
- package/examples/insert-existing-file/expected/file.txt +3 -0
- package/examples/insert-existing-file/patch.blockpatch +10 -0
- package/examples/insert-existing-file/work/file.txt +2 -0
- package/examples/remove-file/README.md +9 -0
- package/examples/remove-file/expected/.gitkeep +1 -0
- package/examples/remove-file/patch.blockpatch +8 -0
- package/examples/remove-file/work/file.txt +1 -0
- package/examples/reverse/README.md +9 -0
- package/examples/reverse/expected/file.txt +4 -0
- package/examples/reverse/patch.blockpatch +14 -0
- package/examples/reverse/work/file.txt +4 -0
- package/examples/same-file-relocation/README.md +9 -0
- package/examples/same-file-relocation/expected/file.txt +4 -0
- package/examples/same-file-relocation/patch.blockpatch +14 -0
- package/examples/same-file-relocation/work/file.txt +4 -0
- package/package.json +33 -13
- package/dist/cli.d.ts +0 -2
- package/dist/engine.d.ts +0 -19
- package/dist/errors.d.ts +0 -5
- package/dist/index.d.ts +0 -4
- package/dist/index.js +0 -263
- package/dist/parser.d.ts +0 -2
- package/dist/types.d.ts +0 -20
package/CONTRIBUTING.md
ADDED
|
@@ -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
|
-
|
|
3
|
+
[](https://github.com/cheese-melted/blockpatch/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/blockpatch)
|
|
4
5
|
|
|
5
|
-
|
|
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
|
-
|
|
17
|
+
Or run without installing:
|
|
17
18
|
|
|
18
19
|
```sh
|
|
19
|
-
|
|
20
|
-
bun test
|
|
21
|
-
bun run build
|
|
22
|
-
bun run publish:dry
|
|
20
|
+
npx blockpatch --help
|
|
23
21
|
```
|
|
24
22
|
|
|
25
|
-
##
|
|
23
|
+
## Usage
|
|
26
24
|
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
32
|
+
Example: move `movedThing` from `src/foo.ts` into `src/bar.ts`, starting from this tree:
|
|
36
33
|
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
function alpha() {
|
|
34
|
+
```ts
|
|
35
|
+
// before: src/foo.ts
|
|
36
|
+
export function keepThing() {
|
|
37
|
+
return 7;
|
|
42
38
|
}
|
|
43
39
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
console.log("keep me exact");
|
|
40
|
+
export function movedThing() {
|
|
41
|
+
return 42;
|
|
47
42
|
}
|
|
48
43
|
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
125
|
+
## Common Commands
|
|
73
126
|
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
148
|
+
## Examples
|
|
83
149
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
159
|
+
Each example has a `patch.blockpatch`, a runnable `work/` directory, and `expected/` output for successful cases.
|
|
95
160
|
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
171
|
+
## Scope
|
|
108
172
|
|
|
109
|
-
|
|
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
|
-
-
|
|
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
|
+
}
|