@bigknoxy/hashpilot 4.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +777 -0
- package/docs/ADAPTER-CONTRACT.md +1260 -0
- package/docs/ARCHITECTURE.md +846 -0
- package/docs/CLI-QUICKREF.md +827 -0
- package/docs/COMPETITIVE-ANALYSIS.md +307 -0
- package/docs/INSTALL.md +403 -0
- package/docs/INTEGRATION-CLAUDE.md +126 -0
- package/docs/INTEGRATION-MCP.md +196 -0
- package/docs/INTEGRATION-OPENCODE.md +136 -0
- package/docs/INTEGRATION-PI.md +195 -0
- package/package.json +77 -0
- package/scripts/build-site.sh +39 -0
- package/scripts/doctor.sh +218 -0
- package/scripts/gen-cli-quickref.ts +232 -0
- package/scripts/install-cli.sh +60 -0
- package/scripts/install.sh +466 -0
- package/scripts/roadmap-lint.ts +200 -0
- package/scripts/uninstall.sh +202 -0
- package/src/cli-node.cjs +51 -0
- package/src/cli.ts +209 -0
- package/src/commands/ast.ts +255 -0
- package/src/commands/diff.ts +98 -0
- package/src/commands/edit.ts +93 -0
- package/src/commands/hash.ts +64 -0
- package/src/commands/intent.ts +68 -0
- package/src/commands/maintenance.ts +191 -0
- package/src/commands/mcp.ts +28 -0
- package/src/commands/provenance.ts +111 -0
- package/src/commands/read.ts +117 -0
- package/src/commands/route.ts +42 -0
- package/src/commands/shared.ts +65 -0
- package/src/commands/telemetry.ts +126 -0
- package/src/commands/verify.ts +61 -0
- package/src/core/ast-edit.ts +2357 -0
- package/src/core/batch-edit.ts +185 -0
- package/src/core/config.ts +189 -0
- package/src/core/diff-engine.ts +474 -0
- package/src/core/doctor.ts +303 -0
- package/src/core/encoding.ts +116 -0
- package/src/core/envelope.ts +163 -0
- package/src/core/exit-codes.ts +198 -0
- package/src/core/format.ts +339 -0
- package/src/core/grep.ts +180 -0
- package/src/core/hash-edit.ts +416 -0
- package/src/core/index.ts +155 -0
- package/src/core/intent.ts +584 -0
- package/src/core/locking.ts +292 -0
- package/src/core/module-system.ts +142 -0
- package/src/core/operations.ts +557 -0
- package/src/core/output.ts +122 -0
- package/src/core/path-normalize.ts +61 -0
- package/src/core/paths.ts +326 -0
- package/src/core/plan-executor.ts +437 -0
- package/src/core/platform.ts +132 -0
- package/src/core/provenance.ts +214 -0
- package/src/core/read.ts +111 -0
- package/src/core/redact.ts +98 -0
- package/src/core/resolve-content.ts +12 -0
- package/src/core/router.ts +463 -0
- package/src/core/snapshot.ts +346 -0
- package/src/core/telemetry.ts +838 -0
- package/src/core/utils.ts +7 -0
- package/src/core/verify-baseline.ts +186 -0
- package/src/core/verify-scope.ts +282 -0
- package/src/core/verify.ts +753 -0
- package/src/mcp/server.ts +325 -0
- package/templates/claude-section.md +12 -0
- package/templates/opencode-agent.md +106 -0
- package/templates/opencode-skill.md +241 -0
- package/templates/pi-extension.ts +288 -0
- package/templates/pi-skill.md +123 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
import { safeWrite } from "./paths";
|
|
2
|
+
import { readDecoded } from "./encoding";
|
|
3
|
+
export interface Hunk {
|
|
4
|
+
oldStart: number;
|
|
5
|
+
oldLines: number;
|
|
6
|
+
newStart: number;
|
|
7
|
+
newLines: number;
|
|
8
|
+
header: string;
|
|
9
|
+
lines: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Where a hunk actually landed. A fuzzy match that silently slid is
|
|
14
|
+
* indistinguishable from an exact one unless the offset is reported, and
|
|
15
|
+
* "applied, but not where you asked" is the failure mode fuzzy matching
|
|
16
|
+
* creates (#33).
|
|
17
|
+
*/
|
|
18
|
+
export interface HunkPlacement {
|
|
19
|
+
/** 1-indexed line the patch recorded for this hunk, adjusted for prior hunks. */
|
|
20
|
+
expectedAt: number;
|
|
21
|
+
/** 1-indexed line the hunk was applied at. */
|
|
22
|
+
appliedAt: number;
|
|
23
|
+
/** `appliedAt - expectedAt`. Zero when the hunk landed where it said it would. */
|
|
24
|
+
offset: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PatchResult {
|
|
28
|
+
success: boolean;
|
|
29
|
+
hunksApplied: number;
|
|
30
|
+
hunksFailed: number;
|
|
31
|
+
message: string;
|
|
32
|
+
newSource?: string;
|
|
33
|
+
diff?: string;
|
|
34
|
+
/** One entry per applied hunk, in patch order. */
|
|
35
|
+
placements?: HunkPlacement[];
|
|
36
|
+
/** Placements whose `offset` is non-zero — the ones worth looking at. */
|
|
37
|
+
fuzzyPlacements?: HunkPlacement[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// --- LCS-based diff ---
|
|
41
|
+
|
|
42
|
+
function lcsTable(a: string[], b: string[]): number[][] {
|
|
43
|
+
const m = a.length;
|
|
44
|
+
const n = b.length;
|
|
45
|
+
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
46
|
+
for (let i = 1; i <= m; i++) {
|
|
47
|
+
for (let j = 1; j <= n; j++) {
|
|
48
|
+
if (a[i - 1] === b[j - 1]) {
|
|
49
|
+
dp[i][j] = dp[i - 1][j - 1] + 1;
|
|
50
|
+
} else {
|
|
51
|
+
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return dp;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function backtrack(a: string[], b: string[], dp: number[][]): DiffOp[] {
|
|
59
|
+
const result: DiffOp[] = [];
|
|
60
|
+
let i = a.length;
|
|
61
|
+
let j = b.length;
|
|
62
|
+
while (i > 0 || j > 0) {
|
|
63
|
+
if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
|
|
64
|
+
result.push({ type: "same", line: a[i - 1] });
|
|
65
|
+
i--;
|
|
66
|
+
j--;
|
|
67
|
+
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
|
68
|
+
result.push({ type: "added", line: b[j - 1] });
|
|
69
|
+
j--;
|
|
70
|
+
} else {
|
|
71
|
+
result.push({ type: "removed", line: a[i - 1] });
|
|
72
|
+
i--;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
result.reverse();
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface DiffOp {
|
|
80
|
+
type: "same" | "removed" | "added";
|
|
81
|
+
line: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// --- Hunk grouping ---
|
|
85
|
+
|
|
86
|
+
function groupHunks(ops: DiffOp[], contextLines: number): Hunk[] {
|
|
87
|
+
const hunks: Hunk[] = [];
|
|
88
|
+
const len = ops.length;
|
|
89
|
+
|
|
90
|
+
let i = 0;
|
|
91
|
+
const regions: Array<{ start: number; end: number }> = [];
|
|
92
|
+
|
|
93
|
+
while (i < len) {
|
|
94
|
+
while (i < len && ops[i].type === "same") i++;
|
|
95
|
+
if (i >= len) break;
|
|
96
|
+
|
|
97
|
+
const changeStart = i;
|
|
98
|
+
while (i < len && ops[i].type !== "same") i++;
|
|
99
|
+
const changeEnd = i;
|
|
100
|
+
|
|
101
|
+
const start = Math.max(0, changeStart - contextLines);
|
|
102
|
+
const end = Math.min(len, changeEnd + contextLines);
|
|
103
|
+
|
|
104
|
+
if (regions.length > 0 && start <= regions[regions.length - 1].end) {
|
|
105
|
+
regions[regions.length - 1].end = end;
|
|
106
|
+
} else {
|
|
107
|
+
regions.push({ start, end });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (const region of regions) {
|
|
112
|
+
let oldStart = 1;
|
|
113
|
+
let oldLines = 0;
|
|
114
|
+
let newStart = 1;
|
|
115
|
+
let newLines = 0;
|
|
116
|
+
|
|
117
|
+
for (let k = 0; k < region.start; k++) {
|
|
118
|
+
if (ops[k].type !== "added") oldStart++;
|
|
119
|
+
if (ops[k].type !== "removed") newStart++;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const lines: string[] = [];
|
|
123
|
+
let k = region.start;
|
|
124
|
+
|
|
125
|
+
for (; k < region.end; k++) {
|
|
126
|
+
if (ops[k].type === "same") {
|
|
127
|
+
oldLines++;
|
|
128
|
+
newLines++;
|
|
129
|
+
lines.push(` ${ops[k].line}`);
|
|
130
|
+
} else if (ops[k].type === "removed") {
|
|
131
|
+
oldLines++;
|
|
132
|
+
lines.push(`-${ops[k].line}`);
|
|
133
|
+
} else {
|
|
134
|
+
newLines++;
|
|
135
|
+
lines.push(`+${ops[k].line}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const header = `@@ -${oldStart},${oldLines} +${newStart},${newLines} @@`;
|
|
140
|
+
hunks.push({ oldStart, oldLines, newStart, newLines, header, lines });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return hunks;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// --- Public API ---
|
|
147
|
+
|
|
148
|
+
export function generateUnifiedDiff(
|
|
149
|
+
oldSource: string,
|
|
150
|
+
newSource: string,
|
|
151
|
+
filePath: string,
|
|
152
|
+
contextLines: number = 3
|
|
153
|
+
): string {
|
|
154
|
+
const oldLines = oldSource.split("\n");
|
|
155
|
+
const newLines = newSource.split("\n");
|
|
156
|
+
|
|
157
|
+
const dp = lcsTable(oldLines, newLines);
|
|
158
|
+
const ops = backtrack(oldLines, newLines, dp);
|
|
159
|
+
const hunks = groupHunks(ops, contextLines);
|
|
160
|
+
|
|
161
|
+
if (hunks.length === 0) return "";
|
|
162
|
+
|
|
163
|
+
const parts: string[] = [];
|
|
164
|
+
parts.push(`--- a/${filePath}`);
|
|
165
|
+
parts.push(`+++ b/${filePath}`);
|
|
166
|
+
|
|
167
|
+
for (const hunk of hunks) {
|
|
168
|
+
parts.push(hunk.header);
|
|
169
|
+
for (const line of hunk.lines) {
|
|
170
|
+
parts.push(line);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return parts.join("\n") + "\n";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function parsePatch(patchText: string): { filePath: string; hunks: Hunk[] } {
|
|
178
|
+
const lines = patchText.split("\n");
|
|
179
|
+
const hunks: Hunk[] = [];
|
|
180
|
+
let filePath = "";
|
|
181
|
+
|
|
182
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") {
|
|
183
|
+
lines.pop();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let i = 0;
|
|
187
|
+
while (i < lines.length) {
|
|
188
|
+
const line = lines[i];
|
|
189
|
+
|
|
190
|
+
if (line.startsWith("--- a/")) {
|
|
191
|
+
filePath = line.slice(6);
|
|
192
|
+
i++;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (line.startsWith("--- ") && !line.startsWith("--- a/")) {
|
|
196
|
+
filePath = line.slice(4);
|
|
197
|
+
i++;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (line.startsWith("+++ ")) {
|
|
201
|
+
i++;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const hdrMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/);
|
|
206
|
+
if (hdrMatch) {
|
|
207
|
+
const oldStart = parseInt(hdrMatch[1]);
|
|
208
|
+
const oldLines = hdrMatch[2] !== undefined ? parseInt(hdrMatch[2]) : 1;
|
|
209
|
+
const newStart = parseInt(hdrMatch[3]);
|
|
210
|
+
const newLines = hdrMatch[4] !== undefined ? parseInt(hdrMatch[4]) : 1;
|
|
211
|
+
|
|
212
|
+
// Consume exactly the number of lines the header declares rather than
|
|
213
|
+
// scanning for the next "@@"/"--- " marker. Hunk bodies are prefixed, so a
|
|
214
|
+
// removed line whose content starts with "-- " renders as "--- ..." and a
|
|
215
|
+
// marker scan would mistake file content for the next file header,
|
|
216
|
+
// truncating the hunk (#31).
|
|
217
|
+
const hunkLines: string[] = [];
|
|
218
|
+
let oldSeen = 0;
|
|
219
|
+
let newSeen = 0;
|
|
220
|
+
i++;
|
|
221
|
+
while (i < lines.length && (oldSeen < oldLines || newSeen < newLines)) {
|
|
222
|
+
const hl = lines[i];
|
|
223
|
+
if (hl.startsWith(" ")) {
|
|
224
|
+
oldSeen++;
|
|
225
|
+
newSeen++;
|
|
226
|
+
} else if (hl.startsWith("-")) {
|
|
227
|
+
oldSeen++;
|
|
228
|
+
} else if (hl.startsWith("+")) {
|
|
229
|
+
newSeen++;
|
|
230
|
+
} else if (!hl.startsWith("\\")) {
|
|
231
|
+
break; // malformed body line; stop rather than mis-consume
|
|
232
|
+
}
|
|
233
|
+
hunkLines.push(hl);
|
|
234
|
+
i++;
|
|
235
|
+
}
|
|
236
|
+
// Trailing "" markers belong to this hunk.
|
|
237
|
+
while (i < lines.length && lines[i].startsWith("\\")) {
|
|
238
|
+
hunkLines.push(lines[i]);
|
|
239
|
+
i++;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
hunks.push({ oldStart, oldLines, newStart, newLines, header: line, lines: hunkLines });
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
i++;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return { filePath, hunks };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function applyPatchToSource(
|
|
253
|
+
source: string,
|
|
254
|
+
patchText: string,
|
|
255
|
+
options?: { fuzzyMatch?: number }
|
|
256
|
+
): PatchResult {
|
|
257
|
+
const fuzzy = options?.fuzzyMatch ?? 3;
|
|
258
|
+
const parsed = parsePatch(patchText);
|
|
259
|
+
|
|
260
|
+
if (parsed.hunks.length === 0) {
|
|
261
|
+
return { success: false, hunksApplied: 0, hunksFailed: 0, message: "No hunks found in patch" };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const srcLines = source.split("\n");
|
|
265
|
+
let hunksApplied = 0;
|
|
266
|
+
let hunksFailed = 0;
|
|
267
|
+
let lineOffset = 0;
|
|
268
|
+
const placements: HunkPlacement[] = [];
|
|
269
|
+
|
|
270
|
+
for (const hunk of parsed.hunks) {
|
|
271
|
+
const result = applyHunk(srcLines, hunk, lineOffset, fuzzy);
|
|
272
|
+
if (result.success) {
|
|
273
|
+
lineOffset += result.offsetDelta;
|
|
274
|
+
hunksApplied++;
|
|
275
|
+
if (result.placement) placements.push(result.placement);
|
|
276
|
+
} else {
|
|
277
|
+
hunksFailed++;
|
|
278
|
+
if (result.error) {
|
|
279
|
+
return { success: false, hunksApplied, hunksFailed, message: result.error, placements };
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const newSource = srcLines.join("\n");
|
|
285
|
+
const fuzzyPlacements = placements.filter((p) => p.offset !== 0);
|
|
286
|
+
const slid = fuzzyPlacements.length > 0
|
|
287
|
+
? ` (${fuzzyPlacements.length} hunk(s) matched off their recorded position)`
|
|
288
|
+
: "";
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
success: hunksFailed === 0,
|
|
292
|
+
hunksApplied,
|
|
293
|
+
hunksFailed,
|
|
294
|
+
message: (hunksFailed === 0 ? `Applied ${hunksApplied} hunk(s)` : `Applied ${hunksApplied}, failed ${hunksFailed}`) + slid,
|
|
295
|
+
newSource,
|
|
296
|
+
placements,
|
|
297
|
+
fuzzyPlacements,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export async function applyPatch(
|
|
302
|
+
filePath: string,
|
|
303
|
+
patchText: string,
|
|
304
|
+
options?: { dryRun?: boolean; fuzzyMatch?: number }
|
|
305
|
+
): Promise<PatchResult> {
|
|
306
|
+
const dryRun = options?.dryRun ?? false;
|
|
307
|
+
|
|
308
|
+
let source: string;
|
|
309
|
+
try {
|
|
310
|
+
source = (await readDecoded(filePath)).text;
|
|
311
|
+
} catch {
|
|
312
|
+
return { success: false, hunksApplied: 0, hunksFailed: parsePatch(patchText).hunks.length, message: `Cannot read file: ${filePath}` };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const result = applyPatchToSource(source, patchText, options);
|
|
316
|
+
|
|
317
|
+
if (result.success && result.newSource && !dryRun) {
|
|
318
|
+
try {
|
|
319
|
+
await safeWrite(filePath, result.newSource);
|
|
320
|
+
} catch {
|
|
321
|
+
return { success: false, hunksApplied: result.hunksApplied, hunksFailed: result.hunksFailed, message: `Cannot write file: ${filePath}` };
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return result;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// --- Internal hunk application ---
|
|
329
|
+
|
|
330
|
+
interface HunkApplyResult {
|
|
331
|
+
success: boolean;
|
|
332
|
+
offsetDelta: number;
|
|
333
|
+
error?: string;
|
|
334
|
+
placement?: HunkPlacement;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function applyHunk(
|
|
338
|
+
srcLines: string[],
|
|
339
|
+
hunk: Hunk,
|
|
340
|
+
lineOffset: number,
|
|
341
|
+
fuzzy: number
|
|
342
|
+
): HunkApplyResult {
|
|
343
|
+
const targetOldStart = hunk.oldStart + lineOffset - 1; // 0-indexed
|
|
344
|
+
|
|
345
|
+
// Search for a hunk match within the fuzzy window. The window is +/- `fuzzy`
|
|
346
|
+
// lines around the recorded position -- it deliberately does NOT extend by
|
|
347
|
+
// `hunk.oldLines`, which used to let a hunk land a whole body-length away from
|
|
348
|
+
// where it was recorded and silently patch the wrong region (#31).
|
|
349
|
+
//
|
|
350
|
+
// `fuzzy: 0` is strict mode: the hunk applies at exactly the recorded offset
|
|
351
|
+
// with exactly the recorded content, or it refuses.
|
|
352
|
+
const searchStart = Math.max(0, targetOldStart - fuzzy);
|
|
353
|
+
const searchEnd = Math.min(srcLines.length, targetOldStart + fuzzy) + 1;
|
|
354
|
+
// Every candidate in the window, not the first one. Taking the first made an
|
|
355
|
+
// ambiguous window look decisive: in repetitive code (case arms, fixture
|
|
356
|
+
// tables, generated blocks) a second region matches just as well, and the
|
|
357
|
+
// patch landed on one of them with a success exit code and no signal (#33).
|
|
358
|
+
const candidates: number[] = [];
|
|
359
|
+
for (let srcPos = searchStart; srcPos < searchEnd; srcPos++) {
|
|
360
|
+
if (hunkMatches(srcLines, hunk, srcPos)) candidates.push(srcPos);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (candidates.length > 1) {
|
|
364
|
+
const lines = candidates.map((c) => c + 1).join(", ");
|
|
365
|
+
return {
|
|
366
|
+
success: false,
|
|
367
|
+
offsetDelta: 0,
|
|
368
|
+
error:
|
|
369
|
+
`Hunk failed at ${hunk.header}: ambiguous — the context matches at lines ${lines}. ` +
|
|
370
|
+
`Re-generate the patch with more context lines, lower fuzzyMatch (0 pins the recorded position), ` +
|
|
371
|
+
`or use the hash tier, which anchors on content rather than position.`,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const matchIdx = candidates.length === 1 ? candidates[0] : -1;
|
|
376
|
+
|
|
377
|
+
if (matchIdx < 0) {
|
|
378
|
+
return {
|
|
379
|
+
success: false,
|
|
380
|
+
offsetDelta: 0,
|
|
381
|
+
error:
|
|
382
|
+
fuzzy === 0
|
|
383
|
+
? `Hunk failed at ${hunk.header}: strict mode (fuzzy 0) requires an exact match at line ${targetOldStart + 1}`
|
|
384
|
+
: `Hunk failed at ${hunk.header}: context not found near line ${targetOldStart + 1}`,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Build replacement: context and added lines, skip removed lines
|
|
389
|
+
const replacementLines: string[] = [];
|
|
390
|
+
for (const hl of hunk.lines) {
|
|
391
|
+
if (hl.startsWith(" ")) {
|
|
392
|
+
replacementLines.push(hl.slice(1));
|
|
393
|
+
} else if (hl.startsWith("+")) {
|
|
394
|
+
replacementLines.push(hl.slice(1));
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Strict mode: refuse a patch that has already been applied. Both sides of a
|
|
399
|
+
// hunk can match the same position -- a pure insertion still matches its own
|
|
400
|
+
// old side after it has landed -- so when both match, the longer side wins. If
|
|
401
|
+
// that is the new side, the region is already in its post-patch state and
|
|
402
|
+
// re-applying would duplicate it (#31).
|
|
403
|
+
if (fuzzy === 0 && replacementLines.length > hunk.oldLines && linesMatchAt(srcLines, replacementLines, matchIdx)) {
|
|
404
|
+
return {
|
|
405
|
+
success: false,
|
|
406
|
+
offsetDelta: 0,
|
|
407
|
+
error: `Hunk failed at ${hunk.header}: already applied at line ${matchIdx + 1}`,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
srcLines.splice(matchIdx, hunk.oldLines, ...replacementLines);
|
|
412
|
+
|
|
413
|
+
const offsetDelta = replacementLines.length - hunk.oldLines;
|
|
414
|
+
return {
|
|
415
|
+
success: true,
|
|
416
|
+
offsetDelta,
|
|
417
|
+
placement: {
|
|
418
|
+
expectedAt: targetOldStart + 1,
|
|
419
|
+
appliedAt: matchIdx + 1,
|
|
420
|
+
offset: matchIdx - targetOldStart,
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** True when `want` appears verbatim in `srcLines` starting at `pos`. */
|
|
426
|
+
function linesMatchAt(srcLines: string[], want: string[], pos: number): boolean {
|
|
427
|
+
if (pos + want.length > srcLines.length) return false;
|
|
428
|
+
for (let k = 0; k < want.length; k++) {
|
|
429
|
+
if (srcLines[pos + k] !== want[k]) return false;
|
|
430
|
+
}
|
|
431
|
+
return true;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function hunkMatches(srcLines: string[], hunk: Hunk, srcPos: number): boolean {
|
|
435
|
+
let s = srcPos;
|
|
436
|
+
for (const hl of hunk.lines) {
|
|
437
|
+
if (hl.startsWith(" ")) {
|
|
438
|
+
if (s >= srcLines.length || srcLines[s] !== hl.slice(1)) return false;
|
|
439
|
+
s++;
|
|
440
|
+
} else if (hl.startsWith("-")) {
|
|
441
|
+
if (s >= srcLines.length || srcLines[s] !== hl.slice(1)) return false;
|
|
442
|
+
s++;
|
|
443
|
+
} else if (hl.startsWith("+")) {
|
|
444
|
+
// Added line: no source line consumed
|
|
445
|
+
}
|
|
446
|
+
// Non-prefix lines (e.g., "\ No newline") are ignored
|
|
447
|
+
}
|
|
448
|
+
return (s - srcPos) === hunk.oldLines;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Turn a dry-run edit result into a preview.
|
|
453
|
+
*
|
|
454
|
+
* A dry run exists so a caller can decide whether to commit the edit, and for an
|
|
455
|
+
* agent the cost of that decision is context tokens. Returning the whole
|
|
456
|
+
* post-edit file made previewing an edit more expensive than making it, so the
|
|
457
|
+
* cheapest correct move became "skip the dry run" — exactly backwards (#98).
|
|
458
|
+
* The default payload is now a unified diff of the changed hunks; the full text
|
|
459
|
+
* stays available behind an explicit `includeSource` opt-in.
|
|
460
|
+
*
|
|
461
|
+
* Only dry runs are rewritten, and only successful ones: a failure carries no
|
|
462
|
+
* `newSource`, and a real write already told the caller what landed on disk.
|
|
463
|
+
*/
|
|
464
|
+
export function toPreview<T extends { success?: boolean; newSource?: string }>(
|
|
465
|
+
result: T,
|
|
466
|
+
source: string | undefined,
|
|
467
|
+
filePath: string,
|
|
468
|
+
includeSource?: boolean
|
|
469
|
+
): T & { diff?: string; sourceOmitted?: boolean } {
|
|
470
|
+
if (includeSource || !result.success || !result.newSource || source === undefined) return result;
|
|
471
|
+
const diff = generateUnifiedDiff(source, result.newSource, filePath);
|
|
472
|
+
const { newSource: _omitted, ...rest } = result as Record<string, unknown>;
|
|
473
|
+
return { ...rest, diff, sourceOmitted: true } as T & { diff?: string; sourceOmitted?: boolean };
|
|
474
|
+
}
|