blockpatch 0.1.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CONTRIBUTING.md +26 -0
  2. package/README.md +131 -74
  3. package/conformance/README.md +29 -0
  4. package/conformance/cases/ambiguous-source/case.json +8 -0
  5. package/conformance/cases/ambiguous-target/case.json +8 -0
  6. package/conformance/cases/create-file/case.json +8 -0
  7. package/conformance/cases/crlf/case.json +10 -0
  8. package/conformance/cases/cross-file-relocation/case.json +12 -0
  9. package/conformance/cases/delete-existing-file/case.json +10 -0
  10. package/conformance/cases/insert-existing-file/case.json +10 -0
  11. package/conformance/cases/no-trailing-newline/case.json +10 -0
  12. package/conformance/cases/remove-file/case.json +9 -0
  13. package/conformance/cases/same-file-relocation/case.json +10 -0
  14. package/conformance/runner.mjs +221 -0
  15. package/dist/cli.js +2908 -244
  16. package/docs/behavior.md +207 -0
  17. package/docs/commands.md +84 -0
  18. package/docs/spec.md +356 -0
  19. package/examples/README.md +15 -0
  20. package/examples/create-file/README.md +9 -0
  21. package/examples/create-file/expected/file.txt +1 -0
  22. package/examples/create-file/patch.blockpatch +8 -0
  23. package/examples/create-file/work/.gitkeep +1 -0
  24. package/examples/cross-file-relocation/README.md +10 -0
  25. package/examples/cross-file-relocation/expected/source.txt +2 -0
  26. package/examples/cross-file-relocation/expected/target.txt +6 -0
  27. package/examples/cross-file-relocation/patch.blockpatch +26 -0
  28. package/examples/cross-file-relocation/work/source.txt +5 -0
  29. package/examples/cross-file-relocation/work/target.txt +3 -0
  30. package/examples/delete-existing-file/README.md +9 -0
  31. package/examples/delete-existing-file/expected/file.txt +2 -0
  32. package/examples/delete-existing-file/patch.blockpatch +10 -0
  33. package/examples/delete-existing-file/work/file.txt +3 -0
  34. package/examples/failure-ambiguous-target/README.md +9 -0
  35. package/examples/failure-ambiguous-target/patch.blockpatch +14 -0
  36. package/examples/failure-ambiguous-target/work/file.txt +5 -0
  37. package/examples/insert-existing-file/README.md +9 -0
  38. package/examples/insert-existing-file/expected/file.txt +3 -0
  39. package/examples/insert-existing-file/patch.blockpatch +10 -0
  40. package/examples/insert-existing-file/work/file.txt +2 -0
  41. package/examples/remove-file/README.md +9 -0
  42. package/examples/remove-file/expected/.gitkeep +1 -0
  43. package/examples/remove-file/patch.blockpatch +8 -0
  44. package/examples/remove-file/work/file.txt +1 -0
  45. package/examples/reverse/README.md +9 -0
  46. package/examples/reverse/expected/file.txt +4 -0
  47. package/examples/reverse/patch.blockpatch +14 -0
  48. package/examples/reverse/work/file.txt +4 -0
  49. package/examples/same-file-relocation/README.md +9 -0
  50. package/examples/same-file-relocation/expected/file.txt +4 -0
  51. package/examples/same-file-relocation/patch.blockpatch +14 -0
  52. package/examples/same-file-relocation/work/file.txt +4 -0
  53. package/package.json +33 -13
  54. package/dist/cli.d.ts +0 -2
  55. package/dist/engine.d.ts +0 -19
  56. package/dist/errors.d.ts +0 -5
  57. package/dist/index.d.ts +0 -4
  58. package/dist/index.js +0 -263
  59. package/dist/parser.d.ts +0 -2
  60. package/dist/types.d.ts +0 -20
package/dist/cli.js CHANGED
@@ -1,265 +1,2612 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { resolve as resolve2 } from "node:path";
4
+ import { createRequire } from "node:module";
5
+ import { resolve as resolve3 } from "node:path";
5
6
 
6
7
  // src/errors.ts
