@aexol/spectral 0.9.127 → 0.9.129
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/dist/agent/index.js +2 -2
- package/dist/sdk/agent-core/agent-loop.js +1 -1
- package/dist/sdk/coding-agent/core/extensions/runner.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/extensions/runner.js +15 -1
- package/dist/sdk/coding-agent/core/tools/apply-patch.d.ts +7 -2
- package/dist/sdk/coding-agent/core/tools/apply-patch.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/tools/apply-patch.js +219 -41
- package/dist/sdk/coding-agent/core/tools/atomic-write.d.ts +30 -0
- package/dist/sdk/coding-agent/core/tools/atomic-write.d.ts.map +1 -0
- package/dist/sdk/coding-agent/core/tools/atomic-write.js +42 -0
- package/dist/sdk/coding-agent/core/tools/edit-diff.d.ts +10 -4
- package/dist/sdk/coding-agent/core/tools/edit-diff.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/tools/edit-diff.js +44 -24
- package/dist/sdk/coding-agent/core/tools/edit.d.ts +6 -0
- package/dist/sdk/coding-agent/core/tools/edit.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/tools/edit.js +31 -5
- package/dist/sdk/coding-agent/core/tools/file-mutation-queue.d.ts +9 -3
- package/dist/sdk/coding-agent/core/tools/file-mutation-queue.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/tools/file-mutation-queue.js +42 -3
- package/dist/sdk/coding-agent/core/tools/read.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/tools/read.js +79 -47
- package/dist/sdk/coding-agent/core/tools/truncate.d.ts +4 -2
- package/dist/sdk/coding-agent/core/tools/truncate.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/tools/truncate.js +43 -23
- package/dist/sdk/coding-agent/core/tools/write.d.ts +5 -8
- package/dist/sdk/coding-agent/core/tools/write.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/tools/write.js +29 -11
- package/package.json +1 -1
|
@@ -7,13 +7,21 @@ import { constants } from "fs";
|
|
|
7
7
|
import { access, readFile } from "fs/promises";
|
|
8
8
|
import { resolveToCwd } from "./path-utils.js";
|
|
9
9
|
export function detectLineEnding(content) {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
let crlfCount = 0;
|
|
11
|
+
let lfCount = 0;
|
|
12
|
+
for (let i = 0; i < content.length; i++) {
|
|
13
|
+
if (content[i] !== "\n")
|
|
14
|
+
continue;
|
|
15
|
+
if (i > 0 && content[i - 1] === "\r") {
|
|
16
|
+
crlfCount++;
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
lfCount++;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (crlfCount === 0 && lfCount === 0)
|
|
15
23
|
return "\n";
|
|
16
|
-
return
|
|
24
|
+
return crlfCount > lfCount ? "\r\n" : "\n";
|
|
17
25
|
}
|
|
18
26
|
export function normalizeToLF(text) {
|
|
19
27
|
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
@@ -22,31 +30,26 @@ export function restoreLineEndings(text, ending) {
|
|
|
22
30
|
return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
|
|
23
31
|
}
|
|
24
32
|
/**
|
|
25
|
-
* Normalize text for fuzzy matching. Applies
|
|
33
|
+
* Normalize text for fuzzy matching. Applies only semantically-safe
|
|
34
|
+
* whitespace/visibility transformations:
|
|
26
35
|
* - Strip trailing whitespace from each line
|
|
27
|
-
* - Normalize
|
|
28
|
-
*
|
|
29
|
-
*
|
|
36
|
+
* - Normalize special Unicode spaces to a regular space
|
|
37
|
+
*
|
|
38
|
+
* Intentionally does NOT perform NFKC compatibility decomposition, smart-quote
|
|
39
|
+
* → straight-quote, or dash/hyphen substitutions: those transformations make
|
|
40
|
+
* semantically different code match silently. Matching across those characters
|
|
41
|
+
* requires the oldText to use the exact same characters as the file.
|
|
30
42
|
*/
|
|
31
43
|
export function normalizeForFuzzyMatch(text) {
|
|
32
|
-
return
|
|
33
|
-
|
|
34
|
-
// Strip trailing whitespace per line
|
|
44
|
+
return text
|
|
45
|
+
// Strip trailing whitespace per line (tab/space visibility tolerance)
|
|
35
46
|
.split("\n")
|
|
36
47
|
.map((line) => line.trimEnd())
|
|
37
48
|
.join("\n")
|
|
38
|
-
//
|
|
39
|
-
.replace(/[\u2018\u2019\u201A\u201B]/g, "'")
|
|
40
|
-
// Smart double quotes → "
|
|
41
|
-
.replace(/[\u201C\u201D\u201E\u201F]/g, '"')
|
|
42
|
-
// Various dashes/hyphens → -
|
|
43
|
-
// U+2010 hyphen, U+2011 non-breaking hyphen, U+2012 figure dash,
|
|
44
|
-
// U+2013 en-dash, U+2014 em-dash, U+2015 horizontal bar, U+2212 minus
|
|
45
|
-
.replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-")
|
|
46
|
-
// Special spaces → regular space
|
|
49
|
+
// Special spaces → regular space (whitespace-equivalence only)
|
|
47
50
|
// U+00A0 NBSP, U+2002-U+200A various spaces, U+202F narrow NBSP,
|
|
48
51
|
// U+205F medium math space, U+3000 ideographic space
|
|
49
|
-
.replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ")
|
|
52
|
+
.replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ");
|
|
50
53
|
}
|
|
51
54
|
function findExactMatches(content, oldText) {
|
|
52
55
|
const matches = [];
|
|
@@ -194,6 +197,12 @@ function findLineTrimmedMatches(content, oldText) {
|
|
|
194
197
|
}
|
|
195
198
|
return dedupeMatches(matches);
|
|
196
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* Files at or above this size skip the O(n) normalized index map. Line-trimmed
|
|
202
|
+
* matching (which already covers tab/space/CRLF tolerance) is still attempted;
|
|
203
|
+
* only the costly normalized pass is skipped for large files.
|
|
204
|
+
*/
|
|
205
|
+
const NORMALIZED_MATCH_MAX_BYTES = 256 * 1024; // 256KB
|
|
197
206
|
function findTextMatches(content, oldText) {
|
|
198
207
|
const exactMatches = findExactMatches(content, oldText);
|
|
199
208
|
if (exactMatches.length > 0)
|
|
@@ -201,6 +210,8 @@ function findTextMatches(content, oldText) {
|
|
|
201
210
|
const lineTrimmedMatches = findLineTrimmedMatches(content, oldText);
|
|
202
211
|
if (lineTrimmedMatches.length > 0)
|
|
203
212
|
return lineTrimmedMatches;
|
|
213
|
+
if (content.length >= NORMALIZED_MATCH_MAX_BYTES)
|
|
214
|
+
return [];
|
|
204
215
|
return findNormalizedTextMatches(content, oldText);
|
|
205
216
|
}
|
|
206
217
|
export function fuzzyFindText(content, oldText) {
|
|
@@ -240,8 +251,14 @@ function adaptIndentationFlexibleReplacement(oldText, newText, matchedText) {
|
|
|
240
251
|
const matchedBaseIndent = leadingWhitespace(matchedBaseLine);
|
|
241
252
|
if (oldBaseIndent === matchedBaseIndent)
|
|
242
253
|
return newText;
|
|
254
|
+
// When oldText/matchedText/newText have differing line counts, only adapt the
|
|
255
|
+
// common-length prefix; leave the remaining newText lines untouched so a
|
|
256
|
+
// divergent tail is not corrupted by fabricated base-indent prefixes.
|
|
257
|
+
const commonLength = Math.min(oldLines.length, matchedLines.length, newLines.length);
|
|
243
258
|
return newLines
|
|
244
259
|
.map((line, index) => {
|
|
260
|
+
if (index >= commonLength)
|
|
261
|
+
return line;
|
|
245
262
|
if (line.trim().length === 0)
|
|
246
263
|
return line;
|
|
247
264
|
const oldLine = oldLines[index];
|
|
@@ -312,6 +329,7 @@ export function applyEditsToNormalizedContent(normalizedContent, edits, path) {
|
|
|
312
329
|
}
|
|
313
330
|
const baseContent = normalizedContent;
|
|
314
331
|
const matchedEdits = [];
|
|
332
|
+
let usedFuzzyMatch = false;
|
|
315
333
|
for (let i = 0; i < normalizedEdits.length; i++) {
|
|
316
334
|
const edit = normalizedEdits[i];
|
|
317
335
|
const matches = findTextMatches(baseContent, edit.oldText);
|
|
@@ -322,6 +340,8 @@ export function applyEditsToNormalizedContent(normalizedContent, edits, path) {
|
|
|
322
340
|
throw getDuplicateError(path, i, normalizedEdits.length, matches.length);
|
|
323
341
|
}
|
|
324
342
|
const match = matches[0];
|
|
343
|
+
if (match.strategy !== "exact")
|
|
344
|
+
usedFuzzyMatch = true;
|
|
325
345
|
matchedEdits.push({
|
|
326
346
|
editIndex: i,
|
|
327
347
|
matchIndex: match.index,
|
|
@@ -348,7 +368,7 @@ export function applyEditsToNormalizedContent(normalizedContent, edits, path) {
|
|
|
348
368
|
if (baseContent === newContent) {
|
|
349
369
|
throw getNoChangeError(path, normalizedEdits.length);
|
|
350
370
|
}
|
|
351
|
-
return { baseContent, newContent };
|
|
371
|
+
return { baseContent, newContent, usedFuzzyMatch };
|
|
352
372
|
}
|
|
353
373
|
/** Generate a standard unified patch. */
|
|
354
374
|
export function generateUnifiedPatch(path, oldContent, newContent, contextLines = 4) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AgentTool } from "../../../agent-core/index.js";
|
|
2
|
+
import type { Stats } from "fs";
|
|
2
3
|
import { type Static, Type } from "typebox";
|
|
3
4
|
import type { ToolDefinition } from "../extensions/types.js";
|
|
4
5
|
declare const editSchema: Type.TObject<{
|
|
@@ -28,6 +29,11 @@ export interface EditOperations {
|
|
|
28
29
|
writeFile: (absolutePath: string, content: string) => Promise<void>;
|
|
29
30
|
/** Check if file is readable and writable (throw if not) */
|
|
30
31
|
access: (absolutePath: string) => Promise<void>;
|
|
32
|
+
/**
|
|
33
|
+
* Stat a file to obtain a freshness token (mtime). Used to detect stale reads
|
|
34
|
+
* between read and write. Optional — defaults to local fs.stat.
|
|
35
|
+
*/
|
|
36
|
+
stat?: (absolutePath: string) => Promise<Stats>;
|
|
31
37
|
}
|
|
32
38
|
export interface EditToolOptions {
|
|
33
39
|
/** Custom operations for file editing. Default: local filesystem */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"edit.d.ts","sourceRoot":"","sources":["../../../../../src/sdk/coding-agent/core/tools/edit.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,8BAA8B,CAAC;AAG9D,OAAO,EAAE,KAAK,MAAM,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAE5C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;
|
|
1
|
+
{"version":3,"file":"edit.d.ts","sourceRoot":"","sources":["../../../../../src/sdk/coding-agent/core/tools/edit.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,8BAA8B,CAAC;AAG9D,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,IAAI,CAAC;AAChC,OAAO,EAAE,KAAK,MAAM,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAE5C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AA8B7D,QAAA,MAAM,UAAU;;;;;;EASf,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG,MAAM,CAAC,OAAO,UAAU,CAAC,CAAC;AAMtD,MAAM,WAAW,eAAe;IAC/B,gDAAgD;IAChD,IAAI,EAAE,MAAM,CAAC;IACb,iDAAiD;IACjD,KAAK,EAAE,MAAM,CAAC;IACd,8EAA8E;IAC9E,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC9B,qCAAqC;IACrC,QAAQ,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACpD,8BAA8B;IAC9B,SAAS,EAAE,CAAC,YAAY,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACpE,4DAA4D;IAC5D,MAAM,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAChD;;;OAGG;IACH,IAAI,CAAC,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,KAAK,CAAC,CAAC;CAChD;AASD,MAAM,WAAW,eAAe;IAC/B,oEAAoE;IACpE,UAAU,CAAC,EAAE,cAAc,CAAC;CAC5B;AAqGD,wBAAgB,wBAAwB,CACvC,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,eAAe,GACvB,cAAc,CAAC,OAAO,UAAU,EAAE,eAAe,GAAG,SAAS,CAAC,CAyJhE;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,eAAe,GAAG,SAAS,CAAC,OAAO,UAAU,CAAC,CAEnG"}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { constants } from "fs";
|
|
2
|
-
import { access as fsAccess, readFile as fsReadFile, writeFile as fsWriteFile } from "fs/promises";
|
|
2
|
+
import { access as fsAccess, readFile as fsReadFile, stat as fsStat, writeFile as fsWriteFile } from "fs/promises";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
4
|
import { renderDiff } from "./render-diff.js";
|
|
5
5
|
import { applyEditsToNormalizedContent, detectLineEnding, generateDiffString, generateUnifiedPatch, normalizeToLF, restoreLineEndings, stripBom, } from "./edit-diff.js";
|
|
6
|
+
import { atomicWriteFile } from "./atomic-write.js";
|
|
6
7
|
import { withFileMutationQueue } from "./file-mutation-queue.js";
|
|
7
8
|
import { resolveToCwd } from "./path-utils.js";
|
|
8
9
|
import { shortenPath, str } from "./render-utils.js";
|
|
@@ -23,6 +24,7 @@ const defaultEditOperations = {
|
|
|
23
24
|
readFile: (path) => fsReadFile(path),
|
|
24
25
|
writeFile: (path, content) => fsWriteFile(path, content, "utf-8"),
|
|
25
26
|
access: (path) => fsAccess(path, constants.R_OK | constants.W_OK),
|
|
27
|
+
stat: (path) => fsStat(path),
|
|
26
28
|
};
|
|
27
29
|
function prepareEditArguments(input) {
|
|
28
30
|
if (!input || typeof input !== "object") {
|
|
@@ -36,7 +38,10 @@ function prepareEditArguments(input) {
|
|
|
36
38
|
if (Array.isArray(parsed))
|
|
37
39
|
args.edits = parsed;
|
|
38
40
|
}
|
|
39
|
-
catch {
|
|
41
|
+
catch (error) {
|
|
42
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
43
|
+
throw new Error(`Invalid "edits" argument for edit tool: expected an array but received a string that could not be parsed as JSON. Parse error: ${reason}`);
|
|
44
|
+
}
|
|
40
45
|
}
|
|
41
46
|
const legacy = args;
|
|
42
47
|
if (typeof legacy.oldText !== "string" || typeof legacy.newText !== "string") {
|
|
@@ -51,6 +56,14 @@ function validateEditInput(input) {
|
|
|
51
56
|
if (!Array.isArray(input.edits) || input.edits.length === 0) {
|
|
52
57
|
throw new Error("Edit tool input is invalid. edits must contain at least one replacement.");
|
|
53
58
|
}
|
|
59
|
+
for (const edit of input.edits) {
|
|
60
|
+
if (!edit ||
|
|
61
|
+
typeof edit !== "object" ||
|
|
62
|
+
typeof edit.oldText !== "string" ||
|
|
63
|
+
typeof edit.newText !== "string") {
|
|
64
|
+
throw new Error("Edit tool input is invalid. Each edit must have string oldText and newText properties.");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
54
67
|
return { path: input.path, edits: input.edits };
|
|
55
68
|
}
|
|
56
69
|
function formatEditCall(args, _theme) {
|
|
@@ -136,6 +149,8 @@ export function createEditToolDefinition(cwd, options) {
|
|
|
136
149
|
// Read the file.
|
|
137
150
|
const buffer = await ops.readFile(absolutePath);
|
|
138
151
|
const rawContent = buffer.toString("utf-8");
|
|
152
|
+
// Capture a freshness token so we can detect stale reads before writing.
|
|
153
|
+
const freshnessTokenBefore = ops.stat ? (await ops.stat(absolutePath)).mtimeMs : undefined;
|
|
139
154
|
// Check if aborted after reading.
|
|
140
155
|
if (aborted) {
|
|
141
156
|
return;
|
|
@@ -144,13 +159,24 @@ export function createEditToolDefinition(cwd, options) {
|
|
|
144
159
|
const { bom, text: content } = stripBom(rawContent);
|
|
145
160
|
const originalEnding = detectLineEnding(content);
|
|
146
161
|
const normalizedContent = normalizeToLF(content);
|
|
147
|
-
const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path);
|
|
162
|
+
const { baseContent, newContent, usedFuzzyMatch } = applyEditsToNormalizedContent(normalizedContent, edits, path);
|
|
148
163
|
// Check if aborted before writing.
|
|
149
164
|
if (aborted) {
|
|
150
165
|
return;
|
|
151
166
|
}
|
|
167
|
+
// Detect stale file: re-check freshness token before writing.
|
|
168
|
+
if (freshnessTokenBefore !== undefined && ops.stat) {
|
|
169
|
+
const freshnessTokenAfter = (await ops.stat(absolutePath)).mtimeMs;
|
|
170
|
+
if (freshnessTokenAfter !== freshnessTokenBefore) {
|
|
171
|
+
if (signal) {
|
|
172
|
+
signal.removeEventListener("abort", onAbort);
|
|
173
|
+
}
|
|
174
|
+
reject(new Error(`File ${path} was modified since read. Re-read the file and retry your edit.`));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
152
178
|
const finalContent = bom + restoreLineEndings(newContent, originalEnding);
|
|
153
|
-
await
|
|
179
|
+
await atomicWriteFile(absolutePath, finalContent);
|
|
154
180
|
// Check if aborted after writing.
|
|
155
181
|
if (aborted) {
|
|
156
182
|
return;
|
|
@@ -165,7 +191,7 @@ export function createEditToolDefinition(cwd, options) {
|
|
|
165
191
|
content: [
|
|
166
192
|
{
|
|
167
193
|
type: "text",
|
|
168
|
-
text: `Successfully replaced ${edits.length} block(s) in ${path}
|
|
194
|
+
text: `Successfully replaced ${edits.length} block(s) in ${path}.${usedFuzzyMatch ? " (used tolerant matching — verify the change)" : ""}`,
|
|
169
195
|
},
|
|
170
196
|
],
|
|
171
197
|
details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Run `fn` while holding the per-file mutation lock for `filePath`.
|
|
3
|
+
*
|
|
4
|
+
* `filePath` may be a single path or an array of paths. For an array, each
|
|
5
|
+
* file gets its own per-file queue key (NOT a joined blob — a joined key would
|
|
6
|
+
* fail to serialize against single-path locks on the same file). Keys are
|
|
7
|
+
* sorted and acquired in a fixed order to avoid deadlock between two array
|
|
8
|
+
* locks that share a subset of files. The callback runs only once all locks
|
|
9
|
+
* are held and every lock is released in the `finally` block.
|
|
4
10
|
*/
|
|
5
|
-
export declare function withFileMutationQueue<T>(filePath: string, fn: () => Promise<T>): Promise<T>;
|
|
11
|
+
export declare function withFileMutationQueue<T>(filePath: string | string[], fn: () => Promise<T>): Promise<T>;
|
|
6
12
|
//# sourceMappingURL=file-mutation-queue.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"file-mutation-queue.d.ts","sourceRoot":"","sources":["../../../../../src/sdk/coding-agent/core/tools/file-mutation-queue.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"file-mutation-queue.d.ts","sourceRoot":"","sources":["../../../../../src/sdk/coding-agent/core/tools/file-mutation-queue.ts"],"names":[],"mappings":"AAqBA;;;;;;;;;GASG;AACH,wBAAsB,qBAAqB,CAAC,CAAC,EAC5C,QAAQ,EAAE,MAAM,GAAG,MAAM,EAAE,EAC3B,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAClB,OAAO,CAAC,CAAC,CAAC,CA0BZ"}
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { realpathSync } from "node:fs";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
const fileMutationQueues = new Map();
|
|
4
|
+
/**
|
|
5
|
+
* Derive a stable per-file queue key. Tries realpath first (canonicalizes
|
|
6
|
+
* symlinks and existing files); falls back to the resolved path for files that
|
|
7
|
+
* do not exist yet (e.g. Add File targets). The fallback is essential —
|
|
8
|
+
* realpathSync throws ENOENT for non-existent files, which would otherwise
|
|
9
|
+
* produce a different key before vs after creation.
|
|
10
|
+
*/
|
|
4
11
|
function getMutationQueueKey(filePath) {
|
|
5
12
|
const resolvedPath = resolve(filePath);
|
|
6
13
|
try {
|
|
@@ -11,11 +18,43 @@ function getMutationQueueKey(filePath) {
|
|
|
11
18
|
}
|
|
12
19
|
}
|
|
13
20
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
21
|
+
* Run `fn` while holding the per-file mutation lock for `filePath`.
|
|
22
|
+
*
|
|
23
|
+
* `filePath` may be a single path or an array of paths. For an array, each
|
|
24
|
+
* file gets its own per-file queue key (NOT a joined blob — a joined key would
|
|
25
|
+
* fail to serialize against single-path locks on the same file). Keys are
|
|
26
|
+
* sorted and acquired in a fixed order to avoid deadlock between two array
|
|
27
|
+
* locks that share a subset of files. The callback runs only once all locks
|
|
28
|
+
* are held and every lock is released in the `finally` block.
|
|
16
29
|
*/
|
|
17
30
|
export async function withFileMutationQueue(filePath, fn) {
|
|
18
|
-
const
|
|
31
|
+
const paths = Array.isArray(filePath) ? filePath : [filePath];
|
|
32
|
+
// Empty/empty-array: no locks to acquire, just run the callback.
|
|
33
|
+
if (paths.length === 0) {
|
|
34
|
+
return fn();
|
|
35
|
+
}
|
|
36
|
+
// Dedupe and sort per-file keys. Sorting gives a stable acquisition order so
|
|
37
|
+
// two overlapping array locks cannot deadlock each other.
|
|
38
|
+
const keys = Array.from(new Set(paths.map((p) => getMutationQueueKey(p)))).sort();
|
|
39
|
+
// For a single unique key, use the original single-path fast path.
|
|
40
|
+
if (keys.length === 1) {
|
|
41
|
+
return withSingleFileMutationQueue(keys[0], fn);
|
|
42
|
+
}
|
|
43
|
+
// For multiple keys, chain each lock. Each step waits on the previous
|
|
44
|
+
// queue before acquiring the next, so the callback runs only once every
|
|
45
|
+
// per-file lock is held.
|
|
46
|
+
let chain = fn;
|
|
47
|
+
for (const key of keys) {
|
|
48
|
+
const next = chain;
|
|
49
|
+
chain = () => withSingleFileMutationQueue(key, next);
|
|
50
|
+
}
|
|
51
|
+
return chain();
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Acquire a single per-file queue, run `fn`, then release. This is the core
|
|
55
|
+
* lock primitive shared by the single-path and array paths above.
|
|
56
|
+
*/
|
|
57
|
+
async function withSingleFileMutationQueue(key, fn) {
|
|
19
58
|
const currentQueue = fileMutationQueues.get(key) ?? Promise.resolve();
|
|
20
59
|
let releaseNext;
|
|
21
60
|
const nextQueue = new Promise((resolveQueue) => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"read.d.ts","sourceRoot":"","sources":["../../../../../src/sdk/coding-agent/core/tools/read.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,8BAA8B,CAAC;AAI9D,OAAO,EAAE,KAAK,MAAM,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAM5C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAI7D,OAAO,EAAoD,KAAK,gBAAgB,EAAgB,MAAM,eAAe,CAAC;AAEtH,QAAA,MAAM,UAAU;;;;EAId,CAAC;AAEH,MAAM,MAAM,aAAa,GAAG,MAAM,CAAC,OAAO,UAAU,CAAC,CAAC;AAEtD,MAAM,WAAW,eAAe;IAC/B,UAAU,CAAC,EAAE,gBAAgB,CAAC;CAC9B;AASD;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC9B,qCAAqC;IACrC,QAAQ,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACpD,+CAA+C;IAC/C,MAAM,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAChD,sEAAsE;IACtE,mBAAmB,CAAC,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;CACnF;AAQD,MAAM,WAAW,eAAe;IAC/B,oEAAoE;IACpE,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,oEAAoE;IACpE,UAAU,CAAC,EAAE,cAAc,CAAC;CAC5B;AAkID,wBAAgB,wBAAwB,CACvC,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,eAAe,GACvB,cAAc,CAAC,OAAO,UAAU,EAAE,eAAe,GAAG,SAAS,CAAC,
|
|
1
|
+
{"version":3,"file":"read.d.ts","sourceRoot":"","sources":["../../../../../src/sdk/coding-agent/core/tools/read.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,8BAA8B,CAAC;AAI9D,OAAO,EAAE,KAAK,MAAM,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAM5C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAI7D,OAAO,EAAoD,KAAK,gBAAgB,EAAgB,MAAM,eAAe,CAAC;AAEtH,QAAA,MAAM,UAAU;;;;EAId,CAAC;AAEH,MAAM,MAAM,aAAa,GAAG,MAAM,CAAC,OAAO,UAAU,CAAC,CAAC;AAEtD,MAAM,WAAW,eAAe;IAC/B,UAAU,CAAC,EAAE,gBAAgB,CAAC;CAC9B;AASD;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC9B,qCAAqC;IACrC,QAAQ,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACpD,+CAA+C;IAC/C,MAAM,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAChD,sEAAsE;IACtE,mBAAmB,CAAC,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;CACnF;AAQD,MAAM,WAAW,eAAe;IAC/B,oEAAoE;IACpE,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,oEAAoE;IACpE,UAAU,CAAC,EAAE,cAAc,CAAC;CAC5B;AAkID,wBAAgB,wBAAwB,CACvC,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,eAAe,GACvB,cAAc,CAAC,OAAO,UAAU,EAAE,eAAe,GAAG,SAAS,CAAC,CA8KhE;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,eAAe,GAAG,SAAS,CAAC,OAAO,UAAU,CAAC,CAEnG"}
|
|
@@ -131,6 +131,13 @@ export function createReadToolDefinition(cwd, options) {
|
|
|
131
131
|
parameters: readSchema,
|
|
132
132
|
async execute(_toolCallId, { path, offset, limit }, signal, _onUpdate, ctx) {
|
|
133
133
|
const absolutePath = resolveReadPath(path, cwd);
|
|
134
|
+
// Validate offset/limit before doing any I/O. Distinguish undefined (unset) from 0.
|
|
135
|
+
if (offset !== undefined && offset <= 0) {
|
|
136
|
+
throw new Error("offset must be ≥ 1");
|
|
137
|
+
}
|
|
138
|
+
if (limit !== undefined && limit <= 0) {
|
|
139
|
+
throw new Error("limit must be ≥ 1");
|
|
140
|
+
}
|
|
134
141
|
return new Promise((resolve, reject) => {
|
|
135
142
|
if (signal?.aborted) {
|
|
136
143
|
reject(new Error("Operation aborted"));
|
|
@@ -199,60 +206,85 @@ export function createReadToolDefinition(cwd, options) {
|
|
|
199
206
|
else {
|
|
200
207
|
// Read text content.
|
|
201
208
|
const buffer = await ops.readFile(absolutePath);
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
//
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
209
|
+
// Binary file detection: scan the first chunk for NUL bytes.
|
|
210
|
+
// Legitimate UTF-8 (including emoji and CJK) never contains NUL bytes, so
|
|
211
|
+
// a NUL byte is a strong signal that this is a non-text binary (executable,
|
|
212
|
+
// archive, etc.). Return a note instead of decoding garbage UTF-8.
|
|
213
|
+
const binaryScanLen = Math.min(buffer.length, 8192);
|
|
214
|
+
let isBinary = false;
|
|
215
|
+
for (let i = 0; i < binaryScanLen; i++) {
|
|
216
|
+
if (buffer[i] === 0x00) {
|
|
217
|
+
isBinary = true;
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
211
220
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const endLine = Math.min(startLine + limit, allLines.length);
|
|
217
|
-
selectedContent = allLines.slice(startLine, endLine).join("\n");
|
|
218
|
-
userLimitedLines = endLine - startLine;
|
|
221
|
+
if (isBinary) {
|
|
222
|
+
content = [
|
|
223
|
+
{ type: "text", text: `[Binary file, ${formatSize(buffer.length)}, not displayed]` },
|
|
224
|
+
];
|
|
219
225
|
}
|
|
220
226
|
else {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
227
|
+
const textContent = buffer.toString("utf-8");
|
|
228
|
+
let allLines = textContent.split("\n");
|
|
229
|
+
// A trailing newline produces an extra empty element on split; drop it so the
|
|
230
|
+
// reported line count and all offset/limit math match the real content.
|
|
231
|
+
const totalFileLines = textContent.endsWith("\n") ? allLines.length - 1 : allLines.length;
|
|
232
|
+
if (textContent.endsWith("\n")) {
|
|
233
|
+
allLines = allLines.slice(0, -1);
|
|
234
|
+
}
|
|
235
|
+
// Apply offset if specified. Convert from 1-indexed input to 0-indexed array access.
|
|
236
|
+
// Use explicit undefined check so offset=0 is rejected above rather than treated as "no offset".
|
|
237
|
+
const startLine = offset !== undefined ? Math.max(0, offset - 1) : 0;
|
|
238
|
+
const startLineDisplay = startLine + 1;
|
|
239
|
+
// Check if offset is out of bounds.
|
|
240
|
+
if (startLine >= allLines.length) {
|
|
241
|
+
throw new Error(`Offset ${offset} is beyond end of file (${totalFileLines} lines total)`);
|
|
242
|
+
}
|
|
243
|
+
let selectedContent;
|
|
244
|
+
let userLimitedLines;
|
|
245
|
+
// If limit is specified by the user, honor it first. Otherwise truncateHead decides.
|
|
246
|
+
if (limit !== undefined) {
|
|
247
|
+
const endLine = Math.min(startLine + limit, allLines.length);
|
|
248
|
+
selectedContent = allLines.slice(startLine, endLine).join("\n");
|
|
249
|
+
userLimitedLines = endLine - startLine;
|
|
239
250
|
}
|
|
240
251
|
else {
|
|
241
|
-
|
|
252
|
+
selectedContent = allLines.slice(startLine).join("\n");
|
|
242
253
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
+
// Apply truncation, respecting both line and byte limits.
|
|
255
|
+
const truncation = truncateHead(selectedContent);
|
|
256
|
+
let outputText;
|
|
257
|
+
if (truncation.firstLineExceedsLimit) {
|
|
258
|
+
// First line alone exceeds the byte limit. Point the model at a bash fallback.
|
|
259
|
+
const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], "utf-8"));
|
|
260
|
+
outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`;
|
|
261
|
+
details = { truncation };
|
|
262
|
+
}
|
|
263
|
+
else if (truncation.truncated) {
|
|
264
|
+
// Truncation occurred. Build an actionable continuation notice.
|
|
265
|
+
const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
|
|
266
|
+
const nextOffset = endLineDisplay + 1;
|
|
267
|
+
outputText = truncation.content;
|
|
268
|
+
if (truncation.truncatedBy === "lines") {
|
|
269
|
+
outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`;
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.]`;
|
|
273
|
+
}
|
|
274
|
+
details = { truncation };
|
|
275
|
+
}
|
|
276
|
+
else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) {
|
|
277
|
+
// User-specified limit stopped early, but the file still has more content.
|
|
278
|
+
const remaining = allLines.length - (startLine + userLimitedLines);
|
|
279
|
+
const nextOffset = startLine + userLimitedLines + 1;
|
|
280
|
+
outputText = `${truncation.content}\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`;
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
// No truncation and no remaining user-limited content.
|
|
284
|
+
outputText = truncation.content;
|
|
285
|
+
}
|
|
286
|
+
content = [{ type: "text", text: outputText }];
|
|
254
287
|
}
|
|
255
|
-
content = [{ type: "text", text: outputText }];
|
|
256
288
|
}
|
|
257
289
|
if (aborted)
|
|
258
290
|
return;
|
|
@@ -48,8 +48,10 @@ export declare function formatSize(bytes: number): string;
|
|
|
48
48
|
* Truncate content from the head (keep first N lines/bytes).
|
|
49
49
|
* Suitable for file reads where you want to see the beginning.
|
|
50
50
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
51
|
+
* If a single line exceeds the byte limit, that line is byte-capped
|
|
52
|
+
* (keeping a valid UTF-8 boundary) rather than aborting the whole read;
|
|
53
|
+
* subsequent lines are still scanned. firstLineExceedsLimit is set true only
|
|
54
|
+
* when the oversized line is the first requested line.
|
|
53
55
|
*/
|
|
54
56
|
export declare function truncateHead(content: string, options?: TruncationOptions): TruncationResult;
|
|
55
57
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"truncate.d.ts","sourceRoot":"","sources":["../../../../../src/sdk/coding-agent/core/tools/truncate.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,eAAO,MAAM,iBAAiB,OAAO,CAAC;AACtC,eAAO,MAAM,iBAAiB,QAAY,CAAC;AAC3C,eAAO,MAAM,oBAAoB,MAAM,CAAC;AAExC,MAAM,WAAW,gBAAgB;IAChC,4BAA4B;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,kCAAkC;IAClC,SAAS,EAAE,OAAO,CAAC;IACnB,sEAAsE;IACtE,WAAW,EAAE,OAAO,GAAG,OAAO,GAAG,IAAI,CAAC;IACtC,oDAAoD;IACpD,UAAU,EAAE,MAAM,CAAC;IACnB,oDAAoD;IACpD,UAAU,EAAE,MAAM,CAAC;IACnB,uDAAuD;IACvD,WAAW,EAAE,MAAM,CAAC;IACpB,8CAA8C;IAC9C,WAAW,EAAE,MAAM,CAAC;IACpB,yFAAyF;IACzF,eAAe,EAAE,OAAO,CAAC;IACzB,2EAA2E;IAC3E,qBAAqB,EAAE,OAAO,CAAC;IAC/B,2CAA2C;IAC3C,QAAQ,EAAE,MAAM,CAAC;IACjB,2CAA2C;IAC3C,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,iBAAiB;IACjC,8CAA8C;IAC9C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,8CAA8C;IAC9C,QAAQ,CAAC,EAAE,MAAM,CAAC;CAClB;AAaD;;GAEG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAQhD;AAED
|
|
1
|
+
{"version":3,"file":"truncate.d.ts","sourceRoot":"","sources":["../../../../../src/sdk/coding-agent/core/tools/truncate.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,eAAO,MAAM,iBAAiB,OAAO,CAAC;AACtC,eAAO,MAAM,iBAAiB,QAAY,CAAC;AAC3C,eAAO,MAAM,oBAAoB,MAAM,CAAC;AAExC,MAAM,WAAW,gBAAgB;IAChC,4BAA4B;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,kCAAkC;IAClC,SAAS,EAAE,OAAO,CAAC;IACnB,sEAAsE;IACtE,WAAW,EAAE,OAAO,GAAG,OAAO,GAAG,IAAI,CAAC;IACtC,oDAAoD;IACpD,UAAU,EAAE,MAAM,CAAC;IACnB,oDAAoD;IACpD,UAAU,EAAE,MAAM,CAAC;IACnB,uDAAuD;IACvD,WAAW,EAAE,MAAM,CAAC;IACpB,8CAA8C;IAC9C,WAAW,EAAE,MAAM,CAAC;IACpB,yFAAyF;IACzF,eAAe,EAAE,OAAO,CAAC;IACzB,2EAA2E;IAC3E,qBAAqB,EAAE,OAAO,CAAC;IAC/B,2CAA2C;IAC3C,QAAQ,EAAE,MAAM,CAAC;IACjB,2CAA2C;IAC3C,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,iBAAiB;IACjC,8CAA8C;IAC9C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,8CAA8C;IAC9C,QAAQ,CAAC,EAAE,MAAM,CAAC;CAClB;AAaD;;GAEG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAQhD;AAED;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,GAAE,iBAAsB,GAAG,gBAAgB,CAkF/F;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,GAAE,iBAAsB,GAAG,gBAAgB,CAyE/F;AA2CD;;;GAGG;AACH,wBAAgB,YAAY,CAC3B,IAAI,EAAE,MAAM,EACZ,QAAQ,GAAE,MAA6B,GACrC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,OAAO,CAAA;CAAE,CAKzC"}
|
|
@@ -38,8 +38,10 @@ export function formatSize(bytes) {
|
|
|
38
38
|
* Truncate content from the head (keep first N lines/bytes).
|
|
39
39
|
* Suitable for file reads where you want to see the beginning.
|
|
40
40
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
41
|
+
* If a single line exceeds the byte limit, that line is byte-capped
|
|
42
|
+
* (keeping a valid UTF-8 boundary) rather than aborting the whole read;
|
|
43
|
+
* subsequent lines are still scanned. firstLineExceedsLimit is set true only
|
|
44
|
+
* when the oversized line is the first requested line.
|
|
43
45
|
*/
|
|
44
46
|
export function truncateHead(content, options = {}) {
|
|
45
47
|
const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
|
|
@@ -63,30 +65,31 @@ export function truncateHead(content, options = {}) {
|
|
|
63
65
|
maxBytes,
|
|
64
66
|
};
|
|
65
67
|
}
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
content: "",
|
|
71
|
-
truncated: true,
|
|
72
|
-
truncatedBy: "bytes",
|
|
73
|
-
totalLines,
|
|
74
|
-
totalBytes,
|
|
75
|
-
outputLines: 0,
|
|
76
|
-
outputBytes: 0,
|
|
77
|
-
lastLinePartial: false,
|
|
78
|
-
firstLineExceedsLimit: true,
|
|
79
|
-
maxLines,
|
|
80
|
-
maxBytes,
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
// Collect complete lines that fit
|
|
68
|
+
// Scan lines, byte-capping any single oversized line instead of aborting.
|
|
69
|
+
// firstLineExceedsLimit is surfaced only when the very first requested line
|
|
70
|
+
// is the oversized one, so callers that render a per-line hint (e.g. read)
|
|
71
|
+
// keep doing so without losing the rest of the content.
|
|
84
72
|
const outputLinesArr = [];
|
|
85
73
|
let outputBytesCount = 0;
|
|
86
74
|
let truncatedBy = "lines";
|
|
75
|
+
let firstLineExceedsLimit = false;
|
|
76
|
+
let lastLinePartial = false;
|
|
87
77
|
for (let i = 0; i < lines.length && i < maxLines; i++) {
|
|
88
78
|
const line = lines[i];
|
|
89
|
-
const
|
|
79
|
+
const newlineCost = i > 0 ? 1 : 0; // +1 for joining newline
|
|
80
|
+
let lineBytes = Buffer.byteLength(line, "utf-8");
|
|
81
|
+
// A single line larger than the whole byte budget: cap it in place.
|
|
82
|
+
if (lineBytes > maxBytes) {
|
|
83
|
+
if (i === 0)
|
|
84
|
+
firstLineExceedsLimit = true;
|
|
85
|
+
const capped = truncateStringToBytesFromStart(line, maxBytes);
|
|
86
|
+
outputLinesArr.push(capped);
|
|
87
|
+
outputBytesCount = maxBytes;
|
|
88
|
+
lastLinePartial = true;
|
|
89
|
+
truncatedBy = "bytes";
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
lineBytes += newlineCost;
|
|
90
93
|
if (outputBytesCount + lineBytes > maxBytes) {
|
|
91
94
|
truncatedBy = "bytes";
|
|
92
95
|
break;
|
|
@@ -108,8 +111,8 @@ export function truncateHead(content, options = {}) {
|
|
|
108
111
|
totalBytes,
|
|
109
112
|
outputLines: outputLinesArr.length,
|
|
110
113
|
outputBytes: finalOutputBytes,
|
|
111
|
-
lastLinePartial
|
|
112
|
-
firstLineExceedsLimit
|
|
114
|
+
lastLinePartial,
|
|
115
|
+
firstLineExceedsLimit,
|
|
113
116
|
maxLines,
|
|
114
117
|
maxBytes,
|
|
115
118
|
};
|
|
@@ -202,6 +205,23 @@ function truncateStringToBytesFromEnd(str, maxBytes) {
|
|
|
202
205
|
}
|
|
203
206
|
return buf.slice(start).toString("utf-8");
|
|
204
207
|
}
|
|
208
|
+
/**
|
|
209
|
+
* Truncate a string to fit within a byte limit (from the start).
|
|
210
|
+
* Handles multi-byte UTF-8 characters correctly.
|
|
211
|
+
*/
|
|
212
|
+
function truncateStringToBytesFromStart(str, maxBytes) {
|
|
213
|
+
const buf = Buffer.from(str, "utf-8");
|
|
214
|
+
if (buf.length <= maxBytes) {
|
|
215
|
+
return str;
|
|
216
|
+
}
|
|
217
|
+
let end = maxBytes;
|
|
218
|
+
// Walk back to a valid UTF-8 character boundary so we don't slice
|
|
219
|
+
// through the middle of a multi-byte sequence.
|
|
220
|
+
while (end > 0 && (buf[end] & 0xc0) === 0x80) {
|
|
221
|
+
end--;
|
|
222
|
+
}
|
|
223
|
+
return buf.slice(0, end).toString("utf-8");
|
|
224
|
+
}
|
|
205
225
|
/**
|
|
206
226
|
* Truncate a single line to max characters, adding [truncated] suffix.
|
|
207
227
|
* Used for grep match lines.
|