blockpatch 0.1.0 → 1.0.0

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