8
+ var maxErrorRanges = 10;
9
+
7
10
  class BlockPatchError extends Error {
8
11
  code;
9
- constructor(code, message) {
12
+ details;
13
+ constructor(code, message, details = {}) {
10
14
  super(message);
11
15
  this.name = "BlockPatchError";
12
16
  this.code = code;
17
+ this.details = details;
18
+ }
19
+ }
20
+ function fail(code, message, details) {
21
+ throw new BlockPatchError(code, message, details);
22
+ }
23
+ function matchedLocations(count, truncated) {
24
+ return `matched ${truncated ? "at least " : ""}${count} locations`;
25
+ }
26
+ function matchCountDetails(count, truncated) {
27
+ return truncated ? { matches: count, matches_truncated: true } : { matches: count };
28
+ }
29
+ function boundedMatchRanges(matches, byteLength) {
30
+ const ranges = [];
31
+ for (const start of matches) {
32
+ if (ranges.length >= maxErrorRanges) {
33
+ break;
34
+ }
35
+ ranges.push({ start, end: start + byteLength });
36
+ }
37
+ return ranges;
38
+ }
39
+ function boundedRanges(matches) {
40
+ const ranges = [];
41
+ for (const range of matches) {
42
+ if (ranges.length >= maxErrorRanges) {
43
+ break;
44
+ }
45
+ ranges.push({ start: range.start, end: range.end });
46
+ }
47
+ return ranges;
48
+ }
49
+ function boundedMatchLineRanges(file, matches, byteLength) {
50
+ const lineRanges = [];
51
+ for (const start of matches) {
52
+ if (lineRanges.length >= maxErrorRanges) {
53
+ break;
54
+ }
55
+ lineRanges.push(byteRangeToLineRange(file, { start, end: start + byteLength }));
56
+ }
57
+ return lineRanges;
58
+ }
59
+ function boundedLineRanges(file, ranges) {
60
+ return boundedRanges(ranges).map((range) => byteRangeToLineRange(file, range));
61
+ }
62
+ function byteRangeToLineRange(file, range) {
63
+ const start = clamp(range.start, 0, file.length);
64
+ const end = clamp(range.end, start, file.length);
65
+ const endByte = end > start ? end - 1 : start;
66
+ return {
67
+ start: lineNumberAt(file, start),
68
+ end: lineNumberAt(file, endByte)
69
+ };
70
+ }
71
+ function lineNumberAt(file, byteIndex) {
72
+ let line = 1;
73
+ const end = Math.min(byteIndex, file.length);
74
+ for (let index = 0;index < end; index += 1) {
75
+ if (file[index] === 10) {
76
+ line += 1;
77
+ }
78
+ }
79
+ return line;
80
+ }
81
+ function clamp(value, min, max) {
82
+ return Math.min(Math.max(value, min), max);
83
+ }
84
+
85
+ // src/engine.ts
86
+ import { Buffer as Buffer3 } from "node:buffer";
87
+ import { randomBytes } from "node:crypto";
88
+ import { chmod, lstat, mkdir, rename, rmdir, stat as stat2, unlink, writeFile } from "node:fs/promises";
89
+ import { basename, dirname, isAbsolute as isAbsolute2, join as join2, relative as relative2, resolve as resolve2, sep as sep2 } from "node:path";
90
+
91
+ // src/files.ts
92
+ import { createHash } from "node:crypto";
93
+ import { lstatSync, realpathSync } from "node:fs";
94
+ import { readFile, stat } from "node:fs/promises";
95
+ async function readFileChecked(path, label) {
96
+ try {
97
+ return await readFile(path);
98
+ } catch (error) {
99
+ failFileSystem(error, path, `Could not read ${label}`);
100
+ }
101
+ }
102
+ async function readFileSnapshot(path, label) {
103
+ const bytes = await readFileChecked(path, label);
104
+ const info = await statChecked(path, label);
105
+ assertRegularFile(info, path, label);
106
+ return fileSnapshot(bytes, info);
107
+ }
108
+ function fileSnapshot(bytes, info) {
109
+ return {
110
+ bytes,
111
+ sha256: hashBytes(bytes),
112
+ stat: statSnapshot(info)
113
+ };
114
+ }
115
+ function hashBytes(bytes) {
116
+ return createHash("sha256").update(bytes).digest("hex");
117
+ }
118
+ async function statChecked(path, label) {
119
+ try {
120
+ return await stat(path);
121
+ } catch (error) {
122
+ failFileSystem(error, path, `Could not stat ${label}`);
123
+ }
124
+ }
125
+ function lstatSyncChecked(path, label, userPath = path) {
126
+ try {
127
+ return lstatSync(path);
128
+ } catch (error) {
129
+ failFileSystem(error, userPath, `Could not stat ${label}`, "path");
130
+ }
131
+ }
132
+ function realpathSyncChecked(path, label, userPath = path) {
133
+ try {
134
+ return realpathSync(path);
135
+ } catch (error) {
136
+ failFileSystem(error, userPath, `Could not resolve ${label}`, "path");
137
+ }
138
+ }
139
+ function assertRegularFile(info, path, label, phase = "io") {
140
+ if (!info.isFile()) {
141
+ fail("not_regular_file", `${label} must be a regular file: ${path}`, { path, phase });
142
+ }
143
+ }
144
+ function failFileSystem(error, path, action, phase = "io") {
145
+ if (error instanceof BlockPatchError) {
146
+ throw error;
147
+ }
148
+ const message = error instanceof Error ? `${action}: ${error.message}` : `${action}: ${path}`;
149
+ fail(fileSystemErrorCode(error), message, { path, phase });
150
+ }
151
+ function statSnapshot(info) {
152
+ return {
153
+ dev: info.dev,
154
+ ino: info.ino,
155
+ mode: info.mode,
156
+ size: info.size,
157
+ mtimeMs: info.mtimeMs,
158
+ ctimeMs: info.ctimeMs
159
+ };
160
+ }
161
+ function fileSystemErrorCode(error) {
162
+ switch (error.code) {
163
+ case "ENOENT":
164
+ case "ENOTDIR":
165
+ return "file_not_found";
166
+ case "EISDIR":
167
+ return "not_regular_file";
168
+ case "EACCES":
169
+ case "EPERM":
170
+ return "permission_denied";
171
+ default:
172
+ return "io_error";
173
+ }
174
+ }
175
+
176
+ // src/parser.ts
177
+ import { Buffer as Buffer2 } from "node:buffer";
178
+ import { createHash as createHash2 } from "node:crypto";
179
+ import { posix } from "node:path";
180
+
181
+ // src/paths.ts
182
+ import { lstatSync as lstatSync2 } from "node:fs";
183
+ import { isAbsolute, join, relative, resolve, sep, win32 } from "node:path";
184
+ function validateOperationPath(path, label) {
185
+ if (path === "") {
186
+ fail("invalid_path", `Invalid ${label}: ${path}`, { path, phase: "path" });
187
+ }
188
+ rejectUnsafeDisplayPath(path, label);
189
+ rejectAbsolutePath(path, label);
190
+ rejectBackslashPath(path, label);
191
+ }
192
+ function rejectUnsafeDisplayPath(path, label) {
193
+ if (/[\x00-\x1f\x7f-\x9f]/u.test(path)) {
194
+ fail("invalid_path", `${label} contains unsupported control characters`, {
195
+ path,
196
+ phase: "path"
197
+ });
198
+ }
199
+ }
200
+ function rejectBackslashPath(path, label) {
201
+ if (path.includes("\\")) {
202
+ fail("invalid_path", `${label} must use POSIX-style / separators: ${path}`, {
203
+ path,
204
+ phase: "path"
205
+ });
206
+ }
207
+ }
208
+ function rejectAbsolutePath(path, label) {
209
+ if (isAbsolute(path) || win32.isAbsolute(path)) {
210
+ fail("path_outside_cwd", `${label} must be relative to the working directory: ${path}`, {
211
+ path,
212
+ phase: "path"
213
+ });
214
+ }
215
+ }
216
+ function rejectDotPathSegments(path, label) {
217
+ if (path.split("/").some((part) => part === "." || part === "..")) {
218
+ fail("invalid_path", `${label} must not contain . or .. path segments: ${path}`, {
219
+ path,
220
+ phase: "path"
221
+ });
222
+ }
223
+ }
224
+ function resolvePath(cwd, path, label) {
225
+ validateOperationPath(path, label);
226
+ const root = resolve(cwd);
227
+ const resolved = resolve(root, path);
228
+ const realRoot = realpathSyncChecked(root, "working directory", root);
229
+ if (!isInside(root, resolved)) {
230
+ fail("path_outside_cwd", `${label} escapes the working directory: ${path}`, { path, phase: "path" });
231
+ }
232
+ rejectDotPathSegments(path, label);
233
+ rejectSymlinkComponents(root, resolved, path, label);
234
+ const info = lstatSyncChecked(resolved, label, path);
235
+ assertRegularFile(info, path, label, "path");
236
+ const realResolved = realpathSyncChecked(resolved, label, path);
237
+ if (!isInside(realRoot, realResolved)) {
238
+ fail("path_outside_cwd", `${label} resolves outside the working directory: ${path}`, { path, phase: "path" });
239
+ }
240
+ return resolved;
241
+ }
242
+ function resolvePathAllowMissing(cwd, path, label) {
243
+ validateOperationPath(path, label);
244
+ const root = resolve(cwd);
245
+ const resolved = resolve(root, path);
246
+ const realRoot = realpathSyncChecked(root, "working directory", root);
247
+ if (!isInside(root, resolved)) {
248
+ fail("path_outside_cwd", `${label} escapes the working directory: ${path}`, { path, phase: "path" });
249
+ }
250
+ rejectDotPathSegments(path, label);
251
+ const deepestExisting = rejectExistingSymlinkComponents(root, resolved, path, label);
252
+ if (deepestExisting !== resolved) {
253
+ return { path: resolved, exists: false };
254
+ }
255
+ const info = lstatSyncChecked(resolved, label, path);
256
+ assertRegularFile(info, path, label, "path");
257
+ const realResolved = realpathSyncChecked(resolved, label, path);
258
+ if (!isInside(realRoot, realResolved)) {
259
+ fail("path_outside_cwd", `${label} resolves outside the working directory: ${path}`, { path, phase: "path" });
260
+ }
261
+ return { path: resolved, exists: true };
262
+ }
263
+ async function sameFileIdentity(left, right) {
264
+ if (left === right) {
265
+ return true;
266
+ }
267
+ const [leftInfo, rightInfo] = await Promise.all([
268
+ statChecked(left, "source path"),
269
+ statChecked(right, "destination path")
270
+ ]);
271
+ return leftInfo.dev === rightInfo.dev && leftInfo.ino === rightInfo.ino;
272
+ }
273
+ function rejectSymlinkComponents(root, resolved, originalPath, label) {
274
+ const relativePath = relative(root, resolved);
275
+ if (relativePath === "") {
276
+ return;
277
+ }
278
+ let current = root;
279
+ for (const part of relativePath.split(sep)) {
280
+ current = join(current, part);
281
+ if (lstatSyncChecked(current, label, originalPath).isSymbolicLink()) {
282
+ fail("symlink_path", `${label} must not contain symbolic links: ${originalPath}`, {
283
+ path: originalPath,
284
+ phase: "path"
285
+ });
286
+ }
287
+ }
288
+ }
289
+ function rejectExistingSymlinkComponents(root, resolved, originalPath, label) {
290
+ const relativePath = relative(root, resolved);
291
+ if (relativePath === "") {
292
+ return resolved;
293
+ }
294
+ let current = root;
295
+ for (const part of relativePath.split(sep)) {
296
+ const next = join(current, part);
297
+ let info;
298
+ try {
299
+ info = lstatSync2(next);
300
+ } catch (error) {
301
+ const code = error.code;
302
+ if (code === "ENOENT" || code === "ENOTDIR") {
303
+ return current;
304
+ }
305
+ failFileSystem(error, originalPath, `Could not stat ${label}`, "path");
306
+ }
307
+ if (info.isSymbolicLink()) {
308
+ fail("symlink_path", `${label} must not contain symbolic links: ${originalPath}`, {
309
+ path: originalPath,
310
+ phase: "path"
311
+ });
312
+ }
313
+ current = next;
314
+ }
315
+ return current;
316
+ }
317
+ function isInside(root, path) {
318
+ const fromRoot = relative(root, path);
319
+ return fromRoot === "" || !fromRoot.startsWith("..") && !isAbsolute(fromRoot);
320
+ }
321
+
322
+ // src/parser.ts
323
+ var devNull = "/dev/null";
324
+ var noNewlineMarker = "\";
325
+ var movePrefix = "blockpatch move ";
326
+ var allowedMetadataKeys = new Set(["id", "payload-sha256", "role"]);
327
+ var hunkPattern = /^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@ blockpatch-(source|target) id=([^\s]+)(?: .*)?$/;
328
+ function parseBlockPatch(input, options = {}) {
329
+ const lines = splitLines(input);
330
+ const sections = parseSections(lines, normalizeStripComponents(options.stripComponents ?? 1));
331
+ if (sections.length === 1) {
332
+ return parseSingleSectionMove(sections[0]);
333
+ }
334
+ if (sections.length === 2) {
335
+ return parseSplitSectionMove(sections);
336
+ }
337
+ fail("parse_error", "Patch must contain one same-file move or one split cross-file move");
338
+ }
339
+ function parseSingleSectionMove(section) {
340
+ if (section.src === null && section.dst === null) {
341
+ fail("parse_error", "Patch cannot use /dev/null for both endpoints");
342
+ }
343
+ if (section.role !== undefined) {
344
+ if (section.role === "source" && section.dst !== null) {
345
+ fail("parse_error", "A source role section without a target role section must target /dev/null");
346
+ }
347
+ if (section.role === "target" && section.src !== null) {
348
+ fail("parse_error", "A target role section without a source role section must source /dev/null");
349
+ }
350
+ }
351
+ if (section.src !== null && section.dst !== null && !samePatchPath(section.src, section.dst)) {
352
+ fail("parse_error", "Cross-file moves must use separate source and target file sections");
353
+ }
354
+ if (section.hunks.length < 1 || section.hunks.length > 2) {
355
+ fail("parse_error", "Patch must contain one or two blockpatch hunks");
356
+ }
357
+ const sourceHunks = section.hunks.filter((hunk) => hunk.kind === "source");
358
+ const targetHunks = section.hunks.filter((hunk) => hunk.kind === "target");
359
+ if (sourceHunks.length > 1 || targetHunks.length > 1) {
360
+ fail("parse_error", "Patch must contain at most one source hunk and at most one target hunk");
361
+ }
362
+ const sourceHunk = sourceHunks[0];
363
+ const targetHunk = targetHunks[0];
364
+ for (const hunk of section.hunks) {
365
+ if (hunk.id !== section.moveId) {
366
+ fail("parse_error", "Move metadata id must match source and target hunk ids");
367
+ }
368
+ }
369
+ if (section.src === null) {
370
+ if (sourceHunk !== undefined || targetHunk === undefined) {
371
+ fail("parse_error", "A move from /dev/null must contain exactly one blockpatch-target hunk");
372
+ }
373
+ return buildTargetOnlyBlockPatch(section, targetHunk, { pathState: true });
374
+ }
375
+ if (section.dst === null) {
376
+ if (sourceHunk === undefined || targetHunk !== undefined) {
377
+ fail("parse_error", "A move to /dev/null must contain exactly one blockpatch-source hunk");
378
+ }
379
+ return buildSourceOnlyBlockPatch(section, sourceHunk, { pathState: true });
380
+ }
381
+ if (sourceHunk !== undefined && targetHunk !== undefined) {
382
+ return buildPairedBlockPatch(section.src, section.dst, section.moveId, section.payloadSha256, sourceHunk, targetHunk);
383
+ }
384
+ if (sourceHunk !== undefined) {
385
+ return buildSourceOnlyBlockPatch(section, sourceHunk, { pathState: false });
386
+ }
387
+ if (targetHunk !== undefined) {
388
+ return buildTargetOnlyBlockPatch(section, targetHunk, { pathState: false });
389
+ }
390
+ fail("parse_error", "Patch must contain at least one blockpatch hunk");
391
+ }
392
+ function parseSplitSectionMove(sections) {
393
+ if (sections.some((section) => section.src === null || section.dst === null)) {
394
+ fail("parse_error", "/dev/null endpoints are only valid in single-section moves");
395
+ }
396
+ if (sections.some((section) => section.role === undefined)) {
397
+ fail("parse_error", "Split cross-file move sections must include role=source or role=target");
398
+ }
399
+ const sourceSections = sections.filter((section) => section.role === "source");
400
+ const targetSections = sections.filter((section) => section.role === "target");
401
+ if (sourceSections.length !== 1 || targetSections.length !== 1) {
402
+ fail("parse_error", "Split cross-file moves must contain one source role section and one target role section");
403
+ }
404
+ const sourceSection = sourceSections[0];
405
+ const targetSection = targetSections[0];
406
+ const sourceFile = sourceSection.src;
407
+ const targetFile = targetSection.src;
408
+ if (!samePatchPath(sourceFile, sourceSection.dst) || !samePatchPath(targetFile, targetSection.dst)) {
409
+ fail("parse_error", "Split cross-file section headers must name the same file in --- and +++");
410
+ }
411
+ if (samePatchPath(sourceFile, targetFile)) {
412
+ fail("parse_error", "Split cross-file moves require different source and target files");
413
+ }
414
+ if (sourceSection.moveId !== targetSection.moveId) {
415
+ fail("parse_error", "Split cross-file move sections must use the same move id");
416
+ }
417
+ if (sourceSection.payloadSha256 !== targetSection.payloadSha256) {
418
+ fail("parse_error", "Split cross-file move sections must use the same payload-sha256");
419
+ }
420
+ if (sourceSection.hunks.length !== 1 || sourceSection.hunks[0].kind !== "source") {
421
+ fail("parse_error", "Source role section must contain exactly one blockpatch-source hunk");
422
+ }
423
+ if (targetSection.hunks.length !== 1 || targetSection.hunks[0].kind !== "target") {
424
+ fail("parse_error", "Target role section must contain exactly one blockpatch-target hunk");
425
+ }
426
+ const sourceHunk = sourceSection.hunks[0];
427
+ const targetHunk = targetSection.hunks[0];
428
+ if (sourceHunk.id !== sourceSection.moveId || targetHunk.id !== targetSection.moveId) {
429
+ fail("parse_error", "Move metadata id must match source and target hunk ids");
430
+ }
431
+ return buildPairedBlockPatch(sourceFile, targetFile, sourceSection.moveId, sourceSection.payloadSha256, sourceHunk, targetHunk);
432
+ }
433
+ function buildPairedBlockPatch(src, dst, moveId, payloadSha256, sourceHunk, targetHunk) {
434
+ const source = parseSourceHunk(sourceHunk);
435
+ const target = parseTargetHunk(targetHunk);
436
+ const targetPayload = payloadBytes(targetHunk, "+");
437
+ if (!source.payload.equals(targetPayload)) {
438
+ fail("payload_mismatch", "Target added payload does not match source removed payload", {
439
+ phase: "payload",
440
+ anchor: "blockpatch-target"
441
+ });
442
+ }
443
+ verifyPayloadHash(source.payload, payloadSha256);
444
+ return {
445
+ type: "move",
446
+ id: moveId,
447
+ src,
448
+ dst,
449
+ payloadSha256,
450
+ hasSourceHunk: true,
451
+ sourceBefore: source.before,
452
+ sourcePayload: source.payload,
453
+ sourceAfter: source.after,
454
+ target
455
+ };
456
+ }
457
+ function buildSourceOnlyBlockPatch(section, sourceHunk, options) {
458
+ const source = parseSourceHunk(sourceHunk, { allowEmptyPayload: options.pathState });
459
+ if (options.pathState && (source.before.length !== 0 || source.after.length !== 0)) {
460
+ fail("parse_error", "A move to /dev/null must describe whole-file payload without context");
461
+ }
462
+ verifyPayloadHash(source.payload, section.payloadSha256);
463
+ return {
464
+ type: "move",
465
+ id: section.moveId,
466
+ src: section.src,
467
+ dst: section.dst,
468
+ payloadSha256: section.payloadSha256,
469
+ hasSourceHunk: true,
470
+ sourceBefore: source.before,
471
+ sourcePayload: source.payload,
472
+ sourceAfter: source.after,
473
+ target: null
474
+ };
475
+ }
476
+ function buildTargetOnlyBlockPatch(section, targetHunk, options) {
477
+ const target = parseTargetHunk(targetHunk, {
478
+ allowEmptyAnchors: options.pathState,
479
+ allowEmptyPayload: options.pathState
480
+ });
481
+ if (options.pathState && (target.before.length !== 0 || target.after.length !== 0)) {
482
+ fail("parse_error", "A move from /dev/null must describe whole-file payload without context");
483
+ }
484
+ const payload = payloadBytes(targetHunk, "+");
485
+ verifyPayloadHash(payload, section.payloadSha256);
486
+ return {
487
+ type: "move",
488
+ id: section.moveId,
489
+ src: section.src,
490
+ dst: section.dst,
491
+ payloadSha256: section.payloadSha256,
492
+ hasSourceHunk: false,
493
+ sourceBefore: Buffer2.alloc(0),
494
+ sourcePayload: payload,
495
+ sourceAfter: Buffer2.alloc(0),
496
+ target
497
+ };
498
+ }
499
+ function verifyPayloadHash(payload, payloadSha256) {
500
+ const actualHash = createHash2("sha256").update(payload).digest("hex");
501
+ if (actualHash !== payloadSha256) {
502
+ fail("hash_mismatch", "payload-sha256 does not match moved payload", {
503
+ phase: "payload",
504
+ anchor: "payload-sha256"
505
+ });
506
+ }
507
+ }
508
+ function parseSections(lines, stripComponents) {
509
+ if (text(lines[0])?.startsWith("diff --blockpatch ") !== true) {
510
+ fail("parse_error", "Patch must start with diff --blockpatch");
511
+ }
512
+ const starts = [];
513
+ for (let index = 0;index < lines.length; index += 1) {
514
+ if (text(lines[index])?.startsWith("diff --blockpatch ") === true) {
515
+ starts.push(index);
516
+ }
517
+ }
518
+ return starts.map((start, index) => {
519
+ const end = starts[index + 1] ?? lines.length;
520
+ return parseSection(lines, start, end, stripComponents);
521
+ });
522
+ }
523
+ function parseSection(lines, start, end, stripComponents) {
524
+ if (text(lines[start + 1]) !== "blockpatch version 1") {
525
+ fail("parse_error", "Patch must declare blockpatch version 1");
526
+ }
527
+ const moveLine = text(lines[start + 2]);
528
+ if (moveLine?.startsWith(movePrefix) !== true) {
529
+ fail("parse_error", "Patch must declare blockpatch move metadata");
530
+ }
531
+ const metadata = parseMetadata(moveLine.slice(movePrefix.length));
532
+ const moveId = metadata.get("id");
533
+ const payloadSha256 = metadata.get("payload-sha256");
534
+ const role = metadata.get("role");
535
+ if (!moveId) {
536
+ fail("parse_error", "blockpatch move metadata must include id=<id>");
537
+ }
538
+ if (!payloadSha256 || !/^[a-f0-9]{64}$/.test(payloadSha256)) {
539
+ fail("parse_error", "blockpatch move metadata must include payload-sha256=<64 hex chars>");
540
+ }
541
+ if (role !== undefined && role !== "source" && role !== "target") {
542
+ fail("parse_error", "blockpatch move role must be source or target");
543
+ }
544
+ const oldRawPath = parseFileHeader(text(lines[start + 3]), "---", "a/");
545
+ const newRawPath = parseFileHeader(text(lines[start + 4]), "+++", "b/");
546
+ if (text(lines[start])?.trimEnd() !== `diff --blockpatch ${oldRawPath} ${newRawPath}`) {
547
+ fail("parse_error", "diff --blockpatch paths must match the --- and +++ headers");
548
+ }
549
+ let hunkStart = start + 5;
550
+ while (hunkStart < end && text(lines[hunkStart]) === "") {
551
+ hunkStart += 1;
552
+ }
553
+ return {
554
+ src: oldRawPath === devNull ? null : stripPath(oldRawPath, stripComponents),
555
+ dst: newRawPath === devNull ? null : stripPath(newRawPath, stripComponents),
556
+ moveId,
557
+ payloadSha256,
558
+ role,
559
+ hunks: parseHunks(lines, hunkStart, end)
560
+ };
561
+ }
562
+ function normalizeStripComponents(value) {
563
+ if (!Number.isInteger(value) || value < 0) {
564
+ fail("parse_error", "stripComponents must be a non-negative integer");
565
+ }
566
+ return value;
567
+ }
568
+ function parseHunks(lines, start, end) {
569
+ const hunks = [];
570
+ let index = start;
571
+ while (index < end) {
572
+ const header = text(lines[index]);
573
+ if (header === "") {
574
+ index += 1;
575
+ continue;
576
+ }
577
+ const parsedHeader = parseHunkHeader(header);
578
+ if (parsedHeader === undefined) {
579
+ fail("parse_error", "Expected blockpatch source/target hunk header");
580
+ }
581
+ const hunk = {
582
+ kind: parsedHeader.kind,
583
+ id: parsedHeader.id,
584
+ oldCount: parsedHeader.oldCount,
585
+ newCount: parsedHeader.newCount,
586
+ lines: []
587
+ };
588
+ index += 1;
589
+ while (index < end) {
590
+ const maybeHeader = text(lines[index]);
591
+ if (parseHunkHeader(maybeHeader) !== undefined) {
592
+ break;
593
+ }
594
+ const body = lines[index].body;
595
+ if (body.length === 0) {
596
+ let lookahead = index + 1;
597
+ while (lookahead < end && lines[lookahead].body.length === 0) {
598
+ lookahead += 1;
599
+ }
600
+ if (lookahead < end && parseHunkHeader(text(lines[lookahead])) === undefined) {
601
+ fail("parse_error", "Hunk bodies must not contain blank lines; encode an empty context line as a single space");
602
+ }
603
+ break;
604
+ }
605
+ const first = body[0];
606
+ if (first !== 32 && first !== 45 && first !== 43) {
607
+ fail("parse_error", "Hunk body lines must start with space, -, or +");
608
+ }
609
+ let content = Buffer2.concat([body.subarray(1), lines[index].eol]);
610
+ if (index + 1 < lines.length && text(lines[index + 1]) === noNewlineMarker) {
611
+ const bareCr = lines[index].eol[0] === 13 ? lines[index].eol.subarray(0, 1) : Buffer2.alloc(0);
612
+ content = Buffer2.concat([body.subarray(1), bareCr]);
613
+ index += 1;
614
+ }
615
+ hunk.lines.push({
616
+ prefix: String.fromCharCode(first),
617
+ content
618
+ });
619
+ index += 1;
620
+ }
621
+ validateHunkCounts(hunk);
622
+ hunks.push(hunk);
623
+ }
624
+ return hunks;
625
+ }
626
+ function samePatchPath(left, right) {
627
+ return posix.normalize(left) === posix.normalize(right);
628
+ }
629
+ function parseHunkHeader(header) {
630
+ const match = header?.match(hunkPattern);
631
+ if (match === undefined || match === null) {
632
+ return;
633
+ }
634
+ return {
635
+ oldCount: match[1] === undefined ? 1 : Number(match[1]),
636
+ newCount: match[2] === undefined ? 1 : Number(match[2]),
637
+ kind: match[3] === "source" ? "source" : "target",
638
+ id: match[4]
639
+ };
640
+ }
641
+ function validateHunkCounts(hunk) {
642
+ const oldCount = hunk.lines.filter((line) => line.prefix === " " || line.prefix === "-").length;
643
+ const newCount = hunk.lines.filter((line) => line.prefix === " " || line.prefix === "+").length;
644
+ if (oldCount !== hunk.oldCount || newCount !== hunk.newCount) {
645
+ fail("parse_error", `Hunk line counts do not match header for blockpatch-${hunk.kind} id=${hunk.id}`);
646
+ }
647
+ }
648
+ function parseSourceHunk(hunk, options = {}) {
649
+ const removedIndexes = hunk.lines.map((line, index) => line.prefix === "-" ? index : -1).filter((index) => index !== -1);
650
+ if (removedIndexes.length === 0) {
651
+ if (options.allowEmptyPayload === true && hunk.lines.length === 0) {
652
+ return { before: Buffer2.alloc(0), payload: Buffer2.alloc(0), after: Buffer2.alloc(0) };
653
+ }
654
+ fail("parse_error", "Source hunk must contain removed payload lines");
655
+ }
656
+ assertContiguous(removedIndexes, "Source payload lines must be contiguous");
657
+ const firstRemoved = removedIndexes[0];
658
+ const lastRemoved = removedIndexes[removedIndexes.length - 1];
659
+ const beforeLines = hunk.lines.slice(0, firstRemoved);
660
+ const afterLines = hunk.lines.slice(lastRemoved + 1);
661
+ if (!beforeLines.every((line) => line.prefix === " ") || !afterLines.every((line) => line.prefix === " ")) {
662
+ fail("parse_error", "Source hunk may only contain context lines around removed payload");
663
+ }
664
+ const before = concatLines(beforeLines);
665
+ const payload = payloadBytes(hunk, "-");
666
+ const after = concatLines(afterLines);
667
+ return { before, payload, after };
668
+ }
669
+ function parseTargetHunk(hunk, options = {}) {
670
+ const addedIndexes = hunk.lines.map((line, index) => line.prefix === "+" ? index : -1).filter((index) => index !== -1);
671
+ if (addedIndexes.length === 0) {
672
+ if (options.allowEmptyPayload === true && hunk.lines.length === 0) {
673
+ return { before: Buffer2.alloc(0), after: Buffer2.alloc(0) };
674
+ }
675
+ fail("parse_error", "Target hunk must contain added payload lines");
676
+ }
677
+ assertContiguous(addedIndexes, "Target payload lines must be contiguous");
678
+ const firstAdded = addedIndexes[0];
679
+ const lastAdded = addedIndexes[addedIndexes.length - 1];
680
+ const beforeLines = hunk.lines.slice(0, firstAdded);
681
+ const afterLines = hunk.lines.slice(lastAdded + 1);
682
+ if (!beforeLines.every((line) => line.prefix === " ") || !afterLines.every((line) => line.prefix === " ")) {
683
+ fail("parse_error", "Target hunk may only contain context lines around added payload");
684
+ }
685
+ const before = concatLines(beforeLines);
686
+ const after = concatLines(afterLines);
687
+ if (before.length === 0 && after.length === 0 && options.allowEmptyAnchors !== true) {
688
+ fail("parse_error", "Target hunk must include context before or after the moved payload");
689
+ }
690
+ return { before, after };
691
+ }
692
+ function payloadBytes(hunk, prefix) {
693
+ return concatLines(hunk.lines.filter((line) => line.prefix === prefix));
694
+ }
695
+ function concatLines(lines) {
696
+ return Buffer2.concat(lines.map((line) => line.content));
697
+ }
698
+ function assertContiguous(indexes, message) {
699
+ for (let index = 1;index < indexes.length; index += 1) {
700
+ if (indexes[index] !== indexes[index - 1] + 1) {
701
+ fail("parse_error", message);
702
+ }
703
+ }
704
+ }
705
+ function parseMetadata(input) {
706
+ const metadata = new Map;
707
+ for (const part of input.trim().split(/\s+/)) {
708
+ const equals = part.indexOf("=");
709
+ if (equals <= 0) {
710
+ fail("parse_error", `Invalid blockpatch move metadata field: ${part}`);
711
+ }
712
+ const key = part.slice(0, equals);
713
+ if (metadata.has(key)) {
714
+ fail("parse_error", `Duplicate blockpatch move metadata field: ${key}`);
715
+ }
716
+ if (!allowedMetadataKeys.has(key) && !key.startsWith("x-")) {
717
+ fail("parse_error", `Unknown blockpatch move metadata field: ${key}`);
718
+ }
719
+ metadata.set(key, part.slice(equals + 1));
720
+ }
721
+ return metadata;
722
+ }
723
+ function parseFileHeader(line, prefix, requiredPathPrefix) {
724
+ const marker = `${prefix} `;
725
+ const rawPath = line?.startsWith(marker) === true ? line.slice(marker.length) : undefined;
726
+ if (rawPath !== undefined) {
727
+ rejectUnsafeDisplayPath(rawPath, `${prefix} file header path`);
728
+ }
729
+ if (rawPath?.trim() === devNull && line?.startsWith(marker)) {
730
+ return devNull;
731
+ }
732
+ if (line?.startsWith(`${marker}${requiredPathPrefix}`) !== true) {
733
+ fail("parse_error", `Patch must include a ${marker}${requiredPathPrefix}<path> or ${marker}${devNull} header`);
734
+ }
735
+ const path = rawPath?.trim() ?? "";
736
+ if (!path || path === requiredPathPrefix) {
737
+ fail("parse_error", `${prefix} file header must include a path`);
738
+ }
739
+ return path;
740
+ }
741
+ function stripPath(path, stripComponents) {
742
+ const stripped = path.split("/").slice(stripComponents).join("/");
743
+ if (!stripped) {
744
+ fail("parse_error", `-p${stripComponents} removes the entire path: ${path}`);
745
+ }
746
+ return stripped;
747
+ }
748
+ function splitLines(input) {
749
+ const lines = [];
750
+ let position = 0;
751
+ while (position < input.length) {
752
+ const lf = input.indexOf(10, position);
753
+ const lineEnd = lf === -1 ? input.length : lf;
754
+ const hasCr = lf !== -1 && lineEnd > position && input[lineEnd - 1] === 13;
755
+ const bodyEnd = hasCr ? lineEnd - 1 : lineEnd;
756
+ const eol = lf === -1 ? Buffer2.alloc(0) : input.subarray(hasCr ? lineEnd - 1 : lineEnd, lf + 1);
757
+ lines.push({
758
+ body: Buffer2.from(input.subarray(position, bodyEnd)),
759
+ eol: Buffer2.from(eol)
760
+ });
761
+ if (lf === -1) {
762
+ break;
763
+ }
764
+ position = lf + 1;
765
+ }
766
+ return lines;
767
+ }
768
+ function text(line) {
769
+ return line?.body.toString("utf8");
770
+ }
771
+
772
+ // src/engine.ts
773
+ async function checkPatchFile(patchPath, options = {}) {
774
+ return runPatchFile(patchPath, { ...options, dryRun: true });
775
+ }
776
+ async function checkPatchBytes(patchBytes, options = {}) {
777
+ return runPatchBytes(patchBytes, { ...options, dryRun: true });
778
+ }
779
+ function checkPatchBytesInMemory(patchBytes, files, options = {}) {
780
+ const patch = parseBlockPatch(patchBytes, { stripComponents: options.stripComponents });
781
+ const effectivePatch = options.reverse === true ? reverseMovePatch(patch) : patch;
782
+ return planMovePatch(effectivePatch, memoryFileMap(files)).result;
783
+ }
784
+ async function applyPatchFile(patchPath, options = {}) {
785
+ return runPatchFile(patchPath, options);
786
+ }
787
+ async function applyPatchBytes(patchBytes, options = {}) {
788
+ return runPatchBytes(patchBytes, options);
789
+ }
790
+ async function runPatchFile(patchPath, options) {
791
+ const cwd = resolve2(options.cwd ?? process.cwd());
792
+ const patchBytes = await readFileChecked(resolve2(cwd, patchPath), "patch file");
793
+ return runPatchBytes(patchBytes, { ...options, cwd });
794
+ }
795
+ async function runPatchBytes(patchBytes, options) {
796
+ const cwd = resolve2(options.cwd ?? process.cwd());
797
+ const patch = parseBlockPatch(patchBytes, { stripComponents: options.stripComponents });
798
+ return applyMovePatch(patch, cwd, options.dryRun ?? false, options.reverse ?? false);
799
+ }
800
+ async function applyMovePatch(patch, cwd, dryRun, reverse) {
801
+ const effectivePatch = reverse ? reverseMovePatch(patch) : patch;
802
+ const workspace = await readPatchWorkspace(effectivePatch, cwd);
803
+ const plan = planMovePatch(effectivePatch, workspace.files);
804
+ if (!dryRun) {
805
+ await commitPatchMutations(plan.mutations, workspace.paths);
806
+ }
807
+ return resultWithWriteFlag(plan.result, dryRun);
808
+ }
809
+ async function readPatchWorkspace(patch, cwd) {
810
+ const workspace = {
811
+ files: new Map,
812
+ paths: new Map
813
+ };
814
+ if (patch.src === null || patch.dst === null) {
815
+ if (patch.src === null && patch.dst !== null && !patch.hasSourceHunk) {
816
+ const resolved = resolvePathAllowMissing(cwd, patch.dst, "destination path");
817
+ if (resolved.exists) {
818
+ rememberFile(workspace, patch.dst, resolved.path, await readFileSnapshot(resolved.path, "destination file"));
819
+ } else {
820
+ rememberMissingPath(workspace, patch.dst, resolved.path);
821
+ }
822
+ return workspace;
823
+ }
824
+ if (patch.dst === null && patch.src !== null && patch.hasSourceHunk) {
825
+ const resolved = resolvePathAllowMissing(cwd, patch.src, "source path");
826
+ if (resolved.exists) {
827
+ rememberFile(workspace, patch.src, resolved.path, await readFileSnapshot(resolved.path, "source file"));
828
+ } else {
829
+ rememberMissingPath(workspace, patch.src, resolved.path);
830
+ }
831
+ return workspace;
832
+ }
833
+ fail("parse_error", "Invalid /dev/null endpoint move shape");
834
+ }
835
+ if (!patch.hasSourceHunk) {
836
+ const dstPath2 = resolvePath(cwd, patch.dst, "destination path");
837
+ rememberFile(workspace, patch.dst, dstPath2, await readFileSnapshot(dstPath2, "destination file"));
838
+ return workspace;
839
+ }
840
+ if (patch.target === null) {
841
+ const srcPath2 = resolvePath(cwd, patch.src, "source path");
842
+ rememberFile(workspace, patch.src, srcPath2, await readFileSnapshot(srcPath2, "source file"));
843
+ return workspace;
844
+ }
845
+ const srcPath = resolvePath(cwd, patch.src, "source path");
846
+ const dstPath = resolvePath(cwd, patch.dst, "destination path");
847
+ const sameFile = await sameFileIdentity(srcPath, dstPath);
848
+ const srcSnapshot = await readFileSnapshot(srcPath, "source file");
849
+ const dstSnapshot = sameFile ? srcSnapshot : await readFileSnapshot(dstPath, "destination file");
850
+ const identity = sameFile ? "paired-file" : undefined;
851
+ rememberFile(workspace, patch.src, srcPath, srcSnapshot, identity);
852
+ rememberFile(workspace, patch.dst, dstPath, dstSnapshot, identity);
853
+ return workspace;
854
+ }
855
+ function rememberMissingPath(workspace, label, path) {
856
+ workspace.paths.set(label, { path, missing: true });
857
+ }
858
+ function rememberFile(workspace, label, path, snapshot, identity = path) {
859
+ workspace.paths.set(label, { path, snapshot });
860
+ workspace.files.set(label, { bytes: snapshot.bytes, identity });
861
+ }
862
+ async function commitPatchMutations(mutations, paths) {
863
+ const writes = [];
864
+ const deletes = [];
865
+ for (const mutation of mutations) {
866
+ const pathState = mutationPath(paths, mutation.label);
867
+ if (mutation.kind === "write") {
868
+ writes.push({
869
+ path: pathState.path,
870
+ bytes: mutation.bytes,
871
+ create: mutation.create,
872
+ label: mutation.label,
873
+ expected: writeExpectation(mutation, pathState)
874
+ });
875
+ } else {
876
+ deletes.push({
877
+ path: pathState.path,
878
+ label: mutation.label,
879
+ expected: fileExpectation(mutation.label, pathState)
880
+ });
881
+ }
882
+ }
883
+ await writeAtomically(writes, deletes);
884
+ }
885
+ function writeExpectation(mutation, state) {
886
+ if (state.snapshot !== undefined) {
887
+ return { kind: "file", label: mutation.label, snapshot: state.snapshot };
888
+ }
889
+ if (state.missing === true && mutation.create === true) {
890
+ return { kind: "missing", label: mutation.label, bytesIfExists: mutation.bytes };
891
+ }
892
+ fail("parse_error", `No original state for planned write: ${mutation.label}`, { path: mutation.label, phase: "path" });
893
+ }
894
+ function fileExpectation(label, state) {
895
+ if (state.snapshot !== undefined) {
896
+ return { kind: "file", label, snapshot: state.snapshot };
897
+ }
898
+ fail("parse_error", `No original file state for planned mutation: ${label}`, { path: label, phase: "path" });
899
+ }
900
+ function mutationPath(paths, label) {
901
+ const state = paths.get(label);
902
+ if (state === undefined) {
903
+ fail("parse_error", `No resolved path for planned mutation: ${label}`, { path: label, phase: "path" });
904
+ }
905
+ return state;
906
+ }
907
+ function resultWithWriteFlag(result, dryRun) {
908
+ return {
909
+ ...result,
910
+ written: result.status === "applied" && !dryRun && result.changed.length > 0
911
+ };
912
+ }
913
+ function planMovePatch(effectivePatch, files) {
914
+ if (effectivePatch.src === null || effectivePatch.dst === null) {
915
+ if (effectivePatch.src === null && effectivePatch.dst !== null && !effectivePatch.hasSourceHunk) {
916
+ return planPathCreationMove(effectivePatch, effectivePatch.dst, files);
917
+ }
918
+ if (effectivePatch.dst === null && effectivePatch.src !== null && effectivePatch.hasSourceHunk) {
919
+ return planPathDeletionMove(effectivePatch, effectivePatch.src, files);
920
+ }
921
+ fail("parse_error", "Invalid /dev/null endpoint move shape");
922
+ }
923
+ if (!effectivePatch.hasSourceHunk) {
924
+ return planInFileInsertionMove(effectivePatch, effectivePatch.src, effectivePatch.dst, files);
925
+ }
926
+ if (effectivePatch.target === null) {
927
+ return planInFileDeletionMove(effectivePatch, effectivePatch.src, effectivePatch.dst, files);
928
+ }
929
+ return planPairedMove(effectivePatch, files);
930
+ }
931
+ function planPairedMove(patch, files) {
932
+ if (patch.src === null || patch.dst === null) {
933
+ fail("parse_error", "Paired move requires source and destination paths");
934
+ }
935
+ const srcLabel = patch.src;
936
+ const dstLabel = patch.dst;
937
+ const srcFile = readMemoryFile(files, srcLabel, "source file");
938
+ const sameFile = sameMemoryFile(files, srcLabel, dstLabel);
939
+ const dstFile = sameFile ? srcFile : readMemoryFile(files, dstLabel, "destination file");
940
+ const plan = selectMovePlan(srcFile, dstFile, patch, sameFile);
941
+ if (plan.status === "already_applied") {
942
+ return {
943
+ result: {
944
+ changed: [],
945
+ affected: unique([srcLabel, dstLabel]),
946
+ written: false,
947
+ noop: true,
948
+ status: "already_applied",
949
+ moves: [plan.details]
950
+ },
951
+ mutations: []
952
+ };
953
+ }
954
+ const selection = plan.selection;
955
+ const next = sameFile ? applyMove(srcFile, selection) : applyCrossFileMove(srcFile, dstFile, selection);
956
+ const changed = changedMoveLabels(srcLabel, dstLabel, sameFile, srcFile, dstFile, next);
957
+ return {
958
+ result: {
959
+ changed,
960
+ affected: unique([srcLabel, dstLabel]),
961
+ written: false,
962
+ noop: changed.length === 0,
963
+ status: changed.length === 0 ? "noop" : "applied",
964
+ moves: [
965
+ moveResultDetails({
966
+ id: patch.id,
967
+ src: srcLabel,
968
+ dst: dstLabel,
969
+ payloadSha256: patch.payloadSha256,
970
+ selection
971
+ })
972
+ ]
973
+ },
974
+ mutations: moveMutations(srcLabel, dstLabel, sameFile, srcFile, dstFile, next)
975
+ };
976
+ }
977
+ function reverseMovePatch(patch) {
978
+ const reversedTarget = patch.hasSourceHunk ? {
979
+ before: patch.sourceBefore,
980
+ after: patch.sourceAfter
981
+ } : null;
982
+ return {
983
+ ...patch,
984
+ src: patch.dst,
985
+ dst: patch.src,
986
+ hasSourceHunk: patch.target !== null,
987
+ sourceBefore: patch.target?.before ?? Buffer3.alloc(0),
988
+ sourcePayload: patch.sourcePayload,
989
+ sourceAfter: patch.target?.after ?? Buffer3.alloc(0),
990
+ target: reversedTarget
991
+ };
992
+ }
993
+ function planInFileInsertionMove(patch, srcLabel, dstLabel, files) {
994
+ const target = requireTarget(patch);
995
+ const original = readMemoryFile(files, dstLabel, "destination file");
996
+ const alreadyApplied = findAlreadyAppliedTargetSelection(original, patch, dstLabel);
997
+ if (alreadyApplied !== undefined) {
998
+ return {
999
+ result: oneSidedResult({
1000
+ patch,
1001
+ src: srcLabel,
1002
+ dst: dstLabel,
1003
+ changed: [],
1004
+ status: "already_applied",
1005
+ sourceRange: null,
1006
+ targetRange: alreadyApplied.range,
1007
+ insertIndex: alreadyApplied.insertIndex
1008
+ }),
1009
+ mutations: []
1010
+ };
1011
+ }
1012
+ const selection = findTargetSelection(original, target.before, target.after, dstLabel, {
1013
+ phase: "target",
1014
+ anchor: "blockpatch-target"
1015
+ });
1016
+ const next = Buffer3.concat([
1017
+ original.subarray(0, selection.insertIndex),
1018
+ patch.sourcePayload,
1019
+ original.subarray(selection.insertIndex)
1020
+ ]);
1021
+ const changed = next.equals(original) ? [] : unique([srcLabel, dstLabel]);
1022
+ return {
1023
+ result: oneSidedResult({
1024
+ patch,
1025
+ src: srcLabel,
1026
+ dst: dstLabel,
1027
+ changed,
1028
+ status: changed.length === 0 ? "noop" : "applied",
1029
+ sourceRange: null,
1030
+ targetRange: selection.range,
1031
+ insertIndex: selection.insertIndex
1032
+ }),
1033
+ mutations: changed.length === 0 ? [] : [{ kind: "write", label: dstLabel, bytes: next }]
1034
+ };
1035
+ }
1036
+ function planInFileDeletionMove(patch, srcLabel, dstLabel, files) {
1037
+ const original = readMemoryFile(files, srcLabel, "source file");
1038
+ const source = findDeletionSourceRange(original, patch, srcLabel);
1039
+ if (source === undefined) {
1040
+ return {
1041
+ result: oneSidedResult({
1042
+ patch,
1043
+ src: srcLabel,
1044
+ dst: dstLabel,
1045
+ changed: [],
1046
+ status: "already_applied",
1047
+ sourceRange: null,
1048
+ targetRange: null,
1049
+ insertIndex: null
1050
+ }),
1051
+ mutations: []
1052
+ };
1053
+ }
1054
+ const next = Buffer3.concat([original.subarray(0, source.start), original.subarray(source.end)]);
1055
+ const changed = next.equals(original) ? [] : unique([srcLabel, dstLabel]);
1056
+ return {
1057
+ result: oneSidedResult({
1058
+ patch,
1059
+ src: srcLabel,
1060
+ dst: dstLabel,
1061
+ changed,
1062
+ status: changed.length === 0 ? "noop" : "applied",
1063
+ sourceRange: source,
1064
+ targetRange: null,
1065
+ insertIndex: null
1066
+ }),
1067
+ mutations: changed.length === 0 ? [] : [{ kind: "write", label: srcLabel, bytes: next }]
1068
+ };
1069
+ }
1070
+ function oneSidedResult(args) {
1071
+ return {
1072
+ changed: args.changed,
1073
+ affected: unique([args.src, args.dst]),
1074
+ written: false,
1075
+ noop: args.status !== "applied" || args.changed.length === 0,
1076
+ status: args.status,
1077
+ moves: [
1078
+ {
1079
+ id: args.patch.id,
1080
+ src: args.src,
1081
+ dst: args.dst,
1082
+ payload_sha256: args.patch.payloadSha256,
1083
+ payload_bytes: args.patch.sourcePayload.length,
1084
+ source_range: args.sourceRange,
1085
+ target_range: args.targetRange,
1086
+ insert_index: args.insertIndex
1087
+ }
1088
+ ]
1089
+ };
1090
+ }
1091
+ function planPathCreationMove(patch, dstLabel, files) {
1092
+ const fullTarget = fullTargetBytes(patch);
1093
+ const original = files.get(dstLabel)?.bytes;
1094
+ if (original !== undefined) {
1095
+ if (original.equals(fullTarget)) {
1096
+ return {
1097
+ result: nullSourceResult(patch, dstLabel, "already_applied", { start: 0, end: original.length }, 0),
1098
+ mutations: []
1099
+ };
1100
+ }
1101
+ fail("destination_exists", `Destination path for file creation already exists with different bytes: ${dstLabel}`, {
1102
+ path: dstLabel,
1103
+ phase: "target",
1104
+ anchor: "blockpatch-target"
1105
+ });
1106
+ }
1107
+ return {
1108
+ result: nullSourceResult(patch, dstLabel, "applied", { start: 0, end: 0 }, 0),
1109
+ mutations: [{ kind: "write", label: dstLabel, bytes: fullTarget, create: true }]
1110
+ };
1111
+ }
1112
+ function planPathDeletionMove(patch, srcLabel, files) {
1113
+ const original = files.get(srcLabel)?.bytes;
1114
+ if (original === undefined) {
1115
+ return {
1116
+ result: nullTargetResult(patch, srcLabel, "already_applied", null),
1117
+ mutations: []
1118
+ };
1119
+ }
1120
+ const fullSource = Buffer3.concat([patch.sourceBefore, patch.sourcePayload, patch.sourceAfter]);
1121
+ if (!original.equals(fullSource)) {
1122
+ fail("source_not_found", `Whole-file source payload was not found in ${srcLabel}`, {
1123
+ path: srcLabel,
1124
+ phase: "source",
1125
+ anchor: "blockpatch-source",
1126
+ matches: 0
1127
+ });
1128
+ }
1129
+ return {
1130
+ result: nullTargetResult(patch, srcLabel, "applied", {
1131
+ start: patch.sourceBefore.length,
1132
+ end: patch.sourceBefore.length + patch.sourcePayload.length
1133
+ }),
1134
+ mutations: [{ kind: "delete", label: srcLabel }]
1135
+ };
1136
+ }
1137
+ function fullTargetBytes(patch) {
1138
+ const target = requireTarget(patch);
1139
+ return Buffer3.concat([target.before, patch.sourcePayload, target.after]);
1140
+ }
1141
+ function requireTarget(patch) {
1142
+ if (patch.target === null) {
1143
+ fail("parse_error", "Patch shape requires a blockpatch-target hunk");
1144
+ }
1145
+ return patch.target;
1146
+ }
1147
+ function findDeletionSourceRange(file, patch, srcLabel) {
1148
+ const fullSource = Buffer3.concat([patch.sourceBefore, patch.sourcePayload, patch.sourceAfter]);
1149
+ const fullMatchResult = indexesOfLimited(file, fullSource);
1150
+ const fullMatches = fullMatchResult.matches;
1151
+ if (fullMatches.length === 1 && !fullMatchResult.truncated) {
1152
+ const start = fullMatches[0] + patch.sourceBefore.length;
1153
+ return { start, end: start + patch.sourcePayload.length };
1154
+ }
1155
+ if (fullMatches.length > 1 || fullMatchResult.truncated) {
1156
+ fail("source_ambiguous", `Source block is ambiguous in ${srcLabel}; ${matchedLocations(fullMatches.length, fullMatchResult.truncated)}`, {
1157
+ path: srcLabel,
1158
+ phase: "source",
1159
+ anchor: "blockpatch-source",
1160
+ ...matchCountDetails(fullMatches.length, fullMatchResult.truncated),
1161
+ ranges: boundedMatchRanges(fullMatches, fullSource.length),
1162
+ line_ranges: boundedMatchLineRanges(file, fullMatches, fullSource.length)
1163
+ });
1164
+ }
1165
+ return deletionAlreadyAppliedOrFail(file, patch, srcLabel);
1166
+ }
1167
+ function deletionAlreadyAppliedOrFail(file, patch, srcLabel) {
1168
+ const anchorless = patch.sourceBefore.length === 0 && patch.sourceAfter.length === 0;
1169
+ if (anchorless) {
1170
+ return;
1171
+ }
1172
+ const adjacent = Buffer3.concat([patch.sourceBefore, patch.sourceAfter]);
1173
+ const adjacentMatchResult = indexesOfLimitedWhere(file, adjacent, (start) => isDeletionAlreadyAppliedMatch(file, patch, start));
1174
+ const adjacentMatches = adjacentMatchResult.matches;
1175
+ if (adjacentMatches.length === 1 && !adjacentMatchResult.truncated) {
1176
+ return;
1177
+ }
1178
+ if (adjacentMatches.length > 1 || adjacentMatchResult.truncated) {
1179
+ fail("source_ambiguous", `Already-deleted source anchors are ambiguous in ${srcLabel}`, {
1180
+ path: srcLabel,
1181
+ phase: "source",
1182
+ anchor: "blockpatch-source",
1183
+ ...matchCountDetails(adjacentMatches.length, adjacentMatchResult.truncated),
1184
+ ranges: boundedMatchRanges(adjacentMatches, adjacent.length),
1185
+ line_ranges: boundedMatchLineRanges(file, adjacentMatches, adjacent.length)
1186
+ });
1187
+ }
1188
+ const envelopes = findSourceEnvelopes(file, patch);
1189
+ if (envelopes.ranges.length === 1 && !envelopes.truncated) {
1190
+ fail("payload_mismatch", `Source payload does not match located source anchors in ${srcLabel}`, {
1191
+ path: srcLabel,
1192
+ phase: "payload",
1193
+ anchor: "blockpatch-source"
1194
+ });
1195
+ }
1196
+ if (envelopes.ranges.length > 1 || envelopes.truncated) {
1197
+ fail("source_ambiguous", `Source anchors are ambiguous in ${srcLabel}; ${matchedLocations(envelopes.ranges.length, envelopes.truncated)}`, {
1198
+ path: srcLabel,
1199
+ phase: "source",
1200
+ anchor: "blockpatch-source",
1201
+ ...matchCountDetails(envelopes.ranges.length, envelopes.truncated),
1202
+ ranges: boundedRanges(envelopes.ranges),
1203
+ line_ranges: boundedLineRanges(file, envelopes.ranges)
1204
+ });
1205
+ }
1206
+ fail("source_not_found", `Source anchors were not found in ${srcLabel}`, {
1207
+ path: srcLabel,
1208
+ phase: "source",
1209
+ anchor: "blockpatch-source",
1210
+ matches: 0
1211
+ });
1212
+ }
1213
+ function isDeletionAlreadyAppliedMatch(file, patch, start) {
1214
+ if (patch.sourceBefore.length === 0) {
1215
+ return start === 0;
1216
+ }
1217
+ if (patch.sourceAfter.length === 0) {
1218
+ return start + patch.sourceBefore.length === file.length;
1219
+ }
1220
+ return true;
1221
+ }
1222
+ function nullSourceResult(patch, dstLabel, status, targetRange, insertIndex) {
1223
+ const applied = status === "applied";
1224
+ return {
1225
+ changed: applied ? [dstLabel] : [],
1226
+ affected: [dstLabel],
1227
+ written: false,
1228
+ noop: !applied,
1229
+ status,
1230
+ moves: [
1231
+ {
1232
+ id: patch.id,
1233
+ src: devNull,
1234
+ dst: dstLabel,
1235
+ payload_sha256: patch.payloadSha256,
1236
+ payload_bytes: patch.sourcePayload.length,
1237
+ source_range: null,
1238
+ target_range: targetRange,
1239
+ insert_index: insertIndex
1240
+ }
1241
+ ]
1242
+ };
1243
+ }
1244
+ function nullTargetResult(patch, srcLabel, status, sourceRange) {
1245
+ const applied = status === "applied";
1246
+ return {
1247
+ changed: applied ? [srcLabel] : [],
1248
+ affected: [srcLabel],
1249
+ written: false,
1250
+ noop: !applied,
1251
+ status,
1252
+ moves: [
1253
+ {
1254
+ id: patch.id,
1255
+ src: srcLabel,
1256
+ dst: devNull,
1257
+ payload_sha256: patch.payloadSha256,
1258
+ payload_bytes: patch.sourcePayload.length,
1259
+ source_range: sourceRange,
1260
+ target_range: null,
1261
+ insert_index: null
1262
+ }
1263
+ ]
1264
+ };
1265
+ }
1266
+ function selectMovePlan(srcFile, dstFile, patch, sameFile) {
1267
+ const srcLabel = patch.src ?? devNull;
1268
+ const dstLabel = patch.dst ?? devNull;
1269
+ const targetAnchor = requireTarget(patch);
1270
+ const source = findSourceRange(srcFile, dstFile, patch);
1271
+ if (source === undefined) {
1272
+ const target2 = findAlreadyAppliedTargetSelection(dstFile, patch);
1273
+ if (target2 === undefined) {
1274
+ fail("source_not_found", `Source anchors were not found in ${srcLabel}`, {
1275
+ path: srcLabel,
1276
+ phase: "source",
1277
+ anchor: "blockpatch-source",
1278
+ matches: 0
1279
+ });
1280
+ }
1281
+ return {
1282
+ status: "already_applied",
1283
+ details: alreadyAppliedMoveResultDetails({
1284
+ id: patch.id,
1285
+ src: srcLabel,
1286
+ dst: dstLabel,
1287
+ payloadSha256: patch.payloadSha256,
1288
+ payload: patch.sourcePayload,
1289
+ target: target2
1290
+ })
1291
+ };
1292
+ }
1293
+ if (!sameFile) {
1294
+ const alreadyAppliedTarget = findAlreadyAppliedTargetSelection(dstFile, patch, dstLabel);
1295
+ if (alreadyAppliedTarget !== undefined) {
1296
+ failPartialAppliedDuplicate(srcLabel, dstLabel, patch, source, alreadyAppliedTarget);
1297
+ }
1298
+ }
1299
+ const target = findTargetSelection(dstFile, targetAnchor.before, targetAnchor.after, dstLabel, {
1300
+ phase: "target",
1301
+ anchor: "blockpatch-target"
1302
+ });
1303
+ return {
1304
+ status: "pending",
1305
+ selection: buildMoveSelection(srcFile, source, target, sameFile, dstLabel)
1306
+ };
1307
+ }
1308
+ function failPartialAppliedDuplicate(srcLabel, dstLabel, patch, source, target) {
1309
+ fail("partial_applied_duplicate", `Cross-file move appears partially applied: ${dstLabel} already contains the moved payload while ${srcLabel} still contains it`, {
1310
+ path: dstLabel,
1311
+ phase: "target",
1312
+ anchor: "blockpatch-target",
1313
+ source_range: source,
1314
+ target_range: target.range,
1315
+ payload_sha256: patch.payloadSha256,
1316
+ suggested_action: "review_then_remove_source"
1317
+ });
1318
+ }
1319
+ function buildMoveSelection(srcFile, source, target, sameFile, dstLabel) {
1320
+ if (sameFile && rangesOverlap(source, target.range)) {
1321
+ fail("target_overlaps_source", `Target anchor for ${dstLabel} overlaps the source block`, {
1322
+ path: dstLabel,
1323
+ phase: "target",
1324
+ anchor: "blockpatch-target"
1325
+ });
1326
+ }
1327
+ return {
1328
+ source,
1329
+ target,
1330
+ payload: Buffer3.from(srcFile.subarray(source.start, source.end))
1331
+ };
1332
+ }
1333
+ async function commitMove(args) {
1334
+ const next = args.sameFile ? applyMove(args.srcOriginal, args.selection) : applyCrossFileMove(args.srcOriginal, args.dstOriginal, args.selection);
1335
+ const srcChanged = !next.src.equals(args.srcOriginal);
1336
+ const dstChanged = !args.sameFile && !next.dst.equals(args.dstOriginal);
1337
+ const sameFileAlias = args.sameFile && args.srcPath !== args.dstPath;
1338
+ if (!args.dryRun) {
1339
+ const writes = [];
1340
+ if (dstChanged || sameFileAlias && srcChanged) {
1341
+ writes.push({
1342
+ path: args.dstPath,
1343
+ bytes: next.dst,
1344
+ label: args.dstLabel,
1345
+ expected: args.dstSnapshot === undefined ? undefined : { kind: "file", label: args.dstLabel, snapshot: args.dstSnapshot }
1346
+ });
1347
+ }
1348
+ if (srcChanged) {
1349
+ writes.push({
1350
+ path: args.srcPath,
1351
+ bytes: next.src,
1352
+ label: args.srcLabel,
1353
+ expected: args.srcSnapshot === undefined ? undefined : { kind: "file", label: args.srcLabel, snapshot: args.srcSnapshot }
1354
+ });
1355
+ }
1356
+ await writeAtomically(writes);
1357
+ }
1358
+ const changed = [];
1359
+ if (srcChanged) {
1360
+ changed.push(args.srcLabel);
1361
+ if (sameFileAlias) {
1362
+ changed.push(args.dstLabel);
1363
+ }
1364
+ }
1365
+ if (dstChanged) {
1366
+ changed.push(args.dstLabel);
1367
+ }
1368
+ return unique(changed);
1369
+ }
1370
+ function applyMove(file, selection) {
1371
+ const withoutSource = Buffer3.concat([
1372
+ file.subarray(0, selection.source.start),
1373
+ file.subarray(selection.source.end)
1374
+ ]);
1375
+ const targetIndex = selection.target.insertIndex >= selection.source.end ? selection.target.insertIndex - selection.payload.length : selection.target.insertIndex;
1376
+ const next = Buffer3.concat([
1377
+ withoutSource.subarray(0, targetIndex),
1378
+ selection.payload,
1379
+ withoutSource.subarray(targetIndex)
1380
+ ]);
1381
+ return { src: next, dst: next };
1382
+ }
1383
+ function applyCrossFileMove(srcFile, dstFile, selection) {
1384
+ return {
1385
+ src: Buffer3.concat([srcFile.subarray(0, selection.source.start), srcFile.subarray(selection.source.end)]),
1386
+ dst: Buffer3.concat([
1387
+ dstFile.subarray(0, selection.target.insertIndex),
1388
+ selection.payload,
1389
+ dstFile.subarray(selection.target.insertIndex)
1390
+ ])
1391
+ };
1392
+ }
1393
+ function findSourceRange(srcFile, dstFile, patch) {
1394
+ const srcLabel = patch.src ?? devNull;
1395
+ const fullSource = Buffer3.concat([patch.sourceBefore, patch.sourcePayload, patch.sourceAfter]);
1396
+ const fullMatchResult = indexesOfLimited(srcFile, fullSource);
1397
+ const fullMatches = fullMatchResult.matches;
1398
+ if (fullMatches.length === 1 && !fullMatchResult.truncated) {
1399
+ const start = fullMatches[0] + patch.sourceBefore.length;
1400
+ return { start, end: start + patch.sourcePayload.length };
1401
+ }
1402
+ if (fullMatches.length > 1 || fullMatchResult.truncated) {
1403
+ fail("source_ambiguous", `Source block is ambiguous in ${srcLabel}; ${matchedLocations(fullMatches.length, fullMatchResult.truncated)}`, {
1404
+ path: srcLabel,
1405
+ phase: "source",
1406
+ anchor: "blockpatch-source",
1407
+ ...matchCountDetails(fullMatches.length, fullMatchResult.truncated),
1408
+ ranges: boundedMatchRanges(fullMatches, fullSource.length),
1409
+ line_ranges: boundedMatchLineRanges(srcFile, fullMatches, fullSource.length)
1410
+ });
1411
+ }
1412
+ const alreadyApplied = findAlreadyAppliedTargetSelection(dstFile, patch);
1413
+ if (alreadyApplied !== undefined) {
1414
+ return;
1415
+ }
1416
+ const envelopes = findSourceEnvelopes(srcFile, patch);
1417
+ if (envelopes.ranges.length === 1 && !envelopes.truncated) {
1418
+ fail("payload_mismatch", `Source payload does not match located source anchors in ${srcLabel}`, {
1419
+ path: srcLabel,
1420
+ phase: "payload",
1421
+ anchor: "blockpatch-source"
1422
+ });
1423
+ }
1424
+ if (envelopes.ranges.length > 1 || envelopes.truncated) {
1425
+ fail("source_ambiguous", `Source anchors are ambiguous in ${srcLabel}; ${matchedLocations(envelopes.ranges.length, envelopes.truncated)}`, {
1426
+ path: srcLabel,
1427
+ phase: "source",
1428
+ anchor: "blockpatch-source",
1429
+ ...matchCountDetails(envelopes.ranges.length, envelopes.truncated),
1430
+ ranges: boundedRanges(envelopes.ranges),
1431
+ line_ranges: boundedLineRanges(srcFile, envelopes.ranges)
1432
+ });
1433
+ }
1434
+ fail("source_not_found", `Source anchors were not found in ${srcLabel}`, {
1435
+ path: srcLabel,
1436
+ phase: "source",
1437
+ anchor: "blockpatch-source",
1438
+ matches: 0
1439
+ });
1440
+ }
1441
+ function findAlreadyAppliedTargetSelection(file, patch, dstLabel = patch.dst ?? devNull) {
1442
+ const target = requireTarget(patch);
1443
+ const alreadyApplied = Buffer3.concat([target.before, patch.sourcePayload, target.after]);
1444
+ const matchResult = indexesOfLimited(file, alreadyApplied);
1445
+ const matches = matchResult.matches;
1446
+ if (matches.length === 0) {
1447
+ return;
1448
+ }
1449
+ if (matches.length > 1 || matchResult.truncated) {
1450
+ fail("target_ambiguous", `Already-applied target is ambiguous in ${dstLabel}; ${matchedLocations(matches.length, matchResult.truncated)}`, {
1451
+ path: dstLabel,
1452
+ phase: "target",
1453
+ anchor: "blockpatch-target",
1454
+ ...matchCountDetails(matches.length, matchResult.truncated),
1455
+ ranges: boundedMatchRanges(matches, alreadyApplied.length),
1456
+ line_ranges: boundedMatchLineRanges(file, matches, alreadyApplied.length)
1457
+ });
1458
+ }
1459
+ const start = matches[0];
1460
+ return {
1461
+ range: { start, end: start + alreadyApplied.length },
1462
+ insertIndex: start + target.before.length
1463
+ };
1464
+ }
1465
+ function findSourceEnvelopes(file, patch, limit = 11) {
1466
+ if (patch.sourceBefore.length === 0 || patch.sourceAfter.length === 0) {
1467
+ return { ranges: [], truncated: false };
1468
+ }
1469
+ const maxMatches = normalizedLimit(limit);
1470
+ const ranges = [];
1471
+ let beforeStart = file.indexOf(patch.sourceBefore);
1472
+ while (beforeStart !== -1) {
1473
+ const payloadStart = beforeStart + patch.sourceBefore.length;
1474
+ const afterStart = file.indexOf(patch.sourceAfter, payloadStart);
1475
+ if (afterStart !== -1) {
1476
+ if (ranges.length >= maxMatches) {
1477
+ return { ranges, truncated: true };
1478
+ }
1479
+ ranges.push({ start: payloadStart, end: afterStart });
1480
+ }
1481
+ beforeStart = file.indexOf(patch.sourceBefore, beforeStart + 1);
1482
+ }
1483
+ return { ranges, truncated: false };
1484
+ }
1485
+ function findTargetSelection(file, before, after, dstLabel, details = {}) {
1486
+ const anchor = Buffer3.concat([before, after]);
1487
+ const matchResult = indexesOfLimited(file, anchor);
1488
+ const matches = matchResult.matches;
1489
+ if (matches.length === 0) {
1490
+ fail("target_not_found", `Target anchor was not found in ${dstLabel}`, {
1491
+ path: dstLabel,
1492
+ ...details,
1493
+ matches: 0
1494
+ });
1495
+ }
1496
+ if (matches.length > 1 || matchResult.truncated) {
1497
+ fail("target_ambiguous", `Target anchor is ambiguous in ${dstLabel}; ${matchedLocations(matches.length, matchResult.truncated)}`, {
1498
+ path: dstLabel,
1499
+ ...details,
1500
+ ...matchCountDetails(matches.length, matchResult.truncated),
1501
+ ranges: boundedMatchRanges(matches, anchor.length),
1502
+ line_ranges: boundedMatchLineRanges(file, matches, anchor.length)
1503
+ });
1504
+ }
1505
+ const start = matches[0];
1506
+ return {
1507
+ range: { start, end: start + anchor.length },
1508
+ insertIndex: start + before.length
1509
+ };
1510
+ }
1511
+ function indexesOfLimited(haystack, needle, limit = 11) {
1512
+ if (needle.length === 0) {
1513
+ return { matches: [], truncated: false };
1514
+ }
1515
+ const maxMatches = normalizedLimit(limit);
1516
+ const matches = [];
1517
+ let index = haystack.indexOf(needle);
1518
+ while (index !== -1) {
1519
+ if (matches.length >= maxMatches) {
1520
+ return { matches, truncated: true };
1521
+ }
1522
+ matches.push(index);
1523
+ index = haystack.indexOf(needle, index + 1);
1524
+ }
1525
+ return { matches, truncated: false };
1526
+ }
1527
+ function indexesOfLimitedWhere(haystack, needle, predicate, limit = 11) {
1528
+ if (needle.length === 0) {
1529
+ return { matches: [], truncated: false };
1530
+ }
1531
+ const maxMatches = normalizedLimit(limit);
1532
+ const matches = [];
1533
+ let index = haystack.indexOf(needle);
1534
+ while (index !== -1) {
1535
+ if (predicate(index)) {
1536
+ if (matches.length >= maxMatches) {
1537
+ return { matches, truncated: true };
1538
+ }
1539
+ matches.push(index);
1540
+ }
1541
+ index = haystack.indexOf(needle, index + 1);
1542
+ }
1543
+ return { matches, truncated: false };
1544
+ }
1545
+ function normalizedLimit(limit) {
1546
+ if (!Number.isFinite(limit)) {
1547
+ return Number.POSITIVE_INFINITY;
1548
+ }
1549
+ return Math.max(0, Math.trunc(limit));
1550
+ }
1551
+ function rangesOverlap(left, right) {
1552
+ return left.start < right.end && right.start < left.end;
1553
+ }
1554
+ function moveResultDetails(args) {
1555
+ return {
1556
+ id: args.id,
1557
+ src: args.src,
1558
+ dst: args.dst,
1559
+ payload_sha256: args.payloadSha256,
1560
+ payload_bytes: args.selection.payload.length,
1561
+ source_range: args.selection.source,
1562
+ target_range: args.selection.target.range,
1563
+ insert_index: args.selection.target.insertIndex
1564
+ };
1565
+ }
1566
+ function alreadyAppliedMoveResultDetails(args) {
1567
+ return {
1568
+ id: args.id,
1569
+ src: args.src,
1570
+ dst: args.dst,
1571
+ payload_sha256: args.payloadSha256,
1572
+ payload_bytes: args.payload.length,
1573
+ source_range: null,
1574
+ target_range: args.target.range,
1575
+ insert_index: args.target.insertIndex
1576
+ };
1577
+ }
1578
+ function unique(paths) {
1579
+ return [...new Set(paths)];
1580
+ }
1581
+ function changedMoveLabels(srcLabel, dstLabel, sameFile, srcOriginal, dstOriginal, next) {
1582
+ const srcChanged = !next.src.equals(srcOriginal);
1583
+ const dstChanged = !sameFile && !next.dst.equals(dstOriginal);
1584
+ const sameFileAlias = sameFile && srcLabel !== dstLabel;
1585
+ const changed = [];
1586
+ if (srcChanged) {
1587
+ changed.push(srcLabel);
1588
+ if (sameFileAlias) {
1589
+ changed.push(dstLabel);
1590
+ }
1591
+ }
1592
+ if (dstChanged) {
1593
+ changed.push(dstLabel);
1594
+ }
1595
+ return unique(changed);
1596
+ }
1597
+ function moveMutations(srcLabel, dstLabel, sameFile, srcOriginal, dstOriginal, next) {
1598
+ const srcChanged = !next.src.equals(srcOriginal);
1599
+ const dstChanged = !sameFile && !next.dst.equals(dstOriginal);
1600
+ const sameFileAlias = sameFile && srcLabel !== dstLabel;
1601
+ const mutations = [];
1602
+ if (dstChanged || sameFileAlias && srcChanged) {
1603
+ mutations.push({ kind: "write", label: dstLabel, bytes: next.dst });
1604
+ }
1605
+ if (srcChanged) {
1606
+ mutations.push({ kind: "write", label: srcLabel, bytes: next.src });
1607
+ }
1608
+ return mutations;
1609
+ }
1610
+ function memoryFileMap(files) {
1611
+ const mapped = new Map;
1612
+ for (const file of files) {
1613
+ if (mapped.has(file.path)) {
1614
+ fail("parse_error", `Duplicate in-memory file: ${file.path}`, { path: file.path, phase: "check" });
1615
+ }
1616
+ mapped.set(file.path, {
1617
+ bytes: file.bytes,
1618
+ identity: file.identity ?? file.path
1619
+ });
1620
+ }
1621
+ return mapped;
1622
+ }
1623
+ function readMemoryFile(files, path, label) {
1624
+ const file = files.get(path);
1625
+ if (file === undefined) {
1626
+ fail("file_not_found", `Could not read ${label}: ${path}`, { path, phase: "io" });
1627
+ }
1628
+ return file.bytes;
1629
+ }
1630
+ function sameMemoryFile(files, left, right) {
1631
+ if (left === right) {
1632
+ return true;
1633
+ }
1634
+ const leftFile = files.get(left);
1635
+ const rightFile = files.get(right);
1636
+ return leftFile !== undefined && rightFile !== undefined && leftFile.identity === rightFile.identity;
1637
+ }
1638
+ async function writeAtomic(path, bytes, options = {}) {
1639
+ await writeAtomically([
1640
+ {
1641
+ path,
1642
+ bytes,
1643
+ create: options.create,
1644
+ expected: options.expected,
1645
+ label: options.label
1646
+ }
1647
+ ]);
1648
+ }
1649
+ async function writeAtomically(writes, deletes = []) {
1650
+ const staged = [];
1651
+ try {
1652
+ for (const write of writes) {
1653
+ staged.push(await stageAtomicWrite(write));
1654
+ }
1655
+ const decisions = [];
1656
+ for (const write of staged) {
1657
+ decisions.push(await verifyStagedWrite(write));
1658
+ }
1659
+ for (const deletion of deletes) {
1660
+ await verifyAtomicDelete(deletion);
1661
+ }
1662
+ for (const [index, write] of staged.entries()) {
1663
+ if (decisions[index] === "skip") {
1664
+ await cleanupStagedWrite(write);
1665
+ continue;
1666
+ }
1667
+ try {
1668
+ await rename(write.temp, write.path);
1669
+ } catch (error) {
1670
+ failFileSystem(error, write.path, "Could not replace file");
1671
+ }
1672
+ }
1673
+ for (const deletion of deletes) {
1674
+ try {
1675
+ await unlink(deletion.path);
1676
+ } catch (error) {
1677
+ failFileSystem(error, deletion.path, "Could not remove file");
1678
+ }
1679
+ }
1680
+ } catch (error) {
1681
+ await cleanupStagedWrites(staged);
1682
+ throw error;
1683
+ }
1684
+ }
1685
+ async function verifyStagedWrite(write) {
1686
+ const expected = write.request.expected;
1687
+ if (expected === undefined) {
1688
+ return "rename";
1689
+ }
1690
+ if (expected.kind === "missing") {
1691
+ const current2 = await readFileSnapshotOptional(write.path, expected.label);
1692
+ if (current2 === undefined) {
1693
+ return "rename";
1694
+ }
1695
+ if (expected.bytesIfExists !== undefined && current2.bytes.equals(expected.bytesIfExists)) {
1696
+ return "skip";
1697
+ }
1698
+ failConcurrentModification(expected.label);
1699
+ }
1700
+ const current = await readFileSnapshotOptional(write.path, expected.label);
1701
+ if (current === undefined || !sameFileSnapshot(expected.snapshot, current)) {
1702
+ failConcurrentModification(expected.label);
1703
+ }
1704
+ return "rename";
1705
+ }
1706
+ async function verifyAtomicDelete(deletion) {
1707
+ const expected = deletion.expected;
1708
+ if (expected === undefined) {
1709
+ return;
1710
+ }
1711
+ if (expected.kind === "missing") {
1712
+ const current2 = await readFileSnapshotOptional(deletion.path, expected.label);
1713
+ if (current2 === undefined) {
1714
+ return;
1715
+ }
1716
+ failConcurrentModification(expected.label);
1717
+ }
1718
+ const current = await readFileSnapshotOptional(deletion.path, expected.label);
1719
+ if (current === undefined || !sameFileSnapshot(expected.snapshot, current)) {
1720
+ failConcurrentModification(expected.label);
1721
+ }
1722
+ }
1723
+ async function readFileSnapshotOptional(path, label) {
1724
+ try {
1725
+ return await readFileSnapshot(path, "current file");
1726
+ } catch (error) {
1727
+ if (error instanceof BlockPatchError && error.code === "file_not_found") {
1728
+ return;
1729
+ }
1730
+ if (error instanceof BlockPatchError && error.code === "not_regular_file") {
1731
+ failConcurrentModification(label);
1732
+ }
1733
+ throw error;
1734
+ }
1735
+ }
1736
+ function sameFileSnapshot(left, right) {
1737
+ return left.sha256 === right.sha256 && sameStatSnapshot(left.stat, right.stat);
1738
+ }
1739
+ function sameStatSnapshot(left, right) {
1740
+ return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode && left.size === right.size && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs;
1741
+ }
1742
+ function failConcurrentModification(label) {
1743
+ fail("concurrent_modification", `File changed after blockpatch verified its input: ${label}`, {
1744
+ path: label,
1745
+ phase: "write"
1746
+ });
1747
+ }
1748
+ async function stageAtomicWrite(write) {
1749
+ let mode;
1750
+ let createMissing = false;
1751
+ if (write.create === true) {
1752
+ const info = await statOptional(write.path);
1753
+ if (info !== undefined) {
1754
+ assertExpectedRegularFile(info, write.path, "output file", write.expected);
1755
+ mode = info.mode;
1756
+ }
1757
+ if (mode === undefined) {
1758
+ createMissing = true;
1759
+ mode = 420;
1760
+ }
1761
+ } else {
1762
+ const info = await statOptional(write.path);
1763
+ if (info === undefined) {
1764
+ if (write.expected !== undefined) {
1765
+ failConcurrentModification(write.expected.label);
1766
+ }
1767
+ fail("file_not_found", `Could not stat output file: ${write.path}`, { path: write.path, phase: "io" });
1768
+ }
1769
+ assertExpectedRegularFile(info, write.path, "output file", write.expected);
1770
+ mode = info.mode;
1771
+ }
1772
+ const dir = dirname(write.path);
1773
+ const base = basename(write.path);
1774
+ const temp = join2(dir, `.${base}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`);
1775
+ let createdDirectory;
1776
+ try {
1777
+ if (createMissing) {
1778
+ const firstCreated = await mkdir(dir, { recursive: true });
1779
+ if (firstCreated !== undefined) {
1780
+ createdDirectory = { first: firstCreated, target: dir };
1781
+ }
1782
+ }
1783
+ await assertSafeOutputParentDirectory(dir, write.path);
1784
+ await writeFile(temp, write.bytes, { flag: "wx" });
1785
+ if (mode !== undefined) {
1786
+ await chmod(temp, mode);
1787
+ }
1788
+ } catch (error) {
1789
+ await cleanupStagedWrite({ temp, createdDirectory });
1790
+ failFileSystem(error, write.path, "Could not stage file replacement");
1791
+ }
1792
+ return { path: write.path, temp, request: write, createdDirectory };
1793
+ }
1794
+ async function assertSafeOutputParentDirectory(dir, outputPath) {
1795
+ let info;
1796
+ try {
1797
+ info = await lstat(dir);
1798
+ } catch (error) {
1799
+ failFileSystem(error, outputPath, "Could not stat output directory", "path");
1800
+ }
1801
+ if (info.isSymbolicLink()) {
1802
+ fail("symlink_path", `output directory must not be a symbolic link: ${outputPath}`, {
1803
+ path: outputPath,
1804
+ phase: "path"
1805
+ });
1806
+ }
1807
+ if (!info.isDirectory()) {
1808
+ fail("not_regular_file", `output directory must be a directory: ${outputPath}`, {
1809
+ path: outputPath,
1810
+ phase: "path"
1811
+ });
1812
+ }
1813
+ }
1814
+ async function cleanupStagedWrites(staged) {
1815
+ await Promise.all(staged.map((write) => unlink(write.temp).catch(() => {
1816
+ return;
1817
+ })));
1818
+ for (const write of [...staged].reverse()) {
1819
+ await cleanupCreatedDirectoryChain(write.createdDirectory);
1820
+ }
1821
+ }
1822
+ async function cleanupStagedWrite(write) {
1823
+ await unlink(write.temp).catch(() => {
1824
+ return;
1825
+ });
1826
+ await cleanupCreatedDirectoryChain(write.createdDirectory);
1827
+ }
1828
+ async function cleanupCreatedDirectoryChain(chain) {
1829
+ if (chain === undefined || !isSameOrChildPath(chain.target, chain.first)) {
1830
+ return;
1831
+ }
1832
+ let current = chain.target;
1833
+ while (true) {
1834
+ try {
1835
+ await rmdir(current);
1836
+ } catch (error) {
1837
+ const code = error.code;
1838
+ if (code !== "ENOENT") {
1839
+ return;
1840
+ }
1841
+ }
1842
+ if (resolve2(current) === resolve2(chain.first)) {
1843
+ return;
1844
+ }
1845
+ const parent = dirname(current);
1846
+ if (parent === current) {
1847
+ return;
1848
+ }
1849
+ current = parent;
1850
+ }
1851
+ }
1852
+ function isSameOrChildPath(child, parent) {
1853
+ const childFromParent = relative2(resolve2(parent), resolve2(child));
1854
+ return childFromParent === "" || childFromParent !== ".." && !childFromParent.startsWith(`..${sep2}`) && !isAbsolute2(childFromParent);
1855
+ }
1856
+ function assertExpectedRegularFile(info, path, label, expected) {
1857
+ if (!info.isFile() && expected !== undefined) {
1858
+ failConcurrentModification(expected.label);
1859
+ }
1860
+ assertRegularFile(info, path, label);
1861
+ }
1862
+ async function statOptional(path) {
1863
+ try {
1864
+ return await stat2(path);
1865
+ } catch (error) {
1866
+ const code = error.code;
1867
+ if (code === "ENOENT" || code === "ENOTDIR") {
1868
+ return;
1869
+ }
1870
+ failFileSystem(error, path, "Could not stat output file");
1871
+ }
1872
+ }
1873
+
1874
+ // src/move.ts
1875
+ import { createHash as createHash3 } from "node:crypto";
1876
+ import { posix as posix2 } from "node:path";
1877
+ import { TextDecoder } from "node:util";
1878
+ var moveArgTypes = {
1879
+ src: "string",
1880
+ src_start: "string",
1881
+ src_end: "string",
1882
+ dst: "string",
1883
+ payload: "string",
1884
+ target_before: "string",
1885
+ target_after: "string",
1886
+ expected_payload_sha256: "string",
1887
+ mode: "string",
1888
+ dry_run: "boolean"
1889
+ };
1890
+ var moveModes = new Set(["create_file", "remove_file"]);
1891
+ var utf8Decoder = new TextDecoder("utf-8", { fatal: true });
1892
+ async function moveBlock(args, options = {}) {
1893
+ const validated = validateMoveArgs(args);
1894
+ const normalized = normalizeArgs(validated);
1895
+ const cwd = options.cwd ?? process.cwd();
1896
+ const dryRun = options.dryRun ?? validated.dry_run ?? false;
1897
+ if (normalized.kind === "create_file") {
1898
+ return createFile(normalized, validated, cwd, dryRun, options);
1899
+ }
1900
+ if (normalized.kind === "remove_file") {
1901
+ return removeFile(normalized, validated, cwd, dryRun, options);
1902
+ }
1903
+ if (normalized.kind === "insertion") {
1904
+ return insertPayload(normalized, validated, cwd, dryRun, options);
1905
+ }
1906
+ if (normalized.kind === "deletion") {
1907
+ return deletePayload(normalized, validated, cwd, dryRun, options);
1908
+ }
1909
+ const srcPath = resolvePath(cwd, normalized.src, "source path");
1910
+ const dstPath = resolvePath(cwd, normalized.dst, "destination path");
1911
+ const sameFile = await sameFileIdentity(srcPath, dstPath);
1912
+ const srcSnapshot = await readFileSnapshot(srcPath, "source file");
1913
+ const dstSnapshot = sameFile ? srcSnapshot : await readFileSnapshot(dstPath, "destination file");
1914
+ const srcOriginal = srcSnapshot.bytes;
1915
+ const dstOriginal = dstSnapshot.bytes;
1916
+ const source = findSource(srcOriginal, normalized);
1917
+ const target = findTargetSelection(dstOriginal, normalized.targetBefore, normalized.targetAfter, normalized.dst, {
1918
+ phase: "target",
1919
+ anchor: targetAnchorName(normalized)
1920
+ });
1921
+ const selection = buildMoveSelection(srcOriginal, source, target, sameFile, normalized.dst);
1922
+ const payloadSha256 = createHash3("sha256").update(selection.payload).digest("hex");
1923
+ verifyExpectedPayloadHash(validated, payloadSha256);
1924
+ const samePatchLabel = samePatchPath2(normalized.src, normalized.dst);
1925
+ const patch = options.diff ? renderMovePatch(normalized, srcOriginal, dstOriginal, selection, sameFile && samePatchLabel, payloadSha256) : undefined;
1926
+ await selfCheckRenderedPatch(patch, pairedSelfCheckFiles(normalized.src, normalized.dst, srcOriginal, dstOriginal, sameFile));
1927
+ const writeSuppressed = dryRun || options.diff === true;
1928
+ const changed = await commitMove({
1929
+ srcPath,
1930
+ dstPath,
1931
+ sameFile,
1932
+ dryRun: writeSuppressed,
1933
+ srcOriginal,
1934
+ dstOriginal,
1935
+ srcSnapshot,
1936
+ dstSnapshot,
1937
+ selection,
1938
+ srcLabel: normalized.src,
1939
+ dstLabel: normalized.dst
1940
+ });
1941
+ return {
1942
+ changed,
1943
+ affected: unique([normalized.src, normalized.dst]),
1944
+ written: !writeSuppressed && changed.length > 0,
1945
+ noop: changed.length === 0,
1946
+ status: changed.length === 0 ? "noop" : "applied",
1947
+ moves: [
1948
+ moveResultDetails({
1949
+ id: "move-1",
1950
+ src: normalized.src,
1951
+ dst: normalized.dst,
1952
+ payloadSha256,
1953
+ selection
1954
+ })
1955
+ ],
1956
+ patch
1957
+ };
1958
+ }
1959
+ function validateMoveArgs(value) {
1960
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
1961
+ fail("invalid_move_args", "move arguments must be a JSON object");
1962
+ }
1963
+ for (const [key, fieldValue] of Object.entries(value)) {
1964
+ const expected = moveArgTypes[key];
1965
+ if (expected === undefined) {
1966
+ fail("invalid_move_args", `Unknown move argument: ${key}`, { field: key });
1967
+ }
1968
+ if (typeof fieldValue !== expected) {
1969
+ fail("invalid_move_args", `move argument ${key} must be a ${expected}`, { field: key });
1970
+ }
1971
+ }
1972
+ const args = value;
1973
+ if (args.expected_payload_sha256 !== undefined && !/^[a-f0-9]{64}$/.test(args.expected_payload_sha256)) {
1974
+ fail("invalid_move_args", "expected_payload_sha256 must be a 64-character lowercase sha256 hex digest", {
1975
+ field: "expected_payload_sha256"
1976
+ });
1977
+ }
1978
+ if (args.mode !== undefined && !moveModes.has(args.mode)) {
1979
+ fail("invalid_move_args", "mode must be create_file or remove_file", { field: "mode" });
1980
+ }
1981
+ return args;
1982
+ }
1983
+ function normalizeArgs(args) {
1984
+ if (!args.src) {
1985
+ fail("invalid_move_args", "move requires src", { field: "src" });
1986
+ }
1987
+ if (args.src !== devNull) {
1988
+ validateOperationPath(args.src, "source path");
1989
+ }
1990
+ if (args.dst !== undefined && args.dst !== devNull) {
1991
+ validateOperationPath(args.dst, "destination path");
1992
+ }
1993
+ if (args.mode === "create_file") {
1994
+ return normalizeFileCreationArgs(args);
1995
+ }
1996
+ if (args.mode === "remove_file") {
1997
+ return normalizeFileRemovalArgs(args);
1998
+ }
1999
+ const dst = args.dst ?? args.src;
2000
+ if (args.src === devNull && dst === devNull) {
2001
+ fail("invalid_move_args", "move cannot use /dev/null for both src and dst", { field: "dst" });
2002
+ }
2003
+ if (args.src === devNull) {
2004
+ if (args.dst === undefined) {
2005
+ fail("invalid_move_args", "move from /dev/null requires dst", { field: "dst" });
2006
+ }
2007
+ if (args.src_start !== undefined || args.src_end !== undefined) {
2008
+ fail("invalid_move_args", "move from /dev/null uses payload instead of src_start/src_end", {
2009
+ field: args.src_start !== undefined ? "src_start" : "src_end"
2010
+ });
2011
+ }
2012
+ if (args.payload === undefined) {
2013
+ fail("invalid_move_args", "move from /dev/null requires payload", { field: "payload" });
2014
+ }
2015
+ if (args.payload.length === 0) {
2016
+ fail("invalid_move_args", "move from /dev/null requires non-empty payload", { field: "payload" });
2017
+ }
2018
+ const target2 = normalizeTargetArgs(args);
2019
+ return {
2020
+ kind: "insertion",
2021
+ src: devNull,
2022
+ dst: args.dst,
2023
+ payload: Buffer.from(args.payload, "utf8"),
2024
+ targetBefore: target2.before,
2025
+ targetAfter: target2.after
2026
+ };
2027
+ }
2028
+ if (dst === devNull) {
2029
+ if (args.payload !== undefined) {
2030
+ fail("invalid_move_args", "move to /dev/null selects payload from src_start/src_end", { field: "payload" });
2031
+ }
2032
+ if (args.target_before !== undefined || args.target_after !== undefined) {
2033
+ fail("invalid_move_args", "move to /dev/null must not include target anchors", {
2034
+ field: args.target_before !== undefined ? "target_before" : "target_after"
2035
+ });
2036
+ }
2037
+ return {
2038
+ kind: "deletion",
2039
+ src: args.src,
2040
+ srcStart: requiredBuffer(args.src_start, "src_start"),
2041
+ srcEnd: requiredBuffer(args.src_end, "src_end"),
2042
+ dst: devNull
2043
+ };
13
2044
  }
2045
+ if (args.payload !== undefined) {
2046
+ fail("invalid_move_args", "payload is only valid when src is /dev/null", { field: "payload" });
2047
+ }
2048
+ const target = normalizeTargetArgs(args);
2049
+ return {
2050
+ kind: "relocation",
2051
+ src: args.src,
2052
+ srcStart: requiredBuffer(args.src_start, "src_start"),
2053
+ srcEnd: requiredBuffer(args.src_end, "src_end"),
2054
+ dst,
2055
+ targetBefore: target.before,
2056
+ targetAfter: target.after
2057
+ };
14
2058
  }
