pi-readseek 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +41 -0
  3. package/index.ts +142 -0
  4. package/package.json +73 -0
  5. package/prompts/edit.md +113 -0
  6. package/prompts/find.md +19 -0
  7. package/prompts/grep.md +26 -0
  8. package/prompts/ls.md +11 -0
  9. package/prompts/read.md +33 -0
  10. package/prompts/sg.md +25 -0
  11. package/prompts/write.md +46 -0
  12. package/src/binary-detect.ts +22 -0
  13. package/src/binary-resolution.ts +77 -0
  14. package/src/coerce-obvious-int.ts +39 -0
  15. package/src/context-application.ts +70 -0
  16. package/src/context-hygiene.ts +503 -0
  17. package/src/diff-data.ts +303 -0
  18. package/src/doom-loop-suggestions.ts +42 -0
  19. package/src/doom-loop.ts +216 -0
  20. package/src/edit-classify.ts +190 -0
  21. package/src/edit-diff.ts +354 -0
  22. package/src/edit-output.ts +107 -0
  23. package/src/edit-render-helpers.ts +141 -0
  24. package/src/edit-syntax-validate.ts +120 -0
  25. package/src/edit.ts +725 -0
  26. package/src/find-parsers.ts +89 -0
  27. package/src/find-stat.ts +36 -0
  28. package/src/find.ts +613 -0
  29. package/src/grep-budget.ts +79 -0
  30. package/src/grep-output.ts +197 -0
  31. package/src/grep-render-helpers.ts +77 -0
  32. package/src/grep-symbol-scope.ts +197 -0
  33. package/src/grep.ts +792 -0
  34. package/src/hashline.ts +747 -0
  35. package/src/ls.ts +293 -0
  36. package/src/map-cache.ts +152 -0
  37. package/src/path-utils.ts +24 -0
  38. package/src/pending-diff-preview.ts +269 -0
  39. package/src/persistent-map-cache.ts +251 -0
  40. package/src/read-local-bundle.ts +87 -0
  41. package/src/read-output.ts +212 -0
  42. package/src/read-render-helpers.ts +104 -0
  43. package/src/read.ts +748 -0
  44. package/src/readseek/constants.ts +21 -0
  45. package/src/readseek/enums.ts +38 -0
  46. package/src/readseek/formatter.ts +431 -0
  47. package/src/readseek/language-detect.ts +29 -0
  48. package/src/readseek/mapper.ts +69 -0
  49. package/src/readseek/parser-errors.ts +22 -0
  50. package/src/readseek/parser-loader.ts +83 -0
  51. package/src/readseek/symbol-error-format.ts +18 -0
  52. package/src/readseek/symbol-lookup.ts +294 -0
  53. package/src/readseek/types.ts +79 -0
  54. package/src/readseek-client.ts +343 -0
  55. package/src/readseek-error-codes.ts +54 -0
  56. package/src/readseek-settings.ts +287 -0
  57. package/src/readseek-value.ts +144 -0
  58. package/src/replace-symbol.ts +74 -0
  59. package/src/runtime.ts +3 -0
  60. package/src/sg-output.ts +88 -0
  61. package/src/sg.ts +308 -0
  62. package/src/syntax-validate-mode.ts +25 -0
  63. package/src/tool-prompt-metadata.ts +76 -0
  64. package/src/tui-diff-component.ts +86 -0
  65. package/src/tui-diff-renderer.ts +92 -0
  66. package/src/tui-render-utils.ts +129 -0
  67. package/src/write.ts +532 -0
