dsh-hashline-edit 0.1.0 → 0.2.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 +72 -70
- package/cordis.patch.yml +1 -1
- package/lib/index.js +4494 -4813
- package/lib/types/dsh/contracts.d.ts +69 -6
- package/lib/types/dsh/fs.d.ts +38 -3
- package/lib/types/dsh/guidance.d.ts +5 -5
- package/lib/types/dsh/hide.d.ts +78 -0
- package/lib/types/dsh/host-compat.d.ts +7 -78
- package/lib/types/dsh/index.d.ts +17 -7
- package/lib/types/dsh/metrics/events.d.ts +8 -1
- package/lib/types/dsh/metrics/instrument.d.ts +20 -14
- package/lib/types/dsh/metrics/observer.d.ts +28 -0
- package/lib/types/dsh/sandbox.d.ts +2 -2
- package/lib/types/dsh/shell-hook.d.ts +17 -8
- package/lib/types/dsh/shell-view.d.ts +65 -3
- package/lib/types/dsh/tools/apply-patch.d.ts +38 -0
- package/lib/types/dsh/tools/context.d.ts +19 -1
- package/lib/types/dsh/tools/output.d.ts +32 -4
- package/lib/types/dsh/tools/read.d.ts +12 -15
- package/lib/types/dsh/tools/undo.d.ts +5 -0
- package/lib/types/dsh/write-hook.d.ts +8 -22
- package/lib/types/src/apply-patch/commit.d.ts +49 -0
- package/lib/types/src/apply-patch/grammar.d.ts +29 -0
- package/lib/types/src/apply-patch/lifecycle.d.ts +32 -0
- package/lib/types/src/apply-patch/plan.d.ts +50 -0
- package/lib/types/src/apply-patch/render.d.ts +51 -0
- package/lib/types/src/apply-patch/resolve.d.ts +129 -0
- package/lib/types/src/apply-patch/tool.d.ts +54 -0
- package/lib/types/src/apply-patch/types.d.ts +362 -0
- package/lib/types/src/bash-view-banner.d.ts +20 -0
- package/lib/types/src/domain-errors.d.ts +16 -2
- package/lib/types/src/edit-undo.d.ts +11 -2
- package/lib/types/src/file-content/detection.d.ts +17 -0
- package/lib/types/src/file-content/index.d.ts +2 -2
- package/lib/types/src/file-content/preview.d.ts +1 -1
- package/lib/types/src/fs-lifecycle.d.ts +44 -0
- package/lib/types/src/hashline/resolve.d.ts +6 -0
- package/lib/types/src/host-utils.d.ts +63 -0
- package/lib/types/src/mutation-engine/batch-span-gate.d.ts +6 -0
- package/lib/types/src/mutation-engine/edit-request.d.ts +34 -0
- package/lib/types/src/mutation-engine/pipeline.d.ts +18 -3
- package/lib/types/src/mutation-engine/types.d.ts +50 -58
- package/lib/types/src/read-expansion/grammar-manifest.d.ts +4 -2
- package/lib/types/src/read-expansion/java-decoration.d.ts +21 -0
- package/lib/types/src/read-expansion/span-expansion.d.ts +6 -2
- package/lib/types/src/read-expansion/span-kind.d.ts +3 -2
- package/lib/types/src/read-expansion/vue-decoration.d.ts +14 -0
- package/lib/types/src/served-session/serve-render.d.ts +9 -3
- package/lib/types/src/undo-store.d.ts +17 -1
- package/package.json +10 -22
- package/prompts/apply-patch-guidelines.md +10 -0
- package/prompts/apply-patch.md +1 -0
- package/prompts/read-guidelines.md +5 -5
- package/prompts/read.md +1 -1
- package/prompts/undo-last-edit-guidelines.md +2 -2
- package/prompts/undo-last-edit-snippet.md +1 -1
- package/prompts/undo-last-edit.md +1 -1
- package/src/apply-patch/commit.ts +190 -0
- package/src/apply-patch/grammar.ts +620 -0
- package/src/apply-patch/index.ts +17 -0
- package/src/apply-patch/lifecycle.ts +195 -0
- package/src/apply-patch/plan.ts +800 -0
- package/src/apply-patch/render.ts +137 -0
- package/src/apply-patch/resolve.ts +470 -0
- package/src/apply-patch/tool.ts +257 -0
- package/src/apply-patch/types.ts +387 -0
- package/src/cut-repair.ts +77 -11
- package/src/domain-errors.ts +58 -10
- package/src/edit-undo.ts +379 -25
- package/src/file-content/detection.ts +34 -0
- package/src/file-content/index.ts +2 -2
- package/src/file-content/preview.ts +1 -1
- package/src/file-content/served-header.ts +1 -1
- package/src/fs-lifecycle.ts +201 -0
- package/src/hash-store.ts +18 -1
- package/src/hashline/lease-resolve.ts +14 -8
- package/src/hashline/resolve.ts +30 -27
- package/src/host-utils.ts +224 -0
- package/src/mutation-engine/batch-span-gate.ts +17 -4
- package/src/mutation-engine/edit-request.ts +39 -0
- package/src/mutation-engine/pipeline.ts +535 -63
- package/src/mutation-engine/types.ts +49 -65
- package/src/mutation-queue.ts +1 -1
- package/src/prompts.ts +0 -28
- package/src/read-expansion/ast-provider.ts +4 -0
- package/src/read-expansion/grammar-manifest.ts +21 -2
- package/src/read-expansion/java-decoration.ts +34 -0
- package/src/read-expansion/span-expansion.ts +6 -2
- package/src/read-expansion/span-kind.ts +3 -1
- package/src/read-expansion/vue-decoration.ts +43 -0
- package/src/served-session/serve-render.ts +5 -5
- package/src/undo-store.ts +74 -5
- package/lib/types/dsh/encoding-bridge.d.ts +0 -25
- package/lib/types/dsh/encoding.d.ts +0 -144
- package/lib/types/dsh/tools/edit.d.ts +0 -10
- package/lib/types/src/edit-tool.d.ts +0 -41
- package/lib/types/src/mutation-engine/engine.d.ts +0 -31
- package/lib/types/src/payload-contract.d.ts +0 -124
- package/prompts/edit-guidelines.md +0 -10
- package/prompts/edit-snippet.md +0 -1
- package/prompts/edit.md +0 -1
- package/src/bash-classifier.ts +0 -1626
- package/src/bash-conversion/decide.ts +0 -812
- package/src/bash-display-transform.ts +0 -50
- package/src/bash-multifile-config.ts +0 -239
- package/src/bash-view-config.ts +0 -158
- package/src/edit-normalize.ts +0 -8
- package/src/edit-render.ts +0 -161
- package/src/edit-tool.ts +0 -116
- package/src/edit.ts +0 -136
- package/src/integrations/pi-lens/command.ts +0 -712
- package/src/integrations/pi-lens/config.ts +0 -148
- package/src/integrations/pi-lens/io-bridge-adapter.ts +0 -234
- package/src/integrations/pi-lens/io-bridge.ts +0 -112
- package/src/integrations/pi-lens/settings-overlay.ts +0 -272
- package/src/lifecycle-hooks/index.ts +0 -1992
- package/src/lifecycle-hooks/types.ts +0 -64
- package/src/mutated-files.ts +0 -75
- package/src/mutation-engine/engine.ts +0 -173
- package/src/mutation-engine/index.ts +0 -17
- package/src/payload-contract.ts +0 -637
- package/src/preview-controller.ts +0 -84
- package/src/read.ts +0 -865
- package/src/search-hook.ts +0 -41
- package/src/store-config.ts +0 -73
- package/src/store-janitor-warning.ts +0 -169
- package/src/store-janitor.ts +0 -539
- package/src/store-kernel/settings.ts +0 -233
- package/src/store.ts +0 -154
- package/src/tui-presenter.ts +0 -134
- package/src/write-hook.ts +0 -130
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { lstat, mkdir, realpath, rename, rm, rmdir } from "node:fs/promises";
|
|
2
|
+
import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { DomainError } from "./domain-errors.js";
|
|
4
|
+
import { reportDiagnostic } from "./diagnostics.js";
|
|
5
|
+
import { resolveTarget } from "./fs-write.js";
|
|
6
|
+
import { errCode } from "./utils.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Filesystem primitives for apply_patch's file lifecycle: classify, create directories, delete a
|
|
10
|
+
* regular file and rename one. Every entry point resolves through {@link resolveTarget}, the same
|
|
11
|
+
* symlink-resolving boundary every read/write uses, so a lifecycle action can never escape it.
|
|
12
|
+
*
|
|
13
|
+
* These are primitives, not a second write engine: file CONTENTS still go through writeAtomic;
|
|
14
|
+
* this module only owns namespace operations that writeAtomic deliberately does not express.
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-hashline-edit/fs-lifecycle
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** The physical kind of a path as the file layer sees it (symlinks resolved first). */
|
|
20
|
+
export type PathKind = "file" | "directory" | "other" | "absent";
|
|
21
|
+
|
|
22
|
+
/** Classify one path through the file layer's symlink-resolving boundary. */
|
|
23
|
+
export async function pathKind(path: string): Promise<PathKind> {
|
|
24
|
+
const targetPath = await resolveTarget(path);
|
|
25
|
+
try {
|
|
26
|
+
const info = await lstat(targetPath);
|
|
27
|
+
if (info.isSymbolicLink()) return "other";
|
|
28
|
+
if (info.isFile()) return "file";
|
|
29
|
+
if (info.isDirectory()) return "directory";
|
|
30
|
+
return "other";
|
|
31
|
+
} catch (error: unknown) {
|
|
32
|
+
if (errCode(error) === "ENOENT") return "absent";
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* True when `path` resolves inside `root` once every existing symlink/junction component is
|
|
39
|
+
* followed. A missing tail is checked against its nearest existing ancestor: an Add File under a
|
|
40
|
+
* junction ancestor therefore fails before mkdir can create anything outside the workspace.
|
|
41
|
+
*/
|
|
42
|
+
export async function isPathWithinWorkspace(root: string, path: string): Promise<boolean> {
|
|
43
|
+
const absolutePath = resolve(path);
|
|
44
|
+
const { root: fsRoot } = parse(absolutePath);
|
|
45
|
+
let cursor = fsRoot;
|
|
46
|
+
for (const part of absolutePath
|
|
47
|
+
.slice(fsRoot.length)
|
|
48
|
+
.split(sep)
|
|
49
|
+
.filter((value) => value.length > 0)) {
|
|
50
|
+
cursor = join(cursor, part);
|
|
51
|
+
try {
|
|
52
|
+
const info = await lstat(cursor);
|
|
53
|
+
if (info.isSymbolicLink()) return false;
|
|
54
|
+
// WHY: Windows junctions are reparse points that some Node builds do not classify as
|
|
55
|
+
// WHY: symlinks; a realpath that differs from the lexical path proves a link component.
|
|
56
|
+
if ((await realpath(cursor)) !== cursor) return false;
|
|
57
|
+
} catch (error: unknown) {
|
|
58
|
+
if (errCode(error) === "ENOENT") break;
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const rootReal = await realpath(resolve(root));
|
|
63
|
+
let ancestor = absolutePath;
|
|
64
|
+
for (;;) {
|
|
65
|
+
try {
|
|
66
|
+
const real = await realpath(ancestor);
|
|
67
|
+
const rel = relative(rootReal, real);
|
|
68
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
69
|
+
} catch (error: unknown) {
|
|
70
|
+
if (errCode(error) !== "ENOENT") throw error;
|
|
71
|
+
const parent = dirname(ancestor);
|
|
72
|
+
if (parent === ancestor) return false;
|
|
73
|
+
ancestor = parent;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Throw the E_PATCH_SYNTAX boundary refusal for one escaping lifecycle path. */
|
|
79
|
+
export async function assertPathWithinWorkspace(root: string, path: string): Promise<void> {
|
|
80
|
+
if (!(await isPathWithinWorkspace(root, path))) {
|
|
81
|
+
throw new DomainError("E_PATCH_SYNTAX", {
|
|
82
|
+
message: `path escapes the workspace through a symlink or junction: ${path}`,
|
|
83
|
+
path,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Create one directory (and missing parents), through the symlink-resolving boundary. */
|
|
89
|
+
export async function mkdirp(path: string, workspaceRoot?: string): Promise<void> {
|
|
90
|
+
if (path.includes("\0")) {
|
|
91
|
+
throw new DomainError("E_INTERNAL_INVARIANT", { message: "Path contains null byte." });
|
|
92
|
+
}
|
|
93
|
+
if (workspaceRoot !== undefined) await assertPathWithinWorkspace(workspaceRoot, path);
|
|
94
|
+
const targetPath = await resolveTarget(path);
|
|
95
|
+
await mkdir(targetPath, { recursive: true });
|
|
96
|
+
if (workspaceRoot !== undefined) await assertPathWithinWorkspace(workspaceRoot, path);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Delete one regular file. Deliberately NOT recursive: a lifecycle delete targets a file, and a
|
|
101
|
+
* directory at the same path is a caller bug that must surface, never be removed wholesale.
|
|
102
|
+
*/
|
|
103
|
+
export async function removeFile(path: string, signal?: AbortSignal): Promise<void> {
|
|
104
|
+
signal?.throwIfAborted();
|
|
105
|
+
const targetPath = await resolveTarget(path);
|
|
106
|
+
const kind = await pathKind(targetPath);
|
|
107
|
+
if (kind === "absent") {
|
|
108
|
+
const error = new Error(
|
|
109
|
+
"ENOENT: no such file or directory, unlink '" + targetPath + "'",
|
|
110
|
+
) as NodeJS.ErrnoException;
|
|
111
|
+
error.code = "ENOENT";
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
if (kind !== "file") {
|
|
115
|
+
throw new DomainError("E_INTERNAL_INVARIANT", {
|
|
116
|
+
message: "Refusing to delete a non-file lifecycle target: " + path,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
await rm(targetPath, { force: false });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Rename one regular file, creating the destination parent first. The destination must not exist:
|
|
124
|
+
* a move never clobbers another file (spec §6: target-exists is a rejection, not an overwrite).
|
|
125
|
+
*/
|
|
126
|
+
export async function renameFile(
|
|
127
|
+
from: string,
|
|
128
|
+
to: string,
|
|
129
|
+
signal?: AbortSignal,
|
|
130
|
+
workspaceRoot?: string,
|
|
131
|
+
): Promise<void> {
|
|
132
|
+
signal?.throwIfAborted();
|
|
133
|
+
if (workspaceRoot !== undefined) {
|
|
134
|
+
await assertPathWithinWorkspace(workspaceRoot, from);
|
|
135
|
+
await assertPathWithinWorkspace(workspaceRoot, to);
|
|
136
|
+
}
|
|
137
|
+
const fromPath = await resolveTarget(from);
|
|
138
|
+
const toPath = await resolveTarget(to);
|
|
139
|
+
const destinationKind = await pathKind(toPath);
|
|
140
|
+
if (destinationKind !== "absent") {
|
|
141
|
+
const error = new Error(
|
|
142
|
+
"EEXIST: file already exists, rename '" + fromPath + "' -> '" + toPath + "'",
|
|
143
|
+
) as NodeJS.ErrnoException;
|
|
144
|
+
error.code = "EEXIST";
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
await mkdirp(dirname(toPath), workspaceRoot);
|
|
148
|
+
if (workspaceRoot !== undefined) await assertPathWithinWorkspace(workspaceRoot, to);
|
|
149
|
+
await rename(fromPath, toPath);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The directories on the way to `path` that do not exist yet, OUTERMOST FIRST. Captured before a
|
|
154
|
+
* lifecycle add writes, so an undo removes exactly the directories the add created and no others.
|
|
155
|
+
*/
|
|
156
|
+
export async function missingAncestorDirs(path: string): Promise<string[]> {
|
|
157
|
+
const targetPath = await resolveTarget(path);
|
|
158
|
+
const missing: string[] = [];
|
|
159
|
+
let cursor = dirname(targetPath);
|
|
160
|
+
const { root } = parse(cursor);
|
|
161
|
+
while (cursor !== root) {
|
|
162
|
+
try {
|
|
163
|
+
const info = await lstat(cursor);
|
|
164
|
+
if (info.isDirectory()) break;
|
|
165
|
+
break;
|
|
166
|
+
} catch (error: unknown) {
|
|
167
|
+
if (errCode(error) !== "ENOENT") throw error;
|
|
168
|
+
missing.unshift(cursor);
|
|
169
|
+
cursor = dirname(cursor);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return missing;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Best-effort removal of directories a lifecycle add created, deepest first. Returns the removed
|
|
177
|
+
* paths. A directory that is no longer empty (an outside writer put something there) is kept.
|
|
178
|
+
*/
|
|
179
|
+
export async function pruneEmptyDirs(dirs: readonly string[]): Promise<string[]> {
|
|
180
|
+
const removed: string[] = [];
|
|
181
|
+
for (const dir of [...dirs].sort((a, b) => b.length - a.length)) {
|
|
182
|
+
try {
|
|
183
|
+
await rmdir(dir);
|
|
184
|
+
removed.push(dir);
|
|
185
|
+
} catch (error: unknown) {
|
|
186
|
+
const code = errCode(error);
|
|
187
|
+
if (
|
|
188
|
+
code === "ENOENT" ||
|
|
189
|
+
code === "ENOTEMPTY" ||
|
|
190
|
+
code === "EEXIST" ||
|
|
191
|
+
code === "EPERM" ||
|
|
192
|
+
code === "EISDIR" ||
|
|
193
|
+
code === "ENOTDIR"
|
|
194
|
+
) {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
reportDiagnostic("fs-lifecycle", "failed to prune a lifecycle-created directory:", error);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return removed;
|
|
201
|
+
}
|
package/src/hash-store.ts
CHANGED
|
@@ -156,7 +156,16 @@ function openDb(storePath: string, budget?: BusyBudget): OpenedDb {
|
|
|
156
156
|
const MIGRATION_COLUMNS = {
|
|
157
157
|
cut_intent: { direction: "TEXT" },
|
|
158
158
|
file_snapshots: { stat_key: "TEXT" },
|
|
159
|
-
file_undo: {
|
|
159
|
+
file_undo: {
|
|
160
|
+
snapshot_hash: "TEXT",
|
|
161
|
+
transaction_id: "TEXT",
|
|
162
|
+
raw_pre: "TEXT",
|
|
163
|
+
lifecycle: "TEXT",
|
|
164
|
+
from_path: "TEXT",
|
|
165
|
+
to_path: "TEXT",
|
|
166
|
+
raw_post: "TEXT",
|
|
167
|
+
created_dirs: "TEXT",
|
|
168
|
+
},
|
|
160
169
|
served: { retired: "TEXT", canons: "TEXT", snapshotId: "TEXT" },
|
|
161
170
|
undo: { result_content: "TEXT NOT NULL DEFAULT ''" },
|
|
162
171
|
} as const;
|
|
@@ -625,6 +634,14 @@ export function ensureFileUndoSchema(db: DatabaseSync): void {
|
|
|
625
634
|
addColumnIfMissing(db, "file_undo", "snapshot_hash");
|
|
626
635
|
addColumnIfMissing(db, "file_undo", "transaction_id");
|
|
627
636
|
addColumnIfMissing(db, "file_undo", "raw_pre");
|
|
637
|
+
// WHY: apply_patch lifecycle rows are still edit-shaped rows: one file_undo row per member,
|
|
638
|
+
// WHY: one shared transaction id. These columns only widen what a row can describe — an
|
|
639
|
+
// WHY: existing row (all NULL) stays an ordinary edit, so the addition is back-compatible.
|
|
640
|
+
addColumnIfMissing(db, "file_undo", "lifecycle");
|
|
641
|
+
addColumnIfMissing(db, "file_undo", "from_path");
|
|
642
|
+
addColumnIfMissing(db, "file_undo", "to_path");
|
|
643
|
+
addColumnIfMissing(db, "file_undo", "raw_post");
|
|
644
|
+
addColumnIfMissing(db, "file_undo", "created_dirs");
|
|
628
645
|
// WHY: versions the persisted anchor set — existing rows keep 0 (never current),
|
|
629
646
|
// WHY: so a pre-bump store is refused, not migrated, on first open after upgrade.
|
|
630
647
|
// WHY: `canon_version` (pre-rename builds) is renamed in place, preserving stamped values.
|
|
@@ -85,6 +85,14 @@ function resolvedAt(edit: HEdit, fileHashes: string[], fromLine: number, toLine:
|
|
|
85
85
|
...(edit.placement !== undefined ? { placement: edit.placement } : {}),
|
|
86
86
|
};
|
|
87
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* SAFETY: one copy of each refused anchor, in submission order. A single-line edit names the same
|
|
90
|
+
* anchor in both bounds; collapsing the pair keeps the refusal's count and quoted list honest (one
|
|
91
|
+
* anchor, not two).
|
|
92
|
+
*/
|
|
93
|
+
function distinctAnchors(anchors: readonly string[]): string[] {
|
|
94
|
+
return [...new Set(anchors)];
|
|
95
|
+
}
|
|
88
96
|
|
|
89
97
|
/**
|
|
90
98
|
* Refuses anchors the lease seam cannot place: the session holds no lease for them in this file.
|
|
@@ -103,14 +111,12 @@ function throwUnknownOrForeign(args: {
|
|
|
103
111
|
}): never {
|
|
104
112
|
const { edit, snapshot, source } = args;
|
|
105
113
|
const path = snapshot.filePath ?? "this file";
|
|
106
|
-
const refused =
|
|
107
|
-
|
|
108
|
-
[
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
),
|
|
113
|
-
];
|
|
114
|
+
const refused = distinctAnchors(
|
|
115
|
+
[
|
|
116
|
+
args.refusedFrom ? edit.hash_bounds[0].hash : undefined,
|
|
117
|
+
args.refusedTo ? edit.hash_bounds[1].hash : undefined,
|
|
118
|
+
].filter((hash): hash is string => hash !== undefined),
|
|
119
|
+
);
|
|
114
120
|
const homes = [
|
|
115
121
|
...new Set(refused.flatMap((anchor) => source.anchorHomes?.(anchor) ?? [])),
|
|
116
122
|
].sort();
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { abortIf, rejectUnknownFields
|
|
1
|
+
import { abortIf, rejectUnknownFields } from "../utils.js";
|
|
2
2
|
import { DomainError, formatWarning } from "../domain-errors.js";
|
|
3
3
|
import { HASH_CLASS, HASH_LEN } from "./hash-identity.js";
|
|
4
4
|
import { parseHashRef, parseText, type Anchor } from "./parse.js";
|
|
@@ -239,51 +239,54 @@ function _fmtMismatch(mismatches: HMismatch[], snapshot: FileSnapshotContext): s
|
|
|
239
239
|
return fmtMismatchWithServes(mismatches, snapshot).message;
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
/**
|
|
243
|
+
* SAFETY: one copy per distinct anchor hash, in submission order. A single-line edit names its one
|
|
244
|
+
* anchor in both bounds, so the size of this array — never the raw mismatch count — owns the label
|
|
245
|
+
* and the count in the rendered refusal.
|
|
246
|
+
*/
|
|
247
|
+
function distinctByHash<T extends { ref: { hash: string } }>(items: readonly T[]): T[] {
|
|
248
|
+
return [...new Map(items.map((item) => [item.ref.hash, item])).values()];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* SAFETY: the summary names which anchors failed and how many distinct anchors they are. Row
|
|
253
|
+
* evidence is collected through `pushRow` for the rejection's single `servedBlock`; echoing the rows
|
|
254
|
+
* here as a second context block is the duplicate this function no longer produces.
|
|
255
|
+
*/
|
|
242
256
|
function formatNotFound(
|
|
243
257
|
notFound: HMismatch[],
|
|
244
258
|
fileLines: string[],
|
|
245
|
-
fileHashes: string[],
|
|
246
259
|
filePath: string | undefined,
|
|
247
260
|
pushRow: (ln: number) => void,
|
|
248
261
|
out: string[],
|
|
249
262
|
): void {
|
|
250
263
|
if (notFound.length === 0) return;
|
|
251
|
-
const distinct =
|
|
264
|
+
const distinct = distinctByHash(notFound);
|
|
252
265
|
const refList = distinct.map((m) => `"${m.ref.hash}"`).join(", ");
|
|
253
266
|
// WHY: the body carries no code tag — the registry owns the `[MODEL] [E_*]`
|
|
254
267
|
// WHY: header when the caller wraps this in a `DomainError`.
|
|
255
268
|
out.push(
|
|
256
|
-
`${distinct.length} stale anchor${distinct.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}
|
|
269
|
+
`${distinct.length} stale anchor${distinct.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}.`,
|
|
257
270
|
);
|
|
258
271
|
for (const m of distinct) {
|
|
259
272
|
const ctx = m.context;
|
|
260
273
|
if (!ctx) continue;
|
|
261
274
|
const from = Math.max(1, ctx.line - 1);
|
|
262
275
|
const to = Math.min(fileLines.length, ctx.line + 1);
|
|
263
|
-
|
|
264
|
-
for (let ln = from; ln <= to; ln++) {
|
|
265
|
-
rows.push(` ${ln}: ${fileHashes[ln - 1]}│${clipLine(fileLines[ln - 1] ?? "")}`);
|
|
266
|
-
pushRow(ln);
|
|
267
|
-
}
|
|
268
|
-
out.push("");
|
|
269
|
-
out.push(
|
|
270
|
-
` Current context around resolved anchor "${ctx.hash}" (line ${ctx.line}):\n${rows.join("\n")}`,
|
|
271
|
-
);
|
|
276
|
+
for (let ln = from; ln <= to; ln++) pushRow(ln);
|
|
272
277
|
}
|
|
273
278
|
}
|
|
274
279
|
function formatAmbiguous(
|
|
275
280
|
ambiguous: HMismatch[],
|
|
276
|
-
fileLines: string[],
|
|
277
|
-
fileHashes: string[],
|
|
278
281
|
filePath: string | undefined,
|
|
279
282
|
pushRow: (ln: number) => void,
|
|
280
283
|
out: string[],
|
|
281
284
|
): void {
|
|
282
285
|
if (ambiguous.length === 0) return;
|
|
283
286
|
if (out.length > 0) out.push("");
|
|
284
|
-
const distinctAmbiguous =
|
|
287
|
+
const distinctAmbiguous = distinctByHash(ambiguous);
|
|
285
288
|
out.push(
|
|
286
|
-
`${distinctAmbiguous.length} ambiguous anchor${distinctAmbiguous.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}
|
|
289
|
+
`${distinctAmbiguous.length} ambiguous anchor${distinctAmbiguous.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}.`,
|
|
287
290
|
);
|
|
288
291
|
for (const m of distinctAmbiguous) {
|
|
289
292
|
const sample = (m.candidates ?? []).slice(0, 5);
|
|
@@ -291,14 +294,8 @@ function formatAmbiguous(
|
|
|
291
294
|
(m.candidates?.length ?? 0) > sample.length
|
|
292
295
|
? `, ... (+${(m.candidates?.length ?? 0) - sample.length} more)`
|
|
293
296
|
: "";
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
const content = clipLine(fileLines[line - 1] ?? "");
|
|
297
|
-
pushRow(line);
|
|
298
|
-
return ` ${line}: ${fileHashes[line - 1]}│${content}`;
|
|
299
|
-
})
|
|
300
|
-
.join("\n");
|
|
301
|
-
out.push(` Hash "${m.ref.hash}" matches lines ${sample.join(", ")}${more}.\n${lines}`);
|
|
297
|
+
for (const line of sample) pushRow(line);
|
|
298
|
+
out.push(` Hash "${m.ref.hash}" matches lines ${sample.join(", ")}${more}.`);
|
|
302
299
|
}
|
|
303
300
|
}
|
|
304
301
|
function buildHashIndex(fileHashes: string[]): Map<string, number[]> {
|
|
@@ -311,6 +308,12 @@ function buildHashIndex(fileHashes: string[]): Map<string, number[]> {
|
|
|
311
308
|
}
|
|
312
309
|
return hashIndex;
|
|
313
310
|
}
|
|
311
|
+
/**
|
|
312
|
+
* SAFETY: the rejection summary plus the rows to serve. The message is facts-only: it names the
|
|
313
|
+
* distinct failing anchors and, for ambiguous ones, the candidate line numbers. Row content travels
|
|
314
|
+
* only in `servedRows` so a caller renders one evidence block; a message that also echoes the rows
|
|
315
|
+
* would make the retry read the same lines twice.
|
|
316
|
+
*/
|
|
314
317
|
export function fmtMismatchWithServes(
|
|
315
318
|
mismatches: HMismatch[],
|
|
316
319
|
snapshot: FileSnapshotContext,
|
|
@@ -330,8 +333,8 @@ export function fmtMismatchWithServes(
|
|
|
330
333
|
};
|
|
331
334
|
const notFound = mismatches.filter((m) => m.kind === "not_found");
|
|
332
335
|
const ambiguous = mismatches.filter((m) => m.kind === "ambiguous");
|
|
333
|
-
formatNotFound(notFound, fileLines,
|
|
334
|
-
formatAmbiguous(ambiguous,
|
|
336
|
+
formatNotFound(notFound, fileLines, filePath, pushRow, out);
|
|
337
|
+
formatAmbiguous(ambiguous, filePath, pushRow, out);
|
|
335
338
|
|
|
336
339
|
return { message: out.join("\n"), servedRows };
|
|
337
340
|
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-neutral utilities the hashline core needs from the historical pi runtime.
|
|
3
|
+
*
|
|
4
|
+
* WHY: the dsh plugin ships without the pi agent package. Three core modules used
|
|
5
|
+
* to import small utility functions from `@earendil-works/pi-coding-agent`:
|
|
6
|
+
* `src/file-content/preview.ts` and `src/file-content/served-header.ts` use
|
|
7
|
+
* `formatSize` / `truncateHead` / `DEFAULT_MAX_LINES` / `DEFAULT_MAX_BYTES`
|
|
8
|
+
* to render read pages, and `src/mutation-queue.ts` uses
|
|
9
|
+
* `withFileMutationQueue` to serialize same-file mutations for the cut
|
|
10
|
+
* transaction and its correlated undo. This module owns that surface as plain
|
|
11
|
+
* host-neutral code; `dsh/host-compat.ts` re-exports it for the dsh bundle.
|
|
12
|
+
*
|
|
13
|
+
* @module dsh-hashline-edit/host-utils
|
|
14
|
+
*/
|
|
15
|
+
import { realpath } from "node:fs/promises";
|
|
16
|
+
import { resolve } from "node:path";
|
|
17
|
+
|
|
18
|
+
/** Retained line budget, matching pi's `DEFAULT_MAX_LINES`. */
|
|
19
|
+
export const DEFAULT_MAX_LINES = 2000;
|
|
20
|
+
|
|
21
|
+
/** Retained byte budget, matching pi's `DEFAULT_MAX_BYTES` (50KB). */
|
|
22
|
+
export const DEFAULT_MAX_BYTES = 50 * 1024;
|
|
23
|
+
|
|
24
|
+
/** The truncation report the core's read page renders. */
|
|
25
|
+
export interface TruncationResult {
|
|
26
|
+
/** The truncated content. */
|
|
27
|
+
content: string;
|
|
28
|
+
/** Whether truncation occurred. */
|
|
29
|
+
truncated: boolean;
|
|
30
|
+
/** Which limit was hit: `"lines"`, `"bytes"`, or `null` when nothing was cut. */
|
|
31
|
+
truncatedBy: "lines" | "bytes" | null;
|
|
32
|
+
/** Total number of lines in the original content. */
|
|
33
|
+
totalLines: number;
|
|
34
|
+
/** Total number of bytes in the original content. */
|
|
35
|
+
totalBytes: number;
|
|
36
|
+
/** Number of complete lines in the truncated output. */
|
|
37
|
+
outputLines: number;
|
|
38
|
+
/** Number of bytes in the truncated output. */
|
|
39
|
+
outputBytes: number;
|
|
40
|
+
/** Whether the last line was partially truncated (tail truncation only). */
|
|
41
|
+
lastLinePartial: boolean;
|
|
42
|
+
/** Whether the first line exceeded the byte limit (head truncation only). */
|
|
43
|
+
firstLineExceedsLimit: boolean;
|
|
44
|
+
/** The line limit that was applied. */
|
|
45
|
+
maxLines: number;
|
|
46
|
+
/** The byte limit that was applied. */
|
|
47
|
+
maxBytes: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The two independent limits a truncation call applies. */
|
|
51
|
+
export interface TruncationOptions {
|
|
52
|
+
/** Maximum number of lines; defaults to {@link DEFAULT_MAX_LINES}. */
|
|
53
|
+
maxLines?: number;
|
|
54
|
+
/** Maximum number of bytes; defaults to {@link DEFAULT_MAX_BYTES}. */
|
|
55
|
+
maxBytes?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Split content into lines for counting, dropping the empty tail of a trailing newline. */
|
|
59
|
+
function splitLinesForCounting(content: string): string[] {
|
|
60
|
+
if (content.length === 0) return [];
|
|
61
|
+
const lines = content.split("\n");
|
|
62
|
+
if (content.endsWith("\n")) lines.pop();
|
|
63
|
+
return lines;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Format bytes as a human-readable size (`512B`, `1.5KB`, `2.0MB`).
|
|
68
|
+
* @param bytes - the byte count to render.
|
|
69
|
+
* @returns the pi-compatible size label.
|
|
70
|
+
*/
|
|
71
|
+
export function formatSize(bytes: number): string {
|
|
72
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
73
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
74
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Truncate content from the head (keep the first N lines / bytes).
|
|
79
|
+
*
|
|
80
|
+
* WHY: the read page's oversize and truncation warnings must agree with the pi
|
|
81
|
+
* implementation the rest of the contract was written against, so this is a
|
|
82
|
+
* faithful port including its two edge rules: complete lines only, and an
|
|
83
|
+
* over-long first line yields empty content with `firstLineExceedsLimit`.
|
|
84
|
+
* @param content - the text to truncate.
|
|
85
|
+
* @param options - the line and byte limits.
|
|
86
|
+
* @returns the truncation report.
|
|
87
|
+
*/
|
|
88
|
+
export function truncateHead(content: string, options: TruncationOptions = {}): TruncationResult {
|
|
89
|
+
const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
|
|
90
|
+
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
91
|
+
const totalBytes = Buffer.byteLength(content, "utf-8");
|
|
92
|
+
const lines = splitLinesForCounting(content);
|
|
93
|
+
const totalLines = lines.length;
|
|
94
|
+
|
|
95
|
+
if (totalLines <= maxLines && totalBytes <= maxBytes) {
|
|
96
|
+
return {
|
|
97
|
+
content,
|
|
98
|
+
truncated: false,
|
|
99
|
+
truncatedBy: null,
|
|
100
|
+
totalLines,
|
|
101
|
+
totalBytes,
|
|
102
|
+
outputLines: totalLines,
|
|
103
|
+
outputBytes: totalBytes,
|
|
104
|
+
lastLinePartial: false,
|
|
105
|
+
firstLineExceedsLimit: false,
|
|
106
|
+
maxLines,
|
|
107
|
+
maxBytes,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const firstLineBytes = Buffer.byteLength(lines[0] ?? "", "utf-8");
|
|
112
|
+
if (firstLineBytes > maxBytes) {
|
|
113
|
+
return {
|
|
114
|
+
content: "",
|
|
115
|
+
truncated: true,
|
|
116
|
+
truncatedBy: "bytes",
|
|
117
|
+
totalLines,
|
|
118
|
+
totalBytes,
|
|
119
|
+
outputLines: 0,
|
|
120
|
+
outputBytes: 0,
|
|
121
|
+
lastLinePartial: false,
|
|
122
|
+
firstLineExceedsLimit: true,
|
|
123
|
+
maxLines,
|
|
124
|
+
maxBytes,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const outputLinesArr: string[] = [];
|
|
129
|
+
let outputBytesCount = 0;
|
|
130
|
+
let truncatedBy: "lines" | "bytes" = "lines";
|
|
131
|
+
for (let index = 0; index < lines.length && index < maxLines; index += 1) {
|
|
132
|
+
const line = lines[index] ?? "";
|
|
133
|
+
const lineBytes = Buffer.byteLength(line, "utf-8") + (index > 0 ? 1 : 0);
|
|
134
|
+
if (outputBytesCount + lineBytes > maxBytes) {
|
|
135
|
+
truncatedBy = "bytes";
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
outputLinesArr.push(line);
|
|
139
|
+
outputBytesCount += lineBytes;
|
|
140
|
+
}
|
|
141
|
+
if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) {
|
|
142
|
+
truncatedBy = "lines";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const outputContent = outputLinesArr.join("\n");
|
|
146
|
+
return {
|
|
147
|
+
content: outputContent,
|
|
148
|
+
truncated: true,
|
|
149
|
+
truncatedBy,
|
|
150
|
+
totalLines,
|
|
151
|
+
totalBytes,
|
|
152
|
+
outputLines: outputLinesArr.length,
|
|
153
|
+
outputBytes: Buffer.byteLength(outputContent, "utf-8"),
|
|
154
|
+
lastLinePartial: false,
|
|
155
|
+
firstLineExceedsLimit: false,
|
|
156
|
+
maxLines,
|
|
157
|
+
maxBytes,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The per-path queue tails; an entry exists only while a mutation holds or awaits it. */
|
|
162
|
+
const fileMutationQueues = new Map<string, Promise<void>>();
|
|
163
|
+
|
|
164
|
+
/** WHY: queue registration itself must be serialized so two callers cannot adopt the same tail. */
|
|
165
|
+
let registrationQueue: Promise<void> = Promise.resolve();
|
|
166
|
+
|
|
167
|
+
/** Whether a realpath failure means "no such path" (the queue then keys by the resolved path). */
|
|
168
|
+
function isMissingPathError(error: unknown): boolean {
|
|
169
|
+
return (
|
|
170
|
+
typeof error === "object" &&
|
|
171
|
+
error !== null &&
|
|
172
|
+
"code" in error &&
|
|
173
|
+
(error.code === "ENOENT" || error.code === "ENOTDIR")
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Key a mutation by the canonical path, falling back to the resolved path for missing files. */
|
|
178
|
+
async function getMutationQueueKey(filePath: string): Promise<string> {
|
|
179
|
+
const resolvedPath = resolve(filePath);
|
|
180
|
+
try {
|
|
181
|
+
return await realpath(resolvedPath);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
if (isMissingPathError(error)) return resolvedPath;
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Serialize file mutation operations that target the same file; different files still run
|
|
190
|
+
* in parallel. A faithful port of pi's queue so the core's cut transaction keeps its
|
|
191
|
+
* lock ordering guarantees under the dsh host.
|
|
192
|
+
* @param filePath - the file the mutation targets.
|
|
193
|
+
* @param run - the critical section to run once the path is free.
|
|
194
|
+
* @returns the critical section's result.
|
|
195
|
+
*/
|
|
196
|
+
export async function withFileMutationQueue<T>(
|
|
197
|
+
filePath: string,
|
|
198
|
+
run: () => Promise<T>,
|
|
199
|
+
): Promise<T> {
|
|
200
|
+
const registration = registrationQueue.then(async () => {
|
|
201
|
+
const key = await getMutationQueueKey(filePath);
|
|
202
|
+
const currentQueue = fileMutationQueues.get(key) ?? Promise.resolve();
|
|
203
|
+
let releaseNext: () => void = () => {};
|
|
204
|
+
const nextQueue = new Promise<void>((resolveQueue) => {
|
|
205
|
+
releaseNext = resolveQueue;
|
|
206
|
+
});
|
|
207
|
+
const chainedQueue = currentQueue.then(() => nextQueue);
|
|
208
|
+
fileMutationQueues.set(key, chainedQueue);
|
|
209
|
+
return { key, currentQueue, chainedQueue, releaseNext };
|
|
210
|
+
});
|
|
211
|
+
registrationQueue = registration.then(
|
|
212
|
+
() => undefined,
|
|
213
|
+
() => undefined,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const { key, currentQueue, chainedQueue, releaseNext } = await registration;
|
|
217
|
+
await currentQueue;
|
|
218
|
+
try {
|
|
219
|
+
return await run();
|
|
220
|
+
} finally {
|
|
221
|
+
releaseNext();
|
|
222
|
+
if (fileMutationQueues.get(key) === chainedQueue) fileMutationQueues.delete(key);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -101,6 +101,12 @@ export interface BaselineSpanContext {
|
|
|
101
101
|
sessionKey: string;
|
|
102
102
|
absolutePath: string;
|
|
103
103
|
isPreview: boolean;
|
|
104
|
+
/**
|
|
105
|
+
* WHY: the CALLER's own item numbers, parallel to `edits` — a per-file plan keeps its items'
|
|
106
|
+
* WHY: global positions, so an aggregated refusal names the item the model sent instead of this
|
|
107
|
+
* WHY: plan's local index. Absent means `edits` IS the whole call, so local index = caller number.
|
|
108
|
+
*/
|
|
109
|
+
itemNumbers?: readonly number[];
|
|
104
110
|
path: string;
|
|
105
111
|
originalHashes: string[];
|
|
106
112
|
originalNormalized: string;
|
|
@@ -224,14 +230,21 @@ export async function assertBatchSpansDisjoint(
|
|
|
224
230
|
failures.push({ error, index });
|
|
225
231
|
}
|
|
226
232
|
}
|
|
233
|
+
const numberOf = (local: number): number => ctx.itemNumbers?.[local] ?? local;
|
|
227
234
|
// WHY: anchor failures mask overlap reporting — without valid anchors there are no coordinates
|
|
228
235
|
// WHY: to compare, so validation rejections throw before the overlap scan below.
|
|
229
236
|
if (failures.length === 1) {
|
|
230
237
|
const only = failures[0]!;
|
|
231
|
-
throw batchAbortFor({ error: only.error, index: only.index, path: ctx.path });
|
|
238
|
+
throw batchAbortFor({ error: only.error, index: numberOf(only.index), path: ctx.path });
|
|
232
239
|
}
|
|
233
240
|
if (failures.length > 1) {
|
|
234
|
-
throw batchAbortForMany({
|
|
241
|
+
throw batchAbortForMany({
|
|
242
|
+
failures: failures.map((failure) => ({
|
|
243
|
+
error: failure.error,
|
|
244
|
+
index: numberOf(failure.index),
|
|
245
|
+
})),
|
|
246
|
+
path: ctx.path,
|
|
247
|
+
});
|
|
235
248
|
}
|
|
236
249
|
for (let i = 0; i < spans.length; i++) {
|
|
237
250
|
for (let j = i + 1; j < spans.length; j++) {
|
|
@@ -250,8 +263,8 @@ export async function assertBatchSpansDisjoint(
|
|
|
250
263
|
const originalLines = splitLines(ctx.originalNormalized);
|
|
251
264
|
const rows = serveRowsForEdit(edits[b.index]!, ctx.originalHashes);
|
|
252
265
|
throw new DomainError("E_BATCH_ABORT", {
|
|
253
|
-
earlierIndex: a.index,
|
|
254
|
-
laterIndex: b.index,
|
|
266
|
+
earlierIndex: numberOf(a.index),
|
|
267
|
+
laterIndex: numberOf(b.index),
|
|
255
268
|
earlierStart: a.startLine,
|
|
256
269
|
earlierEnd: a.endLine,
|
|
257
270
|
laterStart: b.startLine,
|