15
- function fail(code, message) {
16
- throw new BlockPatchError(code, message);
2059
+ function normalizeFileCreationArgs(args) {
2060
+ if (args.src !== devNull) {
2061
+ fail("invalid_move_args", "create_file mode requires src to be /dev/null", { field: "src" });
2062
+ }
2063
+ if (args.dst === undefined) {
2064
+ fail("invalid_move_args", "create_file mode requires dst", { field: "dst" });
2065
+ }
2066
+ if (args.dst === devNull) {
2067
+ fail("invalid_move_args", "create_file mode cannot target /dev/null", { field: "dst" });
2068
+ }
2069
+ if (args.payload === undefined) {
2070
+ fail("invalid_move_args", "create_file mode requires payload", { field: "payload" });
2071
+ }
2072
+ rejectSourceSelectionArgs(args, "create_file mode uses payload as the whole-file content");
2073
+ rejectTargetAnchorArgs(args, "create_file mode must not include target anchors");
2074
+ return {
2075
+ kind: "create_file",
2076
+ src: devNull,
2077
+ dst: args.dst,
2078
+ payload: Buffer.from(args.payload, "utf8")
2079
+ };
17
2080
  }
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");
2081
+ function normalizeFileRemovalArgs(args) {
2082
+ if (args.src === devNull) {
2083
+ fail("invalid_move_args", "remove_file mode requires a real src path", { field: "src" });
70
2084
  }
2085
+ if (args.dst !== devNull) {
2086
+ fail("invalid_move_args", "remove_file mode requires dst to be /dev/null", { field: "dst" });
2087
+ }
2088
+ if (args.payload !== undefined) {
2089
+ fail("invalid_move_args", "remove_file mode reads the whole-file payload from src", { field: "payload" });
2090
+ }
2091
+ rejectSourceSelectionArgs(args, "remove_file mode removes the whole src file");
2092
+ rejectTargetAnchorArgs(args, "remove_file mode must not include target anchors");
71
2093
  return {
72
- type: "move",
73
- path,
74
- sourceBefore,
75
- sourcePayload,
76
- sourceAfter,
77
- target
2094
+ kind: "remove_file",
2095
+ src: args.src,
2096
+ dst: devNull
78
2097
  };
79
2098
  }
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;
2099
+ function rejectSourceSelectionArgs(args, message) {
2100
+ if (args.src_start !== undefined || args.src_end !== undefined) {
2101
+ fail("invalid_move_args", message, {
2102
+ field: args.src_start !== undefined ? "src_start" : "src_end"
2103
+ });
98
2104
  }
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
2105
  }
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
- }
2106
+ function rejectTargetAnchorArgs(args, message) {
2107
+ if (args.target_before !== undefined || args.target_after !== undefined) {
2108
+ fail("invalid_move_args", message, {
2109
+ field: args.target_before !== undefined ? "target_before" : "target_after"
2110
+ });
131
2111
  }
132
- return Buffer.from(input.subarray(marker.contentStart, end));
133
2112
  }
