pi-hashline-edit-pro 0.13.4 → 0.14.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/README.md +38 -5
- package/index.ts +47 -5
- package/package.json +1 -1
- package/prompts/replace-bulk-guidelines.md +4 -0
- package/prompts/replace-bulk-snippet.md +1 -0
- package/prompts/replace-bulk.md +130 -0
- package/prompts/replace-flat-guidelines.md +4 -0
- package/prompts/replace-flat-snippet.md +1 -0
- package/prompts/replace-flat.md +113 -0
- package/prompts/replace-guidelines.md +3 -2
- package/prompts/replace-snippet.md +1 -1
- package/prompts/replace.md +49 -4
- package/src/config.ts +97 -0
- package/src/prompts.ts +19 -13
- package/src/replace-flat.ts +313 -0
- package/src/replace-normalize.ts +18 -0
- package/src/replace.ts +256 -241
package/src/config.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { readFile, writeFile, mkdir } from "fs/promises";
|
|
5
|
+
|
|
6
|
+
export type ReplaceMode = "bulk" | "flat";
|
|
7
|
+
|
|
8
|
+
export interface Config {
|
|
9
|
+
replaceMode: ReplaceMode;
|
|
10
|
+
autoRead: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const DEFAULT_CONFIG: Config = {
|
|
14
|
+
replaceMode: "bulk",
|
|
15
|
+
autoRead: false,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Compute config path lazily so tests can override HOME before calling. */
|
|
19
|
+
function configPath(): string {
|
|
20
|
+
return join(homedir(), ".config", "pi-hashline-edit-pro", "config.json");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function configDir(): string {
|
|
24
|
+
return join(homedir(), ".config", "pi-hashline-edit-pro");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function readConfig(): Promise<Config> {
|
|
28
|
+
try {
|
|
29
|
+
const content = await readFile(configPath(), "utf-8");
|
|
30
|
+
const parsed = JSON.parse(content) as Partial<Config>;
|
|
31
|
+
return {
|
|
32
|
+
replaceMode: parsed.replaceMode === "flat" ? "flat" : "bulk",
|
|
33
|
+
autoRead: parsed.autoRead === true,
|
|
34
|
+
};
|
|
35
|
+
} catch {
|
|
36
|
+
return { ...DEFAULT_CONFIG };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function readConfigSync(): Config {
|
|
41
|
+
try {
|
|
42
|
+
const content = readFileSync(configPath(), "utf-8");
|
|
43
|
+
const parsed = JSON.parse(content) as Partial<Config>;
|
|
44
|
+
return {
|
|
45
|
+
replaceMode: parsed.replaceMode === "flat" ? "flat" : "bulk",
|
|
46
|
+
autoRead: parsed.autoRead === true,
|
|
47
|
+
};
|
|
48
|
+
} catch {
|
|
49
|
+
return { ...DEFAULT_CONFIG };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function writeConfig(config: Config): Promise<void> {
|
|
54
|
+
await mkdir(configDir(), { recursive: true });
|
|
55
|
+
await writeFile(configPath(), JSON.stringify(config, null, 2), "utf-8");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function readReplaceMode(): Promise<ReplaceMode> {
|
|
59
|
+
const config = await readConfig();
|
|
60
|
+
return config.replaceMode;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function writeReplaceMode(mode: ReplaceMode): Promise<void> {
|
|
64
|
+
const config = await readConfig();
|
|
65
|
+
config.replaceMode = mode;
|
|
66
|
+
await writeConfig(config);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function toggleReplaceMode(): Promise<ReplaceMode> {
|
|
70
|
+
const config = await readConfig();
|
|
71
|
+
config.replaceMode = config.replaceMode === "bulk" ? "flat" : "bulk";
|
|
72
|
+
await writeConfig(config);
|
|
73
|
+
return config.replaceMode;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function readAutoRead(): Promise<boolean> {
|
|
77
|
+
const config = await readConfig();
|
|
78
|
+
return config.autoRead;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function readAutoReadSync(): boolean {
|
|
82
|
+
const config = readConfigSync();
|
|
83
|
+
return config.autoRead;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function writeAutoRead(value: boolean): Promise<void> {
|
|
87
|
+
const config = await readConfig();
|
|
88
|
+
config.autoRead = value;
|
|
89
|
+
await writeConfig(config);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function toggleAutoRead(): Promise<boolean> {
|
|
93
|
+
const config = await readConfig();
|
|
94
|
+
config.autoRead = !config.autoRead;
|
|
95
|
+
await writeConfig(config);
|
|
96
|
+
return config.autoRead;
|
|
97
|
+
}
|
package/src/prompts.ts
CHANGED
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
import { readFileSync } from "fs";
|
|
2
2
|
|
|
3
3
|
export function loadP(relativePath: string, replacements?: Record<string, string>): string {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
4
|
+
let content = readFileSync(new URL(relativePath, import.meta.url), "utf-8").trim();
|
|
5
|
+
if (replacements) {
|
|
6
|
+
for (const [key, value] of Object.entries(replacements)) {
|
|
7
|
+
content = content.replaceAll(`{{${key}}}`, value);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
return content;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
export function loadGuide(relativePath: string): string[] {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
export function loadGuide(relativePath: string, replacements?: Record<string, string>): string[] {
|
|
14
|
+
let content = readFileSync(new URL(relativePath, import.meta.url), "utf-8");
|
|
15
|
+
if (replacements) {
|
|
16
|
+
for (const [key, value] of Object.entries(replacements)) {
|
|
17
|
+
content = content.replaceAll(`{{${key}}}`, value);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return content
|
|
21
|
+
.split("\n")
|
|
22
|
+
.map((line) => line.trim())
|
|
23
|
+
.filter((line) => line.startsWith("- "))
|
|
24
|
+
.map((line) => line.slice(2));
|
|
19
25
|
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { Markdown, Text } from "@earendil-works/pi-tui";
|
|
2
|
+
import type {
|
|
3
|
+
ExtensionAPI,
|
|
4
|
+
ToolDefinition,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { Type } from "typebox";
|
|
8
|
+
import { constants } from "fs";
|
|
9
|
+
import {
|
|
10
|
+
genDiff,
|
|
11
|
+
restoreEndings,
|
|
12
|
+
} from "./replace-diff";
|
|
13
|
+
import { readNormFile } from "./file-reader";
|
|
14
|
+
import { normReq } from "./replace-normalize";
|
|
15
|
+
import { isRec } from "./utils";
|
|
16
|
+
import { MAX_HASH_LINES } from "./constants";
|
|
17
|
+
import { resolveTarget, writeAtomic } from "./fs-write";
|
|
18
|
+
import {
|
|
19
|
+
applyEdits,
|
|
20
|
+
lineHashes,
|
|
21
|
+
resEdits,
|
|
22
|
+
type HTEdit,
|
|
23
|
+
} from "./hashline";
|
|
24
|
+
import { toCwd } from "./path-utils";
|
|
25
|
+
import { abortIf } from "./runtime";
|
|
26
|
+
import { fileSnap } from "./snapshot";
|
|
27
|
+
import {
|
|
28
|
+
buildChanged,
|
|
29
|
+
buildNoop,
|
|
30
|
+
type RMeta,
|
|
31
|
+
type RMetrics,
|
|
32
|
+
} from "./replace-response";
|
|
33
|
+
import {
|
|
34
|
+
buildAppliedText,
|
|
35
|
+
mkMdTheme,
|
|
36
|
+
fmtCall,
|
|
37
|
+
fmtResultMd,
|
|
38
|
+
getPreviewInput,
|
|
39
|
+
getResultText,
|
|
40
|
+
isApplied,
|
|
41
|
+
type RPreview,
|
|
42
|
+
type RRState,
|
|
43
|
+
} from "./replace-render";
|
|
44
|
+
import { loadP, loadGuide } from "./prompts";
|
|
45
|
+
import { execPipeline, compPreview, type ReplaceDetails } from "./replace";
|
|
46
|
+
import { readAutoReadSync } from "./config";
|
|
47
|
+
|
|
48
|
+
const contentLinesSchema = Type.Array(Type.String(), {
|
|
49
|
+
description:
|
|
50
|
+
"literal replacement file content, one string per line. Must not include the HASH│ prefix from read output.",
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const hashRangeInclSchema = Type.Array(
|
|
54
|
+
Type.String({ description: "anchor (3-char HASH)" }),
|
|
55
|
+
{
|
|
56
|
+
description: "inclusive hash range to replace [start_hash, end_hash]. Each element must be the 3-character hash anchor only; do not include the │ separator or line content.",
|
|
57
|
+
minItems: 2,
|
|
58
|
+
maxItems: 2,
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Flat-mode schema: hash_range_inclusive and content_lines are at the top
|
|
64
|
+
* level instead of inside a "changes" array. Only a single edit is supported
|
|
65
|
+
* per call (no bulk changes).
|
|
66
|
+
*/
|
|
67
|
+
export const flatEditToolSchema = Type.Object(
|
|
68
|
+
{
|
|
69
|
+
path: Type.String({ description: "path" }),
|
|
70
|
+
hash_range_inclusive: hashRangeInclSchema,
|
|
71
|
+
content_lines: contentLinesSchema,
|
|
72
|
+
},
|
|
73
|
+
{ additionalProperties: false },
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
type ToolDef = ToolDefinition<
|
|
77
|
+
typeof flatEditToolSchema,
|
|
78
|
+
ReplaceDetails,
|
|
79
|
+
RRState
|
|
80
|
+
> & { renderShell?: "default" | "self" };
|
|
81
|
+
|
|
82
|
+
function reuseText(context: any, content: string): Text {
|
|
83
|
+
const t = context.lastComponent instanceof Text
|
|
84
|
+
? context.lastComponent
|
|
85
|
+
: new Text("", 0, 0);
|
|
86
|
+
t.setText(content);
|
|
87
|
+
return t;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function reuseMarkdown(context: any, content: string, theme: any): Markdown {
|
|
91
|
+
const m = context.lastComponent instanceof Markdown
|
|
92
|
+
? context.lastComponent
|
|
93
|
+
: new Markdown("", 0, 0, mkMdTheme(theme));
|
|
94
|
+
m.setText(content);
|
|
95
|
+
return m;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function buildToolDef(): ToolDef {
|
|
99
|
+
const autoRead = readAutoReadSync();
|
|
100
|
+
const readGuidance = autoRead
|
|
101
|
+
? "Anchors are provided automatically after write operations when auto-read is enabled."
|
|
102
|
+
: "Call `read` to get fresh anchors for follow-up edits.";
|
|
103
|
+
|
|
104
|
+
const E_DESC = loadP("../prompts/replace-flat.md", {
|
|
105
|
+
AUTO_READ_GUIDANCE: readGuidance,
|
|
106
|
+
});
|
|
107
|
+
const E_SNIPPET = loadP("../prompts/replace-flat-snippet.md");
|
|
108
|
+
const E_GUIDE = loadGuide("../prompts/replace-flat-guidelines.md", {
|
|
109
|
+
AUTO_READ_GUIDANCE: readGuidance,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
name: "replace",
|
|
114
|
+
label: "Replace",
|
|
115
|
+
description: E_DESC,
|
|
116
|
+
parameters: flatEditToolSchema,
|
|
117
|
+
promptSnippet: E_SNIPPET,
|
|
118
|
+
promptGuidelines: E_GUIDE,
|
|
119
|
+
prepareArguments: (args: unknown) => {
|
|
120
|
+
// Minimal normalization: file_path → path, JSON string parsing.
|
|
121
|
+
// The flat-to-canonical conversion happens in execute().
|
|
122
|
+
if (!isRec(args)) return args as any;
|
|
123
|
+
const record = { ...args };
|
|
124
|
+
if (typeof record.path !== "string" && typeof record.file_path === "string") {
|
|
125
|
+
record.path = record.file_path;
|
|
126
|
+
delete record.file_path;
|
|
127
|
+
}
|
|
128
|
+
if (typeof record.hash_range_inclusive === "string") {
|
|
129
|
+
try { record.hash_range_inclusive = JSON.parse(record.hash_range_inclusive as string); } catch { /* keep as-is */ }
|
|
130
|
+
}
|
|
131
|
+
if (typeof record.content_lines === "string") {
|
|
132
|
+
try { record.content_lines = JSON.parse(record.content_lines as string); } catch { /* keep as-is */ }
|
|
133
|
+
}
|
|
134
|
+
return record as any;
|
|
135
|
+
},
|
|
136
|
+
renderShell: "default",
|
|
137
|
+
renderCall(args, theme, context) {
|
|
138
|
+
const previewInput = getPreviewInput(args);
|
|
139
|
+
if (context.executionStarted) {
|
|
140
|
+
context.state.argsKey = undefined;
|
|
141
|
+
context.state.preview = undefined;
|
|
142
|
+
context.state.previewGeneration =
|
|
143
|
+
(context.state.previewGeneration ?? 0) + 1;
|
|
144
|
+
} else if (!context.argsComplete || !previewInput) {
|
|
145
|
+
context.state.argsKey = undefined;
|
|
146
|
+
context.state.preview = undefined;
|
|
147
|
+
context.state.previewGeneration =
|
|
148
|
+
(context.state.previewGeneration ?? 0) + 1;
|
|
149
|
+
} else {
|
|
150
|
+
const argsKey = JSON.stringify(previewInput);
|
|
151
|
+
if (context.state.argsKey !== argsKey) {
|
|
152
|
+
context.state.argsKey = argsKey;
|
|
153
|
+
context.state.preview = undefined;
|
|
154
|
+
const previewGeneration = (context.state.previewGeneration ?? 0) + 1;
|
|
155
|
+
context.state.previewGeneration = previewGeneration;
|
|
156
|
+
compPreview(previewInput, context.cwd)
|
|
157
|
+
.then((preview) => {
|
|
158
|
+
if (
|
|
159
|
+
context.state.argsKey === argsKey &&
|
|
160
|
+
context.state.previewGeneration === previewGeneration
|
|
161
|
+
) {
|
|
162
|
+
context.state.preview = preview;
|
|
163
|
+
context.invalidate();
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
.catch((err: unknown) => {
|
|
167
|
+
if (
|
|
168
|
+
context.state.argsKey === argsKey &&
|
|
169
|
+
context.state.previewGeneration === previewGeneration
|
|
170
|
+
) {
|
|
171
|
+
context.state.preview = {
|
|
172
|
+
error: err instanceof Error ? err.message : String(err),
|
|
173
|
+
};
|
|
174
|
+
context.invalidate();
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const text =
|
|
180
|
+
(context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
181
|
+
text.setText(
|
|
182
|
+
fmtCall(
|
|
183
|
+
getPreviewInput(args) ?? undefined,
|
|
184
|
+
context.state as RRState,
|
|
185
|
+
context.expanded,
|
|
186
|
+
theme,
|
|
187
|
+
),
|
|
188
|
+
);
|
|
189
|
+
return text;
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
renderResult(result, { isPartial }, theme, context) {
|
|
193
|
+
if (isPartial) {
|
|
194
|
+
return reuseText(context, theme.fg("warning", "Editing..."));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const typedResult = result as {
|
|
198
|
+
content?: Array<{ type: string; text?: string }>;
|
|
199
|
+
details?: ReplaceDetails;
|
|
200
|
+
};
|
|
201
|
+
const renderedText = getResultText(typedResult);
|
|
202
|
+
|
|
203
|
+
const renderState = context.state as RRState | undefined;
|
|
204
|
+
if (renderState) {
|
|
205
|
+
renderState.preview = undefined;
|
|
206
|
+
renderState.previewGeneration = (renderState.previewGeneration ?? 0) + 1;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (context.isError) {
|
|
210
|
+
return renderedText
|
|
211
|
+
? reuseText(context, `\n${theme.fg("error", renderedText)}`)
|
|
212
|
+
: new Text("", 0, 0);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (isApplied(typedResult.details)) {
|
|
216
|
+
const appliedText = buildAppliedText(renderedText, typedResult.details, theme);
|
|
217
|
+
return appliedText ? reuseText(context, appliedText) : new Text("", 0, 0);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (!renderedText) return new Text("", 0, 0);
|
|
221
|
+
return reuseMarkdown(context, fmtResultMd(renderedText), theme);
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
225
|
+
// Wrap flat params into canonical shape for the pipeline
|
|
226
|
+
const canonical = normReq({
|
|
227
|
+
path: params.path,
|
|
228
|
+
changes: [{
|
|
229
|
+
hash_range_inclusive: params.hash_range_inclusive,
|
|
230
|
+
content_lines: params.content_lines,
|
|
231
|
+
}],
|
|
232
|
+
});
|
|
233
|
+
const normalizedParams = canonical as { path: string; changes: HTEdit[] };
|
|
234
|
+
const path = normalizedParams.path;
|
|
235
|
+
const absolutePath = toCwd(path, ctx.cwd);
|
|
236
|
+
const mutationTargetPath = await resolveTarget(absolutePath);
|
|
237
|
+
return withFileMutationQueue(mutationTargetPath, async () => {
|
|
238
|
+
abortIf(signal);
|
|
239
|
+
|
|
240
|
+
const {
|
|
241
|
+
originalNormalized,
|
|
242
|
+
result,
|
|
243
|
+
bom,
|
|
244
|
+
originalEnding,
|
|
245
|
+
hadUtf8DecodeErrors,
|
|
246
|
+
warnings,
|
|
247
|
+
noopEdits,
|
|
248
|
+
firstChangedLine,
|
|
249
|
+
lastChangedLine,
|
|
250
|
+
resultHashes,
|
|
251
|
+
} = await execPipeline(
|
|
252
|
+
normalizedParams,
|
|
253
|
+
ctx.cwd,
|
|
254
|
+
constants.R_OK | constants.W_OK,
|
|
255
|
+
signal,
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
const editsAttempted = 1; // flat mode: exactly one edit per call
|
|
259
|
+
|
|
260
|
+
if (originalNormalized === result) {
|
|
261
|
+
const noopSnapshotId = (await fileSnap(absolutePath)).snapshotId;
|
|
262
|
+
return buildNoop({
|
|
263
|
+
path,
|
|
264
|
+
noopEdits,
|
|
265
|
+
snapshotId: noopSnapshotId,
|
|
266
|
+
editMeta: {
|
|
267
|
+
editsAttempted,
|
|
268
|
+
noopEditsCount: noopEdits?.length ?? 0,
|
|
269
|
+
},
|
|
270
|
+
warnings,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (hadUtf8DecodeErrors) {
|
|
275
|
+
warnings.push(
|
|
276
|
+
"Non-UTF-8 bytes were shown as U+FFFD; this edit rewrote the file as UTF-8.",
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
abortIf(signal);
|
|
281
|
+
await writeAtomic(
|
|
282
|
+
absolutePath,
|
|
283
|
+
bom + restoreEndings(result, originalEnding),
|
|
284
|
+
);
|
|
285
|
+
const updatedSnapshotId = (await fileSnap(absolutePath))
|
|
286
|
+
.snapshotId;
|
|
287
|
+
|
|
288
|
+
const editMeta: RMeta = {
|
|
289
|
+
editsAttempted,
|
|
290
|
+
noopEditsCount: noopEdits?.length ?? 0,
|
|
291
|
+
firstChangedLine,
|
|
292
|
+
lastChangedLine,
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const successInput = {
|
|
296
|
+
path,
|
|
297
|
+
originalNormalized,
|
|
298
|
+
result,
|
|
299
|
+
resultHashes,
|
|
300
|
+
warnings,
|
|
301
|
+
snapshotId: updatedSnapshotId,
|
|
302
|
+
editMeta,
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
return buildChanged(successInput);
|
|
306
|
+
});
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function regReplaceFlat(pi: ExtensionAPI): void {
|
|
312
|
+
pi.registerTool(buildToolDef());
|
|
313
|
+
}
|
package/src/replace-normalize.ts
CHANGED
|
@@ -68,6 +68,24 @@ export function normReq(input: unknown): unknown {
|
|
|
68
68
|
normalizeField(record, "changes", "changes");
|
|
69
69
|
normalizeField(record, "edits", "changes");
|
|
70
70
|
|
|
71
|
+
// Handle flat format: hash_range_inclusive and content_lines at top level
|
|
72
|
+
// (no changes array). Wrap them into a single-element changes array.
|
|
73
|
+
if (!Array.isArray(record.changes) && has(record, "hash_range_inclusive") && has(record, "content_lines")) {
|
|
74
|
+
const hri = tryParseJSON(record.hash_range_inclusive, (v): v is string[] =>
|
|
75
|
+
Array.isArray(v) && v.length === 2 && v.every((i) => typeof i === "string")
|
|
76
|
+
) ?? record.hash_range_inclusive;
|
|
77
|
+
|
|
78
|
+
const cl = tryParseJSON(record.content_lines, (v): v is string[] =>
|
|
79
|
+
Array.isArray(v) && v.every((i) => typeof i === "string")
|
|
80
|
+
) ?? record.content_lines;
|
|
81
|
+
|
|
82
|
+
if (Array.isArray(hri) && Array.isArray(cl)) {
|
|
83
|
+
record.changes = [{ hash_range_inclusive: hri, content_lines: cl }];
|
|
84
|
+
delete record.hash_range_inclusive;
|
|
85
|
+
delete record.content_lines;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
71
89
|
return record;
|
|
72
90
|
}
|
|
73
91
|
|