pi-hashline-edit-pro 4.2.4 → 4.2.6

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/README.md CHANGED
@@ -192,7 +192,7 @@ On non-Windows platforms the directory honors `XDG_CONFIG_HOME` when set (fallin
192
192
 
193
193
  ## How anchors work
194
194
 
195
- Anchors are allocated, never derived. Every line that is served to you, by `read`, `anchor_grep`, the auto-read block after `write`, or a post-edit diff, gets the next free anchor from the session's pool, claimed by walking the table with a large stride (roughly the golden ratio of the anchor space) coprime to it, so consecutively minted anchors land in unrelated regions of the table instead of sharing leading characters. Ownership is exclusive: an anchor is owned by one file's line until it is freed (the line was edited, the file was written or deleted, or you ran `/clear-anchors`). Minting prefers anchors the session has never used; when every unused anchor has been spent, freed anchors are recycled after their stale served records are purged, so an anchor is never shared by two live lines. Because ownership is exclusive, an anchor resolves to exactly one file. Two byte-identical lines never share an anchor, and that guarantee sets the file size cap: at most 1,353,139 lines per file, beyond which `read`, `replace`, and `insert` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
195
+ Anchors are allocated, never derived. Every line that is served to you, by `read`, `anchor_grep`, the auto-read block after `write`, or a post-edit diff, gets the next free anchor from the session's pool, claimed by walking the table with a large stride (roughly the golden ratio of the anchor space) coprime to it, so consecutively minted anchors land in unrelated regions of the table instead of sharing leading characters. Each session seeds its walk from its own offset (derived from the session key and the process id), so concurrent sessions mint disjoint sequences instead of identical ones: an anchor minted in one session is unknown in another and is rejected with `[E_STALE_ANCHOR]` rather than resolving to a different file. Ownership is exclusive: an anchor is owned by one file's line until it is freed (the line was edited, the file was written or deleted, or you ran `/clear-anchors`). Minting prefers anchors the session has never used; when every unused anchor has been spent, freed anchors are recycled after their stale served records are purged, so an anchor is never shared by two live lines. Because ownership is exclusive, an anchor resolves to exactly one file. Two byte-identical lines never share an anchor, and that guarantee sets the file size cap: at most 1,353,139 lines per file, beyond which `read`, `replace`, and `insert` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
196
196
 
197
197
  The table is curated for tokenizers, not for humans. Every anchor is the concatenation of two 2-character pieces that each encode as a single token, and beside the `│` separator the whole 5-character `anchor│` unit is verified to tokenize as exactly three tokens in each of eight modern open-weights tokenizers (Qwen 3.5, DeepSeek V4, Gemma 4, GLM 5.3 Flash, Tencent Hy4-preview, MiniMax M3, MiMo V2.5, Kimi K3). The shipped table is the intersection that satisfies the criterion on all of them; Nemotron 3 Ultra is the one modern tokenizer excluded. An anchor therefore costs 2 tokens on a read row and 2 in an edit call, with the `│` separator as the third. Anchors are letters only. The table is shipped as `src/hashline/anchor-table.json`.
198
198
 
@@ -218,7 +218,7 @@ Codes starting with `E_` are errors: nothing was written — except `File was wr
218
218
  | Code | Meaning |
219
219
  | --- | --- |
220
220
  | `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (for example `replacement_lines` must be an array of strings, one element per line). |
221
- | `[W_BAD_SHAPE]` | Auto-corrected request slip reported as a warning (for example unwrapped JSON array syntax or embedded newlines split into lines). |
221
+ | `[W_BAD_SHAPE]` | Auto-corrected request slip reported as a warning (for example unwrapped JSON array syntax, embedded newlines split into lines, or stringified array text that could not be parsed and was kept as one literal line). |
222
222
  | `[E_BAD_REF]` | An anchor in `remove_from`/`remove_to` is not a bare 4-char anchor. |
223
223
  | `[W_BAD_REF]` | A pasted `anchor│` or diff-preview marker was stripped from an anchor field with a warning. |
224
224
  | `[E_STALE_ANCHOR]` | An anchor is not owned in this session (it was never shown to you, or its line was edited or the file was rewritten); call `read` for fresh anchors. |
@@ -236,6 +236,7 @@ Codes starting with `E_` are errors: nothing was written — except `File was wr
236
236
  | `[E_BOUNDARY_STRICT]` | Strict boundary dedup rejected the edit because replacement lines re-include edge lines; resend without those lines. |
237
237
  | `[E_FILE_TOO_LARGE]` | The file exceeds the 1,353,139-line hashline limit or the 100MB size limit. |
238
238
  | `[E_REGISTRY]` | The anchor registry was not initialized; a serve or edit ran outside an initialized session. |
239
+ | `[E_STORE_UNAVAILABLE]` | No SQLite runtime could be loaded: the host exposes neither `node:sqlite` (Node 22.19+) nor `bun:sqlite`. The pi release binary's bundled Bun lacks `node:sqlite`; run pi under Node or a Bun build that ships SQLite. |
239
240
  | `[E_WRITE_HASH_ECHO]` | A `write` `content` line begins with the exact `anchor│` served for this file at the same line. The write is refused, file byte-identical; retry with bare content (remove the copied anchors). |
240
241
  | `[E_PATH_CHANGED]` | A write target changed identity after it was read; the write was refused to avoid following a swapped symlink or overwriting a replacement file. |
241
242
  | `[E_BATCH_OVERLAP]` | Batched `replace`/`insert` calls target overlapping ranges; the whole batch was refused. Retry with disjoint ranges. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "4.2.4",
3
+ "version": "4.2.6",
4
4
  "type": "module",
5
5
  "description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 4-char tokenizer-friendly anchor that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
6
6
  "main": "index.ts",
@@ -60,8 +60,8 @@ function seedServedFromOwned(state: SessionState): void {
60
60
  }
61
61
  }
62
62
 
63
- export function foldRegistryEvents(events: RegistryEvent[]): SessionState {
64
- const state = newSessionState();
63
+ export function foldRegistryEvents(events: RegistryEvent[], seed?: string): SessionState {
64
+ const state = newSessionState(seed);
65
65
  for (const event of events) {
66
66
  if (event.kind === "clear") {
67
67
  state.owned.clear();
@@ -157,7 +157,7 @@ export async function initRegistry(sessionFile: string | undefined): Promise<voi
157
157
  if (!sessionFile) {
158
158
  currentKey = `__ephemeral__-${Date.now()}-${Math.random().toString(36).slice(2)}`;
159
159
  currentSidecar = undefined;
160
- registries.set(currentKey, newSessionState());
160
+ registries.set(currentKey, newSessionState(currentKey));
161
161
  return;
162
162
  }
163
163
  const key = sidecarKeyFor(sessionFile);
@@ -173,7 +173,7 @@ export async function initRegistry(sessionFile: string | undefined): Promise<voi
173
173
  console.error("Failed to read anchor registry sidecar:", error);
174
174
  }
175
175
  }
176
- const folded = foldRegistryEvents(events);
176
+ const folded = foldRegistryEvents(events, `${key}:${process.pid}`);
177
177
  seedServedFromOwned(folded);
178
178
  registries.set(key, folded);
179
179
  if (rawLog.length > 0) {
package/src/config.ts CHANGED
@@ -46,10 +46,10 @@ export function normalizeDiffContextLines(value: unknown): number {
46
46
 
47
47
  function parseConfig(content: string): Config {
48
48
  const parsed = JSON.parse(content) as unknown;
49
- const autoRead = isRec(parsed) ? parsed.autoRead : undefined;
50
- if (typeof autoRead !== "boolean") {
49
+ if (!isRec(parsed) || (parsed.autoRead !== undefined && typeof parsed.autoRead !== "boolean")) {
51
50
  throw new Error("config.json must be an object with a boolean autoRead field");
52
51
  }
52
+ const autoRead = parsed.autoRead;
53
53
  const anchorGrepEnabled = isRec(parsed) ? parsed.anchorGrepEnabled : undefined;
54
54
  const requirePath = isRec(parsed) ? parsed.requirePath : undefined;
55
55
  const strictInput = isRec(parsed) ? parsed.strictInput : undefined;
@@ -57,7 +57,7 @@ function parseConfig(content: string): Config {
57
57
  const legacyBoundaryDedup = isRec(parsed) ? parsed.boundaryDedupEnabled : undefined;
58
58
  const diffContextLines = isRec(parsed) ? parsed.diffContextLines : undefined;
59
59
  return {
60
- autoRead,
60
+ autoRead: typeof autoRead === "boolean" ? autoRead : DEFAULT_CONFIG.autoRead,
61
61
  anchorGrepEnabled: typeof anchorGrepEnabled === "boolean" ? anchorGrepEnabled : DEFAULT_CONFIG.anchorGrepEnabled,
62
62
  requirePath: typeof requirePath === "boolean" ? requirePath : DEFAULT_CONFIG.requirePath,
63
63
  strictInput: typeof strictInput === "boolean" ? strictInput : DEFAULT_CONFIG.strictInput,
package/src/hash-store.ts CHANGED
@@ -39,10 +39,81 @@ interface RawDb {
39
39
  close(): void;
40
40
  readonly isOpen: boolean;
41
41
  }
42
- export type SqliteEngine = "node:sqlite";
43
- const sqliteEngine: SqliteEngine = "node:sqlite";
44
- const { DatabaseSync } = await import("node:sqlite");
45
- const openDbFn = (path: string): RawDb => new DatabaseSync(path, { timeout: HASH_STORE_BUSY_TIMEOUT }) as unknown as RawDb;
42
+ export type SqliteEngine = "node:sqlite" | "bun:sqlite";
43
+
44
+ interface BunStatementLike {
45
+ get(...params: SqlParams): unknown;
46
+ all(...params: SqlParams): unknown[];
47
+ run(...params: SqlParams): unknown;
48
+ }
49
+
50
+ interface BunDbLike {
51
+ exec(sql: string): void;
52
+ prepare(sql: string): BunStatementLike;
53
+ close(): void;
54
+ }
55
+
56
+ function wrapBunDatabase(mod: { Database: new (path: string) => BunDbLike }): (path: string) => RawDb {
57
+ return (path) => {
58
+ const db = new mod.Database(path);
59
+ db.exec(`PRAGMA busy_timeout = ${HASH_STORE_BUSY_TIMEOUT}`);
60
+ let closed = false;
61
+ return {
62
+ exec: (sql) => db.exec(sql),
63
+ prepare: (sql) => {
64
+ const stmt = db.prepare(sql);
65
+ return {
66
+ get: (...params) => stmt.get(...params) ?? undefined,
67
+ all: (...params) => stmt.all(...params),
68
+ run: (...params) => stmt.run(...params),
69
+ };
70
+ },
71
+ close: () => {
72
+ if (!closed) {
73
+ closed = true;
74
+ db.close();
75
+ }
76
+ },
77
+ get isOpen() {
78
+ return !closed;
79
+ },
80
+ };
81
+ };
82
+ }
83
+
84
+ async function loadNodeEngine(): Promise<{ engine: SqliteEngine; open: (path: string) => RawDb }> {
85
+ const { DatabaseSync } = await import("node:sqlite");
86
+ return {
87
+ engine: "node:sqlite",
88
+ open: (path) => new DatabaseSync(path, { timeout: HASH_STORE_BUSY_TIMEOUT }) as unknown as RawDb,
89
+ };
90
+ }
91
+
92
+ async function loadBunEngine(): Promise<{ engine: SqliteEngine; open: (path: string) => RawDb }> {
93
+ const specifier = "bun:sqlite";
94
+ const mod = await import(specifier) as { Database: new (path: string) => BunDbLike };
95
+ return { engine: "bun:sqlite", open: wrapBunDatabase(mod) };
96
+ }
97
+
98
+ const isBunRuntime = typeof process !== "undefined" && typeof (process.versions as Record<string, string | undefined>).bun === "string";
99
+
100
+ async function selectSqliteEngine(): Promise<{ engine: SqliteEngine; open: (path: string) => RawDb }> {
101
+ const candidates = isBunRuntime ? [loadBunEngine, loadNodeEngine] : [loadNodeEngine, loadBunEngine];
102
+ let lastError: unknown;
103
+ for (const candidate of candidates) {
104
+ try {
105
+ return await candidate();
106
+ } catch (error) {
107
+ lastError = error;
108
+ }
109
+ }
110
+ const detail = lastError instanceof Error ? lastError.message : String(lastError);
111
+ throw new Error(`[E_STORE_UNAVAILABLE] No SQLite runtime available (node:sqlite and bun:sqlite both failed to load): ${detail}`);
112
+ }
113
+
114
+ const selectedEngine = await selectSqliteEngine();
115
+ const sqliteEngine: SqliteEngine = selectedEngine.engine;
116
+ const openDbFn = selectedEngine.open;
46
117
 
47
118
  interface Prepared {
48
119
  get: (...params: SqlParams) => Record<string, unknown> | undefined;
@@ -1,4 +1,4 @@
1
- import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, clipLine, getCached } from "../utils";
1
+ import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, clipLine, getCached, decodeStringArray } from "../utils";
2
2
  import { parseHashRef, parseText, type Anchor } from "./parse";
3
3
  import { HASH_SEP, stripRowPrefix, canon } from "./hash";
4
4
  import { HASH_RUN } from "./alphabet";
@@ -171,7 +171,7 @@ export function stripAnchorRow(
171
171
  export function resEdit(edit: HTEdit, warnings?: string[]): HEdit {
172
172
  assertItem(edit as Record<string, unknown>);
173
173
 
174
- const replaceLines = parseText(edit.replacement_lines, warnings);
174
+ const replaceLines = parseText(decodeStringArray(edit.replacement_lines, warnings) ?? edit.replacement_lines, warnings);
175
175
  const bounds = [edit.remove_from, edit.remove_to].map((ref) => {
176
176
  return stripAnchorRow(ref.trim(), "remove_from/remove_to entry", warnings);
177
177
  }) as [string, string];
package/src/insert.ts CHANGED
@@ -95,11 +95,8 @@ export async function insertPreview(request: unknown, cwd: string, signal?: Abor
95
95
  const normalized = normReq(request);
96
96
  const previewFixes: string[] = [];
97
97
  if (isRec(normalized)) {
98
- const expanded = decodeStringArray(normalized.lines);
99
- if (expanded) {
100
- previewFixes.push('[W_BAD_SHAPE] Unwrapped JSON array syntax from a lines element.');
101
- normalized.lines = expanded;
102
- }
98
+ const expanded = decodeStringArray(normalized.lines, previewFixes, "lines");
99
+ if (expanded) normalized.lines = expanded;
103
100
  }
104
101
  assertInsertReq(normalized);
105
102
  const previewReq = normalized as InsertReq;
@@ -178,11 +175,8 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
178
175
  const canonical = normReq(params);
179
176
  const insertWarnings: string[] = [];
180
177
  if (isRec(canonical)) {
181
- const expanded = decodeStringArray(canonical.lines);
182
- if (expanded) {
183
- insertWarnings.push('[W_BAD_SHAPE] Unwrapped JSON array syntax from a lines element.');
184
- canonical.lines = expanded;
185
- }
178
+ const expanded = decodeStringArray(canonical.lines, insertWarnings, "lines");
179
+ if (expanded) canonical.lines = expanded;
186
180
  }
187
181
  assertInsertReq(canonical);
188
182
  const req = canonical;
package/src/replace.ts CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  } from "./replace-diff";
12
12
  import { readNormFile, type NormFile } from "./file-reader";
13
13
  import { editToolSchema, buildEditToolSchema, type ReqParams, assertReq, normReq } from "./payload-contract";
14
- import { decodeStringArray, splitLines } from "./utils";
14
+ import { splitLines } from "./utils";
15
15
  import { loadP, loadGuide } from "./prompts";
16
16
  import { type FileIdentity } from "./fs-write";
17
17
  import { applyEdit,
@@ -118,17 +118,11 @@ function countLineChanges(
118
118
 
119
119
  export function buildReplaceHEdit(params: ReqParams): { edit: HEdit; warnings: string[] } {
120
120
  const editWarnings: string[] = [];
121
- let replacementLines = params.replacement_lines;
122
- const expandedReplacement = decodeStringArray(replacementLines);
123
- if (expandedReplacement) {
124
- editWarnings.push('[W_BAD_SHAPE] Unwrapped JSON array syntax from a replacement_lines element.');
125
- replacementLines = expandedReplacement;
126
- }
127
121
  const edit = resEdit(
128
122
  {
129
123
  remove_from: params.remove_from,
130
124
  remove_to: params.remove_to,
131
- replacement_lines: replacementLines,
125
+ replacement_lines: params.replacement_lines,
132
126
  },
133
127
  editWarnings,
134
128
  );
package/src/utils.ts CHANGED
@@ -181,83 +181,117 @@ function formatLineLimit(displayPath: string, limit: number, count: number | und
181
181
  return `[E_FILE_TOO_LARGE] ${displayPath} ${detail} lines, exceeding the ${limit}-line hashline limit. For very large files, use write.`;
182
182
  }
183
183
 
184
- function escapeRawControl(value: string): string {
185
- if (value === "\b") return "\\b";
186
- if (value === "\t") return "\\t";
187
- if (value === "\n") return "\\n";
188
- if (value === "\f") return "\\f";
189
- if (value === "\r") return "\\r";
190
- const hex = value.charCodeAt(0).toString(16).padStart(4, "0");
191
- return `\\u${hex}`;
184
+ function stripCodeFence(text: string): string {
185
+ const trimmed = text.trim();
186
+ const fenced = /^```[^\n]*\n([\s\S]*?)\n?```$/.exec(trimmed);
187
+ return fenced ? fenced[1]!.trim() : trimmed;
192
188
  }
193
189
 
194
- function escapeControls(value: string): string {
195
- let out = "";
196
- for (const char of value) {
197
- out += char.charCodeAt(0) < 32 ? escapeRawControl(char) : char;
190
+ function jsonStringArray(text: string): string[] | undefined {
191
+ try {
192
+ const parsed: unknown = JSON.parse(text);
193
+ if (Array.isArray(parsed) && parsed.every((entry) => typeof entry === "string")) {
194
+ return parsed as string[];
195
+ }
196
+ } catch {
198
197
  }
199
- return out;
198
+ return undefined;
200
199
  }
201
200
 
202
- function decodeArraySegment(segment: string): string | undefined {
203
- const trimmed = segment.trim();
204
- if (trimmed.length < 2 || !trimmed.startsWith('"') || !trimmed.endsWith('"')) return undefined;
205
- try {
206
- const cleaned = escapeControls(trimmed);
207
- const parsed: unknown = JSON.parse(cleaned);
208
- return typeof parsed === "string" ? parsed : undefined;
209
- } catch {
210
- return undefined;
201
+ function decodeEscape(char: string): string | undefined {
202
+ switch (char) {
203
+ case "\"": return "\"";
204
+ case "'": return "'";
205
+ case "\\": return "\\";
206
+ case "/": return "/";
207
+ case "b": return "\b";
208
+ case "f": return "\f";
209
+ case "n": return "\n";
210
+ case "r": return "\r";
211
+ case "t": return "\t";
212
+ default: return undefined;
211
213
  }
212
214
  }
213
215
 
214
- function splitArraySegments(inner: string): string[] | undefined {
215
- const segments: string[] = [];
216
- let current = "";
217
- let inQuotes = false;
218
- let escaped = false;
219
- for (const char of inner) {
220
- if (inQuotes) {
221
- current += char;
222
- if (escaped) escaped = false;
223
- else if (char === "\\") escaped = true;
224
- else if (char === '"') inQuotes = false;
225
- continue;
226
- }
227
- if (char === '"') {
228
- inQuotes = true;
229
- current += char;
230
- continue;
231
- }
232
- if (char === ",") {
233
- segments.push(current);
234
- current = "";
216
+ function scanQuotedSegment(inner: string, start: number): { value: string; next: number } | undefined {
217
+ const quote = inner[start]!;
218
+ let out = "";
219
+ let index = start + 1;
220
+ while (index < inner.length) {
221
+ const char = inner[index]!;
222
+ if (char === "\\") {
223
+ const escaped = inner[index + 1];
224
+ if (escaped === undefined) return undefined;
225
+ if (escaped === "u") {
226
+ const hex = inner.slice(index + 2, index + 6);
227
+ if (!/^[0-9a-fA-F]{4}$/.test(hex)) return undefined;
228
+ out += String.fromCharCode(Number.parseInt(hex, 16));
229
+ index += 6;
230
+ continue;
231
+ }
232
+ const decoded = decodeEscape(escaped);
233
+ if (decoded === undefined) return undefined;
234
+ out += decoded;
235
+ index += 2;
235
236
  continue;
236
237
  }
237
- current += char;
238
+ if (char === quote) return { value: out, next: index + 1 };
239
+ out += char;
240
+ index += 1;
241
+ }
242
+ return undefined;
243
+ }
244
+
245
+ function scanArrayText(inner: string): string[] | undefined {
246
+ const values: string[] = [];
247
+ let index = 0;
248
+ while (index < inner.length && /\s/.test(inner[index]!)) index += 1;
249
+ if (index >= inner.length) return [];
250
+ for (;;) {
251
+ if (inner[index] !== "\"" && inner[index] !== "'") return undefined;
252
+ const segment = scanQuotedSegment(inner, index);
253
+ if (!segment) return undefined;
254
+ values.push(segment.value);
255
+ index = segment.next;
256
+ while (index < inner.length && /\s/.test(inner[index]!)) index += 1;
257
+ if (index >= inner.length) return values;
258
+ if (inner[index] !== ",") return undefined;
259
+ index += 1;
260
+ while (index < inner.length && /\s/.test(inner[index]!)) index += 1;
261
+ if (index >= inner.length) return values;
238
262
  }
239
- if (inQuotes || escaped) return undefined;
240
- segments.push(current);
241
- return segments;
242
263
  }
243
264
 
244
265
  function decodeArrayText(value: unknown): string[] | undefined {
245
266
  if (typeof value !== "string") return undefined;
246
- const trimmed = value.trim();
267
+ const trimmed = stripCodeFence(value);
247
268
  if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return undefined;
248
- const segments = splitArraySegments(trimmed.slice(1, -1));
249
- if (!segments) return undefined;
250
- const decoded: string[] = [];
251
- for (const segment of segments) {
252
- const part = decodeArraySegment(segment);
253
- if (part === undefined) return undefined;
254
- decoded.push(part);
255
- }
256
- return decoded.length > 0 ? decoded : undefined;
269
+ const decoded = jsonStringArray(trimmed);
270
+ if (decoded !== undefined && decoded.length > 0) return decoded;
271
+ const scanned = scanArrayText(trimmed.slice(1, -1));
272
+ return scanned !== undefined && scanned.length > 0 ? scanned : undefined;
273
+ }
274
+
275
+ function looksLikeStringArray(value: unknown): boolean {
276
+ if (typeof value !== "string") return false;
277
+ const trimmed = stripCodeFence(value);
278
+ return trimmed.endsWith("]") && /^\[\s*['"]/.test(trimmed);
257
279
  }
258
280
 
259
- export function decodeStringArray(value: unknown): string[] | undefined {
260
- if (typeof value === "string") return decodeArrayText(value);
261
- if (Array.isArray(value) && value.length === 1) return decodeArrayText(value[0]);
281
+ export function decodeStringArray(value: unknown, warnings?: string[], label = "replacement_lines"): string[] | undefined {
282
+ const candidate = typeof value === "string"
283
+ ? value
284
+ : Array.isArray(value) && value.length === 1 && typeof value[0] === "string"
285
+ ? value[0]
286
+ : undefined;
287
+ if (candidate === undefined) return undefined;
288
+ const decoded = decodeArrayText(candidate);
289
+ if (decoded !== undefined) {
290
+ warnings?.push(`[W_BAD_SHAPE] Unwrapped JSON array syntax from a ${label} element.`);
291
+ return decoded;
292
+ }
293
+ if (looksLikeStringArray(candidate)) {
294
+ warnings?.push(`[W_BAD_SHAPE] ${label} looked like a JSON array but could not be parsed; kept as one literal line: ${clipLine(candidate, 60)}`);
295
+ }
262
296
  return undefined;
263
297
  }