dsh-hashline-edit 0.2.1 → 0.2.2
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 +60 -56
- package/cordis.patch.yml +3 -3
- package/lib/index.js +7224 -6454
- package/lib/types/dsh/contracts.d.ts +3 -12
- package/lib/types/dsh/fs.d.ts +0 -3
- package/lib/types/dsh/guidance.d.ts +5 -5
- package/lib/types/dsh/host-compat.d.ts +78 -7
- package/lib/types/dsh/index.d.ts +9 -21
- package/lib/types/dsh/metrics/events.d.ts +1 -1
- package/lib/types/dsh/metrics/observer.d.ts +1 -1
- package/lib/types/dsh/sandbox.d.ts +2 -2
- package/lib/types/dsh/shell-hook.d.ts +1 -1
- package/lib/types/dsh/tools/edit.d.ts +10 -0
- package/lib/types/dsh/tools/read.d.ts +3 -3
- package/lib/types/src/domain-errors.d.ts +2 -16
- package/lib/types/src/edit-tool.d.ts +41 -0
- package/lib/types/src/edit-undo.d.ts +2 -11
- package/lib/types/src/file-content/index.d.ts +1 -1
- package/lib/types/src/file-content/preview.d.ts +1 -1
- package/lib/types/src/hash-store.d.ts +2 -2
- package/lib/types/src/hashline/alphabet.d.ts +16 -1
- package/lib/types/src/hashline/hash-identity.d.ts +10 -4
- package/lib/types/src/mutation-engine/engine.d.ts +31 -0
- package/lib/types/src/mutation-engine/pipeline.d.ts +1 -8
- package/lib/types/src/mutation-engine/types.d.ts +50 -40
- package/lib/types/src/payload-contract.d.ts +135 -0
- package/lib/types/src/served-session/serve-render.d.ts +0 -6
- package/lib/types/src/undo-store.d.ts +1 -17
- package/package.json +21 -18
- package/prompts/edit-guidelines.md +10 -0
- package/prompts/edit-snippet.md +1 -0
- package/prompts/edit.md +1 -0
- package/prompts/read-guidelines.md +4 -4
- 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/bash-classifier.ts +1626 -0
- package/src/bash-conversion/decide.ts +812 -0
- package/src/bash-display-transform.ts +50 -0
- package/src/bash-multifile-config.ts +239 -0
- package/src/bash-view-config.ts +158 -0
- package/src/cut-repair.ts +11 -77
- package/src/domain-errors.ts +3 -35
- package/src/edit-normalize.ts +8 -0
- package/src/edit-render.ts +161 -0
- package/src/edit-tool.ts +118 -0
- package/src/edit-undo.ts +25 -379
- package/src/edit.ts +136 -0
- package/src/file-content/index.ts +1 -1
- package/src/file-content/preview.ts +1 -1
- package/src/file-content/served-header.ts +1 -1
- package/src/hash-store.ts +1 -18
- package/src/hashline/alphabet.ts +42 -1
- package/src/hashline/hash-identity.ts +12 -3
- package/src/integrations/pi-lens/command.ts +712 -0
- package/src/integrations/pi-lens/config.ts +148 -0
- package/src/integrations/pi-lens/io-bridge-adapter.ts +234 -0
- package/src/integrations/pi-lens/io-bridge.ts +112 -0
- package/src/integrations/pi-lens/settings-overlay.ts +272 -0
- package/src/lifecycle-hooks/index.ts +1992 -0
- package/src/lifecycle-hooks/types.ts +64 -0
- package/src/mutated-files.ts +75 -0
- package/src/mutation-engine/engine.ts +239 -0
- package/src/mutation-engine/index.ts +17 -0
- package/src/mutation-engine/pipeline.ts +27 -198
- package/src/mutation-engine/types.ts +57 -39
- package/src/mutation-queue.ts +1 -1
- package/src/payload-contract.ts +685 -0
- package/src/preview-controller.ts +84 -0
- package/src/prompts.ts +43 -10
- package/src/read.ts +865 -0
- package/src/search-hook.ts +41 -0
- package/src/served-session/serve-render.ts +2 -2
- package/src/store-config.ts +73 -0
- package/src/store-janitor-warning.ts +169 -0
- package/src/store-janitor.ts +539 -0
- package/src/store-kernel/settings.ts +233 -0
- package/src/store.ts +154 -0
- package/src/tui-presenter.ts +134 -0
- package/src/undo-store.ts +5 -74
- package/src/write-hook.ts +130 -0
- package/lib/types/dsh/hide.d.ts +0 -79
- package/lib/types/dsh/tools/apply-patch.d.ts +0 -38
- package/lib/types/src/apply-patch/commit.d.ts +0 -49
- package/lib/types/src/apply-patch/grammar.d.ts +0 -29
- package/lib/types/src/apply-patch/lifecycle.d.ts +0 -32
- package/lib/types/src/apply-patch/plan.d.ts +0 -50
- package/lib/types/src/apply-patch/render.d.ts +0 -51
- package/lib/types/src/apply-patch/resolve.d.ts +0 -129
- package/lib/types/src/apply-patch/tool.d.ts +0 -54
- package/lib/types/src/apply-patch/types.d.ts +0 -362
- package/lib/types/src/fs-lifecycle.d.ts +0 -44
- package/lib/types/src/host-utils.d.ts +0 -63
- package/lib/types/src/mutation-engine/edit-request.d.ts +0 -34
- package/prompts/apply-patch-guidelines.md +0 -10
- package/prompts/apply-patch.md +0 -1
- package/src/apply-patch/commit.ts +0 -190
- package/src/apply-patch/grammar.ts +0 -620
- package/src/apply-patch/index.ts +0 -17
- package/src/apply-patch/lifecycle.ts +0 -195
- package/src/apply-patch/plan.ts +0 -800
- package/src/apply-patch/render.ts +0 -137
- package/src/apply-patch/resolve.ts +0 -470
- package/src/apply-patch/tool.ts +0 -257
- package/src/apply-patch/types.ts +0 -387
- package/src/fs-lifecycle.ts +0 -201
- package/src/host-utils.ts +0 -224
- package/src/mutation-engine/edit-request.ts +0 -39
package/src/fs-lifecycle.ts
DELETED
|
@@ -1,201 +0,0 @@
|
|
|
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/host-utils.ts
DELETED
|
@@ -1,224 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import type { EditPlacement } from "../hashline/resolve.js";
|
|
2
|
-
|
|
3
|
-
/** The internal payload vocabulary the mutation engine consumes; no wire schema lives here. */
|
|
4
|
-
type SpanRef = {
|
|
5
|
-
anchor_from: string;
|
|
6
|
-
anchor_to: string;
|
|
7
|
-
file?: string;
|
|
8
|
-
mode: "copy" | "cut";
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
type EditMode = "general" | "literal";
|
|
12
|
-
|
|
13
|
-
type Placement = "in-place" | "before" | "after";
|
|
14
|
-
|
|
15
|
-
export type DesiredContent =
|
|
16
|
-
| { kind: "literal"; text: string }
|
|
17
|
-
| { kind: "reference"; span: SpanRef; mode: "copy" | "cut" }
|
|
18
|
-
| { kind: "empty" };
|
|
19
|
-
|
|
20
|
-
export type NormalizedEditItem = {
|
|
21
|
-
target: { anchor_from: string; anchor_to: string };
|
|
22
|
-
at: Placement;
|
|
23
|
-
payload: DesiredContent;
|
|
24
|
-
file?: string;
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
export type NormalizedEditRequest = {
|
|
28
|
-
file: string;
|
|
29
|
-
edits: NormalizedEditItem[];
|
|
30
|
-
mode?: EditMode;
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
// WHY: the engine seam's placement union must stay mutually assignable with the hashline resolver's
|
|
34
|
-
// WHY: EditPlacement; this assertion fails typecheck if either side drifts.
|
|
35
|
-
const _placementAgreement: [
|
|
36
|
-
Placement extends EditPlacement ? true : never,
|
|
37
|
-
EditPlacement extends Placement ? true : never,
|
|
38
|
-
] = [true, true];
|
|
39
|
-
void _placementAgreement;
|