pi-hashline-edit-pro 4.2.11 → 4.3.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 +19 -11
- package/index.ts +29 -8
- package/package.json +1 -1
- package/src/anchor-registry.ts +21 -31
- package/src/auto-read-all.ts +305 -0
- package/src/batch.ts +3 -3
- package/src/commit.ts +10 -19
- package/src/config-ui.ts +2 -1
- package/src/config.ts +52 -29
- package/src/constants.ts +6 -0
- package/src/fs-write.ts +1 -1
- package/src/grep.ts +12 -18
- package/src/hash-store/cache.ts +0 -18
- package/src/hash-store/retry.ts +0 -4
- package/src/hash-store/validation.ts +0 -31
- package/src/hash-store.ts +22 -32
- package/src/hashline/apply.ts +2 -2
- package/src/hashline/hash.ts +7 -8
- package/src/hashline/index.ts +1 -0
- package/src/hashline/parse.ts +3 -16
- package/src/hashline/resolve.ts +19 -39
- package/src/payload-contract.ts +3 -11
- package/src/read.ts +3 -3
- package/src/replace-diff.ts +107 -108
- package/src/replace-response.ts +37 -3
- package/src/replace-undo.ts +9 -9
- package/src/replace.ts +11 -5
- package/src/served.ts +7 -3
- package/src/utils.ts +9 -8
- package/src/write-hook.ts +0 -4
package/src/batch.ts
CHANGED
|
@@ -17,12 +17,12 @@ import {
|
|
|
17
17
|
type PlannedEdit,
|
|
18
18
|
} from "./hashline";
|
|
19
19
|
import { boundaryDedupNoopWarning, boundaryDedupWarning, dedupRowsFromFixes } from "./commit";
|
|
20
|
-
import { adoptAnchors,
|
|
20
|
+
import { adoptAnchors, servedForPath } from "./anchor-registry";
|
|
21
21
|
import { restoreEndings, stripBOM, toLF, type LineEnding } from "./normalize";
|
|
22
22
|
import { assertInsertReq, assertReq, normReq } from "./payload-contract";
|
|
23
23
|
import { saveUndo } from "./replace-undo";
|
|
24
24
|
import { buildChanged, buildNoop, type RMetrics, type TResult } from "./replace-response";
|
|
25
|
-
import {
|
|
25
|
+
import { serveRows, servedHashesFromDiff } from "./served";
|
|
26
26
|
import { abortIf, assertLineLimit, errCode, isRec, splitLines } from "./utils";
|
|
27
27
|
import { MAX_BYTES } from "./constants";
|
|
28
28
|
|
|
@@ -750,7 +750,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
750
750
|
changed.details.diff = `${header}\n${changed.details.diff}`;
|
|
751
751
|
changed.details.diffLineNumbers?.unshift(undefined);
|
|
752
752
|
try {
|
|
753
|
-
|
|
753
|
+
serveRows(runtime.target, resultHashes, splitLines(composed), servedHashesFromDiff(changed.details.diff));
|
|
754
754
|
} catch (error) {
|
|
755
755
|
console.error("Failed to mark batch diff served:", error);
|
|
756
756
|
}
|
package/src/commit.ts
CHANGED
|
@@ -8,11 +8,10 @@ import { saveUndo } from "./replace-undo";
|
|
|
8
8
|
import { getDiffContextLines } from "./config";
|
|
9
9
|
import { safeSnapId } from "./file-reader";
|
|
10
10
|
import { writeAtomic } from "./fs-write";
|
|
11
|
-
import { servedHashesFromDiff,
|
|
11
|
+
import { servedHashesFromDiff, serveRows } from "./served";
|
|
12
12
|
import { lineHashes, type AutoFix } from "./hashline";
|
|
13
|
-
import {
|
|
13
|
+
import { spanForEdit } from "./replace";
|
|
14
14
|
import { restoreEndings, stripBOM, toLF } from "./normalize";
|
|
15
|
-
import { markServed as markServedScoped } from "./anchor-registry";
|
|
16
15
|
export interface CommitMeta {
|
|
17
16
|
editAnchors?: [string, string];
|
|
18
17
|
path: string;
|
|
@@ -131,18 +130,14 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
131
130
|
removedLines: pipe.totalRemovedLines,
|
|
132
131
|
};
|
|
133
132
|
|
|
134
|
-
const span = meta.editAnchors ?
|
|
135
|
-
const resultCount = splitLines(pipe.result).length;
|
|
136
|
-
const replacementCount = span ? resultCount - (pipe.originalHashes.length - (span[1] - span[0] + 1)) : 0;
|
|
133
|
+
const span = meta.editAnchors ? spanForEdit(pipe.originalHashes, meta.editAnchors[0], meta.editAnchors[1], pipe.result) : undefined;
|
|
137
134
|
let resultHashes: string[];
|
|
138
135
|
try {
|
|
139
|
-
resultHashes = pipe.result
|
|
140
|
-
|
|
141
|
-
:
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
spans: span ? [{ start: span[0], end: span[1], replacementCount }] : undefined,
|
|
145
|
-
});
|
|
136
|
+
resultHashes = await lineHashes(pipe.result, mutationTargetPath, {
|
|
137
|
+
content: pipe.originalNormalized,
|
|
138
|
+
hashes: pipe.originalHashes,
|
|
139
|
+
spans: span ? [span] : undefined,
|
|
140
|
+
});
|
|
146
141
|
} catch (error) {
|
|
147
142
|
const detail = error instanceof Error ? error.message : String(error);
|
|
148
143
|
throw new Error(`${detail} File was written; anchor finalization failed. One undo reverts. Call read for fresh anchors.`);
|
|
@@ -158,15 +153,11 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
158
153
|
editMeta,
|
|
159
154
|
boundaryDedupAbove: pipe.boundaryDedupAbove,
|
|
160
155
|
boundaryDedupBelow: pipe.boundaryDedupBelow,
|
|
161
|
-
...(span ? { spans: [
|
|
156
|
+
...(span ? { spans: [span] } : {}),
|
|
162
157
|
};
|
|
163
158
|
const changed = buildChanged(successInput, meta.verb, await getDiffContextLines());
|
|
164
159
|
if (changed.details.diff) {
|
|
165
|
-
|
|
166
|
-
mutationTargetPath,
|
|
167
|
-
buildServedMap(resultHashes, splitLines(pipe.result), servedHashesFromDiff(changed.details.diff)),
|
|
168
|
-
new Set(resultHashes),
|
|
169
|
-
);
|
|
160
|
+
serveRows(mutationTargetPath, resultHashes, splitLines(pipe.result), servedHashesFromDiff(changed.details.diff));
|
|
170
161
|
}
|
|
171
162
|
return changed;
|
|
172
163
|
}
|
package/src/config-ui.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Key, matchesKey, visibleWidth } from "@earendil-works/pi-tui";
|
|
|
2
2
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { readConfig, type Config } from "./config";
|
|
4
4
|
|
|
5
|
-
export type ConfigToggleKey = "autoRead" | "anchorGrepEnabled" | "requirePath" | "strictInput" | "boundaryDedupMode" | "diffContextLines";
|
|
5
|
+
export type ConfigToggleKey = "autoRead" | "autoReadAll" | "anchorGrepEnabled" | "requirePath" | "strictInput" | "boundaryDedupMode" | "diffContextLines";
|
|
6
6
|
|
|
7
7
|
export interface ConfigRow {
|
|
8
8
|
key: ConfigToggleKey;
|
|
@@ -18,6 +18,7 @@ export interface ConfigRow {
|
|
|
18
18
|
export function configRows(config: Config): ConfigRow[] {
|
|
19
19
|
return [
|
|
20
20
|
{ key: "autoRead", label: "Auto-read", hint: "Anchors after write + post-edit diffs", enabled: config.autoRead !== false },
|
|
21
|
+
{ key: "autoReadAll", label: "Auto-read all", hint: "Attach every non-ignored file with anchors on the first turn", enabled: config.autoReadAll === true },
|
|
21
22
|
{ key: "diffContextLines", label: "Diff context", hint: "Surrounding lines in post-edit diffs (needs Auto-read)", enabled: config.autoRead !== false, value: config.diffContextLines ?? 1, disabled: config.autoRead === false },
|
|
22
23
|
{ key: "anchorGrepEnabled", label: "Anchor grep", hint: "anchor_grep tool (builtin grep off while on)", enabled: config.anchorGrepEnabled === true },
|
|
23
24
|
{ key: "requirePath", label: "Require path", hint: "replace + insert need path (RPC visibility)", enabled: config.requirePath === true },
|
package/src/config.ts
CHANGED
|
@@ -12,6 +12,7 @@ export const MAX_DIFF_CONTEXT_LINES = 10;
|
|
|
12
12
|
export interface Config {
|
|
13
13
|
autoRead: boolean;
|
|
14
14
|
anchorGrepEnabled: boolean;
|
|
15
|
+
autoReadAll?: boolean;
|
|
15
16
|
requirePath?: boolean;
|
|
16
17
|
strictInput?: boolean;
|
|
17
18
|
boundaryDedupMode?: BoundaryDedupMode;
|
|
@@ -21,6 +22,7 @@ export interface Config {
|
|
|
21
22
|
const DEFAULT_CONFIG: Config = {
|
|
22
23
|
autoRead: true,
|
|
23
24
|
anchorGrepEnabled: true,
|
|
25
|
+
autoReadAll: false,
|
|
24
26
|
requirePath: false,
|
|
25
27
|
strictInput: false,
|
|
26
28
|
boundaryDedupMode: "on",
|
|
@@ -50,15 +52,17 @@ function parseConfig(content: string): Config {
|
|
|
50
52
|
throw new Error("config.json must be an object with a boolean autoRead field");
|
|
51
53
|
}
|
|
52
54
|
const autoRead = parsed.autoRead;
|
|
53
|
-
const anchorGrepEnabled =
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
const
|
|
55
|
+
const anchorGrepEnabled = parsed.anchorGrepEnabled;
|
|
56
|
+
const autoReadAll = parsed.autoReadAll;
|
|
57
|
+
const requirePath = parsed.requirePath;
|
|
58
|
+
const strictInput = parsed.strictInput;
|
|
59
|
+
const boundaryDedupMode = parsed.boundaryDedupMode;
|
|
60
|
+
const legacyBoundaryDedup = parsed.boundaryDedupEnabled;
|
|
61
|
+
const diffContextLines = parsed.diffContextLines;
|
|
59
62
|
return {
|
|
60
63
|
autoRead: typeof autoRead === "boolean" ? autoRead : DEFAULT_CONFIG.autoRead,
|
|
61
64
|
anchorGrepEnabled: typeof anchorGrepEnabled === "boolean" ? anchorGrepEnabled : DEFAULT_CONFIG.anchorGrepEnabled,
|
|
65
|
+
autoReadAll: typeof autoReadAll === "boolean" ? autoReadAll : DEFAULT_CONFIG.autoReadAll,
|
|
62
66
|
requirePath: typeof requirePath === "boolean" ? requirePath : DEFAULT_CONFIG.requirePath,
|
|
63
67
|
strictInput: typeof strictInput === "boolean" ? strictInput : DEFAULT_CONFIG.strictInput,
|
|
64
68
|
boundaryDedupMode: parseBoundaryDedupMode(boundaryDedupMode, legacyBoundaryDedup),
|
|
@@ -92,17 +96,23 @@ export async function readConfig(): Promise<Config> {
|
|
|
92
96
|
export async function readConfigWithStatus(): Promise<{ config: Config; corrupted: boolean }> {
|
|
93
97
|
return loadConfigFile();
|
|
94
98
|
}
|
|
95
|
-
const CONFIG_LOCK_RETRIES = 80;
|
|
96
99
|
const CONFIG_LOCK_DELAY_MS = 25;
|
|
97
100
|
const CONFIG_LOCK_STALE_MS = 5000;
|
|
98
|
-
|
|
101
|
+
const CONFIG_LOCK_RETRIES = Math.ceil(CONFIG_LOCK_STALE_MS / CONFIG_LOCK_DELAY_MS) * 2;
|
|
102
|
+
|
|
103
|
+
interface ConfigLock {
|
|
104
|
+
path: string;
|
|
105
|
+
dev: number;
|
|
106
|
+
ino: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function acquireConfigLock(lockPath: string): Promise<ConfigLock> {
|
|
99
110
|
try {
|
|
100
111
|
await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
101
112
|
} catch { }
|
|
102
113
|
for (let attempt = 0; attempt < CONFIG_LOCK_RETRIES; attempt++) {
|
|
103
114
|
try {
|
|
104
115
|
await mkdir(lockPath, { mode: 0o700 });
|
|
105
|
-
return;
|
|
106
116
|
} catch (error) {
|
|
107
117
|
if (errCode(error) === "ENOENT") {
|
|
108
118
|
try {
|
|
@@ -119,26 +129,44 @@ async function acquireConfigLock(lockPath: string): Promise<void> {
|
|
|
119
129
|
}
|
|
120
130
|
} catch { }
|
|
121
131
|
await new Promise<void>((r) => setTimeout(r, CONFIG_LOCK_DELAY_MS));
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
const st = await stat(lockPath);
|
|
136
|
+
return { path: lockPath, dev: st.dev, ino: st.ino };
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (errCode(error) !== "ENOENT") {
|
|
139
|
+
try {
|
|
140
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
141
|
+
} catch { }
|
|
142
|
+
}
|
|
143
|
+
continue;
|
|
122
144
|
}
|
|
123
145
|
}
|
|
124
146
|
throw new Error(`[E_ACCESS] Could not acquire config lock: ${lockPath}`);
|
|
125
147
|
}
|
|
126
|
-
async function releaseConfigLock(
|
|
148
|
+
async function releaseConfigLock(lock: ConfigLock): Promise<void> {
|
|
149
|
+
try {
|
|
150
|
+
const st = await stat(lock.path);
|
|
151
|
+
if (st.dev !== lock.dev || st.ino !== lock.ino) return;
|
|
152
|
+
} catch {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
127
155
|
try {
|
|
128
|
-
await rm(
|
|
156
|
+
await rm(lock.path, { recursive: true, force: true });
|
|
129
157
|
} catch { }
|
|
130
158
|
}
|
|
131
159
|
export async function updateConfig(mut: (config: Config) => void): Promise<Config> {
|
|
132
160
|
const cfgPath = configPath();
|
|
133
161
|
const lockPath = `${cfgPath}.lock`;
|
|
134
|
-
await acquireConfigLock(lockPath);
|
|
162
|
+
const lock = await acquireConfigLock(lockPath);
|
|
135
163
|
try {
|
|
136
164
|
const config = await readConfig();
|
|
137
165
|
mut(config);
|
|
138
166
|
await writeConfig(config);
|
|
139
167
|
return config;
|
|
140
168
|
} finally {
|
|
141
|
-
await releaseConfigLock(
|
|
169
|
+
await releaseConfigLock(lock);
|
|
142
170
|
}
|
|
143
171
|
}
|
|
144
172
|
export async function writeConfig(config: Config): Promise<void> {
|
|
@@ -146,22 +174,17 @@ export async function writeConfig(config: Config): Promise<void> {
|
|
|
146
174
|
}
|
|
147
175
|
|
|
148
176
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
export
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
export async function toggleStrictInput(): Promise<boolean> {
|
|
162
|
-
const config = await updateConfig((c) => { c.strictInput = !(c.strictInput === true); });
|
|
163
|
-
return config.strictInput === true;
|
|
164
|
-
}
|
|
177
|
+
type ToggleKey = "autoRead" | "anchorGrepEnabled" | "autoReadAll" | "requirePath" | "strictInput";
|
|
178
|
+
|
|
179
|
+
async function toggleFlag(key: ToggleKey): Promise<boolean> {
|
|
180
|
+
const config = await updateConfig((c) => { c[key] = !(c[key] === true); });
|
|
181
|
+
return config[key] === true;
|
|
182
|
+
}
|
|
183
|
+
export const toggleAutoRead = (): Promise<boolean> => toggleFlag("autoRead");
|
|
184
|
+
export const toggleAnchorGrep = (): Promise<boolean> => toggleFlag("anchorGrepEnabled");
|
|
185
|
+
export const toggleAutoReadAll = (): Promise<boolean> => toggleFlag("autoReadAll");
|
|
186
|
+
export const toggleRequirePath = (): Promise<boolean> => toggleFlag("requirePath");
|
|
187
|
+
export const toggleStrictInput = (): Promise<boolean> => toggleFlag("strictInput");
|
|
165
188
|
export async function cycleBoundaryDedupMode(): Promise<BoundaryDedupMode> {
|
|
166
189
|
let next: BoundaryDedupMode = "on";
|
|
167
190
|
await updateConfig((c) => {
|
package/src/constants.ts
CHANGED
|
@@ -20,3 +20,9 @@ export const ANCHOR_POOL_EXHAUSTED_PREFIX =
|
|
|
20
20
|
"[E_FILE_TOO_LARGE] The session's anchor pool is exhausted";
|
|
21
21
|
|
|
22
22
|
export const DEDUP_ANCHOR = "dedup";
|
|
23
|
+
|
|
24
|
+
export const AUTO_READ_ALL_CUSTOM_TYPE = "hashline-auto-read-all";
|
|
25
|
+
export const AUTO_READ_ALL_MAX_FILES = 500;
|
|
26
|
+
export const AUTO_READ_ALL_MAX_FILE_BYTES = 200_000;
|
|
27
|
+
export const AUTO_READ_ALL_MIN_BUDGET_BYTES = 200_000;
|
|
28
|
+
export const AUTO_READ_ALL_MAX_BUDGET_BYTES = 2_000_000;
|
package/src/fs-write.ts
CHANGED
|
@@ -21,7 +21,7 @@ export interface FileIdentity {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
function sameIdentity(
|
|
24
|
-
actual:
|
|
24
|
+
actual: FileIdentity,
|
|
25
25
|
expected: FileIdentity,
|
|
26
26
|
): boolean {
|
|
27
27
|
return actual.dev === expected.dev && actual.ino === expected.ino;
|
package/src/grep.ts
CHANGED
|
@@ -12,8 +12,8 @@ import { toCwd } from "./paths";
|
|
|
12
12
|
import { loadP, loadGuide } from "./prompts";
|
|
13
13
|
import { normReq } from "./payload-contract";
|
|
14
14
|
import { abortIf, errCode, isRec, makePrepareArguments, rejectUnknownFields, truncateToBytes, visLines } from "./utils";
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
15
|
+
import { withAnchorSession } from "./anchor-registry";
|
|
16
|
+
import { serveRows } from "./served";
|
|
17
17
|
import { Text } from "@earendil-works/pi-tui";
|
|
18
18
|
import { expandHint, getResultText, reuseText, type CallT, type FgT } from "./replace-render";
|
|
19
19
|
const GREP_KS = new Set(["pattern", "path", "glob", "context", "ignoreCase", "literal", "limit"]);
|
|
@@ -281,11 +281,8 @@ function makeHitFromIndices(
|
|
|
281
281
|
}
|
|
282
282
|
|
|
283
283
|
let cachedRgPath: string | undefined;
|
|
284
|
-
export function clearRgPathCache(): void {
|
|
285
|
-
cachedRgPath = undefined;
|
|
286
|
-
}
|
|
287
284
|
|
|
288
|
-
async function resolveRgPath(): Promise<string> {
|
|
285
|
+
export async function resolveRgPath(): Promise<string> {
|
|
289
286
|
if (cachedRgPath !== undefined) return cachedRgPath;
|
|
290
287
|
try {
|
|
291
288
|
const r = spawnSync("rg", ["--version"], { stdio: "pipe" });
|
|
@@ -572,6 +569,12 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
572
569
|
}
|
|
573
570
|
const globRoot = baseStat.isFile() ? dirname(base) : base;
|
|
574
571
|
const globRegex = req.glob === undefined ? undefined : globToRegex(req.glob);
|
|
572
|
+
const matchesGlob = (absPath: string): boolean => {
|
|
573
|
+
if (globRegex === undefined) return true;
|
|
574
|
+
const displayPath = relative(ctx.cwd, absPath).replace(/\\/g, "/");
|
|
575
|
+
const globPath = relative(globRoot, absPath).replace(/\\/g, "/");
|
|
576
|
+
return globRegex.test(globPath) || globRegex.test(displayPath);
|
|
577
|
+
};
|
|
575
578
|
const validatedRegex = buildRegex(req.pattern, req.literal === true, req.ignoreCase === true);
|
|
576
579
|
const rgPath = await resolveRgPath();
|
|
577
580
|
const hits: FileHit[] = [];
|
|
@@ -607,11 +610,7 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
607
610
|
const sortedNums = [...allNums].sort((a, b) => a - b);
|
|
608
611
|
const indices = sortedNums.map((n) => n - 1).filter((n) => n >= 0);
|
|
609
612
|
if (countOnly) {
|
|
610
|
-
if (
|
|
611
|
-
const displayPath = relative(ctx.cwd, absPath).replace(/\\/g, "/");
|
|
612
|
-
const globPath = relative(globRoot, absPath).replace(/\\/g, "/");
|
|
613
|
-
if (!globRegex.test(globPath) && !globRegex.test(displayPath)) continue;
|
|
614
|
-
}
|
|
613
|
+
if (!matchesGlob(absPath)) continue;
|
|
615
614
|
const norm = await readGrepFileShadow(absPath);
|
|
616
615
|
if (!norm) continue;
|
|
617
616
|
const hit = makeHitFromIndices(norm, relative(ctx.cwd, absPath).replace(/\\/g, "/"), indices, context, validatedRegex, totalForFile, indices.length);
|
|
@@ -633,15 +632,10 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
633
632
|
limitTruncated = true;
|
|
634
633
|
break;
|
|
635
634
|
}
|
|
636
|
-
if (
|
|
637
|
-
const displayPath = relative(ctx.cwd, absPath).replace(/\\/g, "/");
|
|
638
|
-
const globPath = relative(globRoot, absPath).replace(/\\/g, "/");
|
|
639
|
-
if (!globRegex.test(globPath) && !globRegex.test(displayPath)) continue;
|
|
640
|
-
}
|
|
635
|
+
if (!matchesGlob(absPath)) continue;
|
|
641
636
|
const norm = await readGrepFile(absPath);
|
|
642
637
|
if (!norm) continue;
|
|
643
638
|
const hit = makeHitFromIndices(norm, relative(ctx.cwd, absPath).replace(/\\/g, "/"), indices, context, validatedRegex, totalForFile, Math.min(totalForFile, remaining));
|
|
644
|
-
if (!hit) continue;
|
|
645
639
|
const display = displayRowsForHit(hit);
|
|
646
640
|
const keptRows: string[] = [];
|
|
647
641
|
const keptHashes: string[] = [];
|
|
@@ -677,7 +671,7 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
677
671
|
}
|
|
678
672
|
hits.sort((a, b) => cmp(a.displayPath, b.displayPath));
|
|
679
673
|
for (const hit of hits) {
|
|
680
|
-
|
|
674
|
+
serveRows(hit.path, hit.fileHashes, hit.fileLines, hit.hashes);
|
|
681
675
|
}
|
|
682
676
|
const blocks = hits
|
|
683
677
|
.map((hit) => `=== ${hit.displayPath} ===\n${hit.rows.join("\n")}`)
|
package/src/hash-store/cache.ts
CHANGED
|
@@ -16,21 +16,3 @@ export function cacheSnapshot(path: string, checksum: string, lineCount: number,
|
|
|
16
16
|
if (oldest !== undefined) snapshotCache.delete(oldest);
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
-
|
|
20
|
-
let sessionSeq = 0;
|
|
21
|
-
export const sessionTouched = new Map<string, number>();
|
|
22
|
-
|
|
23
|
-
export function touchSession(path: string): void {
|
|
24
|
-
sessionSeq += 1;
|
|
25
|
-
sessionTouched.delete(path);
|
|
26
|
-
sessionTouched.set(path, sessionSeq);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function clearSession(): void {
|
|
30
|
-
sessionTouched.clear();
|
|
31
|
-
sessionSeq = 0;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function forgetSession(path: string): void {
|
|
35
|
-
sessionTouched.delete(path);
|
|
36
|
-
}
|
package/src/hash-store/retry.ts
CHANGED
|
@@ -9,15 +9,6 @@ export function isValidHashList(value: unknown): value is string[] {
|
|
|
9
9
|
return true;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
export function isValidServedMap(value: unknown): value is Record<string, string> {
|
|
13
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
14
|
-
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
15
|
-
if (typeof k !== "string" || !HASH_RE.test(k)) return false;
|
|
16
|
-
if (typeof v !== "string") return false;
|
|
17
|
-
}
|
|
18
|
-
return true;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
12
|
export function parseHashList(raw: string, onInvalid: () => void, context?: string): string[] | undefined {
|
|
22
13
|
let parsed: unknown;
|
|
23
14
|
try {
|
|
@@ -35,33 +26,11 @@ export function parseHashList(raw: string, onInvalid: () => void, context?: stri
|
|
|
35
26
|
return parsed;
|
|
36
27
|
}
|
|
37
28
|
|
|
38
|
-
export function parseServedMap(raw: string, onInvalid: () => void, context?: string): Map<string, string> | undefined {
|
|
39
|
-
let parsed: unknown;
|
|
40
|
-
try {
|
|
41
|
-
parsed = JSON.parse(raw);
|
|
42
|
-
} catch (error) {
|
|
43
|
-
console.error(`[parseServedMap]${context ? ` ${context}:` : ""} failed to parse stored served JSON:`, error);
|
|
44
|
-
onInvalid();
|
|
45
|
-
return undefined;
|
|
46
|
-
}
|
|
47
|
-
if (!isValidServedMap(parsed)) {
|
|
48
|
-
console.error(`[parseServedMap]${context ? ` ${context}:` : ""} stored served did not pass validation:`, (() => { try { return JSON.stringify(parsed)?.slice(0, 500) ?? String(parsed).slice(0, 500); } catch { return String(parsed).slice(0, 500); } })());
|
|
49
|
-
onInvalid();
|
|
50
|
-
return undefined;
|
|
51
|
-
}
|
|
52
|
-
return new Map(Object.entries(parsed as Record<string, string>));
|
|
53
|
-
}
|
|
54
|
-
|
|
55
29
|
export function parseStoredHashes(row: Record<string, unknown> | undefined, onInvalid: () => void): string[] | undefined {
|
|
56
30
|
if (!row) return undefined;
|
|
57
31
|
return parseHashList(row.hashes as string, onInvalid);
|
|
58
32
|
}
|
|
59
33
|
|
|
60
|
-
export function parseStoredServed(row: Record<string, unknown> | undefined, onInvalid: () => void): Map<string, string> | undefined {
|
|
61
|
-
if (!row) return undefined;
|
|
62
|
-
return parseServedMap(row.hashes as string, onInvalid);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
34
|
export function isValidSnapshot(value: unknown): value is { content: string; hashes: string[] } {
|
|
66
35
|
if (typeof value !== "object" || value === null) return false;
|
|
67
36
|
const v = value as Record<string, unknown>;
|
package/src/hash-store.ts
CHANGED
|
@@ -7,22 +7,19 @@ import { initHasher, contentChecksum } from "./hashline/hasher";
|
|
|
7
7
|
import { HASH_STORE_VERSION, HASH_STORE_BUSY_TIMEOUT } from "./constants";
|
|
8
8
|
import {
|
|
9
9
|
isValidHashList,
|
|
10
|
-
isValidServedMap,
|
|
11
10
|
parseStoredHashes,
|
|
12
|
-
parseStoredServed,
|
|
13
11
|
isValidSnapshot,
|
|
14
12
|
isCorruptionError,
|
|
15
13
|
parseHashList,
|
|
16
|
-
parseServedMap,
|
|
17
14
|
} from "./hash-store/validation";
|
|
18
15
|
import {
|
|
19
16
|
withBusyRetry,
|
|
20
17
|
retriedWrite,
|
|
21
|
-
|
|
18
|
+
withBusyRetryAsync,
|
|
22
19
|
} from "./hash-store/retry";
|
|
23
|
-
import { snapshotCache, cacheSnapshot, SNAPSHOT_CACHE_LIMIT
|
|
20
|
+
import { snapshotCache, cacheSnapshot, SNAPSHOT_CACHE_LIMIT } from "./hash-store/cache";
|
|
24
21
|
|
|
25
|
-
export { isValidHashList,
|
|
22
|
+
export { isValidHashList, parseHashList, parseStoredHashes, isCorruptionError };
|
|
26
23
|
export { SNAPSHOT_CACHE_LIMIT };
|
|
27
24
|
export const STORE_NOT_OPEN_MESSAGE = "Hash store is not open; transactional update aborted";
|
|
28
25
|
|
|
@@ -283,19 +280,19 @@ async function openStore(storePath: string): Promise<HashStore> {
|
|
|
283
280
|
let existed = existsSync(storePath);
|
|
284
281
|
let opened: { db: RawDb; stmts: Prepared };
|
|
285
282
|
try {
|
|
286
|
-
opened = await
|
|
283
|
+
opened = await withBusyRetryAsync(() => openDb(storePath));
|
|
287
284
|
} catch (error) {
|
|
288
285
|
if (!isCorruptionError(error)) throw error;
|
|
289
286
|
console.error("Hash store failed to open, rebuilding:", error);
|
|
290
287
|
await quarantineStore(storePath);
|
|
291
288
|
existed = false;
|
|
292
|
-
opened = await
|
|
289
|
+
opened = await withBusyRetryAsync(() => openDb(storePath));
|
|
293
290
|
}
|
|
294
291
|
if (!isHealthy(opened.db)) {
|
|
295
292
|
shutdownDb(opened.db);
|
|
296
293
|
await quarantineStore(storePath);
|
|
297
294
|
existed = false;
|
|
298
|
-
opened = await
|
|
295
|
+
opened = await withBusyRetryAsync(() => openDb(storePath));
|
|
299
296
|
}
|
|
300
297
|
const { db, stmts } = opened;
|
|
301
298
|
try {
|
|
@@ -367,25 +364,28 @@ export function shutdownHashStore(): void {
|
|
|
367
364
|
cachedDb = null;
|
|
368
365
|
}
|
|
369
366
|
snapshotCache.clear();
|
|
370
|
-
clearSession();
|
|
371
367
|
}
|
|
372
368
|
|
|
373
|
-
|
|
374
|
-
if (!cachedDb || !cachedDb.db.isOpen) {
|
|
375
|
-
throw new Error(STORE_NOT_OPEN_MESSAGE);
|
|
376
|
-
}
|
|
369
|
+
function withTransaction(db: RawDb, fn: () => void): void {
|
|
377
370
|
withBusyRetry(() => {
|
|
378
|
-
|
|
371
|
+
db.exec("BEGIN IMMEDIATE");
|
|
379
372
|
try {
|
|
380
373
|
fn();
|
|
381
|
-
|
|
374
|
+
db.exec("COMMIT");
|
|
382
375
|
} catch (e) {
|
|
383
|
-
try {
|
|
376
|
+
try { db.exec("ROLLBACK"); } catch {}
|
|
384
377
|
throw e;
|
|
385
378
|
}
|
|
386
379
|
});
|
|
387
380
|
}
|
|
388
381
|
|
|
382
|
+
export function withStore(fn: () => void): void {
|
|
383
|
+
if (!cachedDb || !cachedDb.db.isOpen) {
|
|
384
|
+
throw new Error(STORE_NOT_OPEN_MESSAGE);
|
|
385
|
+
}
|
|
386
|
+
withTransaction(cachedDb.db, fn);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
389
|
async function migrateLegacy(db: RawDb): Promise<void> {
|
|
390
390
|
const legacyPath = legacyHashStorePath();
|
|
391
391
|
let content: string;
|
|
@@ -431,18 +431,11 @@ async function migrateLegacy(db: RawDb): Promise<void> {
|
|
|
431
431
|
]);
|
|
432
432
|
}
|
|
433
433
|
if (rows.length > 0) {
|
|
434
|
-
|
|
435
|
-
db.
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
);
|
|
440
|
-
for (const row of rows) stmt.run(...row);
|
|
441
|
-
db.exec("COMMIT");
|
|
442
|
-
} catch (e) {
|
|
443
|
-
try { db.exec("ROLLBACK"); } catch {}
|
|
444
|
-
throw e;
|
|
445
|
-
}
|
|
434
|
+
withTransaction(db, () => {
|
|
435
|
+
const stmt = db.prepare(
|
|
436
|
+
"INSERT OR REPLACE INTO snapshots (path, checksum, line_count, hashes, line_checksums, updated_at) VALUES (?, ?, ?, ?, ?, ?)"
|
|
437
|
+
);
|
|
438
|
+
for (const row of rows) stmt.run(...row);
|
|
446
439
|
});
|
|
447
440
|
}
|
|
448
441
|
|
|
@@ -487,7 +480,6 @@ export function upsertSnapshot(
|
|
|
487
480
|
): void {
|
|
488
481
|
store.stmts.upsert(path, checksum, lineCount, JSON.stringify(hashes), lineChecksums ? JSON.stringify(lineChecksums) : "", Date.now());
|
|
489
482
|
cacheSnapshot(path, checksum, lineCount, hashes);
|
|
490
|
-
touchSession(path);
|
|
491
483
|
}
|
|
492
484
|
export function persistSnapshot(
|
|
493
485
|
store: HashStore,
|
|
@@ -540,7 +532,6 @@ export function upsertUndo(store: HashStore, path: string, entry: UndoRecord): v
|
|
|
540
532
|
typeof entry.mode === "number" ? entry.mode : null,
|
|
541
533
|
Date.now(),
|
|
542
534
|
);
|
|
543
|
-
touchSession(path);
|
|
544
535
|
}
|
|
545
536
|
|
|
546
537
|
export function getUndoEntry(store: HashStore, path: string): UndoRecord | undefined {
|
|
@@ -600,7 +591,6 @@ export async function pruneMissing(store: HashStore): Promise<string[]> {
|
|
|
600
591
|
}
|
|
601
592
|
});
|
|
602
593
|
for (const path of missing) snapshotCache.delete(path);
|
|
603
|
-
for (const path of missing) forgetSession(path);
|
|
604
594
|
return missing;
|
|
605
595
|
}
|
|
606
596
|
|
package/src/hashline/apply.ts
CHANGED
|
@@ -184,9 +184,9 @@ export function planEdit(
|
|
|
184
184
|
const fileHashes = precomputedHashes;
|
|
185
185
|
const warnings: string[] = [];
|
|
186
186
|
|
|
187
|
-
const rangeFixed = swapReversedRanges(edit, fileHashes
|
|
187
|
+
const rangeFixed = swapReversedRanges(edit, fileHashes);
|
|
188
188
|
const prefixFixed = stripDiffPrefixes(
|
|
189
|
-
stripBarePrefixes(rangeFixed,
|
|
189
|
+
stripBarePrefixes(rangeFixed, warnings),
|
|
190
190
|
warnings,
|
|
191
191
|
);
|
|
192
192
|
|
package/src/hashline/hash.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { splitLines, truncateToBytes, getCached } from "../utils";
|
|
|
2
2
|
import { MAX_HASH_SOURCE_BYTES } from "../constants";
|
|
3
3
|
import { loadHashStore, type HashStore } from "../hash-store";
|
|
4
4
|
import { allocateFileAnchors } from "../anchor-registry";
|
|
5
|
-
import { xxh32, initHasher } from "./hasher";
|
|
5
|
+
import { xxh32, initHasher, contentChecksum } from "./hasher";
|
|
6
6
|
import { HASH_LEN, ANCHOR_COUNT, anchorAt, HASH_CLASS, HASH_RUN } from "./alphabet";
|
|
7
7
|
export { initHasher, HASH_LEN, HASH_CLASS, HASH_RUN };
|
|
8
8
|
|
|
@@ -15,11 +15,6 @@ export const MAX_HASH_LINES = HASH_SPACE;
|
|
|
15
15
|
|
|
16
16
|
export const HASH_PROBE_STRIDE = 836286;
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
function hashAt(idx: number): string {
|
|
20
|
-
return anchorAt(idx);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
18
|
export const HL_PREFIX_PLUS_RE = new RegExp(
|
|
24
19
|
`^\\+${HASH_RUN}│`,
|
|
25
20
|
);
|
|
@@ -61,6 +56,10 @@ export function hashSource(line: string): string {
|
|
|
61
56
|
return truncateToBytes(canon(line), MAX_HASH_SOURCE_BYTES);
|
|
62
57
|
}
|
|
63
58
|
|
|
59
|
+
export function lineChecksum(line: string): string {
|
|
60
|
+
return contentChecksum(hashSource(line));
|
|
61
|
+
}
|
|
62
|
+
|
|
64
63
|
const BITSET_WORDS = Math.ceil(HASH_SPACE / 32);
|
|
65
64
|
|
|
66
65
|
function getBit(bits: Uint32Array, idx: number): boolean {
|
|
@@ -88,12 +87,12 @@ function assignHash(used: Uint32Array, baseIdx: number, hint: { value: number })
|
|
|
88
87
|
if (!getBit(used, baseIdx)) {
|
|
89
88
|
setBit(used, baseIdx);
|
|
90
89
|
hint.value = baseIdx + HASH_PROBE_STRIDE;
|
|
91
|
-
return
|
|
90
|
+
return anchorAt(baseIdx);
|
|
92
91
|
}
|
|
93
92
|
const nextIdx = nextZeroBit(used, hint.value);
|
|
94
93
|
setBit(used, nextIdx);
|
|
95
94
|
hint.value = nextIdx + HASH_PROBE_STRIDE;
|
|
96
|
-
return
|
|
95
|
+
return anchorAt(nextIdx);
|
|
97
96
|
}
|
|
98
97
|
|
|
99
98
|
export function _lineHashesPure(content: string): string[] {
|