pi-hashline-edit-pro 2.6.4 → 2.7.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/src/grep.ts ADDED
@@ -0,0 +1,323 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { readdir, stat } from "fs/promises";
4
+ import { dirname, join, relative } from "path";
5
+ import { loadFileKindAndText } from "./file-kind";
6
+ import { readNormFile } from "./file-reader";
7
+ import { MAX_HASH_LINES, HASH_SEP } from "./hashline";
8
+ import { toCwd } from "./paths";
9
+ import { loadP, loadGuide } from "./prompts";
10
+ import { normReq } from "./replace-normalize";
11
+ import { recordServedSafe } from "./served";
12
+ import { abortIf, errCode, isRec, makePrepareArguments, rejectUnknownFields, visLines } from "./utils";
13
+
14
+ const GREP_KS = new Set(["pattern", "path", "glob", "context", "ignoreCase", "literal", "limit"]);
15
+ const SKIP_DIRS = new Set(["node_modules", ".git", ".tmp", "coverage"]);
16
+ const MAX_SCAN_FILES = 4000;
17
+ const MAX_SHOWN_ROWS = 2000;
18
+
19
+ export interface GrepReq {
20
+ pattern: string;
21
+ path?: string;
22
+ glob?: string;
23
+ context?: number;
24
+ ignoreCase?: boolean;
25
+ literal?: boolean;
26
+ limit?: number;
27
+ }
28
+
29
+ export function assertGrepReq(request: unknown): asserts request is GrepReq {
30
+ if (!isRec(request)) {
31
+ throw new Error("[E_BAD_SHAPE] Grep request must be an object.");
32
+ }
33
+ rejectUnknownFields(request, GREP_KS, "Grep request");
34
+ if (typeof request.pattern !== "string" || request.pattern.length === 0) {
35
+ throw new Error('[E_BAD_SHAPE] Grep request requires a non-empty "pattern" string.');
36
+ }
37
+ if (request.context !== undefined && (typeof request.context !== "number" || !Number.isInteger(request.context) || request.context < 0)) {
38
+ throw new Error('[E_BAD_SHAPE] Grep request field "context" must be a non-negative integer.');
39
+ }
40
+ if (request.limit !== undefined && (typeof request.limit !== "number" || !Number.isInteger(request.limit) || request.limit < 1)) {
41
+ throw new Error('[E_BAD_SHAPE] Grep request field "limit" must be a positive integer.');
42
+ }
43
+ }
44
+
45
+ function buildRegex(pattern: string, literal: boolean, ignoreCase: boolean): RegExp {
46
+ const source = literal ? pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : pattern;
47
+ try {
48
+ return new RegExp(source, ignoreCase ? "ui" : "u");
49
+ } catch {
50
+ throw new Error(`[E_BAD_SHAPE] Invalid pattern: ${pattern}`);
51
+ }
52
+ }
53
+
54
+ function globToRegex(glob: string): RegExp {
55
+ let source = "";
56
+ let i = 0;
57
+ while (i < glob.length) {
58
+ const ch = glob[i]!;
59
+ if (ch === "*") {
60
+ if (glob[i + 1] === "*") {
61
+ i += 2;
62
+ if (glob[i] === "/") {
63
+ i += 1;
64
+ source += "(?:.*\\/)?";
65
+ } else {
66
+ source += ".*";
67
+ }
68
+ continue;
69
+ }
70
+ source += ".*";
71
+ } else if (ch === "?") {
72
+ source += "[^/]";
73
+ } else {
74
+ source += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
75
+ }
76
+ i += 1;
77
+ }
78
+ return new RegExp(`^${source}$`);
79
+ }
80
+
81
+ function isSkipableLoadError(error: unknown): boolean {
82
+ const code = errCode(error);
83
+ if (code === "EACCES" || code === "EPERM" || code === "ENOENT" || code === "ELOOP") return true;
84
+ return error instanceof Error && error.message.startsWith("[E_FILE_TOO_LARGE]");
85
+ }
86
+
87
+ interface FileHit {
88
+ path: string;
89
+ displayPath: string;
90
+ fileHashes: string[];
91
+ rows: string[];
92
+ hashes: string[];
93
+ matchCount: number;
94
+ totalMatchCount: number;
95
+ }
96
+
97
+ interface ScanState {
98
+ scanned: number;
99
+ stopped: boolean;
100
+ }
101
+
102
+ async function walkFiles(
103
+ root: string,
104
+ state: ScanState,
105
+ onFile: (absPath: string) => Promise<void>,
106
+ ): Promise<void> {
107
+ const queue: string[] = [root];
108
+ while (queue.length > 0 && !state.stopped) {
109
+ const dir = queue.pop()!;
110
+ let entries;
111
+ try {
112
+ entries = await readdir(dir, { withFileTypes: true });
113
+ } catch {
114
+ continue;
115
+ }
116
+ for (const entry of entries) {
117
+ if (state.stopped) break;
118
+ const full = join(dir, entry.name);
119
+ if (entry.isDirectory()) {
120
+ if (SKIP_DIRS.has(entry.name)) continue;
121
+ queue.push(full);
122
+ } else if (entry.isFile()) {
123
+ state.scanned += 1;
124
+ if (state.scanned > MAX_SCAN_FILES) {
125
+ state.stopped = true;
126
+ break;
127
+ }
128
+ await onFile(full);
129
+ }
130
+ }
131
+ }
132
+ }
133
+
134
+ async function searchFile(
135
+ absPath: string,
136
+ globRoot: string,
137
+ cwd: string,
138
+ regex: RegExp,
139
+ globRegex: RegExp | undefined,
140
+ context: number,
141
+ maxMatches: number,
142
+ ): Promise<FileHit | undefined> {
143
+ const displayPath = relative(cwd, absPath).replace(/\\/g, "/");
144
+ if (globRegex) {
145
+ const globPath = relative(globRoot, absPath).replace(/\\/g, "/");
146
+ if (!globRegex.test(globPath)) return undefined;
147
+ }
148
+ let file;
149
+ try {
150
+ file = await loadFileKindAndText(absPath, { maxLines: MAX_HASH_LINES, displayPath });
151
+ } catch (error) {
152
+ if (isSkipableLoadError(error)) return undefined;
153
+ throw error;
154
+ }
155
+ if (file.kind !== "text") return undefined;
156
+ let norm;
157
+ try {
158
+ norm = await readNormFile(absPath, cwd, { maxLines: MAX_HASH_LINES, preloadedFile: file });
159
+ } catch (error) {
160
+ if (isSkipableLoadError(error)) return undefined;
161
+ throw error;
162
+ }
163
+ const lines = visLines(norm.normalized);
164
+ const matchLines: number[] = [];
165
+ for (let i = 0; i < lines.length; i++) {
166
+ if (regex.test(lines[i]!)) matchLines.push(i);
167
+ }
168
+ if (matchLines.length === 0) return undefined;
169
+ const keptMatches = matchLines.length > maxMatches ? matchLines.slice(0, maxMatches) : matchLines;
170
+ const shown = new Set<number>();
171
+ for (const i of keptMatches) {
172
+ for (let j = Math.max(0, i - context); j <= Math.min(lines.length - 1, i + context); j++) shown.add(j);
173
+ }
174
+ const sorted = [...shown].sort((a, b) => a - b);
175
+ const rows: string[] = [];
176
+ const hashes: string[] = [];
177
+ for (const idx of sorted) {
178
+ rows.push(`${norm.fileHashes[idx]}${HASH_SEP}${lines[idx]}`);
179
+ hashes.push(norm.fileHashes[idx]!);
180
+ }
181
+ return {
182
+ path: norm.absolutePath,
183
+ displayPath,
184
+ fileHashes: norm.fileHashes,
185
+ rows,
186
+ hashes,
187
+ matchCount: keptMatches.length,
188
+ totalMatchCount: matchLines.length,
189
+ };
190
+ }
191
+
192
+ const grepToolSchema = Type.Object(
193
+ {
194
+ pattern: Type.String({
195
+ description: "Search pattern (regex or literal string)",
196
+ }),
197
+ path: Type.Optional(
198
+ Type.String({
199
+ description: "Directory or file to search (default: current directory)",
200
+ }),
201
+ ),
202
+ glob: Type.Optional(
203
+ Type.String({
204
+ description: "Filter files by glob pattern; * matches across directories, e.g. '*.ts' or '**/*.spec.ts'",
205
+ }),
206
+ ),
207
+ ignoreCase: Type.Optional(
208
+ Type.Boolean({
209
+ description: "Case-insensitive search (default: false)",
210
+ }),
211
+ ),
212
+ literal: Type.Optional(
213
+ Type.Boolean({
214
+ description: "Treat pattern as literal string instead of regex (default: false)",
215
+ }),
216
+ ),
217
+ context: Type.Optional(
218
+ Type.Integer({
219
+ minimum: 0,
220
+ description: "Number of lines to show before and after each match (default: 0)",
221
+ }),
222
+ ),
223
+ limit: Type.Optional(
224
+ Type.Integer({
225
+ minimum: 1,
226
+ description: "Maximum number of matches to return (default: 100)",
227
+ }),
228
+ ),
229
+ },
230
+ { additionalProperties: false },
231
+ );
232
+
233
+ export function regGrep(pi: ExtensionAPI): void {
234
+ pi.registerTool({
235
+ name: "grep",
236
+ label: "Grep",
237
+ description: loadP("../prompts/grep.md"),
238
+ promptSnippet: loadP("../prompts/grep-snippet.md"),
239
+ promptGuidelines: loadGuide("../prompts/grep-guidelines.md"),
240
+ prepareArguments: makePrepareArguments(),
241
+ parameters: grepToolSchema,
242
+
243
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
244
+ const canonical = normReq(params);
245
+ assertGrepReq(canonical);
246
+ const req = canonical;
247
+ const regex = buildRegex(req.pattern, req.literal === true, req.ignoreCase === true);
248
+ const context = req.context ?? 0;
249
+ const limit = req.limit ?? 100;
250
+ const globRegex = req.glob === undefined ? undefined : globToRegex(req.glob);
251
+ const base = req.path ? toCwd(req.path, ctx.cwd) : ctx.cwd;
252
+ abortIf(signal);
253
+ let baseStat;
254
+ try {
255
+ baseStat = await stat(base);
256
+ } catch (error) {
257
+ if (errCode(error) === "ENOENT") {
258
+ throw new Error(`[E_NOT_FOUND] File not found: ${req.path ?? ctx.cwd}`);
259
+ }
260
+ throw new Error(`[E_ACCESS] Cannot access path: ${req.path ?? ctx.cwd}`);
261
+ }
262
+ const globRoot = baseStat.isFile() ? dirname(base) : base;
263
+ const state: ScanState = { scanned: 0, stopped: false };
264
+ const files: string[] = [];
265
+ if (baseStat.isFile()) {
266
+ files.push(base);
267
+ } else {
268
+ await walkFiles(base, state, async (absPath) => {
269
+ files.push(absPath);
270
+ });
271
+ }
272
+ const hits: FileHit[] = [];
273
+ let matches = 0;
274
+ let limitTruncated = false;
275
+ let rowTruncated = false;
276
+ let rowCount = 0;
277
+ for (const absPath of files) {
278
+ abortIf(signal);
279
+ const remaining = limit - matches;
280
+ if (remaining <= 0) {
281
+ limitTruncated = true;
282
+ break;
283
+ }
284
+ const hit = await searchFile(absPath, globRoot, ctx.cwd, regex, globRegex, context, remaining);
285
+ if (!hit) continue;
286
+ const rowBudget = MAX_SHOWN_ROWS - rowCount;
287
+ if (rowBudget <= 0) {
288
+ rowTruncated = true;
289
+ break;
290
+ }
291
+ const keptRows = hit.rows.slice(0, rowBudget);
292
+ const keptHashes = hit.hashes.slice(0, rowBudget);
293
+ rowCount += keptRows.length;
294
+ if (hit.totalMatchCount > hit.matchCount) limitTruncated = true;
295
+ if (keptRows.length < hit.rows.length) rowTruncated = true;
296
+ matches += hit.matchCount;
297
+ hits.push({ ...hit, rows: keptRows, hashes: keptHashes });
298
+ }
299
+ for (const hit of hits) {
300
+ await recordServedSafe(hit.path, hit.hashes, "grep", new Set(hit.fileHashes));
301
+ }
302
+ const blocks = hits
303
+ .map((hit) => `=== ${hit.displayPath} ===\n${hit.rows.join("\n")}`)
304
+ .join("\n");
305
+ const notes: string[] = [];
306
+ if (rowTruncated) notes.push(`[grep: output truncated at ${MAX_SHOWN_ROWS} rows; refine the pattern to see more.]`);
307
+ if (limitTruncated) notes.push(`[grep: showing first ${limit} matches; increase limit to see more.]`);
308
+ if (state.stopped) notes.push(`[grep: scan cap of ${MAX_SCAN_FILES} files reached; results may be incomplete.]`);
309
+ const truncated = limitTruncated || rowTruncated;
310
+ const text = blocks.length > 0 ? `${blocks}${notes.length > 0 ? `\n${notes.join("\n")}` : ""}` : "No matches found.";
311
+ return {
312
+ content: [{ type: "text", text }],
313
+ details: {
314
+ metrics: {
315
+ matches,
316
+ files: hits.length,
317
+ truncated: truncated || state.stopped,
318
+ },
319
+ },
320
+ };
321
+ },
322
+ });
323
+ }
@@ -55,7 +55,7 @@ type NoopSpan = {
55
55
  function assertNotEmpty(originalContent: string, result: string): void {
56
56
  if (originalContent.length > 0 && result.length === 0) {
57
57
  throw new Error(
58
- "[E_WOULD_EMPTY] Cannot empty a non-empty file via edit. Use `write` if you need to clear the file."
58
+ "[E_WOULD_EMPTY] A replace cannot empty a non-empty file. Use `write` to clear the file."
59
59
  );
60
60
  }
61
61
  }
@@ -70,7 +70,7 @@ function nextZeroBit(bits: Uint32Array, start: number): number {
70
70
  if (idx >= totalBits) idx -= totalBits;
71
71
  }
72
72
  throw new Error(
73
- `[E_FILE_TOO_LARGE] Cannot allocate a unique hash anchor: the file exceeds the ${HASH_SPACE}-line limit for ${HASH_LEN}-char hashline anchors. For very large files use write or a non-line-based approach.`,
73
+ `[E_FILE_TOO_LARGE] File exceeds the ${HASH_SPACE}-line hashline limit; use write for very large files.`,
74
74
  );
75
75
  }
