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 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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
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
+ });
@@ -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 {};
@@ -0,0 +1,5 @@
1
+ export declare class BlockPatchError extends Error {
2
+ readonly code: string;
3
+ constructor(code: string, message: string);
4
+ }
5
+ export declare function fail(code: string, message: string): never;
@@ -0,0 +1,4 @@
1
+ export { applyMove, applyPatchFile, checkPatchFile, selectMove } from "./engine";
2
+ export { BlockPatchError } from "./errors";
3
+ export { parseBlockPatch } from "./parser";
4
+ export type { ApplyOptions, ApplyResult, BlockPatch, TargetAnchor, TargetKind } from "./types";
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
+ };
@@ -0,0 +1,2 @@
1
+ import type { BlockPatch } from "./types";
2
+ export declare function parseBlockPatch(input: Buffer): BlockPatch;
@@ -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
+ }