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 +3 -2
- package/package.json +1 -1
- package/src/anchor-registry.ts +4 -4
- package/src/config.ts +3 -3
- package/src/hash-store.ts +75 -4
- package/src/hashline/resolve.ts +2 -2
- package/src/insert.ts +4 -10
- package/src/replace.ts +2 -8
- package/src/utils.ts +94 -60
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
|
|
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.
|
|
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",
|
package/src/anchor-registry.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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;
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
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
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
198
|
+
return undefined;
|
|
200
199
|
}
|
|
201
200
|
|
|
202
|
-
function
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
return
|
|
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
|
|
215
|
-
const
|
|
216
|
-
let
|
|
217
|
-
let
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
if (
|
|
221
|
-
|
|
222
|
-
if (escaped)
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
|
267
|
+
const trimmed = stripCodeFence(value);
|
|
247
268
|
if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return undefined;
|
|
248
|
-
const
|
|
249
|
-
if (
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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
|
}
|