@@ -0,0 +1,190 @@
1
+ import * as Diff from "diff";
2
+ import { execFile } from "node:child_process";
3
+ import { writeFileSync, mkdtempSync, rmSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+
7
+ export type EditClassification = "no-op" | "whitespace-only" | "semantic" | "mixed";
8
+
9
+ export interface EditClassifyResult {
10
+ classification: EditClassification;
11
+ }
12
+
13
+ export function classifyEdit(oldContent: string, newContent: string): EditClassifyResult {
14
+ if (oldContent === newContent) {
15
+ return { classification: "no-op" };
16
+ }
17
+
18
+ const oldLines = oldContent.split("\n");
19
+ const newLines = newContent.split("\n");
20
+
21
+ // When line counts match, do pairwise comparison
22
+ if (oldLines.length === newLines.length) {
23
+ let hasWhitespaceChange = false;
24
+ let hasSemanticChange = false;
25
+ for (let i = 0; i < oldLines.length; i++) {
26
+ if (oldLines[i] === newLines[i]) continue;
27
+ if (oldLines[i].trim() === newLines[i].trim()) {
28
+ hasWhitespaceChange = true;
29
+ } else {
30
+ hasSemanticChange = true;
31
+ }
32
+ }
33
+ if (hasSemanticChange && hasWhitespaceChange) return { classification: "mixed" };
34
+ if (hasWhitespaceChange) return { classification: "whitespace-only" };
35
+ return { classification: "semantic" };
36
+ }
37
+
38
+ // When line counts differ, use diff library to find changes
39
+ const parts = Diff.diffLines(oldContent, newContent);
40
+ let hasWhitespaceChange = false;
41
+ let hasSemanticChange = false;
42
+
43
+ for (const part of parts) {
44
+ if (!part.added && !part.removed) continue;
45
+
46
+ const lines = part.value.split("\n");
47
+ if (lines[lines.length - 1] === "") lines.pop();
48
+
49
+ for (const line of lines) {
50
+ if (line.trim() === "") {
51
+ hasWhitespaceChange = true;
52
+ } else {
53
+ hasSemanticChange = true;
54
+ }
55
+ }
56
+ }
57
+
58
+ if (hasSemanticChange && hasWhitespaceChange) return { classification: "mixed" };
59
+ if (hasWhitespaceChange) return { classification: "whitespace-only" };
60
+ return { classification: "semantic" };
61
+ }
62
+
63
+ let difftCachedResult: boolean | null = null;
64
+
65
+ export function _resetDifftCache(): void {
66
+ difftCachedResult = null;
67
+ }
68
+
69
+ export async function isDifftAvailable(): Promise<boolean> {
70
+ if (difftCachedResult !== null) return difftCachedResult;
71
+
72
+ return new Promise<boolean>((resolve) => {
73
+ execFile("which", ["difft"], (err) => {
74
+ difftCachedResult = !err;
75
+ resolve(difftCachedResult);
76
+ });
77
+ });
78
+ }
79
+
80
+ export interface DifftClassifyResult {
81
+ classification: EditClassification;
82
+ movedBlocks: number;
83
+ }
84
+
85
+ function getChunkSideText(side: any): string {
86
+ if (!side || typeof side !== "object") return "";
87
+ if (!Array.isArray(side.changes)) return "";
88
+ return side.changes
89
+ .map((change: any) => (change && typeof change.content === "string" ? change.content : ""))
90
+ .join("");
91
+ }
92
+
93
+ function getChunkOnlySideSignature(chunk: any[]): { side: "lhs" | "rhs"; text: string } | null {
94
+ let hasLhs = false;
95
+ let hasRhs = false;
96
+ let text = "";
97
+
98
+ for (const entry of chunk) {
99
+ if (entry?.lhs) {
100
+ hasLhs = true;
101
+ text += getChunkSideText(entry.lhs);
102
+ }
103
+ if (entry?.rhs) {
104
+ hasRhs = true;
105
+ text += getChunkSideText(entry.rhs);
106
+ }
107
+ }
108
+
109
+ if (hasLhs === hasRhs) return null;
110
+ return { side: hasLhs ? "lhs" : "rhs", text };
111
+ }
112
+
113
+ export function parseDifftJson(json: any): DifftClassifyResult | null {
114
+ if (!json || typeof json !== "object" || !("status" in json)) return null;
115
+
116
+ if (json.status === "unchanged") {
117
+ return { classification: "whitespace-only", movedBlocks: 0 };
118
+ }
119
+
120
+ if (json.status !== "changed" || !Array.isArray(json.chunks)) {
121
+ return null;
122
+ }
123
+
124
+ const lhsOnlyChunkTexts: string[] = [];
125
+ const rhsOnlyChunkTexts: string[] = [];
126
+ for (const chunk of json.chunks) {
127
+ if (!Array.isArray(chunk)) continue;
128
+ const signature = getChunkOnlySideSignature(chunk);
129
+ if (!signature) continue;
130
+ if (signature.side === "lhs") lhsOnlyChunkTexts.push(signature.text);
131
+ if (signature.side === "rhs") rhsOnlyChunkTexts.push(signature.text);
132
+ }
133
+
134
+ const remainingRhs = new Map<string, number>();
135
+ for (const text of rhsOnlyChunkTexts) {
136
+ remainingRhs.set(text, (remainingRhs.get(text) ?? 0) + 1);
137
+ }
138
+
139
+ let movedBlocks = 0;
140
+ for (const text of lhsOnlyChunkTexts) {
141
+ const count = remainingRhs.get(text) ?? 0;
142
+ if (count <= 0) continue;
143
+ movedBlocks++;
144
+ if (count === 1) remainingRhs.delete(text);
145
+ else remainingRhs.set(text, count - 1);
146
+ }
147
+
148
+ return { classification: "semantic", movedBlocks };
149
+
150
+ return { classification: "semantic", movedBlocks };
151
+ }
152
+
153
+ export async function runDifftastic(
154
+ oldContent: string,
155
+ newContent: string,
156
+ fileExtension: string,
157
+ ): Promise<DifftClassifyResult | null> {
158
+ const available = await isDifftAvailable();
159
+ if (!available) return null;
160
+
161
+ let tempDir: string | null = null;
162
+ try {
163
+ tempDir = mkdtempSync(resolve(tmpdir(), "pi-difft-"));
164
+ const oldPath = resolve(tempDir, `old.${fileExtension}`);
165
+ const newPath = resolve(tempDir, `new.${fileExtension}`);
166
+ writeFileSync(oldPath, oldContent, "utf-8");
167
+ writeFileSync(newPath, newContent, "utf-8");
168
+
169
+ const stdout = await new Promise<string>((resolve, reject) => {
170
+ execFile(
171
+ "difft",
172
+ ["--display=json", oldPath, newPath],
173
+ { env: { ...process.env, DFT_UNSTABLE: "yes" }, timeout: 10_000 },
174
+ (err, stdout) => {
175
+ if (err) return reject(err);
176
+ resolve(stdout);
177
+ },
178
+ );
179
+ });
180
+
181
+ const json = JSON.parse(stdout);
182
+ return parseDifftJson(json);
183
+ } catch {
184
+ return null;
185
+ } finally {
186
+ if (tempDir) {
187
+ try { rmSync(tempDir, { recursive: true, force: true }); } catch { /* ignore */ }
188
+ }
189
+ }
190
+ }
@@ -0,0 +1,354 @@
1
+ import * as Diff from "diff";
2
+ import { computeLineHash } from "./hashline.js";
3
+
4
+ // ─── Line ending normalization ──────────────────────────────────────────
5
+
6
+ export function detectLineEnding(content: string): "\r\n" | "\n" {
7
+ const crlfIdx = content.indexOf("\r\n");
8
+ const lfIdx = content.indexOf("\n");
9
+ if (lfIdx === -1 || crlfIdx === -1) return "\n";
10
+ return crlfIdx < lfIdx ? "\r\n" : "\n";
11
+ }
12
+
13
+ export function normalizeToLF(text: string): string {
14
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
15
+ }
16
+
17
+ export function restoreLineEndings(text: string, ending: "\r\n" | "\n"): string {
18
+ return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
19
+ }
20
+
21
+ export function stripBom(content: string): { bom: string; text: string } {
22
+ return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content };
23
+ }
24
+
25
+ /**
26
+ * Detect bare \r characters that are NOT part of \r\n sequences.
27
+ * These cause line-count mismatches between normalizeToLF and external tools (ripgrep, wc).
28
+ */
29
+ export function hasBareCarriageReturn(content: string): boolean {
30
+ // Remove all \r\n first, then check if any \r remains
31
+ return content.replace(/\r\n/g, "").includes("\r");
32
+ }
33
+
34
+ // ─── Fuzzy text matching ────────────────────────────────────────────────
35
+
36
+ const SINGLE_QUOTES_RE = /[\u2018\u2019\u201A\u201B]/g;
37
+ const DOUBLE_QUOTES_RE = /[\u201C\u201D\u201E\u201F]/g;
38
+ const HYPHENS_RE = /[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g;
39
+ const UNICODE_SPACES_RE = /[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g;
40
+
41
+ function normalizeFuzzyChar(ch: string): string {
42
+ return ch.replace(SINGLE_QUOTES_RE, "'").replace(DOUBLE_QUOTES_RE, '"').replace(HYPHENS_RE, "-").replace(UNICODE_SPACES_RE, " ");
43
+ }
44
+
45
+ function normalizeForFuzzyMatch(text: string): string {
46
+ return text
47
+ .split("\n")
48
+ .map((line) => line.trimEnd())
49
+ .join("\n")
50
+ .replace(SINGLE_QUOTES_RE, "'")
51
+ .replace(DOUBLE_QUOTES_RE, '"')
52
+ .replace(HYPHENS_RE, "-")
53
+ .replace(UNICODE_SPACES_RE, " ");
54
+ }
55
+
56
+ function buildNormalizedWithMap(text: string): { normalized: string; indexMap: number[] } {
57
+ const lines = text.split("\n");
58
+ const normalizedChars: string[] = [];
59
+ const indexMap: number[] = [];
60
+ let originalOffset = 0;
61
+
62
+ for (let i = 0; i < lines.length; i++) {
63
+ const line = lines[i]!;
64
+ const trimmed = line.replace(/\s+$/u, "");
65
+
66
+ for (let j = 0; j < trimmed.length; j++) {
67
+ normalizedChars.push(normalizeFuzzyChar(trimmed[j]!));
68
+ indexMap.push(originalOffset + j);
69
+ }
70
+
71
+ if (i < lines.length - 1) {
72
+ normalizedChars.push("\n");
73
+ indexMap.push(originalOffset + line.length);
74
+ }
75
+
76
+ originalOffset += line.length + 1;
77
+ }
78
+
79
+ return { normalized: normalizedChars.join(""), indexMap };
80
+ }
81
+
82
+ function mapNormalizedSpanToOriginal(
83
+ indexMap: number[],
84
+ normalizedStart: number,
85
+ normalizedLength: number,
86
+ ): { index: number; matchLength: number } | null {
87
+ if (normalizedStart < 0 || normalizedLength <= 0) return null;
88
+ const normalizedEnd = normalizedStart + normalizedLength;
89
+ if (normalizedEnd > indexMap.length) return null;
90
+
91
+ const start = indexMap[normalizedStart];
92
+ const end = indexMap[normalizedEnd - 1];
93
+ if (start === undefined || end === undefined || end < start) return null;
94
+
95
+ return { index: start, matchLength: end - start + 1 };
96
+ }
97
+
98
+ /**
99
+ * Find `oldText` in `content` with optional fuzzy whitespace/unicode matching.
100
+ * Always returns an index/length in the original content.
101
+ */
102
+ export function fuzzyFindText(
103
+ content: string,
104
+ oldText: string,
105
+ ): { found: boolean; index: number; matchLength: number; usedFuzzyMatch: boolean } {
106
+ const exactIndex = content.indexOf(oldText);
107
+ if (exactIndex !== -1) {
108
+ return { found: true, index: exactIndex, matchLength: oldText.length, usedFuzzyMatch: false };
109
+ }
110
+
111
+ const normalizedNeedle = normalizeForFuzzyMatch(oldText);
112
+ if (!normalizedNeedle.length) return { found: false, index: -1, matchLength: 0, usedFuzzyMatch: false };
113
+
114
+ const { normalized, indexMap } = buildNormalizedWithMap(content);
115
+ const normalizedIndex = normalized.indexOf(normalizedNeedle);
116
+ if (normalizedIndex === -1) {
117
+ return { found: false, index: -1, matchLength: 0, usedFuzzyMatch: false };
118
+ }
119
+
120
+ const mapped = mapNormalizedSpanToOriginal(indexMap, normalizedIndex, normalizedNeedle.length);
121
+ if (!mapped) {
122
+ return { found: false, index: -1, matchLength: 0, usedFuzzyMatch: false };
123
+ }
124
+
125
+ return { found: true, index: mapped.index, matchLength: mapped.matchLength, usedFuzzyMatch: true };
126
+ }
127
+
128
+ /**
129
+ * Replace `oldText` with `newText` in `content`.
130
+ * Fuzzy matching only determines target spans; replacement always applies to
131
+ * the original content (never normalizes the whole file).
132
+ */
133
+ export type ReplaceTextResult = { content: string; count: number; usedFuzzyMatch: boolean };
134
+
135
+ export function replaceText(
136
+ content: string,
137
+ oldText: string,
138
+ newText: string,
139
+ opts: { all?: boolean; fuzzy?: boolean },
140
+ ): ReplaceTextResult {
141
+ if (!oldText.length) return { content, count: 0, usedFuzzyMatch: false };
142
+ const normalizedNew = normalizeToLF(newText);
143
+
144
+ if (opts.all) {
145
+ const exactCount = content.split(oldText).length - 1;
146
+ if (exactCount > 0) {
147
+ return { content: content.split(oldText).join(normalizedNew), count: exactCount, usedFuzzyMatch: false };
148
+ }
149
+ if (!opts.fuzzy) return { content, count: 0, usedFuzzyMatch: false };
150
+
151
+ const normalizedNeedle = normalizeForFuzzyMatch(oldText);
152
+ if (!normalizedNeedle.length) return { content, count: 0, usedFuzzyMatch: false };
153
+
154
+ const { normalized, indexMap } = buildNormalizedWithMap(content);
155
+ const spans: Array<{ index: number; matchLength: number }> = [];
156
+ let searchFrom = 0;
157
+
158
+ while (searchFrom <= normalized.length - normalizedNeedle.length) {
159
+ const pos = normalized.indexOf(normalizedNeedle, searchFrom);
160
+ if (pos === -1) break;
161
+ const mapped = mapNormalizedSpanToOriginal(indexMap, pos, normalizedNeedle.length);
162
+ if (mapped) {
163
+ const prev = spans[spans.length - 1];
164
+ if (!prev || mapped.index >= prev.index + prev.matchLength) {
165
+ spans.push(mapped);
166
+ }
167
+ }
168
+ searchFrom = pos + Math.max(1, normalizedNeedle.length);
169
+ }
170
+
171
+ if (!spans.length) return { content, count: 0, usedFuzzyMatch: false };
172
+
173
+ let out = content;
174
+ for (let i = spans.length - 1; i >= 0; i--) {
175
+ const span = spans[i]!;
176
+ out = out.substring(0, span.index) + normalizedNew + out.substring(span.index + span.matchLength);
177
+ }
178
+ return { content: out, count: spans.length, usedFuzzyMatch: true };
179
+ }
180
+
181
+ const exactIndex = content.indexOf(oldText);
182
+ if (exactIndex !== -1) {
183
+ return {
184
+ content: content.substring(0, exactIndex) + normalizedNew + content.substring(exactIndex + oldText.length),
185
+ count: 1,
186
+ usedFuzzyMatch: false,
187
+ };
188
+ }
189
+
190
+ if (!opts.fuzzy) return { content, count: 0, usedFuzzyMatch: false };
191
+
192
+ const result = fuzzyFindText(content, oldText);
193
+ if (!result.found) return { content, count: 0, usedFuzzyMatch: false };
194
+
195
+ return {
196
+ content: content.substring(0, result.index) + normalizedNew + content.substring(result.index + result.matchLength),
197
+ count: 1,
198
+ usedFuzzyMatch: result.usedFuzzyMatch,
199
+ };
200
+ }
201
+
202
+ // ─── Diff generation ────────────────────────────────────────────────────
203
+
204
+ export function generateDiffString(
205
+ oldContent: string,
206
+ newContent: string,
207
+ contextLines = 4,
208
+ ): { diff: string; firstChangedLine: number | undefined } {
209
+ const parts = Diff.diffLines(oldContent, newContent);
210
+ const output: string[] = [];
211
+ const maxLineNum = Math.max(oldContent.split("\n").length, newContent.split("\n").length);
212
+ const lineNumWidth = String(maxLineNum).length;
213
+ let oldLineNum = 1;
214
+ let newLineNum = 1;
215
+ let lastWasChange = false;
216
+ let firstChangedLine: number | undefined;
217
+
218
+ for (let i = 0; i < parts.length; i++) {
219
+ const part = parts[i]!;
220
+ const raw = part.value.split("\n");
221
+ if (raw[raw.length - 1] === "") raw.pop();
222
+
223
+ if (part.added || part.removed) {
224
+ if (firstChangedLine === undefined) firstChangedLine = newLineNum;
225
+ for (const line of raw) {
226
+ if (part.added) {
227
+ output.push(`+${String(newLineNum).padStart(lineNumWidth, " ")} ${line}`);
228
+ newLineNum++;
229
+ } else {
230
+ output.push(`-${String(oldLineNum).padStart(lineNumWidth, " ")} ${line}`);
231
+ oldLineNum++;
232
+ }
233
+ }
234
+ lastWasChange = true;
235
+ continue;
236
+ }
237
+
238
+ const nextPartIsChange = i < parts.length - 1 && (parts[i + 1]!.added || parts[i + 1]!.removed);
239
+ if (lastWasChange || nextPartIsChange) {
240
+ let linesToShow = raw;
241
+ let skipStart = 0;
242
+ let skipEnd = 0;
243
+
244
+ if (!lastWasChange) {
245
+ skipStart = Math.max(0, raw.length - contextLines);
246
+ linesToShow = raw.slice(skipStart);
247
+ }
248
+ if (!nextPartIsChange && linesToShow.length > contextLines) {
249
+ skipEnd = linesToShow.length - contextLines;
250
+ linesToShow = linesToShow.slice(0, contextLines);
251
+ }
252
+
253
+ if (skipStart > 0) {
254
+ output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
255
+ oldLineNum += skipStart;
256
+ newLineNum += skipStart;
257
+ }
258
+ for (const line of linesToShow) {
259
+ output.push(` ${String(oldLineNum).padStart(lineNumWidth, " ")} ${line}`);
260
+ oldLineNum++;
261
+ newLineNum++;
262
+ }
263
+ if (skipEnd > 0) {
264
+ output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
265
+ oldLineNum += skipEnd;
266
+ newLineNum += skipEnd;
267
+ }
268
+ } else {
269
+ oldLineNum += raw.length;
270
+ newLineNum += raw.length;
271
+ }
272
+ lastWasChange = false;
273
+ }
274
+
275
+ return { diff: output.join("\n"), firstChangedLine };
276
+ }
277
+
278
+ /**
279
+ * Generate a compact diff for single-line edits, or fall back to the full diff.
280
+ *
281
+ * - Single-line replacement: `LINE:HASH|old → LINE:HASH|new`
282
+ * - Single-line deletion: `LINE:HASH|old → [deleted]`
283
+ * - Multi-line changes: full output from generateDiffString()
284
+ */
285
+ export function generateCompactOrFullDiff(
286
+ oldContent: string,
287
+ newContent: string,
288
+ contextLines = 4,
289
+ ): { diff: string; firstChangedLine: number | undefined } {
290
+ if (oldContent === newContent) return { diff: "", firstChangedLine: undefined };
291
+
292
+ const oldLines = oldContent.split("\n");
293
+ const newLines = newContent.split("\n");
294
+
295
+ // Case 1: Same line count, exactly one changed line → compact replacement.
296
+ if (oldLines.length === newLines.length) {
297
+ let changedIndex = -1;
298
+ let changeCount = 0;
299
+
300
+ for (let i = 0; i < oldLines.length; i++) {
301
+ if (oldLines[i] !== newLines[i]) {
302
+ changedIndex = i;
303
+ changeCount++;
304
+ if (changeCount > 1) break;
305
+ }
306
+ }
307
+
308
+ if (changeCount === 1 && changedIndex >= 0) {
309
+ const lineNum = changedIndex + 1;
310
+ const oldLine = oldLines[changedIndex] ?? "";
311
+ const newLine = newLines[changedIndex] ?? "";
312
+ const oldHash = computeLineHash(lineNum, oldLine);
313
+ const newHash = computeLineHash(lineNum, newLine);
314
+ return {
315
+ diff: `${lineNum}:${oldHash}|${oldLine} → ${lineNum}:${newHash}|${newLine}`,
316
+ firstChangedLine: lineNum,
317
+ };
318
+ }
319
+ }
320
+
321
+ // Case 2: Exactly one line deleted.
322
+ // old has one more line than new, and removing a single line makes them equal.
323
+ if (oldLines.length === newLines.length + 1) {
324
+ let deletedIndex = -1;
325
+ let j = 0;
326
+ let failed = false;
327
+
328
+ for (let i = 0; i < oldLines.length; i++) {
329
+ if (j < newLines.length && oldLines[i] === newLines[j]) {
330
+ j++;
331
+ continue;
332
+ }
333
+ if (deletedIndex === -1) {
334
+ deletedIndex = i;
335
+ continue;
336
+ }
337
+ failed = true;
338
+ break;
339
+ }
340
+
341
+ if (!failed && deletedIndex !== -1 && j === newLines.length) {
342
+ const lineNum = deletedIndex + 1;
343
+ const oldLine = oldLines[deletedIndex] ?? "";
344
+ const oldHash = computeLineHash(lineNum, oldLine);
345
+ return {
346
+ diff: `${lineNum}:${oldHash}|${oldLine} → [deleted]`,
347
+ firstChangedLine: lineNum,
348
+ };
349
+ }
350
+ }
351
+
352
+ // Fall back to the full (existing) diff format.
353
+ return generateDiffString(oldContent, newContent, contextLines);
354
+ }
@@ -0,0 +1,107 @@
1
+ import { countEditTypes, parseDiffStats } from "./edit-render-helpers.js";
2
+ import { buildReadseekEditResult, type SemanticSummary } from "./readseek-value.js";
3
+ import { buildContextHygieneMetadata, buildFileResource, type ContextHygieneMetadata } from "./context-hygiene.js";
4
+ import type { DiffData } from "./diff-data.js";
5
+ export interface BuildEditOutputInput {
6
+ path: string;
7
+ displayPath: string;
8
+ diff: string;
9
+ patch?: string;
10
+ diffData?: DiffData;
11
+ firstChangedLine: number | undefined;
12
+ warnings: string[];
13
+ noopEdits: unknown[];
14
+ semanticSummary?: SemanticSummary;
15
+ edits?: unknown[];
16
+ }
17
+ export interface EditOutputResult {
18
+ text: string;
19
+ patch: string;
20
+ readseekValue: ReturnType<typeof buildReadseekEditResult>;
21
+ contextHygiene: ContextHygieneMetadata;
22
+ }
23
+ function getVisibleDiffStats(diff: string): { added: number; removed: number } {
24
+ const stats = parseDiffStats(diff);
25
+ if (stats.added > 0 || stats.removed > 0) return stats;
26
+ if (!diff.includes("→")) return stats;
27
+ if (diff.includes("→ [deleted]")) return { added: 0, removed: 1 };
28
+ return { added: 1, removed: 1 };
29
+ }
30
+ function buildVisibleSummary(displayPath: string, diff: string, edits: unknown[] | undefined): string {
31
+ let stats = getVisibleDiffStats(diff);
32
+ const counts = countEditTypes(edits);
33
+ const editCount = counts.total || 1;
34
+
35
+ if (
36
+ counts.total > 0 &&
37
+ counts.insert_after === counts.total &&
38
+ stats.removed > 0 &&
39
+ stats.added === stats.removed + counts.insert_after
40
+ ) {
41
+ stats = { added: counts.insert_after, removed: 0 };
42
+ }
43
+
44
+ const changeWord = editCount === 1 ? "change" : "changes";
45
+ const changedLineCount = Math.max(stats.added, stats.removed);
46
+ const lineWord = changedLineCount === 1 ? "line" : "lines";
47
+ return `Edited ${displayPath} (${editCount} ${changeWord}, +${stats.added} -${stats.removed} ${lineWord})`;
48
+ }
49
+ function extractNewTextValues(edits: unknown[] | undefined): string[] {
50
+ const values: string[] = [];
51
+ for (const edit of edits ?? []) {
52
+ if (!edit || typeof edit !== "object") continue;
53
+ if ("set_line" in edit && typeof (edit as any).set_line?.new_text === "string") values.push((edit as any).set_line.new_text);
54
+ if ("replace_lines" in edit && typeof (edit as any).replace_lines?.new_text === "string") values.push((edit as any).replace_lines.new_text);
55
+ if ("insert_after" in edit && typeof (edit as any).insert_after?.new_text === "string") values.push((edit as any).insert_after.new_text);
56
+ if ("replace" in edit && typeof (edit as any).replace?.new_text === "string") values.push((edit as any).replace.new_text);
57
+ }
58
+ return values;
59
+ }
60
+ function formatWhitespaceOnlyWarning(semanticSummary: SemanticSummary | undefined, edits: unknown[] | undefined): string | undefined {
61
+ if (semanticSummary?.classification !== "whitespace-only") return undefined;
62
+ if (!extractNewTextValues(edits).some((text) => /\S/.test(text))) return undefined;
63
+ return "⚠ Edit classified as whitespace-only — if you intended a behavior change, re-read to verify.";
64
+ }
65
+ function formatSemanticSuffix(semanticSummary: SemanticSummary | undefined): string {
66
+ const movedBlocks = semanticSummary?.movedBlocks ?? 0;
67
+ if (movedBlocks <= 0) return "";
68
+ const blockWord = movedBlocks === 1 ? "block" : "blocks";
69
+ return ` [semantic: ${semanticSummary!.classification}, ${movedBlocks} ${blockWord} moved]`;
70
+ }
71
+ function formatReplaceHint(edits: unknown[] | undefined, noopEdits: unknown[]): string | undefined {
72
+ if ((noopEdits ?? []).length > 0) return undefined;
73
+ const counts = countEditTypes(edits);
74
+ if (counts.replace === 0) return undefined;
75
+ if (counts.replace !== counts.total) return undefined;
76
+ return "[info: this edit used replace (unverified). For safer future edits, prefer set_line/replace_lines with an anchor from read/grep/search.]";
77
+ }
78
+ export function buildEditOutput(input: BuildEditOutputInput): EditOutputResult {
79
+ const summary = `Updated ${input.displayPath}`;
80
+ const visibleSummary = `${buildVisibleSummary(input.displayPath, input.diff, input.edits)}${formatSemanticSuffix(input.semanticSummary)}`;
81
+ const semanticWarning = formatWhitespaceOnlyWarning(input.semanticSummary, input.edits);
82
+ const warningText = input.warnings.length ? `\n\nWarnings:\n${input.warnings.join("\n")}` : "";
83
+ const replaceHint = formatReplaceHint(input.edits, input.noopEdits);
84
+ let text = visibleSummary;
85
+ if (semanticWarning) text += `\n${semanticWarning}`;
86
+ text += warningText;
87
+ if (replaceHint) text += `\n${replaceHint}`;
88
+ return {
89
+ text,
90
+ patch: input.patch ?? "",
91
+ readseekValue: buildReadseekEditResult({
92
+ path: input.path,
93
+ summary,
94
+ diff: input.diff,
95
+ ...(input.diffData ? { diffData: input.diffData } : {}),
96
+ firstChangedLine: input.firstChangedLine,
97
+ warnings: input.warnings,
98
+ noopEdits: input.noopEdits,
99
+ ...(input.semanticSummary ? { semanticSummary: input.semanticSummary } : {}),
100
+ }),
101
+ contextHygiene: buildContextHygieneMetadata({
102
+ tool: "edit",
103
+ classification: "mutation",
104
+ resources: [buildFileResource(input.path)],
105
+ }),
106
+ };
107
+ }