blockpatch 0.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/LICENSE +21 -0
- package/README.md +118 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +350 -0
- package/dist/engine.d.ts +19 -0
- package/dist/errors.d.ts +5 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +263 -0
- package/dist/parser.d.ts +2 -0
- package/dist/types.d.ts +20 -0
- package/package.json +36 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 blockpatch contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# blockpatch
|
|
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.
|
|
4
|
+
|
|
5
|
+
The core invariant is simple: locate an exact source payload by surrounding anchors, remove those exact original bytes, and insert those same bytes at an independently located target anchor.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npx blockpatch check patch.blockpatch
|
|
11
|
+
npx blockpatch apply patch.blockpatch
|
|
12
|
+
bunx blockpatch apply patch.blockpatch --dry-run
|
|
13
|
+
npm install -g blockpatch
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
For local development:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
bun install
|
|
20
|
+
bun test
|
|
21
|
+
bun run build
|
|
22
|
+
bun run publish:dry
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Commands
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
blockpatch check patch.blockpatch
|
|
29
|
+
blockpatch apply patch.blockpatch
|
|
30
|
+
blockpatch apply patch.blockpatch --dry-run
|
|
31
|
+
```
|
|
32
|
+
|
|
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.
|
|
34
|
+
|
|
35
|
+
## V0 Format
|
|
36
|
+
|
|
37
|
+
```text
|
|
38
|
+
*** Begin BlockPatch
|
|
39
|
+
*** Move File: src/example.ts
|
|
40
|
+
*** Source Before
|
|
41
|
+
function alpha() {
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
*** Source Payload
|
|
45
|
+
function movedThing() {
|
|
46
|
+
console.log("keep me exact");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
*** Source After
|
|
50
|
+
function omega() {
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
*** Target After
|
|
54
|
+
constructor() {
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
*** End BlockPatch
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`*** Target Before` is also supported:
|
|
61
|
+
|
|
62
|
+
```text
|
|
63
|
+
*** Target Before
|
|
64
|
+
function omega() {
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Only one target marker is allowed.
|
|
69
|
+
|
|
70
|
+
## Section Bytes
|
|
71
|
+
|
|
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.
|
|
73
|
+
|
|
74
|
+
That means:
|
|
75
|
+
|
|
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.
|
|
79
|
+
|
|
80
|
+
## Semantics
|
|
81
|
+
|
|
82
|
+
For one patch:
|
|
83
|
+
|
|
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.
|
|
93
|
+
|
|
94
|
+
## Failure Rules
|
|
95
|
+
|
|
96
|
+
`blockpatch` exits non-zero and does not modify the file when:
|
|
97
|
+
|
|
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
|
|
106
|
+
|
|
107
|
+
## Intentionally Out Of Scope
|
|
108
|
+
|
|
109
|
+
V0 does not implement:
|
|
110
|
+
|
|
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
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { resolve as resolve2 } from "node:path";
|
|
5
|
+
|
|
6
|
+
// src/errors.ts
|
|
7
|
+
class BlockPatchError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
constructor(code, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "BlockPatchError";
|
|
12
|
+
this.code = code;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function fail(code, message) {
|
|
16
|
+
throw new BlockPatchError(code, message);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// src/engine.ts
|
|
20
|
+
import { randomBytes } from "node:crypto";
|
|
21
|
+
import { chmod, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
22
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
23
|
+
|
|
24
|
+
// src/parser.ts
|
|
25
|
+
var beginLine = "*** Begin BlockPatch";
|
|
26
|
+
var sourceBeforeLine = "*** Source Before";
|
|
27
|
+
var sourcePayloadLine = "*** Source Payload";
|
|
28
|
+
var sourceAfterLine = "*** Source After";
|
|
29
|
+
var targetBeforeLine = "*** Target Before";
|
|
30
|
+
var targetAfterLine = "*** Target After";
|
|
31
|
+
var endLine = "*** End BlockPatch";
|
|
32
|
+
var moveFilePrefix = "*** Move File: ";
|
|
33
|
+
function parseBlockPatch(input) {
|
|
34
|
+
const markers = collectMarkers(input);
|
|
35
|
+
const sequence = markers.map((marker) => marker.name);
|
|
36
|
+
const targetIndex = sequence.findIndex((name) => name === "targetBefore" || name === "targetAfter");
|
|
37
|
+
if (targetIndex === -1) {
|
|
38
|
+
fail("parse_error", "Patch must contain *** Target Before or *** Target After");
|
|
39
|
+
}
|
|
40
|
+
const expectedPrefix = ["begin", "moveFile", "sourceBefore", "sourcePayload", "sourceAfter"];
|
|
41
|
+
const expectedSuffix = ["end"];
|
|
42
|
+
const expected = [...expectedPrefix, sequence[targetIndex], ...expectedSuffix];
|
|
43
|
+
if (sequence.length !== expected.length || sequence.some((name, index) => name !== expected[index])) {
|
|
44
|
+
fail("parse_error", "Patch markers must appear in the v0 order");
|
|
45
|
+
}
|
|
46
|
+
const moveFile = markers[1];
|
|
47
|
+
const path = moveFile.value?.trim();
|
|
48
|
+
if (!path) {
|
|
49
|
+
fail("parse_error", "*** Move File must include a path");
|
|
50
|
+
}
|
|
51
|
+
const sourceBefore = section(input, markers[2], markers[3]);
|
|
52
|
+
const sourcePayload = section(input, markers[3], markers[4]);
|
|
53
|
+
const sourceAfter = section(input, markers[4], markers[5]);
|
|
54
|
+
const targetAnchor = section(input, markers[5], markers[6]);
|
|
55
|
+
const target = {
|
|
56
|
+
kind: markers[5].name === "targetBefore" ? "before" : "after",
|
|
57
|
+
anchor: targetAnchor
|
|
58
|
+
};
|
|
59
|
+
if (sourceBefore.length === 0) {
|
|
60
|
+
fail("parse_error", "*** Source Before must not be empty");
|
|
61
|
+
}
|
|
62
|
+
if (sourcePayload.length === 0) {
|
|
63
|
+
fail("parse_error", "*** Source Payload must not be empty");
|
|
64
|
+
}
|
|
65
|
+
if (sourceAfter.length === 0) {
|
|
66
|
+
fail("parse_error", "*** Source After must not be empty");
|
|
67
|
+
}
|
|
68
|
+
if (target.anchor.length === 0) {
|
|
69
|
+
fail("parse_error", "*** Target Before/After must not be empty");
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
type: "move",
|
|
73
|
+
path,
|
|
74
|
+
sourceBefore,
|
|
75
|
+
sourcePayload,
|
|
76
|
+
sourceAfter,
|
|
77
|
+
target
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function collectMarkers(input) {
|
|
81
|
+
const markers = [];
|
|
82
|
+
let position = 0;
|
|
83
|
+
while (position <= input.length) {
|
|
84
|
+
const lineStart = position;
|
|
85
|
+
const lf = input.indexOf(10, position);
|
|
86
|
+
const lineEnd = lf === -1 ? input.length : lf;
|
|
87
|
+
const contentStart = lf === -1 ? input.length : lf + 1;
|
|
88
|
+
const textEnd = lineEnd > lineStart && input[lineEnd - 1] === 13 ? lineEnd - 1 : lineEnd;
|
|
89
|
+
const line = input.subarray(lineStart, textEnd).toString("utf8");
|
|
90
|
+
const marker = markerForLine(line, lineStart, contentStart);
|
|
91
|
+
if (marker !== undefined) {
|
|
92
|
+
markers.push(marker);
|
|
93
|
+
}
|
|
94
|
+
if (lf === -1) {
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
position = lf + 1;
|
|
98
|
+
}
|
|
99
|
+
if (markers.length === 0) {
|
|
100
|
+
fail("parse_error", "Patch does not contain BlockPatch markers");
|
|
101
|
+
}
|
|
102
|
+
return markers;
|
|
103
|
+
}
|
|
104
|
+
function markerForLine(line, lineStart, contentStart) {
|
|
105
|
+
if (line === beginLine)
|
|
106
|
+
return { name: "begin", lineStart, contentStart };
|
|
107
|
+
if (line.startsWith(moveFilePrefix)) {
|
|
108
|
+
return { name: "moveFile", value: line.slice(moveFilePrefix.length), lineStart, contentStart };
|
|
109
|
+
}
|
|
110
|
+
if (line === sourceBeforeLine)
|
|
111
|
+
return { name: "sourceBefore", lineStart, contentStart };
|
|
112
|
+
if (line === sourcePayloadLine)
|
|
113
|
+
return { name: "sourcePayload", lineStart, contentStart };
|
|
114
|
+
if (line === sourceAfterLine)
|
|
115
|
+
return { name: "sourceAfter", lineStart, contentStart };
|
|
116
|
+
if (line === targetBeforeLine)
|
|
117
|
+
return { name: "targetBefore", lineStart, contentStart };
|
|
118
|
+
if (line === targetAfterLine)
|
|
119
|
+
return { name: "targetAfter", lineStart, contentStart };
|
|
120
|
+
if (line === endLine)
|
|
121
|
+
return { name: "end", lineStart, contentStart };
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
function section(input, marker, nextMarker) {
|
|
125
|
+
let end = nextMarker.lineStart;
|
|
126
|
+
if (end > marker.contentStart && input[end - 1] === 10) {
|
|
127
|
+
end -= 1;
|
|
128
|
+
if (end > marker.contentStart && input[end - 1] === 13) {
|
|
129
|
+
end -= 1;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return Buffer.from(input.subarray(marker.contentStart, end));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// src/engine.ts
|
|
136
|
+
async function checkPatchFile(patchPath, options = {}) {
|
|
137
|
+
return runPatchFile(patchPath, { ...options, dryRun: true });
|
|
138
|
+
}
|
|
139
|
+
async function applyPatchFile(patchPath, options = {}) {
|
|
140
|
+
return runPatchFile(patchPath, options);
|
|
141
|
+
}
|
|
142
|
+
async function runPatchFile(patchPath, options) {
|
|
143
|
+
const cwd = options.cwd ?? process.cwd();
|
|
144
|
+
const patchBytes = await readFile(resolve(cwd, patchPath));
|
|
145
|
+
const patch = parseBlockPatch(patchBytes);
|
|
146
|
+
const changedPath = await applyMovePatch(patch, cwd, options.dryRun ?? false);
|
|
147
|
+
return { changed: [changedPath] };
|
|
148
|
+
}
|
|
149
|
+
async function applyMovePatch(patch, cwd, dryRun) {
|
|
150
|
+
const absolutePath = resolve(cwd, patch.path);
|
|
151
|
+
const original = await readFile(absolutePath);
|
|
152
|
+
const selection = selectMove(original, patch);
|
|
153
|
+
const next = applyMove(original, selection);
|
|
154
|
+
if (!dryRun && !next.equals(original)) {
|
|
155
|
+
await writeAtomic(absolutePath, next);
|
|
156
|
+
}
|
|
157
|
+
return patch.path;
|
|
158
|
+
}
|
|
159
|
+
function selectMove(file, patch) {
|
|
160
|
+
const source = findSourceRange(file, patch);
|
|
161
|
+
const target = findTarget(file, patch);
|
|
162
|
+
if (rangesOverlap(source, target.range)) {
|
|
163
|
+
fail("target_overlaps_source", `Target anchor for ${patch.path} overlaps the source block`);
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
source,
|
|
167
|
+
target,
|
|
168
|
+
payload: Buffer.from(file.subarray(source.start, source.end))
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
function applyMove(file, selection) {
|
|
172
|
+
const withoutSource = Buffer.concat([
|
|
173
|
+
file.subarray(0, selection.source.start),
|
|
174
|
+
file.subarray(selection.source.end)
|
|
175
|
+
]);
|
|
176
|
+
const targetIndex = selection.target.insertIndex > selection.source.end ? selection.target.insertIndex - selection.payload.length : selection.target.insertIndex;
|
|
177
|
+
return Buffer.concat([
|
|
178
|
+
withoutSource.subarray(0, targetIndex),
|
|
179
|
+
selection.payload,
|
|
180
|
+
withoutSource.subarray(targetIndex)
|
|
181
|
+
]);
|
|
182
|
+
}
|
|
183
|
+
function findSourceRange(file, patch) {
|
|
184
|
+
const fullSource = Buffer.concat([patch.sourceBefore, patch.sourcePayload, patch.sourceAfter]);
|
|
185
|
+
const fullMatches = indexesOf(file, fullSource);
|
|
186
|
+
if (fullMatches.length === 1) {
|
|
187
|
+
const start = fullMatches[0] + patch.sourceBefore.length;
|
|
188
|
+
return { start, end: start + patch.sourcePayload.length };
|
|
189
|
+
}
|
|
190
|
+
if (fullMatches.length > 1) {
|
|
191
|
+
fail("source_ambiguous", `Source block is ambiguous in ${patch.path}; matched ${fullMatches.length} locations`);
|
|
192
|
+
}
|
|
193
|
+
const envelopes = findSourceEnvelopes(file, patch);
|
|
194
|
+
if (envelopes.length === 1) {
|
|
195
|
+
fail("payload_mismatch", `Source payload does not match located source anchors in ${patch.path}`);
|
|
196
|
+
}
|
|
197
|
+
if (envelopes.length > 1) {
|
|
198
|
+
fail("source_ambiguous", `Source anchors are ambiguous in ${patch.path}; matched ${envelopes.length} locations`);
|
|
199
|
+
}
|
|
200
|
+
fail("source_not_found", `Source anchors were not found in ${patch.path}`);
|
|
201
|
+
}
|
|
202
|
+
function findSourceEnvelopes(file, patch) {
|
|
203
|
+
const beforeMatches = indexesOf(file, patch.sourceBefore);
|
|
204
|
+
const afterMatches = indexesOf(file, patch.sourceAfter);
|
|
205
|
+
const ranges = [];
|
|
206
|
+
for (const beforeStart of beforeMatches) {
|
|
207
|
+
const payloadStart = beforeStart + patch.sourceBefore.length;
|
|
208
|
+
const afterStart = afterMatches.find((candidate) => candidate >= payloadStart);
|
|
209
|
+
if (afterStart !== undefined) {
|
|
210
|
+
ranges.push({ start: payloadStart, end: afterStart });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return ranges;
|
|
214
|
+
}
|
|
215
|
+
function findTarget(file, patch) {
|
|
216
|
+
const matches = indexesOf(file, patch.target.anchor);
|
|
217
|
+
if (matches.length === 0) {
|
|
218
|
+
fail("target_not_found", `Target anchor was not found in ${patch.path}`);
|
|
219
|
+
}
|
|
220
|
+
if (matches.length > 1) {
|
|
221
|
+
fail("target_ambiguous", `Target anchor is ambiguous in ${patch.path}; matched ${matches.length} locations`);
|
|
222
|
+
}
|
|
223
|
+
const start = matches[0];
|
|
224
|
+
const end = start + patch.target.anchor.length;
|
|
225
|
+
return {
|
|
226
|
+
range: { start, end },
|
|
227
|
+
insertIndex: patch.target.kind === "before" ? start : end
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
function indexesOf(haystack, needle) {
|
|
231
|
+
if (needle.length === 0) {
|
|
232
|
+
return [];
|
|
233
|
+
}
|
|
234
|
+
const indexes = [];
|
|
235
|
+
let index = haystack.indexOf(needle);
|
|
236
|
+
while (index !== -1) {
|
|
237
|
+
indexes.push(index);
|
|
238
|
+
index = haystack.indexOf(needle, index + 1);
|
|
239
|
+
}
|
|
240
|
+
return indexes;
|
|
241
|
+
}
|
|
242
|
+
function rangesOverlap(left, right) {
|
|
243
|
+
return left.start < right.end && right.start < left.end;
|
|
244
|
+
}
|
|
245
|
+
async function writeAtomic(path, bytes) {
|
|
246
|
+
const info = await stat(path);
|
|
247
|
+
const dir = dirname(path);
|
|
248
|
+
const base = basename(path);
|
|
249
|
+
const temp = join(dir, `.${base}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`);
|
|
250
|
+
try {
|
|
251
|
+
await writeFile(temp, bytes, { flag: "wx" });
|
|
252
|
+
await chmod(temp, info.mode);
|
|
253
|
+
await rename(temp, path);
|
|
254
|
+
} catch (error) {
|
|
255
|
+
await unlink(temp).catch(() => {
|
|
256
|
+
return;
|
|
257
|
+
});
|
|
258
|
+
throw error;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// src/cli.ts
|
|
263
|
+
async function main(argv) {
|
|
264
|
+
const options = parseArgs(argv);
|
|
265
|
+
if (options.command === "help") {
|
|
266
|
+
printHelp();
|
|
267
|
+
return 0;
|
|
268
|
+
}
|
|
269
|
+
if (options.command === "version") {
|
|
270
|
+
console.log("blockpatch 0.1.0");
|
|
271
|
+
return 0;
|
|
272
|
+
}
|
|
273
|
+
if (options.patchPath === undefined) {
|
|
274
|
+
throw new BlockPatchError("missing_patch", "Missing patch file path");
|
|
275
|
+
}
|
|
276
|
+
const result = options.command === "check" ? await checkPatchFile(options.patchPath, { cwd: options.cwd }) : await applyPatchFile(options.patchPath, {
|
|
277
|
+
cwd: options.cwd,
|
|
278
|
+
dryRun: options.dryRun
|
|
279
|
+
});
|
|
280
|
+
const verb = options.command === "check" || options.dryRun ? "would change" : "changed";
|
|
281
|
+
for (const path of result.changed) {
|
|
282
|
+
console.log(`${verb} ${path}`);
|
|
283
|
+
}
|
|
284
|
+
return 0;
|
|
285
|
+
}
|
|
286
|
+
function parseArgs(argv) {
|
|
287
|
+
const args = [...argv];
|
|
288
|
+
const first = args.shift();
|
|
289
|
+
if (first === undefined || first === "help" || first === "--help" || first === "-h") {
|
|
290
|
+
return { command: "help", cwd: process.cwd(), dryRun: false };
|
|
291
|
+
}
|
|
292
|
+
if (first === "version" || first === "--version" || first === "-v") {
|
|
293
|
+
return { command: "version", cwd: process.cwd(), dryRun: false };
|
|
294
|
+
}
|
|
295
|
+
if (first !== "apply" && first !== "check") {
|
|
296
|
+
throw new BlockPatchError("unknown_command", `Unknown command: ${first}`);
|
|
297
|
+
}
|
|
298
|
+
let cwd = process.cwd();
|
|
299
|
+
let dryRun = false;
|
|
300
|
+
let patchPath;
|
|
301
|
+
while (args.length > 0) {
|
|
302
|
+
const arg = args.shift();
|
|
303
|
+
if (arg === "--dry-run") {
|
|
304
|
+
dryRun = true;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (arg === "--cwd") {
|
|
308
|
+
const value = args.shift();
|
|
309
|
+
if (value === undefined) {
|
|
310
|
+
throw new BlockPatchError("missing_cwd", "Missing value for --cwd");
|
|
311
|
+
}
|
|
312
|
+
cwd = resolve2(value);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (arg?.startsWith("--cwd=")) {
|
|
316
|
+
cwd = resolve2(arg.slice("--cwd=".length));
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (arg?.startsWith("-")) {
|
|
320
|
+
throw new BlockPatchError("unknown_option", `Unknown option: ${arg}`);
|
|
321
|
+
}
|
|
322
|
+
if (patchPath !== undefined) {
|
|
323
|
+
throw new BlockPatchError("too_many_args", `Unexpected argument: ${arg}`);
|
|
324
|
+
}
|
|
325
|
+
patchPath = arg;
|
|
326
|
+
}
|
|
327
|
+
if (first === "check" && dryRun) {
|
|
328
|
+
throw new BlockPatchError("invalid_option", "--dry-run is only valid with apply");
|
|
329
|
+
}
|
|
330
|
+
return { command: first, patchPath, cwd, dryRun };
|
|
331
|
+
}
|
|
332
|
+
function printHelp() {
|
|
333
|
+
console.log(`blockpatch
|
|
334
|
+
|
|
335
|
+
Usage:
|
|
336
|
+
blockpatch check <patch.blockpatch> [--cwd <dir>]
|
|
337
|
+
blockpatch apply <patch.blockpatch> [--cwd <dir>] [--dry-run]
|
|
338
|
+
blockpatch version
|
|
339
|
+
`);
|
|
340
|
+
}
|
|
341
|
+
main(process.argv.slice(2)).then((code) => {
|
|
342
|
+
process.exitCode = code;
|
|
343
|
+
}, (error) => {
|
|
344
|
+
if (error instanceof BlockPatchError) {
|
|
345
|
+
console.error(`blockpatch: ${error.message}`);
|
|
346
|
+
process.exitCode = 1;
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
throw error;
|
|
350
|
+
});
|
package/dist/engine.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ApplyOptions, ApplyResult, BlockPatch } from "./types";
|
|
2
|
+
interface ByteRange {
|
|
3
|
+
start: number;
|
|
4
|
+
end: number;
|
|
5
|
+
}
|
|
6
|
+
interface TargetSelection {
|
|
7
|
+
range: ByteRange;
|
|
8
|
+
insertIndex: number;
|
|
9
|
+
}
|
|
10
|
+
interface MoveSelection {
|
|
11
|
+
source: ByteRange;
|
|
12
|
+
target: TargetSelection;
|
|
13
|
+
payload: Buffer;
|
|
14
|
+
}
|
|
15
|
+
export declare function checkPatchFile(patchPath: string, options?: ApplyOptions): Promise<ApplyResult>;
|
|
16
|
+
export declare function applyPatchFile(patchPath: string, options?: ApplyOptions): Promise<ApplyResult>;
|
|
17
|
+
export declare function selectMove(file: Buffer, patch: BlockPatch): MoveSelection;
|
|
18
|
+
export declare function applyMove(file: Buffer, selection: MoveSelection): Buffer;
|
|
19
|
+
export {};
|
package/dist/errors.d.ts
ADDED
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// src/engine.ts
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { chmod, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
// src/errors.ts
|
|
7
|
+
class BlockPatchError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
constructor(code, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "BlockPatchError";
|
|
12
|
+
this.code = code;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function fail(code, message) {
|
|
16
|
+
throw new BlockPatchError(code, message);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// src/parser.ts
|
|
20
|
+
var beginLine = "*** Begin BlockPatch";
|
|
21
|
+
var sourceBeforeLine = "*** Source Before";
|
|
22
|
+
var sourcePayloadLine = "*** Source Payload";
|
|
23
|
+
var sourceAfterLine = "*** Source After";
|
|
24
|
+
var targetBeforeLine = "*** Target Before";
|
|
25
|
+
var targetAfterLine = "*** Target After";
|
|
26
|
+
var endLine = "*** End BlockPatch";
|
|
27
|
+
var moveFilePrefix = "*** Move File: ";
|
|
28
|
+
function parseBlockPatch(input) {
|
|
29
|
+
const markers = collectMarkers(input);
|
|
30
|
+
const sequence = markers.map((marker) => marker.name);
|
|
31
|
+
const targetIndex = sequence.findIndex((name) => name === "targetBefore" || name === "targetAfter");
|
|
32
|
+
if (targetIndex === -1) {
|
|
33
|
+
fail("parse_error", "Patch must contain *** Target Before or *** Target After");
|
|
34
|
+
}
|
|
35
|
+
const expectedPrefix = ["begin", "moveFile", "sourceBefore", "sourcePayload", "sourceAfter"];
|
|
36
|
+
const expectedSuffix = ["end"];
|
|
37
|
+
const expected = [...expectedPrefix, sequence[targetIndex], ...expectedSuffix];
|
|
38
|
+
if (sequence.length !== expected.length || sequence.some((name, index) => name !== expected[index])) {
|
|
39
|
+
fail("parse_error", "Patch markers must appear in the v0 order");
|
|
40
|
+
}
|
|
41
|
+
const moveFile = markers[1];
|
|
42
|
+
const path = moveFile.value?.trim();
|
|
43
|
+
if (!path) {
|
|
44
|
+
fail("parse_error", "*** Move File must include a path");
|
|
45
|
+
}
|
|
46
|
+
const sourceBefore = section(input, markers[2], markers[3]);
|
|
47
|
+
const sourcePayload = section(input, markers[3], markers[4]);
|
|
48
|
+
const sourceAfter = section(input, markers[4], markers[5]);
|
|
49
|
+
const targetAnchor = section(input, markers[5], markers[6]);
|
|
50
|
+
const target = {
|
|
51
|
+
kind: markers[5].name === "targetBefore" ? "before" : "after",
|
|
52
|
+
anchor: targetAnchor
|
|
53
|
+
};
|
|
54
|
+
if (sourceBefore.length === 0) {
|
|
55
|
+
fail("parse_error", "*** Source Before must not be empty");
|
|
56
|
+
}
|
|
57
|
+
if (sourcePayload.length === 0) {
|
|
58
|
+
fail("parse_error", "*** Source Payload must not be empty");
|
|
59
|
+
}
|
|
60
|
+
if (sourceAfter.length === 0) {
|
|
61
|
+
fail("parse_error", "*** Source After must not be empty");
|
|
62
|
+
}
|
|
63
|
+
if (target.anchor.length === 0) {
|
|
64
|
+
fail("parse_error", "*** Target Before/After must not be empty");
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
type: "move",
|
|
68
|
+
path,
|
|
69
|
+
sourceBefore,
|
|
70
|
+
sourcePayload,
|
|
71
|
+
sourceAfter,
|
|
72
|
+
target
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function collectMarkers(input) {
|
|
76
|
+
const markers = [];
|
|
77
|
+
let position = 0;
|
|
78
|
+
while (position <= input.length) {
|
|
79
|
+
const lineStart = position;
|
|
80
|
+
const lf = input.indexOf(10, position);
|
|
81
|
+
const lineEnd = lf === -1 ? input.length : lf;
|
|
82
|
+
const contentStart = lf === -1 ? input.length : lf + 1;
|
|
83
|
+
const textEnd = lineEnd > lineStart && input[lineEnd - 1] === 13 ? lineEnd - 1 : lineEnd;
|
|
84
|
+
const line = input.subarray(lineStart, textEnd).toString("utf8");
|
|
85
|
+
const marker = markerForLine(line, lineStart, contentStart);
|
|
86
|
+
if (marker !== undefined) {
|
|
87
|
+
markers.push(marker);
|
|
88
|
+
}
|
|
89
|
+
if (lf === -1) {
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
position = lf + 1;
|
|
93
|
+
}
|
|
94
|
+
if (markers.length === 0) {
|
|
95
|
+
fail("parse_error", "Patch does not contain BlockPatch markers");
|
|
96
|
+
}
|
|
97
|
+
return markers;
|
|
98
|
+
}
|
|
99
|
+
function markerForLine(line, lineStart, contentStart) {
|
|
100
|
+
if (line === beginLine)
|
|
101
|
+
return { name: "begin", lineStart, contentStart };
|
|
102
|
+
if (line.startsWith(moveFilePrefix)) {
|
|
103
|
+
return { name: "moveFile", value: line.slice(moveFilePrefix.length), lineStart, contentStart };
|
|
104
|
+
}
|
|
105
|
+
if (line === sourceBeforeLine)
|
|
106
|
+
return { name: "sourceBefore", lineStart, contentStart };
|
|
107
|
+
if (line === sourcePayloadLine)
|
|
108
|
+
return { name: "sourcePayload", lineStart, contentStart };
|
|
109
|
+
if (line === sourceAfterLine)
|
|
110
|
+
return { name: "sourceAfter", lineStart, contentStart };
|
|
111
|
+
if (line === targetBeforeLine)
|
|
112
|
+
return { name: "targetBefore", lineStart, contentStart };
|
|
113
|
+
if (line === targetAfterLine)
|
|
114
|
+
return { name: "targetAfter", lineStart, contentStart };
|
|
115
|
+
if (line === endLine)
|
|
116
|
+
return { name: "end", lineStart, contentStart };
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
function section(input, marker, nextMarker) {
|
|
120
|
+
let end = nextMarker.lineStart;
|
|
121
|
+
if (end > marker.contentStart && input[end - 1] === 10) {
|
|
122
|
+
end -= 1;
|
|
123
|
+
if (end > marker.contentStart && input[end - 1] === 13) {
|
|
124
|
+
end -= 1;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return Buffer.from(input.subarray(marker.contentStart, end));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// src/engine.ts
|
|
131
|
+
async function checkPatchFile(patchPath, options = {}) {
|
|
132
|
+
return runPatchFile(patchPath, { ...options, dryRun: true });
|
|
133
|
+
}
|
|
134
|
+
async function applyPatchFile(patchPath, options = {}) {
|
|
135
|
+
return runPatchFile(patchPath, options);
|
|
136
|
+
}
|
|
137
|
+
async function runPatchFile(patchPath, options) {
|
|
138
|
+
const cwd = options.cwd ?? process.cwd();
|
|
139
|
+
const patchBytes = await readFile(resolve(cwd, patchPath));
|
|
140
|
+
const patch = parseBlockPatch(patchBytes);
|
|
141
|
+
const changedPath = await applyMovePatch(patch, cwd, options.dryRun ?? false);
|
|
142
|
+
return { changed: [changedPath] };
|
|
143
|
+
}
|
|
144
|
+
async function applyMovePatch(patch, cwd, dryRun) {
|
|
145
|
+
const absolutePath = resolve(cwd, patch.path);
|
|
146
|
+
const original = await readFile(absolutePath);
|
|
147
|
+
const selection = selectMove(original, patch);
|
|
148
|
+
const next = applyMove(original, selection);
|
|
149
|
+
if (!dryRun && !next.equals(original)) {
|
|
150
|
+
await writeAtomic(absolutePath, next);
|
|
151
|
+
}
|
|
152
|
+
return patch.path;
|
|
153
|
+
}
|
|
154
|
+
function selectMove(file, patch) {
|
|
155
|
+
const source = findSourceRange(file, patch);
|
|
156
|
+
const target = findTarget(file, patch);
|
|
157
|
+
if (rangesOverlap(source, target.range)) {
|
|
158
|
+
fail("target_overlaps_source", `Target anchor for ${patch.path} overlaps the source block`);
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
source,
|
|
162
|
+
target,
|
|
163
|
+
payload: Buffer.from(file.subarray(source.start, source.end))
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
function applyMove(file, selection) {
|
|
167
|
+
const withoutSource = Buffer.concat([
|
|
168
|
+
file.subarray(0, selection.source.start),
|
|
169
|
+
file.subarray(selection.source.end)
|
|
170
|
+
]);
|
|
171
|
+
const targetIndex = selection.target.insertIndex > selection.source.end ? selection.target.insertIndex - selection.payload.length : selection.target.insertIndex;
|
|
172
|
+
return Buffer.concat([
|
|
173
|
+
withoutSource.subarray(0, targetIndex),
|
|
174
|
+
selection.payload,
|
|
175
|
+
withoutSource.subarray(targetIndex)
|
|
176
|
+
]);
|
|
177
|
+
}
|
|
178
|
+
function findSourceRange(file, patch) {
|
|
179
|
+
const fullSource = Buffer.concat([patch.sourceBefore, patch.sourcePayload, patch.sourceAfter]);
|
|
180
|
+
const fullMatches = indexesOf(file, fullSource);
|
|
181
|
+
if (fullMatches.length === 1) {
|
|
182
|
+
const start = fullMatches[0] + patch.sourceBefore.length;
|
|
183
|
+
return { start, end: start + patch.sourcePayload.length };
|
|
184
|
+
}
|
|
185
|
+
if (fullMatches.length > 1) {
|
|
186
|
+
fail("source_ambiguous", `Source block is ambiguous in ${patch.path}; matched ${fullMatches.length} locations`);
|
|
187
|
+
}
|
|
188
|
+
const envelopes = findSourceEnvelopes(file, patch);
|
|
189
|
+
if (envelopes.length === 1) {
|
|
190
|
+
fail("payload_mismatch", `Source payload does not match located source anchors in ${patch.path}`);
|
|
191
|
+
}
|
|
192
|
+
if (envelopes.length > 1) {
|
|
193
|
+
fail("source_ambiguous", `Source anchors are ambiguous in ${patch.path}; matched ${envelopes.length} locations`);
|
|
194
|
+
}
|
|
195
|
+
fail("source_not_found", `Source anchors were not found in ${patch.path}`);
|
|
196
|
+
}
|
|
197
|
+
function findSourceEnvelopes(file, patch) {
|
|
198
|
+
const beforeMatches = indexesOf(file, patch.sourceBefore);
|
|
199
|
+
const afterMatches = indexesOf(file, patch.sourceAfter);
|
|
200
|
+
const ranges = [];
|
|
201
|
+
for (const beforeStart of beforeMatches) {
|
|
202
|
+
const payloadStart = beforeStart + patch.sourceBefore.length;
|
|
203
|
+
const afterStart = afterMatches.find((candidate) => candidate >= payloadStart);
|
|
204
|
+
if (afterStart !== undefined) {
|
|
205
|
+
ranges.push({ start: payloadStart, end: afterStart });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return ranges;
|
|
209
|
+
}
|
|
210
|
+
function findTarget(file, patch) {
|
|
211
|
+
const matches = indexesOf(file, patch.target.anchor);
|
|
212
|
+
if (matches.length === 0) {
|
|
213
|
+
fail("target_not_found", `Target anchor was not found in ${patch.path}`);
|
|
214
|
+
}
|
|
215
|
+
if (matches.length > 1) {
|
|
216
|
+
fail("target_ambiguous", `Target anchor is ambiguous in ${patch.path}; matched ${matches.length} locations`);
|
|
217
|
+
}
|
|
218
|
+
const start = matches[0];
|
|
219
|
+
const end = start + patch.target.anchor.length;
|
|
220
|
+
return {
|
|
221
|
+
range: { start, end },
|
|
222
|
+
insertIndex: patch.target.kind === "before" ? start : end
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
function indexesOf(haystack, needle) {
|
|
226
|
+
if (needle.length === 0) {
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
const indexes = [];
|
|
230
|
+
let index = haystack.indexOf(needle);
|
|
231
|
+
while (index !== -1) {
|
|
232
|
+
indexes.push(index);
|
|
233
|
+
index = haystack.indexOf(needle, index + 1);
|
|
234
|
+
}
|
|
235
|
+
return indexes;
|
|
236
|
+
}
|
|
237
|
+
function rangesOverlap(left, right) {
|
|
238
|
+
return left.start < right.end && right.start < left.end;
|
|
239
|
+
}
|
|
240
|
+
async function writeAtomic(path, bytes) {
|
|
241
|
+
const info = await stat(path);
|
|
242
|
+
const dir = dirname(path);
|
|
243
|
+
const base = basename(path);
|
|
244
|
+
const temp = join(dir, `.${base}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`);
|
|
245
|
+
try {
|
|
246
|
+
await writeFile(temp, bytes, { flag: "wx" });
|
|
247
|
+
await chmod(temp, info.mode);
|
|
248
|
+
await rename(temp, path);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
await unlink(temp).catch(() => {
|
|
251
|
+
return;
|
|
252
|
+
});
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
export {
|
|
257
|
+
selectMove,
|
|
258
|
+
parseBlockPatch,
|
|
259
|
+
checkPatchFile,
|
|
260
|
+
applyPatchFile,
|
|
261
|
+
applyMove,
|
|
262
|
+
BlockPatchError
|
|
263
|
+
};
|
package/dist/parser.d.ts
ADDED
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type TargetKind = "before" | "after";
|
|
2
|
+
export interface BlockPatch {
|
|
3
|
+
type: "move";
|
|
4
|
+
path: string;
|
|
5
|
+
sourceBefore: Buffer;
|
|
6
|
+
sourcePayload: Buffer;
|
|
7
|
+
sourceAfter: Buffer;
|
|
8
|
+
target: TargetAnchor;
|
|
9
|
+
}
|
|
10
|
+
export interface TargetAnchor {
|
|
11
|
+
kind: TargetKind;
|
|
12
|
+
anchor: Buffer;
|
|
13
|
+
}
|
|
14
|
+
export interface ApplyOptions {
|
|
15
|
+
cwd?: string;
|
|
16
|
+
dryRun?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface ApplyResult {
|
|
19
|
+
changed: string[];
|
|
20
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "blockpatch",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Anchored text block relocation patches",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"blockpatch": "dist/cli.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "bun test",
|
|
25
|
+
"build": "bun build ./src/cli.ts ./src/index.ts --target=node --outdir=dist && tsc -p tsconfig.build.json",
|
|
26
|
+
"prepublishOnly": "bun test && bun run build",
|
|
27
|
+
"publish:dry": "npm publish --dry-run"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/bun": "latest",
|
|
31
|
+
"typescript": "^5.8.3"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=20"
|
|
35
|
+
}
|
|
36
|
+
}
|