134
-
135
- // src/engine.ts
136
- async function checkPatchFile(patchPath, options = {}) {
137
- return runPatchFile(patchPath, { ...options, dryRun: true });
2113
+ function normalizeTargetArgs(args) {
2114
+ const hasTargetBefore = args.target_before !== undefined;
2115
+ const hasTargetAfter = args.target_after !== undefined;
2116
+ if (!hasTargetBefore && !hasTargetAfter) {
2117
+ fail("invalid_move_args", "move requires target_before or target_after", { field: "target_before" });
2118
+ }
2119
+ const targetBefore = Buffer.from(args.target_before ?? "", "utf8");
2120
+ const targetAfter = Buffer.from(args.target_after ?? "", "utf8");
2121
+ if (targetBefore.length === 0 && targetAfter.length === 0) {
2122
+ fail("invalid_move_args", "move requires non-empty target context", { field: "target_before" });
2123
+ }
2124
+ return { before: targetBefore, after: targetAfter };
138
2125
  }
139
- async function applyPatchFile(patchPath, options = {}) {
140
- return runPatchFile(patchPath, options);
2126
+ function requiredBuffer(value, field) {
2127
+ if (!value) {
2128
+ fail("invalid_move_args", `move requires ${field}`, { field });
2129
+ }
2130
+ return Buffer.from(value, "utf8");
141
2131
  }
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] };
2132
+ async function createFile(args, validated, cwd, dryRun, options) {
2133
+ const payloadSha256 = createHash3("sha256").update(args.payload).digest("hex");
2134
+ verifyExpectedPayloadHash(validated, payloadSha256);
2135
+ const patch = renderFileCreationPatch(args, payloadSha256);
2136
+ return runRenderedPatch(patch, cwd, dryRun, options);
148
2137
  }
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;
2138
+ async function removeFile(args, validated, cwd, dryRun, options) {
2139
+ const srcPath = resolvePath(cwd, args.src, "source path");
2140
+ const payload = await readFileChecked(srcPath, "source file");
2141
+ const payloadSha256 = createHash3("sha256").update(payload).digest("hex");
2142
+ verifyExpectedPayloadHash(validated, payloadSha256);
2143
+ const patch = renderFileRemovalPatch(args, payload, payloadSha256);
2144
+ return runRenderedPatch(patch, cwd, dryRun, options);
158
2145
  }
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
- }
2146
+ async function runRenderedPatch(patch, cwd, dryRun, options) {
2147
+ const writeSuppressed = dryRun || options.diff === true;
2148
+ const result = await applyPatchBytes(Buffer.from(patch, "utf8"), { cwd, dryRun: writeSuppressed });
165
2149
  return {
166
- source,
167
- target,
168
- payload: Buffer.from(file.subarray(source.start, source.end))
2150
+ ...result,
2151
+ patch: options.diff === true ? patch : undefined
169
2152
  };
170
2153
  }
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)
2154
+ async function insertPayload(args, validated, cwd, dryRun, options) {
2155
+ const dstPath = resolvePath(cwd, args.dst, "destination path");
2156
+ const snapshot = await readFileSnapshot(dstPath, "destination file");
2157
+ const original = snapshot.bytes;
2158
+ const payloadSha256 = createHash3("sha256").update(args.payload).digest("hex");
2159
+ verifyExpectedPayloadHash(validated, payloadSha256);
2160
+ const alreadyApplied = findAlreadyAppliedTarget(original, args, args.dst);
2161
+ if (alreadyApplied !== undefined) {
2162
+ const renderedPatch2 = options.diff ? renderInsertionPatch(args, original, alreadyApplied, payloadSha256) : undefined;
2163
+ await selfCheckRenderedPatch(renderedPatch2, [{ path: args.dst, bytes: original }]);
2164
+ return {
2165
+ changed: [],
2166
+ affected: [args.dst],
2167
+ written: false,
2168
+ noop: true,
2169
+ status: "already_applied",
2170
+ moves: [
2171
+ {
2172
+ id: "move-1",
2173
+ src: devNull,
2174
+ dst: args.dst,
2175
+ payload_sha256: payloadSha256,
2176
+ payload_bytes: args.payload.length,
2177
+ source_range: null,
2178
+ target_range: alreadyApplied.range,
2179
+ insert_index: alreadyApplied.insertIndex
2180
+ }
2181
+ ],
2182
+ patch: renderedPatch2
2183
+ };
2184
+ }
2185
+ const target = findTargetSelection(original, args.targetBefore, args.targetAfter, args.dst, {
2186
+ phase: "target",
2187
+ anchor: targetAnchorName(args)
2188
+ });
2189
+ const renderedPatch = options.diff ? renderInsertionPatch(args, original, target, payloadSha256) : undefined;
2190
+ await selfCheckRenderedPatch(renderedPatch, [{ path: args.dst, bytes: original }]);
2191
+ const writeSuppressed = dryRun || options.diff === true;
2192
+ const next = Buffer.concat([
2193
+ original.subarray(0, target.insertIndex),
2194
+ args.payload,
2195
+ original.subarray(target.insertIndex)
181
2196
  ]);
2197
+ const changed = next.equals(original) ? [] : [args.dst];
2198
+ if (!writeSuppressed && changed.length > 0) {
2199
+ await writeAtomic(dstPath, next, {
2200
+ expected: { kind: "file", label: args.dst, snapshot }
2201
+ });
2202
+ }
2203
+ return {
2204
+ changed,
2205
+ affected: [args.dst],
2206
+ written: !writeSuppressed && changed.length > 0,
2207
+ noop: changed.length === 0,
2208
+ status: changed.length === 0 ? "noop" : "applied",
2209
+ moves: [
2210
+ {
2211
+ id: "move-1",
2212
+ src: devNull,
2213
+ dst: args.dst,
2214
+ payload_sha256: payloadSha256,
2215
+ payload_bytes: args.payload.length,
2216
+ source_range: null,
2217
+ target_range: target.range,
2218
+ insert_index: target.insertIndex
2219
+ }
2220
+ ],
2221
+ patch: renderedPatch
2222
+ };
182
2223
  }
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 };
2224
+ function verifyExpectedPayloadHash(args, payloadSha256) {
2225
+ if (args.expected_payload_sha256 !== undefined && args.expected_payload_sha256 !== payloadSha256) {
2226
+ fail("hash_mismatch", "expected_payload_sha256 does not match selected source payload", {
2227
+ field: "expected_payload_sha256",
2228
+ phase: "payload",
2229
+ anchor: "expected_payload_sha256"
2230
+ });
189
2231
  }
190
- if (fullMatches.length > 1) {
191
- fail("source_ambiguous", `Source block is ambiguous in ${patch.path}; matched ${fullMatches.length} locations`);
2232
+ }
2233
+ function findAlreadyAppliedTarget(file, args, dstLabel) {
2234
+ const alreadyApplied = Buffer.concat([args.targetBefore, args.payload, args.targetAfter]);
2235
+ const matchResult = indexesOfLimited(file, alreadyApplied);
2236
+ const matches = matchResult.matches;
2237
+ if (matches.length === 0) {
2238
+ return;
192
2239
  }
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}`);
2240
+ if (matches.length > 1 || matchResult.truncated) {
2241
+ const ranges = boundedRanges(matches.map((start2) => ({ start: start2, end: start2 + alreadyApplied.length })));
2242
+ fail("target_ambiguous", `Already-applied target is ambiguous in ${dstLabel}; ${matchedLocations(matches.length, matchResult.truncated)}`, {
2243
+ path: dstLabel,
2244
+ phase: "target",
2245
+ anchor: "target_before+payload+target_after",
2246
+ ...matchCountDetails(matches.length, matchResult.truncated),
2247
+ ranges,
2248
+ line_ranges: boundedLineRanges(file, ranges)
2249
+ });
2250
+ }
2251
+ const start = matches[0];
2252
+ return {
2253
+ range: { start, end: start + alreadyApplied.length },
2254
+ insertIndex: start + args.targetBefore.length
2255
+ };
2256
+ }
2257
+ async function deletePayload(args, validated, cwd, dryRun, options) {
2258
+ const srcPath = resolvePath(cwd, args.src, "source path");
2259
+ const snapshot = await readFileSnapshot(srcPath, "source file");
2260
+ const original = snapshot.bytes;
2261
+ const source = findSource(original, args);
2262
+ const payload = Buffer.from(original.subarray(source.start, source.end));
2263
+ const payloadSha256 = createHash3("sha256").update(payload).digest("hex");
2264
+ verifyExpectedPayloadHash(validated, payloadSha256);
2265
+ const renderedPatch = options.diff ? renderDeletionPatch(args, original, source, payload, payloadSha256) : undefined;
2266
+ await selfCheckRenderedPatch(renderedPatch, [{ path: args.src, bytes: original }]);
2267
+ const writeSuppressed = dryRun || options.diff === true;
2268
+ const next = Buffer.concat([original.subarray(0, source.start), original.subarray(source.end)]);
2269
+ const changed = next.equals(original) ? [] : [args.src];
2270
+ if (!writeSuppressed && changed.length > 0) {
2271
+ await writeAtomic(srcPath, next, {
2272
+ expected: { kind: "file", label: args.src, snapshot }
2273
+ });
2274
+ }
2275
+ return {
2276
+ changed,
2277
+ affected: [args.src],
2278
+ written: !writeSuppressed && changed.length > 0,
2279
+ noop: changed.length === 0,
2280
+ status: changed.length === 0 ? "noop" : "applied",
2281
+ moves: [
2282
+ {
2283
+ id: "move-1",
2284
+ src: args.src,
2285
+ dst: devNull,
2286
+ payload_sha256: payloadSha256,
2287
+ payload_bytes: payload.length,
2288
+ source_range: source,
2289
+ target_range: null,
2290
+ insert_index: null
2291
+ }
2292
+ ],
2293
+ patch: renderedPatch
2294
+ };
2295
+ }
2296
+ function findSource(file, args) {
2297
+ const result = findDelimitedRanges(file, args.srcStart, args.srcEnd);
2298
+ const ranges = result.ranges;
2299
+ if (ranges.length === 0) {
2300
+ fail("source_not_found", `Source delimiters were not found in ${args.src}`, {
2301
+ path: args.src,
2302
+ phase: "source",
2303
+ anchor: "src_start/src_end",
2304
+ matches: 0
2305
+ });
196
2306
  }
197
- if (envelopes.length > 1) {
198
- fail("source_ambiguous", `Source anchors are ambiguous in ${patch.path}; matched ${envelopes.length} locations`);
2307
+ if (ranges.length > 1 || result.truncated) {
2308
+ fail("source_ambiguous", `Source delimiters are ambiguous in ${args.src}; ${matchedLocations(ranges.length, result.truncated)}`, {
2309
+ path: args.src,
2310
+ phase: "source",
2311
+ anchor: "src_start/src_end",
2312
+ ...matchCountDetails(ranges.length, result.truncated),
2313
+ ranges: boundedRanges(ranges),
2314
+ line_ranges: boundedLineRanges(file, ranges)
2315
+ });
199
2316
  }
200
- fail("source_not_found", `Source anchors were not found in ${patch.path}`);
2317
+ return ranges[0];
201
2318
  }
202
- function findSourceEnvelopes(file, patch) {
203
- const beforeMatches = indexesOf(file, patch.sourceBefore);
204
- const afterMatches = indexesOf(file, patch.sourceAfter);
2319
+ function findDelimitedRanges(file, startNeedle, endNeedle, limit = 11) {
2320
+ if (startNeedle.length === 0) {
2321
+ return { ranges: [], truncated: false };
2322
+ }
2323
+ const maxMatches = normalizedLimit2(limit);
205
2324
  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 });
2325
+ let start = file.indexOf(startNeedle);
2326
+ while (start !== -1) {
2327
+ const searchFrom = start + startNeedle.length;
2328
+ const endStart = file.indexOf(endNeedle, searchFrom);
2329
+ if (endStart !== -1) {
2330
+ if (ranges.length >= maxMatches) {
2331
+ return { ranges, truncated: true };
2332
+ }
2333
+ ranges.push({ start, end: endStart + endNeedle.length });
211
2334
  }
2335
+ start = file.indexOf(startNeedle, start + 1);
212
2336
  }
213
- return ranges;
2337
+ return { ranges, truncated: false };
214
2338
  }
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}`);
2339
+ function normalizedLimit2(limit) {
2340
+ if (!Number.isFinite(limit)) {
2341
+ return Number.POSITIVE_INFINITY;
219
2342
  }
220
- if (matches.length > 1) {
221
- fail("target_ambiguous", `Target anchor is ambiguous in ${patch.path}; matched ${matches.length} locations`);
2343
+ return Math.max(0, Math.trunc(limit));
2344
+ }
2345
+ function targetAnchorName(args) {
2346
+ if (args.targetBefore.length > 0 && args.targetAfter.length > 0) {
2347
+ return "target_before+target_after";
222
2348
  }
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
- };
2349
+ return args.targetBefore.length > 0 ? "target_before" : "target_after";
229
2350
  }
