pi-hashline-edit-pro 0.16.12 → 0.16.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "0.16.12",
3
+ "version": "0.16.13",
4
4
  "description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 18-bit, perfect hashing)",
5
5
  "main": "index.ts",
6
6
  "repository": {
@@ -14,6 +14,20 @@ import {
14
14
  } from "./resolve";
15
15
  import { visLines } from "../utils";
16
16
 
17
+ function lastNonEmptyIndex(lines: string[]): number {
18
+ for (let i = lines.length - 1; i >= 0; i--) {
19
+ if (lines[i]!.length > 0) return i;
20
+ }
21
+ return -1;
22
+ }
23
+
24
+ function firstNonEmptyIndex(lines: string[]): number {
25
+ for (let i = 0; i < lines.length; i++) {
26
+ if (lines[i]!.length > 0) return i;
27
+ }
28
+ return -1;
29
+ }
30
+
17
31
  type LIdx = {
18
32
  fileLines: string[];
19
33
  lineStarts: number[];
@@ -306,13 +320,15 @@ export function applyEdits(
306
320
  const edit = correctedEdits[bw.editIndex];
307
321
  if (!edit) continue;
308
322
  if (bw.kind === "trailing") {
309
- const removed = edit.content_lines.pop();
310
- if (removed !== undefined) {
323
+ const idx = lastNonEmptyIndex(edit.content_lines);
324
+ if (idx >= 0) {
325
+ const removed = edit.content_lines.splice(idx, 1)[0];
311
326
  autoFixes.push({ kind: "trailing", editIndex: bw.editIndex, removedLine: removed });
312
327
  }
313
328
  } else {
314
- const removed = edit.content_lines.shift();
315
- if (removed !== undefined) {
329
+ const idx = firstNonEmptyIndex(edit.content_lines);
330
+ if (idx >= 0) {
331
+ const removed = edit.content_lines.splice(idx, 1)[0];
316
332
  autoFixes.push({ kind: "leading", editIndex: bw.editIndex, removedLine: removed });
317
333
  }
318
334
  }
@@ -94,7 +94,7 @@ export function fmtMismatch(
94
94
  const refList = notFound.map((m) => `"${m.ref.hash}"`).join(", ");
95
95
  if (notFound.length > 0) {
96
96
  out.push(
97
- `[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}. Call read() to get fresh anchors, then copy the 3-char HASH of the start and end of the range you are replacing into hash_range_inclusive of your next replace call.`
97
+ `[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 hash_range_inclusive of your next replace call.`
98
98
  );
99
99
  }
100
100
  if (ambiguous.length > 0) {
@@ -153,9 +153,19 @@ function assertItem(edit: Record<string, unknown>, index: number): void {
153
153
  if ("content_lines" in edit && !isStrArr(edit.content_lines)) {
154
154
  const val = edit.content_lines;
155
155
  if (typeof val === "string") {
156
- throw new Error(CONTENT_LINES_NOT_STRING_MSG);
156
+ try {
157
+ const parsed = JSON.parse(val);
158
+ if (Array.isArray(parsed)) {
159
+ edit.content_lines = parsed;
160
+ } else {
161
+ throw new Error(CONTENT_LINES_NOT_STRING_MSG);
162
+ }
163
+ } catch {
164
+ throw new Error(CONTENT_LINES_NOT_STRING_MSG);
165
+ }
166
+ } else {
167
+ throw new Error(`[E_BAD_SHAPE] Edit ${index} field "content_lines" must be a string array.`);
157
168
  }
158
- throw new Error(`[E_BAD_SHAPE] Edit ${index} field "content_lines" must be a string array.`);
159
169
  }
160
170
  if (!isStrPair(edit.hash_range_inclusive)) {
161
171
  throw new Error(
@@ -230,6 +240,20 @@ export function descEdit(edit: RHEdit): string {
230
240
  return `replace ${edit.hash_range_inclusive[0].hash}-${edit.hash_range_inclusive[1].hash}`;
231
241
  }
232
242
 
243
+ function lastNonEmpty(lines: string[]): string | undefined {
244
+ for (let i = lines.length - 1; i >= 0; i--) {
245
+ if (lines[i]!.length > 0) return lines[i]!;
246
+ }
247
+ return undefined;
248
+ }
249
+
250
+ function firstNonEmpty(lines: string[]): string | undefined {
251
+ for (let i = 0; i < lines.length; i++) {
252
+ if (lines[i]!.length > 0) return lines[i]!;
253
+ }
254
+ return undefined;
255
+ }
256
+
233
257
  function checkBoundaryDup(
234
258
  adjacentLine: string | undefined,
235
259
  replacementEdge: string | undefined,
@@ -296,11 +320,11 @@ export function valEdits(
296
320
  }
297
321
  const endLine = endResolved.line;
298
322
  const nextLine = fileLines[endLine];
299
- const replacementLastLine = edit.content_lines.at(-1);
323
+ const replacementLastLine = lastNonEmpty(edit.content_lines);
300
324
  const trailing = checkBoundaryDup(nextLine, replacementLastLine, "trailing", endLine, resolved.length);
301
325
  if (trailing) boundaryWarnings.push(trailing);
302
326
  const prevLine = fileLines[startResolved.line - 2];
303
- const replacementFirstLine = edit.content_lines[0];
327
+ const replacementFirstLine = firstNonEmpty(edit.content_lines);
304
328
  const leading = checkBoundaryDup(prevLine, replacementFirstLine, "leading", startResolved.line - 2, resolved.length);
305
329
  if (leading) boundaryWarnings.push(leading);
306
330
  resolved.push({
@@ -1,12 +1,19 @@
1
1
  import { isRec, has } from "./utils";
2
2
  import { CONTENT_LINES_NOT_STRING_MSG } from "./constants";
3
- function assertContentLinesNotString(
4
- value: unknown,
5
- label: string,
6
- ): void {
7
- if (typeof value === "string") {
8
- throw new Error(CONTENT_LINES_NOT_STRING_MSG);
9
- }
3
+
4
+ function tryParseContentLines(record: Record<string, unknown>, key: string, label: string): void {
5
+ const val = record[key];
6
+ if (typeof val !== "string") return;
7
+ try {
8
+ const parsed = JSON.parse(val);
9
+ if (Array.isArray(parsed)) {
10
+ record[key] = parsed;
11
+ return;
12
+ }
13
+ } catch {
14
+ // fall through to error
15
+ }
16
+ throw new Error(CONTENT_LINES_NOT_STRING_MSG);
10
17
  }
11
18
 
12
19
  export function normalizeFilePath(record: Record<string, unknown>): void {
@@ -27,6 +34,17 @@ function normalizeField(
27
34
  record[to] = raw;
28
35
  } else if (isRec(raw)) {
29
36
  record[to] = [raw];
37
+ } else if (typeof raw === "string") {
38
+ try {
39
+ const parsed = JSON.parse(raw);
40
+ if (Array.isArray(parsed)) {
41
+ record[to] = parsed;
42
+ } else if (isRec(parsed)) {
43
+ record[to] = [parsed];
44
+ }
45
+ } catch {
46
+ // not valid JSON, leave as-is for downstream validation
47
+ }
30
48
  }
31
49
  if (from !== to) delete record[from];
32
50
  }
@@ -41,7 +59,7 @@ export function normReq(input: unknown): unknown {
41
59
  normalizeFilePath(record);
42
60
 
43
61
  if (has(record, "content_lines") && typeof record.content_lines === "string") {
44
- assertContentLinesNotString(record.content_lines, "Top-level");
62
+ tryParseContentLines(record, "content_lines", "Top-level");
45
63
  }
46
64
 
47
65
  normalizeField(record, "changes", "changes");
@@ -51,7 +69,7 @@ export function normReq(input: unknown): unknown {
51
69
  for (let i = 0; i < record.changes.length; i++) {
52
70
  const item = record.changes[i];
53
71
  if (isRec(item) && has(item, "content_lines") && typeof item.content_lines === "string") {
54
- assertContentLinesNotString(item.content_lines, `changes[${i}]`);
72
+ tryParseContentLines(item, "content_lines", `changes[${i}]`);
55
73
  }
56
74
  }
57
75
  }
package/src/replace.ts CHANGED
@@ -113,10 +113,11 @@ interface PipelineResult {
113
113
  resultHashes: string[];
114
114
  }
115
115
 
116
- const ROOT_KS = new Set(["path", "changes"]);
116
+ const ROOT_KS = new Set(["path", "changes", "content_lines", "hash_range_inclusive"]);
117
117
 
118
118
  export function assertReq(
119
119
  request: unknown,
120
+ flat?: boolean
120
121
  ): asserts request is ReqParams {
121
122
  if (!isRec(request)) {
122
123
  throw new Error("[E_BAD_SHAPE] Edit request must be an object.");
@@ -137,10 +138,14 @@ export function assertReq(
137
138
  }
138
139
 
139
140
  if (!Array.isArray(request.changes)) {
141
+ if (flat) {
142
+ throw new Error(
143
+ '[E_BAD_SHAPE] Edit request requires both "content_lines" and "hash_range_inclusive" at the top level.',
144
+ );
145
+ }
140
146
  throw new Error('[E_BAD_SHAPE] Edit request requires a "changes" array. Each change is { content_lines: [...], hash_range_inclusive: ["<START>", "<END>"] }.');
141
147
  }
142
148
  }
143
-
144
149
  export async function execPipeline(
145
150
  params: ReqParams,
146
151
  cwd: string,
@@ -217,10 +222,11 @@ export async function execPipeline(
217
222
  export async function compPreview(
218
223
  request: unknown,
219
224
  cwd: string,
225
+ flat?: boolean
220
226
  ): Promise<RPreview> {
221
227
  try {
222
228
  const normalized = normReq(request);
223
- assertReq(normalized);
229
+ assertReq(normalized, flat);
224
230
  const { path, originalNormalized, originalHashes, result, resultHashes } = await execPipeline(
225
231
  normalized,
226
232
  cwd,
@@ -352,7 +358,7 @@ export function buildToolDef(opts: { flat: boolean; autoRead?: boolean }): ToolD
352
358
  context.state.preview = undefined;
353
359
  const previewGeneration = (context.state.previewGeneration ?? 0) + 1;
354
360
  context.state.previewGeneration = previewGeneration;
355
- compPreview(previewInput, context.cwd)
361
+ compPreview(previewInput, context.cwd, opts.flat)
356
362
  .then((preview) => {
357
363
  if (
358
364
  context.state.argsKey === argsKey &&