76
76
 
@@ -27,6 +27,8 @@ export {
27
27
  type BDup,
28
28
  type AutoFix,
29
29
  resEdit,
30
+ stripAnchorRow,
31
+ resolveAnchorLine,
30
32
  valEdit,
31
33
  stripBarePrefixes,
32
34
  stripDiffPrefixes,
@@ -14,11 +14,11 @@ function diagRef(ref: string): string {
14
14
  }
15
15
 
16
16
  if (/^\d+/.test(trimmed)) {
17
- return `[E_BAD_REF] Invalid anchor. Use the hash alone (e.g. "aB3") no line numbers or trailing content.`;
17
+ return `[E_BAD_REF] Invalid anchor. Use the hash alone (e.g. "aB3"): no line numbers or trailing content.`;
18
18
  }
19
19
 
20
20
  if (trimmed.includes("│")) {
21
- return `[E_BAD_REF] Invalid anchor "${trimmed}". remove_from and remove_to must contain the 3-char hash only — remove everything from "│" onward.`;
21
+ return `[E_BAD_REF] Invalid anchor "${trimmed}": use only the 3-char hash, drop everything from "│" onward.`;
22
22
  }
23
23
 
24
24
  return `[E_BAD_REF] Invalid anchor "${trimmed}". Expected a 3-char alphanumeric anchor (e.g. "aB3").`;
@@ -52,7 +52,7 @@ export function parseText(edit: string[], warnings?: string[]): string[] {
52
52
  }
53
53
  if (split) {
54
54
  warnings?.push(
55
- "[E_BAD_SHAPE] Autocorrected: split replacement_lines element(s) containing embedded newlines into separate lines.",
55
+ "[E_BAD_SHAPE] Autocorrected: split embedded newlines in replacement_lines into separate lines.",
56
56
  );
57
57
  }
58
58
  return out;
@@ -90,7 +90,7 @@ export function fmtMismatchWithHashes(
90
90
  const refList = notFound.map((m) => `"${m.ref.hash}"`).join(", ");
91
91
  if (notFound.length > 0) {
92
92
  out.push(
93
- `[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}. The file content has changed since those anchors were read. Call read() to get fresh anchors, then copy the 3-char HASH of the start and end of the range you are replacing into remove_from and remove_to of your next replace call.`
93
+ `[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}. The file changed since read. Call read() for fresh anchors.`
94
94
  );
95
95
  for (const m of notFound) {
96
96
  const ctx = m.context;
@@ -109,7 +109,7 @@ export function fmtMismatchWithHashes(
109
109
  if (ambiguous.length > 0) {
110
110
  if (out.length > 0) out.push("");
111
111
  out.push(
112
- `[E_AMBIGUOUS_ANCHOR] ${ambiguous.length} ambiguous anchor${ambiguous.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}. Call read() to get fresh anchors, then copy the 3-char HASH of the start and end of the range you are replacing into remove_from and remove_to of your next replace call.`
112
+ `[E_AMBIGUOUS_ANCHOR] ${ambiguous.length} ambiguous anchor${ambiguous.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}. Call read() for fresh anchors.`
113
113
  );
114
114
  for (const m of ambiguous) {
115
115
  const sample = (m.candidates ?? []).slice(0, 5);
@@ -150,7 +150,7 @@ function assertItem(edit: Record<string, unknown>): void {
150
150
  );
151
151
  }
152
152
  if (!("replacement_lines" in edit)) {
153
- throw new Error(`[E_BAD_SHAPE] The edit requires a "replacement_lines" field. Provide the replacement lines as an array of strings (use [] to delete).`);
153
+ throw new Error(`[E_BAD_SHAPE] The edit requires a "replacement_lines" array (use [] to delete).`);
154
154
  }
155
155
  if (!Array.isArray(edit.replacement_lines) || edit.replacement_lines.some((line) => typeof line !== "string")) {
156
156
  throw new Error(NEW_CONTENT_NOT_ARRAY_MSG);
@@ -164,26 +164,29 @@ function assertItem(edit: Record<string, unknown>): void {
164
164
 
165
165
  export const ANCHOR_ROW_RE = new RegExp(`^([+-]?)(${HASH_RUN})│`);
166
166
 
167
+ export function stripAnchorRow(
168
+ trimmed: string,
169
+ entryLabel: string,
170
+ warnings?: string[],
171
+ ): string {
172
+ const match = trimmed.match(ANCHOR_ROW_RE);
173
+ if (!match) return trimmed;
174
+ const marker =
175
+ match[1] === "+"
176
+ ? "diff-preview marker"
177
+ : match[1] === "-"
178
+ ? 'leading "-" marker'
179
+ : '"HASH│" prefix';
180
+ warnings?.push(`[E_BAD_REF] Stripped ${marker} from ${entryLabel} "${trimmed}".`);
181
+ return match[2]!;
182
+ }
183
+
167
184
  export function resEdit(edit: HTEdit, warnings?: string[]): HEdit {
168
185
  assertItem(edit as Record<string, unknown>);
169
186
 
170
187
  const replaceLines = parseText(edit.replacement_lines, warnings);
171
188
  const bounds = [edit.remove_from, edit.remove_to].map((ref) => {
172
- const trimmed = ref.trim();
173
- const match = trimmed.match(ANCHOR_ROW_RE);
174
- if (match) {
175
- let message: string;
176
- if (match[1] === "+") {
177
- message = `[E_BAD_REF] Autocorrected: stripped diff-preview marker copied from the diff preview in remove_from/remove_to entry "${trimmed}".`;
178
- } else if (match[1] === "-") {
179
- message = `[E_BAD_REF] Autocorrected: stripped leading "-" marker in remove_from/remove_to entry "${trimmed}".`;
180
- } else {
181
- message = `[E_BAD_REF] Autocorrected: stripped "HASH│" prefix copied from read output in remove_from/remove_to entry "${trimmed}".`;
182
- }
183
- warnings?.push(message);
184
- return match[2]!;
185
- }
186
- return ref;
189
+ return stripAnchorRow(ref.trim(), "remove_from/remove_to entry", warnings);
187
190
  }) as [string, string];
188
191
  return {
189
192
  content_lines: replaceLines,
@@ -197,7 +200,7 @@ function warnUnicodeEsc(
197
200
  ): void {
198
201
  if (edit.content_lines.some((line) => /\\uDDDD/i.test(line))) {
199
202
  warnings.push(
200
- "Detected literal \\uDDDD in edit content; no autocorrection applied. Verify whether this should be a real Unicode escape or plain text.",
203
+ "Detected literal \\uDDDD in edit content; no autocorrection applied.",
201
204
  );
202
205
  }
203
206
  }
@@ -220,16 +223,12 @@ export function stripBarePrefixes(
220
223
  .map((s) => `replacement_lines line ${s.lineIndex + 1}`)
221
224
  .join(", ");
222
225
  const matchedCount = stripped.filter((s) => s.matched).length;
223
- const evidence =
224
- matchedCount === 0
225
- ? "none of the stripped hashes match current file lines"
226
- : `${matchedCount} of ${stripped.length} stripped hash(es) match current file lines`;
227
226
  const guidance =
228
227
  matchedCount === 0
229
- ? " Verify that these lines were pasted from read output; literal content starting with 'HASH│' would be altered by this strip."
228
+ ? " Verify it was pasted from read output."
230
229
  : "";
231
230
  warnings.push(
232
- `[E_BARE_HASH_PREFIX] Autocorrected: stripped "HASH│" prefix copied from read output in ${locations} (${evidence}).${guidance}`
231
+ `[E_BARE_HASH_PREFIX] Stripped "HASH│" prefix from ${locations}.${guidance}`
233
232
  );
234
233
  return { ...edit, content_lines: contentLines };
235
234
  }
@@ -255,7 +254,7 @@ export function stripDiffPrefixes(
255
254
  if (stripped.length === 0) return edit;
256
255
  const locations = stripped.map((i) => `replacement_lines line ${i + 1}`).join(", ");
257
256
  warnings.push(
258
- `[E_INVALID_PATCH] Autocorrected: stripped diff-preview marker copied from the diff preview in ${locations}.`
257
+ `[E_INVALID_PATCH] Stripped diff-preview marker from ${locations}.`
259
258
  );
260
259
  return { ...edit, content_lines: contentLines };
261
260
  }
@@ -280,7 +279,7 @@ export function swapReversedRanges(
280
279
  return edit;
281
280
  }
282
281
  warnings.push(
283
- `[E_BAD_OP] Autocorrected: remove_from and remove_to were reversed (remove_from ${startRef.hash} is after remove_to ${endRef.hash}); swapped the pair.`
282
+ `[E_BAD_OP] Autocorrected: remove_from/remove_to were reversed; swapped them.`
284
283
  );
285
284
  return { ...edit, hash_bounds: [endRef, startRef] as [Anchor, Anchor] };
286
285
  }
@@ -345,6 +344,7 @@ function firstNewAfterDups(
345
344
  let runLen = 0;
346
345
  while (
347
346
  runLen < maxK &&
347
+ canonLines[endLine + runLen]!.length > 0 &&
348
348
  canon(contentLines[firstNew.index + runLen]!) === canonLines[endLine + runLen]!
349
349
  ) {
350
350
  runLen++;
@@ -369,6 +369,7 @@ function lastNewBeforeDups(
369
369
  let runLen = 0;
370
370
  while (
371
371
  runLen < maxK &&
372
+ canonLines[startLine - 2 - runLen]!.length > 0 &&
372
373
  canon(contentLines[lastNew.index - runLen]!) === canonLines[startLine - 2 - runLen]!
373
374
  ) {
374
375
  runLen++;
@@ -402,8 +403,8 @@ export function findNewEdge(
402
403
  const start = fromEnd ? contentLines.length - 1 : 0;
403
404
  for (let i = start; i >= 0 && i < contentLines.length; i += step) {
404
405
  const line = contentLines[i]!;
405
- if (line.length === 0) continue;
406
406
  const key = canon(line);
407
+ if (key.length === 0) continue;
407
408
  const count = multiset.get(key) ?? 0;
408
409
  if (count > 0) {
409
410
  multiset.set(key, count - 1);
@@ -475,6 +476,31 @@ export function valEdit(
475
476
  };
476
477
  }
477
478
 
479
+ export function resolveAnchorLine(
480
+ ref: Anchor,
481
+ fileLines: string[],
482
+ fileHashes: string[],
483
+ filePath?: string,
484
+ ): number {
485
+ const { resolved, mismatches } = valEdit(
486
+ { hash_bounds: [ref, ref], content_lines: [] },
487
+ fileLines,
488
+ fileHashes,
489
+ [],
490
+ undefined,
491
+ );
492
+ if (mismatches.length > 0 || !resolved) {
493
+ const feedback = fmtMismatchWithHashes(
494
+ mismatches,
495
+ fileLines,
496
+ fileHashes,
497
+ filePath,
498
+ );
499
+ throw new AnchorMismatchError(feedback.text, feedback.hashes);
500
+ }
501
+ return resolved.hash_bounds[0].line;
502
+ }
503
+
478
504
  export class RangeStaleError extends Error {
479
505
  readonly firstMismatchLine: number;
480
506
  readonly rangeHashes: string[];
@@ -531,7 +557,7 @@ export function assertRangeServed(
531
557
  ? `\n\n[The range has ${rangeLength} lines; showing the first ${shownLength}. Call read() with offset=${startLine + shownLength} to see the rest.]`
532
558
  : "";
533
559
  const message =
534
- `[E_RANGE_STALE] ${mismatchText} what was previously shown: the file changed on disk after the anchors were read, or the line(s) were never shown. Nothing was modified. Current range with fresh anchors:\n\n${rows.join("\n")}${capHint}`;
560
+ `[E_RANGE_STALE] ${mismatchText} what was shown. Nothing was modified. Current range with fresh anchors:\n\n${rows.join("\n")}${capHint}`;
535
561
  throw new RangeStaleError(message, first, shownHashes);
536
562
  }
537
563