230
- function indexesOf(haystack, needle) {
231
- if (needle.length === 0) {
232
- return [];
2351
+ function samePatchPath2(left, right) {
2352
+ return posix2.normalize(left) === posix2.normalize(right);
2353
+ }
2354
+ async function selfCheckRenderedPatch(patch, files) {
2355
+ if (patch === undefined) {
2356
+ return;
233
2357
  }
234
- const indexes = [];
235
- let index = haystack.indexOf(needle);
236
- while (index !== -1) {
237
- indexes.push(index);
238
- index = haystack.indexOf(needle, index + 1);
2358
+ checkPatchBytesInMemory(Buffer.from(patch, "utf8"), files);
2359
+ }
2360
+ function pairedSelfCheckFiles(srcLabel, dstLabel, srcOriginal, dstOriginal, sameFile) {
2361
+ const identity = sameFile ? "move-file" : undefined;
2362
+ const files = [{ path: srcLabel, bytes: srcOriginal, identity }];
2363
+ if (dstLabel !== srcLabel) {
2364
+ files.push({ path: dstLabel, bytes: dstOriginal, identity });
239
2365
  }
240
- return indexes;
2366
+ return files;
241
2367
  }
242
- function rangesOverlap(left, right) {
243
- return left.start < right.end && right.start < left.end;
2368
+ function renderMovePatch(args, srcOriginal, dstOriginal, selection, sameFile, payloadSha256) {
2369
+ const id = "move-1";
2370
+ const { source, target, payload } = selection;
2371
+ const sourceBefore = adjacentLineBefore(srcOriginal, source.start);
2372
+ const sourceAfter = adjacentLineAfter(srcOriginal, source.end);
2373
+ const targetBefore = args.targetBefore.length === 0 ? adjacentLineBefore(dstOriginal, target.range.start) : args.targetBefore;
2374
+ const targetAfter = args.targetAfter.length === 0 ? adjacentLineAfter(dstOriginal, target.range.end) : args.targetAfter;
2375
+ const payloadLines = countLines(payload);
2376
+ const sourceHunkStart = source.start - sourceBefore.length;
2377
+ const sourceOldStart = lineNumberAt2(srcOriginal, sourceHunkStart);
2378
+ const sourceOldCount = countLines(sourceBefore) + payloadLines + countLines(sourceAfter);
2379
+ const sourceNewStart = sameFile && target.insertIndex <= sourceHunkStart ? sourceOldStart + payloadLines : sourceOldStart;
2380
+ const sourceNewCount = sourceOldCount - payloadLines;
2381
+ const targetHunkStart = target.insertIndex - targetBefore.length;
2382
+ const targetOldStart = lineNumberAt2(dstOriginal, targetHunkStart);
2383
+ const targetOldCount = countLines(targetBefore) + countLines(targetAfter);
2384
+ const targetNewStart = sameFile && source.end <= targetHunkStart ? targetOldStart - payloadLines : targetOldStart;
2385
+ const targetNewCount = targetOldCount + payloadLines;
2386
+ const sourceBody = joinPatchChunks([
2387
+ renderHunkBytes(sourceBefore, " "),
2388
+ renderHunkBytes(payload, "-"),
2389
+ renderHunkBytes(sourceAfter, " ")
2390
+ ]);
2391
+ const targetBody = joinPatchChunks([
2392
+ renderHunkBytes(targetBefore, " "),
2393
+ renderHunkBytes(payload, "+"),
2394
+ renderHunkBytes(targetAfter, " ")
2395
+ ]);
2396
+ const sourceHunkHeader = `@@ -${sourceOldStart},${sourceOldCount} +${sourceNewStart},${sourceNewCount} @@ blockpatch-source id=${id}`;
2397
+ const targetHunkHeader = `@@ -${targetOldStart},${targetOldCount} +${targetNewStart},${targetNewCount} @@ blockpatch-target id=${id}`;
2398
+ if (!sameFile) {
2399
+ return [
2400
+ `diff --blockpatch a/${args.src} b/${args.src}`,
2401
+ "blockpatch version 1",
2402
+ `blockpatch move id=${id} role=source payload-sha256=${payloadSha256}`,
2403
+ `--- a/${args.src}`,
2404
+ `+++ b/${args.src}`,
2405
+ "",
2406
+ sourceHunkHeader,
2407
+ sourceBody,
2408
+ "",
2409
+ `diff --blockpatch a/${args.dst} b/${args.dst}`,
2410
+ "blockpatch version 1",
2411
+ `blockpatch move id=${id} role=target payload-sha256=${payloadSha256}`,
2412
+ `--- a/${args.dst}`,
2413
+ `+++ b/${args.dst}`,
2414
+ "",
2415
+ targetHunkHeader,
2416
+ targetBody
2417
+ ].join(`
2418
+ `) + `
2419
+ `;
2420
+ }
2421
+ return [
2422
+ `diff --blockpatch a/${args.src} b/${args.dst}`,
2423
+ "blockpatch version 1",
2424
+ `blockpatch move id=${id} payload-sha256=${payloadSha256}`,
2425
+ `--- a/${args.src}`,
2426
+ `+++ b/${args.dst}`,
2427
+ "",
2428
+ sourceHunkHeader,
2429
+ sourceBody,
2430
+ targetHunkHeader,
2431
+ targetBody
2432
+ ].join(`
2433
+ `) + `
2434
+ `;
2435
+ }
2436
+ function renderInsertionPatch(args, dstOriginal, target, payloadSha256) {
2437
+ const id = "move-1";
2438
+ const targetBefore = args.targetBefore.length === 0 ? adjacentLineBefore(dstOriginal, target.range.start) : args.targetBefore;
2439
+ const targetAfter = args.targetAfter.length === 0 ? adjacentLineAfter(dstOriginal, target.range.end) : args.targetAfter;
2440
+ const payloadLines = countLines(args.payload);
2441
+ const targetHunkStart = target.insertIndex - targetBefore.length;
2442
+ const targetOldStart = lineNumberAt2(dstOriginal, targetHunkStart);
2443
+ const targetOldCount = countLines(targetBefore) + countLines(targetAfter);
2444
+ const targetNewCount = targetOldCount + payloadLines;
2445
+ const targetBody = joinPatchChunks([
2446
+ renderHunkBytes(targetBefore, " "),
2447
+ renderHunkBytes(args.payload, "+"),
2448
+ renderHunkBytes(targetAfter, " ")
2449
+ ]);
2450
+ return [
2451
+ `diff --blockpatch a/${args.dst} b/${args.dst}`,
2452
+ "blockpatch version 1",
2453
+ `blockpatch move id=${id} payload-sha256=${payloadSha256}`,
2454
+ `--- a/${args.dst}`,
2455
+ `+++ b/${args.dst}`,
2456
+ "",
2457
+ `@@ -${targetOldStart},${targetOldCount} +${targetOldStart},${targetNewCount} @@ blockpatch-target id=${id}`,
2458
+ targetBody
2459
+ ].join(`
2460
+ `) + `
2461
+ `;
2462
+ }
2463
+ function renderDeletionPatch(args, srcOriginal, source, payload, payloadSha256) {
2464
+ const id = "move-1";
2465
+ const sourceBefore = adjacentLineBefore(srcOriginal, source.start);
2466
+ const sourceAfter = adjacentLineAfter(srcOriginal, source.end);
2467
+ const payloadLines = countLines(payload);
2468
+ const sourceHunkStart = source.start - sourceBefore.length;
2469
+ const sourceOldStart = lineNumberAt2(srcOriginal, sourceHunkStart);
2470
+ const sourceOldCount = countLines(sourceBefore) + payloadLines + countLines(sourceAfter);
2471
+ const sourceNewCount = sourceOldCount - payloadLines;
2472
+ const sourceBody = joinPatchChunks([
2473
+ renderHunkBytes(sourceBefore, " "),
2474
+ renderHunkBytes(payload, "-"),
2475
+ renderHunkBytes(sourceAfter, " ")
2476
+ ]);
2477
+ return [
2478
+ `diff --blockpatch a/${args.src} b/${args.src}`,
2479
+ "blockpatch version 1",
2480
+ `blockpatch move id=${id} payload-sha256=${payloadSha256}`,
2481
+ `--- a/${args.src}`,
2482
+ `+++ b/${args.src}`,
2483
+ "",
2484
+ `@@ -${sourceOldStart},${sourceOldCount} +${sourceOldStart},${sourceNewCount} @@ blockpatch-source id=${id}`,
2485
+ sourceBody
2486
+ ].join(`
2487
+ `) + `
2488
+ `;
2489
+ }
2490
+ function renderFileCreationPatch(args, payloadSha256) {
2491
+ const id = "move-1";
2492
+ const payloadLines = countLines(args.payload);
2493
+ const targetBody = renderHunkBytes(args.payload, "+");
2494
+ return [
2495
+ `diff --blockpatch ${devNull} b/${args.dst}`,
2496
+ "blockpatch version 1",
2497
+ `blockpatch move id=${id} payload-sha256=${payloadSha256}`,
2498
+ `--- ${devNull}`,
2499
+ `+++ b/${args.dst}`,
2500
+ "",
2501
+ `@@ -0,0 +${payloadLines === 0 ? "0,0" : `1,${payloadLines}`} @@ blockpatch-target id=${id}`,
2502
+ targetBody
2503
+ ].join(`
2504
+ `) + `
2505
+ `;
2506
+ }
2507
+ function renderFileRemovalPatch(args, payload, payloadSha256) {
2508
+ const id = "move-1";
2509
+ const payloadLines = countLines(payload);
2510
+ const sourceBody = renderHunkBytes(payload, "-");
2511
+ return [
2512
+ `diff --blockpatch a/${args.src} ${devNull}`,
2513
+ "blockpatch version 1",
2514
+ `blockpatch move id=${id} payload-sha256=${payloadSha256}`,
2515
+ `--- a/${args.src}`,
2516
+ `+++ ${devNull}`,
2517
+ "",
2518
+ `@@ -${payloadLines === 0 ? "0,0" : `1,${payloadLines}`} +0,0 @@ blockpatch-source id=${id}`,
2519
+ sourceBody
2520
+ ].join(`
2521
+ `) + `
2522
+ `;
2523
+ }
2524
+ function lineNumberAt2(file, byteIndex) {
2525
+ let line = 1;
2526
+ for (let index = 0;index < byteIndex; index += 1) {
2527
+ if (file[index] === 10) {
2528
+ line += 1;
2529
+ }
2530
+ }
2531
+ return line;
2532
+ }
2533
+ function countLines(bytes) {
2534
+ if (bytes.length === 0) {
2535
+ return 0;
2536
+ }
2537
+ let newlines = 0;
2538
+ for (const byte of bytes) {
2539
+ if (byte === 10) {
2540
+ newlines += 1;
2541
+ }
2542
+ }
2543
+ return bytes[bytes.length - 1] === 10 ? newlines : newlines + 1;
2544
+ }
2545
+ function joinPatchChunks(chunks) {
2546
+ return chunks.filter((chunk) => chunk.length > 0).join(`
2547
+ `);
2548
+ }
2549
+ function adjacentLineBefore(file, index) {
2550
+ if (index === 0) {
2551
+ return Buffer.alloc(0);
2552
+ }
2553
+ const previousLf = file.lastIndexOf(10, index - 2);
2554
+ return Buffer.from(file.subarray(previousLf === -1 ? 0 : previousLf + 1, index));
2555
+ }
2556
+ function adjacentLineAfter(file, index) {
2557
+ if (index >= file.length) {
2558
+ return Buffer.alloc(0);
2559
+ }
2560
+ const nextLf = file.indexOf(10, index);
2561
+ return Buffer.from(file.subarray(index, nextLf === -1 ? file.length : nextLf + 1));
2562
+ }
2563
+ function renderHunkBytes(bytes, prefix) {
2564
+ const text2 = decodeUtf8(bytes);
2565
+ if (text2.length === 0) {
2566
+ return "";
2567
+ }
2568
+ const lines = text2.split(/(?<=\n)/);
2569
+ return lines.filter((line) => line.length > 0).map((line) => {
2570
+ if (line.endsWith(`
2571
+ `)) {
2572
+ return `${prefix}${line.slice(0, -1)}`;
2573
+ }
2574
+ return `${prefix}${line}
2575
+ \`;
2576
+ }).join(`
2577
+ `);
244
2578
  }
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`);
2579
+ function decodeUtf8(bytes) {
250
2580
  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;
2581
+ return utf8Decoder.decode(bytes);
2582
+ } catch {
2583
+ fail("invalid_utf8", "move --diff cannot render invalid UTF-8 bytes", {
2584
+ phase: "render",
2585
+ anchor: "move --diff"
257
2586
  });
258
- throw error;
259
2587
  }
260
2588
  }
261
2589
 
262
2590
  // src/cli.ts
2591
+ var packageJson = createRequire(import.meta.url)("../package.json");
2592
+ var OUTPUT_FLAGS = new Set(["--json-output", "--explain"]);
2593
+ var CWD_VALUE_FLAGS = new Set(["--cwd", "--directory", "-d"]);
2594
+ var PATCH_INPUT_VALUE_FLAGS = new Set(["-i", "--input"]);
2595
+ var STRIP_VALUE_FLAGS = new Set(["-p", "--strip"]);
2596
+ var PATCH_VALUE_FLAGS = new Set([...CWD_VALUE_FLAGS, ...PATCH_INPUT_VALUE_FLAGS, ...STRIP_VALUE_FLAGS]);
2597
+ var MOVE_JSON_VALUE_FLAGS = new Set(["--json"]);
2598
+ var MOVE_KEY_BY_FLAG = {
2599
+ "--src": "src",
2600
+ "--src-start": "src_start",
2601
+ "--src-end": "src_end",
2602
+ "--dst": "dst",
2603
+ "--payload": "payload",
2604
+ "--target-before": "target_before",
2605
+ "--target-after": "target_after",
2606
+ "--expected-payload-sha256": "expected_payload_sha256"
2607
+ };
2608
+ var MOVE_ARG_VALUE_FLAGS = new Set(Object.keys(MOVE_KEY_BY_FLAG));
2609
+ var MOVE_VALUE_FLAGS = new Set([...CWD_VALUE_FLAGS, ...MOVE_JSON_VALUE_FLAGS, ...MOVE_ARG_VALUE_FLAGS]);
263
2610
  async function main(argv) {
264
2611
  const options = parseArgs(argv);
265
2612
  if (options.command === "help") {
@@ -267,84 +2614,401 @@ async function main(argv) {
267
2614
  return 0;
268
2615
  }
269
2616
  if (options.command === "version") {
270
- console.log("blockpatch 0.1.0");
2617
+ writeSuccess(options, { version: packageJson.version });
2618
+ return 0;
2619
+ }
2620
+ if (options.command === "move" || options.command === "plan") {
2621
+ const args = await loadMoveArgs(options);
2622
+ const result2 = await moveBlock(args, {
2623
+ cwd: options.cwd,
2624
+ dryRun: options.dryRun ? true : undefined,
2625
+ diff: options.diff
2626
+ });
2627
+ if (options.diff && result2.patch !== undefined) {
2628
+ writeSuccess(options, result2, result2.patch);
2629
+ return 0;
2630
+ }
2631
+ writeChangeResult(options, result2, options.dryRun || args.dry_run === true ? "would change" : "changed");
271
2632
  return 0;
272
2633
  }
273
- if (options.patchPath === undefined) {
274
- throw new BlockPatchError("missing_patch", "Missing patch file path");
2634
+ const result = await runPatchCommand(options);
2635
+ const verb = options.command === "check" || options.dryRun ? "would change" : "changed";
2636
+ writeChangeResult(options, result, verb);
2637
+ return 0;
2638
+ }
2639
+ async function runPatchCommand(options) {
2640
+ const patchPath = options.patchPath ?? "-";
2641
+ const inputPatchPath = patchPath === "-" ? patchPath : resolve3(patchPath);
2642
+ if (options.command === "check") {
2643
+ return patchPath === "-" ? checkPatchBytes(await readStdin(), {
2644
+ cwd: options.cwd,
2645
+ reverse: options.reverse,
2646
+ stripComponents: options.stripComponents
2647
+ }) : checkPatchFile(inputPatchPath, {
2648
+ cwd: options.cwd,
2649
+ reverse: options.reverse,
2650
+ stripComponents: options.stripComponents
2651
+ });
275
2652
  }
276
- const result = options.command === "check" ? await checkPatchFile(options.patchPath, { cwd: options.cwd }) : await applyPatchFile(options.patchPath, {
2653
+ return patchPath === "-" ? applyPatchBytes(await readStdin(), {
2654
+ cwd: options.cwd,
2655
+ dryRun: options.dryRun,
2656
+ reverse: options.reverse,
2657
+ stripComponents: options.stripComponents
2658
+ }) : applyPatchFile(inputPatchPath, {
277
2659
  cwd: options.cwd,
278
- dryRun: options.dryRun
2660
+ dryRun: options.dryRun,
2661
+ reverse: options.reverse,
2662
+ stripComponents: options.stripComponents
279
2663
  });
280
- const verb = options.command === "check" || options.dryRun ? "would change" : "changed";
281
- for (const path of result.changed) {
282
- console.log(`${verb} ${path}`);
2664
+ }
2665
+ async function loadMoveArgs(options) {
2666
+ if (options.moveArgs !== undefined) {
2667
+ return options.moveArgs;
2668
+ }
2669
+ if (options.moveJsonPath === undefined) {
2670
+ throw new BlockPatchError("missing_move_args", `${options.command} requires --json or --src flags`);
2671
+ }
2672
+ const jsonBytes = options.moveJsonPath === "-" ? await readStdin() : await readFileChecked(resolve3(options.moveJsonPath), "move JSON file");
2673
+ try {
2674
+ return JSON.parse(jsonBytes.toString("utf8"));
2675
+ } catch (error) {
2676
+ const message = error instanceof Error ? error.message : String(error);
2677
+ throw new BlockPatchError("invalid_json", `Invalid move JSON: ${message}`);
283
2678
  }
284
- return 0;
285
2679
  }
286
2680
  function parseArgs(argv) {
287
2681
  const args = [...argv];
2682
+ const outputFlags = takeLeadingOutputFlags(args);
288
2683
  const first = args.shift();
289
2684
  if (first === undefined || first === "help" || first === "--help" || first === "-h") {
290
- return { command: "help", cwd: process.cwd(), dryRun: false };
2685
+ return base("help", outputFlags.jsonOutput);
291
2686
  }
292
2687
  if (first === "version" || first === "--version" || first === "-v") {
293
- return { command: "version", cwd: process.cwd(), dryRun: false };
2688
+ const options = base("version", outputFlags.jsonOutput);
2689
+ for (const arg of args) {
2690
+ if (isOutputFlag(arg)) {
2691
+ options.jsonOutput = true;
2692
+ continue;
2693
+ }
2694
+ if (arg.startsWith("-")) {
2695
+ throw new BlockPatchError("unknown_option", `Unknown option: ${arg}`);
2696
+ }
2697
+ throw new BlockPatchError("too_many_args", `Unexpected argument: ${arg}`);
2698
+ }
2699
+ return options;
294
2700
  }
295
- if (first !== "apply" && first !== "check") {
2701
+ if (first !== "apply" && first !== "check" && first !== "move" && first !== "plan") {
296
2702
  throw new BlockPatchError("unknown_command", `Unknown command: ${first}`);
297
2703
  }
298
- let cwd = process.cwd();
299
- let dryRun = false;
300
- let patchPath;
2704
+ if (first === "move" || first === "plan") {
2705
+ return parseMoveArgs(first, args, first === "plan" ? true : outputFlags.jsonOutput, outputFlags.explain);
2706
+ }
2707
+ return parsePatchArgs(first, args, outputFlags.jsonOutput, outputFlags.explain);
2708
+ }
2709
+ function parsePatchArgs(command, args, jsonOutput, explain) {
2710
+ const options = base(command, jsonOutput);
2711
+ if (explain && command === "apply") {
2712
+ options.dryRun = true;
2713
+ }
301
2714
  while (args.length > 0) {
302
2715
  const arg = args.shift();
2716
+ if (takeOutputFlag(options, arg)) {
2717
+ continue;
2718
+ }
303
2719
  if (arg === "--dry-run") {
304
- dryRun = true;
2720
+ options.dryRun = true;
305
2721
  continue;
306
2722
  }
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);
2723
+ if (arg === "--reverse" || arg === "-R") {
2724
+ options.reverse = true;
2725
+ continue;
2726
+ }
2727
+ if (isValueFlag(arg, CWD_VALUE_FLAGS)) {
2728
+ options.cwd = requireValue(args, arg, true);
313
2729
  continue;
314
2730
  }
315
2731
  if (arg?.startsWith("--cwd=")) {
316
- cwd = resolve2(arg.slice("--cwd=".length));
2732
+ options.cwd = resolve3(arg.slice("--cwd=".length));
2733
+ continue;
2734
+ }
2735
+ if (arg?.startsWith("--directory=")) {
2736
+ options.cwd = resolve3(arg.slice("--directory=".length));
2737
+ continue;
2738
+ }
2739
+ if (isValueFlag(arg, PATCH_INPUT_VALUE_FLAGS)) {
2740
+ setPatchPath(options, requireValue(args, arg, false));
2741
+ continue;
2742
+ }
2743
+ if (arg?.startsWith("--input=")) {
2744
+ setPatchPath(options, arg.slice("--input=".length));
2745
+ continue;
2746
+ }
2747
+ const stripComponents = parseStripOption(arg, args);
2748
+ if (stripComponents !== undefined) {
2749
+ options.stripComponents = stripComponents;
317
2750
  continue;
318
2751
  }
319
- if (arg?.startsWith("-")) {
2752
+ if (arg?.startsWith("-") && arg !== "-") {
320
2753
  throw new BlockPatchError("unknown_option", `Unknown option: ${arg}`);
321
2754
  }
322
- if (patchPath !== undefined) {
323
- throw new BlockPatchError("too_many_args", `Unexpected argument: ${arg}`);
2755
+ setPatchPath(options, arg ?? "");
2756
+ }
2757
+ if (command === "check" && options.dryRun) {
2758
+ throw new BlockPatchError("invalid_option", "--dry-run is only valid with apply or move");
2759
+ }
2760
+ return options;
2761
+ }
2762
+ function parseMoveArgs(command, args, jsonOutput, explain) {
2763
+ const options = base(command, jsonOutput);
2764
+ if (command === "plan") {
2765
+ options.dryRun = true;
2766
+ options.diff = true;
2767
+ options.jsonOutput = true;
2768
+ }
2769
+ if (explain) {
2770
+ options.dryRun = true;
2771
+ }
2772
+ const moveArgs = {};
2773
+ let sawFlagArgs = false;
2774
+ while (args.length > 0) {
2775
+ const arg = args.shift();
2776
+ if (takeOutputFlag(options, arg)) {
2777
+ continue;
2778
+ }
2779
+ if (arg === "--dry-run") {
2780
+ options.dryRun = true;
2781
+ continue;
2782
+ }
2783
+ if (arg === "--diff") {
2784
+ options.diff = true;
2785
+ continue;
2786
+ }
2787
+ if (isValueFlag(arg, CWD_VALUE_FLAGS)) {
2788
+ options.cwd = requireValue(args, arg, true);
2789
+ continue;
2790
+ }
2791
+ if (arg?.startsWith("--cwd=")) {
2792
+ options.cwd = resolve3(arg.slice("--cwd=".length));
2793
+ continue;
2794
+ }
2795
+ if (arg?.startsWith("--directory=")) {
2796
+ options.cwd = resolve3(arg.slice("--directory=".length));
2797
+ continue;
2798
+ }
2799
+ if (isValueFlag(arg, MOVE_JSON_VALUE_FLAGS)) {
2800
+ options.moveJsonPath = requireValue(args, arg, false);
2801
+ continue;
324
2802
  }
325
- patchPath = arg;
2803
+ if (arg?.startsWith("--json=")) {
2804
+ options.moveJsonPath = arg.slice("--json=".length);
2805
+ continue;
2806
+ }
2807
+ const key = argToMoveKey(arg);
2808
+ if (key !== undefined) {
2809
+ moveArgs[key] = requireValue(args, arg ?? "", false);
2810
+ sawFlagArgs = true;
2811
+ continue;
2812
+ }
2813
+ throw new BlockPatchError("unknown_option", `Unknown option: ${String(arg)}`);
2814
+ }
2815
+ if (options.moveJsonPath !== undefined && sawFlagArgs) {
2816
+ throw new BlockPatchError("invalid_option", "move cannot combine --json with --src flags");
2817
+ }
2818
+ if (sawFlagArgs) {
2819
+ options.moveArgs = moveArgs;
2820
+ }
2821
+ return options;
2822
+ }
2823
+ function argToMoveKey(arg) {
2824
+ return MOVE_KEY_BY_FLAG[arg];
2825
+ }
2826
+ function base(command, jsonOutput) {
2827
+ return {
2828
+ command,
2829
+ cwd: process.cwd(),
2830
+ dryRun: false,
2831
+ diff: false,
2832
+ jsonOutput,
2833
+ reverse: false,
2834
+ stripComponents: 1
2835
+ };
2836
+ }
2837
+ function setPatchPath(options, path) {
2838
+ if (options.patchPath !== undefined) {
2839
+ throw new BlockPatchError("too_many_args", `Unexpected argument: ${path}`);
2840
+ }
2841
+ options.patchPath = path;
2842
+ }
2843
+ function parseStripOption(arg, args) {
2844
+ if (isValueFlag(arg, STRIP_VALUE_FLAGS)) {
2845
+ return parseStripComponents(requireValue(args, arg, false), arg);
2846
+ }
2847
+ if (arg?.startsWith("-p") === true && arg.length > 2) {
2848
+ return parseStripComponents(arg.slice(2), "-p");
2849
+ }
2850
+ if (arg?.startsWith("--strip=") === true) {
2851
+ return parseStripComponents(arg.slice("--strip=".length), "--strip");
2852
+ }
2853
+ return;
2854
+ }
2855
+ function parseStripComponents(value, option) {
2856
+ if (!/^\d+$/.test(value)) {
2857
+ throw new BlockPatchError("invalid_option", `Invalid strip count for ${option}: ${value}`);
2858
+ }
2859
+ const parsed = Number(value);
2860
+ if (!Number.isSafeInteger(parsed)) {
2861
+ throw new BlockPatchError("invalid_option", `Invalid strip count for ${option}: ${value}`);
2862
+ }
2863
+ return parsed;
2864
+ }
2865
+ function takeLeadingOutputFlags(args) {
2866
+ let jsonOutput = false;
2867
+ let explain = false;
2868
+ while (isOutputFlag(args[0])) {
2869
+ const arg = args.shift();
2870
+ jsonOutput = true;
2871
+ explain = explain || arg === "--explain";
2872
+ }
2873
+ return { jsonOutput, explain };
2874
+ }
2875
+ function takeOutputFlag(options, arg) {
2876
+ if (arg === "--explain") {
2877
+ options.jsonOutput = true;
2878
+ if (options.command === "apply" || options.command === "move" || options.command === "plan") {
2879
+ options.dryRun = true;
2880
+ }
2881
+ return true;
2882
+ }
2883
+ if (arg === "--json-output") {
2884
+ options.jsonOutput = true;
2885
+ return true;
2886
+ }
2887
+ return false;
2888
+ }
2889
+ function isOutputFlag(arg) {
2890
+ return arg !== undefined && OUTPUT_FLAGS.has(arg);
2891
+ }
2892
+ function isValueFlag(arg, flags) {
2893
+ return arg !== undefined && flags.has(arg);
2894
+ }
2895
+ function requireValue(args, option, pathValue) {
2896
+ const value = args.shift();
2897
+ if (value === undefined) {
2898
+ throw new BlockPatchError("missing_option_value", `Missing value for ${option}`);
2899
+ }
2900
+ return pathValue ? resolve3(value) : value;
2901
+ }
2902
+ async function readStdin() {
2903
+ const chunks = [];
2904
+ for await (const chunk of process.stdin) {
2905
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
2906
+ }
2907
+ return Buffer.concat(chunks);
2908
+ }
2909
+ function writeChangeResult(options, result, verb) {
2910
+ if (options.jsonOutput) {
2911
+ writeSuccess(options, result);
2912
+ return;
2913
+ }
2914
+ for (const path of result.changed) {
2915
+ console.log(`${verb} ${path}`);
2916
+ }
2917
+ if (result.changed.length === 0) {
2918
+ for (const path of result.affected) {
2919
+ console.log(`unchanged ${path}`);
2920
+ }
2921
+ }
2922
+ }
2923
+ function writeSuccess(options, result, plainText) {
2924
+ if (options.jsonOutput) {
2925
+ console.log(JSON.stringify({ ok: true, ...objectResult(result), ...jsonSuccessMetadata(options) }));
2926
+ return;
326
2927
  }
327
- if (first === "check" && dryRun) {
328
- throw new BlockPatchError("invalid_option", "--dry-run is only valid with apply");
2928
+ if (plainText !== undefined) {
2929
+ console.log(plainText);
2930
+ return;
2931
+ }
2932
+ if (typeof result === "object" && result !== null && "version" in result) {
2933
+ console.log(`blockpatch ${result.version}`);
2934
+ }
2935
+ }
2936
+ function objectResult(result) {
2937
+ return typeof result === "object" && result !== null ? result : {};
2938
+ }
2939
+ function jsonSuccessMetadata(options) {
2940
+ if (options.command === "apply" || options.command === "check") {
2941
+ return { strip_components: options.stripComponents };
329
2942
  }
330
- return { command: first, patchPath, cwd, dryRun };
2943
+ return {};
331
2944
  }
332
2945
  function printHelp() {
333
2946
  console.log(`blockpatch
334
2947
 
335
2948
  Usage:
336
- blockpatch check <patch.blockpatch> [--cwd <dir>]
337
- blockpatch apply <patch.blockpatch> [--cwd <dir>] [--dry-run]
2949
+ blockpatch check [patch.blockpatch|-] [-d <dir>] [-pN] [-R|--reverse] [--json-output|--explain]
2950
+ blockpatch apply [patch.blockpatch|-] [-i <patch.blockpatch>] [-d <dir>] [-pN] [-R|--reverse] [--dry-run] [--json-output|--explain]
2951
+ blockpatch plan --json <path.json|-> [--cwd <dir>]
2952
+ blockpatch move --json <path.json|-> [--cwd <dir>] [--dry-run] [--diff] [--json-output|--explain]
2953
+ blockpatch move --src <path> --src-start <text> --src-end <text> --dst <path> --target-before <text> --target-after <text> [--expected-payload-sha256 <sha256>]
2954
+ blockpatch move --src /dev/null --dst <path> --payload <text> --target-before <text>
2955
+ blockpatch move --src <path> --src-start <text> --src-end <text> --dst /dev/null
338
2956
  blockpatch version
339
2957
  `);
340
2958
  }
341
2959
  main(process.argv.slice(2)).then((code) => {
342
2960
  process.exitCode = code;
343
2961
  }, (error) => {
2962
+ const jsonOutput = hasJsonOutputFlag(process.argv.slice(2));
344
2963
  if (error instanceof BlockPatchError) {
345
- console.error(`blockpatch: ${error.message}`);
2964
+ writeError(error.code, error.message, jsonOutput, error.details);
346
2965
  process.exitCode = 1;
347
2966
  return;
348
2967
  }
349
- throw error;
2968
+ const message = error instanceof Error ? error.message : String(error);
2969
+ writeError("unexpected_error", message, jsonOutput);
2970
+ process.exitCode = 1;
350
2971
  });
2972
+ function hasJsonOutputFlag(argv) {
2973
+ const args = [...argv];
2974
+ const outputFlags = takeLeadingOutputFlags(args);
2975
+ if (outputFlags.jsonOutput) {
2976
+ return true;
2977
+ }
2978
+ const command = args.shift();
2979
+ if (command === "apply" || command === "check") {
2980
+ return hasPatchJsonOutputFlag(args);
2981
+ }
2982
+ if (command === "move") {
2983
+ return hasMoveJsonOutputFlag(args);
2984
+ }
2985
+ if (command === "plan") {
2986
+ return true;
2987
+ }
2988
+ return args.some(isOutputFlag);
2989
+ }
2990
+ function hasPatchJsonOutputFlag(args) {
2991
+ return hasJsonOutputAfterValueFlags(args, PATCH_VALUE_FLAGS);
2992
+ }
2993
+ function hasMoveJsonOutputFlag(args) {
2994
+ return hasJsonOutputAfterValueFlags(args, MOVE_VALUE_FLAGS);
2995
+ }
2996
+ function hasJsonOutputAfterValueFlags(args, valueFlags) {
2997
+ for (let index = 0;index < args.length; index += 1) {
2998
+ const arg = args[index];
2999
+ if (isOutputFlag(arg)) {
3000
+ return true;
3001
+ }
3002
+ if (isValueFlag(arg, valueFlags)) {
3003
+ index += 1;
3004
+ }
3005
+ }
3006
+ return false;
3007
+ }
3008
+ function writeError(code, message, jsonOutput, details = {}) {
3009
+ if (jsonOutput) {
3010
+ console.error(JSON.stringify({ ok: false, error: { code, message, ...details } }));
3011
+ return;
3012
+ }
3013
+ console.error(`blockpatch: ${message}`);
3014
+ }