@namzu/sdk 39.0.0 → 40.0.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/CHANGELOG.md +151 -0
- package/dist/connector/mcp/adapter.d.ts.map +1 -1
- package/dist/connector/mcp/adapter.js +20 -6
- package/dist/connector/mcp/adapter.js.map +1 -1
- package/dist/manager/run/persistence.d.ts +8 -0
- package/dist/manager/run/persistence.d.ts.map +1 -1
- package/dist/manager/run/persistence.js +12 -0
- package/dist/manager/run/persistence.js.map +1 -1
- package/dist/public-runtime.d.ts +3 -1
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +5 -1
- package/dist/public-runtime.js.map +1 -1
- package/dist/public-tools.d.ts +11 -0
- package/dist/public-tools.d.ts.map +1 -1
- package/dist/public-tools.js +14 -0
- package/dist/public-tools.js.map +1 -1
- package/dist/registry/tool/execute.d.ts.map +1 -1
- package/dist/registry/tool/execute.js +2 -3
- package/dist/registry/tool/execute.js.map +1 -1
- package/dist/registry/tool/portable.d.ts +65 -0
- package/dist/registry/tool/portable.d.ts.map +1 -0
- package/dist/registry/tool/portable.js +244 -0
- package/dist/registry/tool/portable.js.map +1 -0
- package/dist/registry/tool/schema.d.ts +32 -5
- package/dist/registry/tool/schema.d.ts.map +1 -1
- package/dist/registry/tool/schema.js +35 -9
- package/dist/registry/tool/schema.js.map +1 -1
- package/dist/registry/toolset/catalog.js +8 -8
- package/dist/registry/toolset/catalog.js.map +1 -1
- package/dist/runtime/jobs/awaited-jobs.d.ts +215 -0
- package/dist/runtime/jobs/awaited-jobs.d.ts.map +1 -0
- package/dist/runtime/jobs/awaited-jobs.js +259 -0
- package/dist/runtime/jobs/awaited-jobs.js.map +1 -0
- package/dist/runtime/jobs/registry.d.ts +33 -2
- package/dist/runtime/jobs/registry.d.ts.map +1 -1
- package/dist/runtime/jobs/registry.js +37 -0
- package/dist/runtime/jobs/registry.js.map +1 -1
- package/dist/runtime/query/executor.d.ts +28 -0
- package/dist/runtime/query/executor.d.ts.map +1 -1
- package/dist/runtime/query/executor.js +39 -1
- package/dist/runtime/query/executor.js.map +1 -1
- package/dist/runtime/query/file-evidence-context.d.ts.map +1 -1
- package/dist/runtime/query/file-evidence-context.js +159 -43
- package/dist/runtime/query/file-evidence-context.js.map +1 -1
- package/dist/runtime/query/file-evidence-replay.d.ts +260 -0
- package/dist/runtime/query/file-evidence-replay.d.ts.map +1 -0
- package/dist/runtime/query/file-evidence-replay.js +647 -0
- package/dist/runtime/query/file-evidence-replay.js.map +1 -0
- package/dist/runtime/query/file-evidence-seed.d.ts +50 -0
- package/dist/runtime/query/file-evidence-seed.d.ts.map +1 -0
- package/dist/runtime/query/file-evidence-seed.js +100 -0
- package/dist/runtime/query/file-evidence-seed.js.map +1 -0
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +94 -2
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/iteration/index.d.ts +87 -9
- package/dist/runtime/query/iteration/index.d.ts.map +1 -1
- package/dist/runtime/query/iteration/index.js +193 -28
- package/dist/runtime/query/iteration/index.js.map +1 -1
- package/dist/runtime/query/iteration/phases/context.d.ts +10 -0
- package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/context.js.map +1 -1
- package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/tool-review.js +5 -1
- package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
- package/dist/runtime/query/plugin-hooks.d.ts +14 -0
- package/dist/runtime/query/plugin-hooks.d.ts.map +1 -1
- package/dist/runtime/query/plugin-hooks.js +18 -0
- package/dist/runtime/query/plugin-hooks.js.map +1 -1
- package/dist/runtime/query/repeat-call.d.ts +17 -4
- package/dist/runtime/query/repeat-call.d.ts.map +1 -1
- package/dist/runtime/query/repeat-call.js +26 -19
- package/dist/runtime/query/repeat-call.js.map +1 -1
- package/dist/runtime/query/steering.d.ts +11 -1
- package/dist/runtime/query/steering.d.ts.map +1 -1
- package/dist/runtime/query/steering.js +12 -1
- package/dist/runtime/query/steering.js.map +1 -1
- package/dist/runtime/query/tooling.d.ts +2 -0
- package/dist/runtime/query/tooling.d.ts.map +1 -1
- package/dist/runtime/query/tooling.js +1 -0
- package/dist/runtime/query/tooling.js.map +1 -1
- package/dist/scheduler/completion-inbox.d.ts +48 -2
- package/dist/scheduler/completion-inbox.d.ts.map +1 -1
- package/dist/scheduler/completion-inbox.js +102 -10
- package/dist/scheduler/completion-inbox.js.map +1 -1
- package/dist/tools/builtins/bash.d.ts.map +1 -1
- package/dist/tools/builtins/bash.js +4 -10
- package/dist/tools/builtins/bash.js.map +1 -1
- package/dist/tools/builtins/edit-apply.d.ts +126 -0
- package/dist/tools/builtins/edit-apply.d.ts.map +1 -0
- package/dist/tools/builtins/edit-apply.js +360 -0
- package/dist/tools/builtins/edit-apply.js.map +1 -0
- package/dist/tools/builtins/edit.d.ts +143 -1
- package/dist/tools/builtins/edit.d.ts.map +1 -1
- package/dist/tools/builtins/edit.js +37 -219
- package/dist/tools/builtins/edit.js.map +1 -1
- package/dist/tools/builtins/index.d.ts +1 -0
- package/dist/tools/builtins/index.d.ts.map +1 -1
- package/dist/tools/builtins/index.js +9 -3
- package/dist/tools/builtins/index.js.map +1 -1
- package/dist/tools/builtins/job.js +1 -1
- package/dist/tools/builtins/job.js.map +1 -1
- package/dist/tools/builtins/read-file.d.ts +2 -2
- package/dist/tools/builtins/read-file.d.ts.map +1 -1
- package/dist/tools/builtins/read-file.js +50 -65
- package/dist/tools/builtins/read-file.js.map +1 -1
- package/dist/tools/builtins/read-render.d.ts +56 -0
- package/dist/tools/builtins/read-render.d.ts.map +1 -0
- package/dist/tools/builtins/read-render.js +73 -0
- package/dist/tools/builtins/read-render.js.map +1 -0
- package/dist/tools/builtins/wait-for-job-bounds.d.ts +67 -0
- package/dist/tools/builtins/wait-for-job-bounds.d.ts.map +1 -0
- package/dist/tools/builtins/wait-for-job-bounds.js +108 -0
- package/dist/tools/builtins/wait-for-job-bounds.js.map +1 -0
- package/dist/tools/builtins/wait-for-job.d.ts +6 -0
- package/dist/tools/builtins/wait-for-job.d.ts.map +1 -0
- package/dist/tools/builtins/wait-for-job.js +162 -0
- package/dist/tools/builtins/wait-for-job.js.map +1 -0
- package/dist/tools/builtins/write-file.js +5 -0
- package/dist/tools/builtins/write-file.js.map +1 -1
- package/dist/tools/coordinator/index.d.ts.map +1 -1
- package/dist/tools/coordinator/index.js +1 -7
- package/dist/tools/coordinator/index.js.map +1 -1
- package/dist/tools/file-read-tracker.d.ts.map +1 -1
- package/dist/tools/file-read-tracker.js +88 -10
- package/dist/tools/file-read-tracker.js.map +1 -1
- package/dist/types/message/index.d.ts +1 -1
- package/dist/types/message/index.d.ts.map +1 -1
- package/dist/types/message/index.js +2 -0
- package/dist/types/message/index.js.map +1 -1
- package/dist/types/run/entity.d.ts +13 -0
- package/dist/types/run/entity.d.ts.map +1 -1
- package/dist/types/sandbox/index.d.ts +15 -14
- package/dist/types/sandbox/index.d.ts.map +1 -1
- package/dist/types/sandbox/index.js.map +1 -1
- package/dist/types/tool/index.d.ts +109 -0
- package/dist/types/tool/index.d.ts.map +1 -1
- package/dist/types/tool/index.js.map +1 -1
- package/dist/utils/env.d.ts +19 -0
- package/dist/utils/env.d.ts.map +1 -0
- package/dist/utils/env.js +25 -0
- package/dist/utils/env.js.map +1 -0
- package/package.json +1 -1
- package/src/connector/mcp/adapter.ts +20 -6
- package/src/manager/run/persistence.ts +12 -0
- package/src/public-runtime.ts +9 -1
- package/src/public-tools.ts +18 -0
- package/src/registry/tool/execute.ts +2 -4
- package/src/registry/tool/portable.ts +264 -0
- package/src/registry/tool/schema.ts +38 -8
- package/src/registry/toolset/catalog.ts +8 -9
- package/src/runtime/jobs/awaited-jobs.ts +271 -0
- package/src/runtime/jobs/registry.ts +50 -0
- package/src/runtime/query/executor.ts +49 -1
- package/src/runtime/query/file-evidence-context.ts +190 -46
- package/src/runtime/query/file-evidence-replay.ts +776 -0
- package/src/runtime/query/file-evidence-seed.ts +126 -0
- package/src/runtime/query/index.ts +104 -2
- package/src/runtime/query/iteration/index.ts +202 -28
- package/src/runtime/query/iteration/phases/context.ts +10 -0
- package/src/runtime/query/iteration/phases/tool-review.ts +4 -0
- package/src/runtime/query/plugin-hooks.ts +20 -0
- package/src/runtime/query/repeat-call.ts +28 -18
- package/src/runtime/query/steering.ts +11 -0
- package/src/runtime/query/tooling.ts +3 -0
- package/src/scheduler/completion-inbox.ts +105 -9
- package/src/tools/builtins/bash.ts +4 -10
- package/src/tools/builtins/edit-apply.ts +456 -0
- package/src/tools/builtins/edit.ts +39 -270
- package/src/tools/builtins/index.ts +9 -3
- package/src/tools/builtins/job.ts +1 -1
- package/src/tools/builtins/read-file.ts +56 -77
- package/src/tools/builtins/read-render.ts +104 -0
- package/src/tools/builtins/wait-for-job-bounds.ts +179 -0
- package/src/tools/builtins/wait-for-job.ts +184 -0
- package/src/tools/builtins/write-file.ts +5 -0
- package/src/tools/coordinator/index.ts +1 -7
- package/src/tools/file-read-tracker.ts +85 -7
- package/src/types/message/index.ts +2 -0
- package/src/types/run/entity.ts +14 -0
- package/src/types/sandbox/index.ts +15 -14
- package/src/types/tool/index.ts +104 -0
- package/src/utils/env.ts +23 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import type { EditInput } from './edit.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The apply core `edit`'s tool definition calls at mutation time, pulled out
|
|
5
|
+
* on its own so a second caller — the step-context projection that replays a
|
|
6
|
+
* visible edit call to verify a claimed post-edit body — runs this exact
|
|
7
|
+
* code instead of a parallel implementation that could drift from it (most
|
|
8
|
+
* easily on the CRLF reconciliation below, which depends on the real file's
|
|
9
|
+
* line-ending mix).
|
|
10
|
+
*
|
|
11
|
+
* Pure by construction: no filesystem access, no `ToolContext`. Everything
|
|
12
|
+
* here is a function from strings to strings.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type NormalizedEditInput =
|
|
16
|
+
| {
|
|
17
|
+
operation: 'replace'
|
|
18
|
+
oldString: string
|
|
19
|
+
newString: string
|
|
20
|
+
replace_all: boolean
|
|
21
|
+
}
|
|
22
|
+
| {
|
|
23
|
+
operation: 'insert'
|
|
24
|
+
insertLine: number | 'end'
|
|
25
|
+
newString: string
|
|
26
|
+
replace_all: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Turn one call into the ordered list of operations it stands for.
|
|
31
|
+
*
|
|
32
|
+
* A list rather than a single operation because the batch shape is not a
|
|
33
|
+
* different kind of edit, only a longer one. Keeping ONE representation is
|
|
34
|
+
* what stops the two shapes diverging: everything below this function — the
|
|
35
|
+
* uniqueness check, the CRLF reconciliation, the identical-text refusal, the
|
|
36
|
+
* atomic write — sees a list of length one for a single edit and never learns
|
|
37
|
+
* which shape the caller used.
|
|
38
|
+
*/
|
|
39
|
+
export function normalizeEditInput(
|
|
40
|
+
input: EditInput,
|
|
41
|
+
): { success: true; operations: NormalizedEditInput[] } | { success: false; error: string } {
|
|
42
|
+
if (input.edits !== undefined) {
|
|
43
|
+
return {
|
|
44
|
+
success: true,
|
|
45
|
+
operations: input.edits.map((edit) => ({
|
|
46
|
+
operation: 'replace' as const,
|
|
47
|
+
oldString: edit.old_string,
|
|
48
|
+
newString: edit.new_string,
|
|
49
|
+
replace_all: edit.replace_all ?? false,
|
|
50
|
+
})),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const newString = input.new_string ?? input.newStr
|
|
55
|
+
if (typeof newString !== 'string') {
|
|
56
|
+
return {
|
|
57
|
+
success: false,
|
|
58
|
+
error: 'Either new_string or newStr is required.',
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (input.insertLine !== undefined) {
|
|
63
|
+
const insertLine = normalizeInsertLine(input.insertLine)
|
|
64
|
+
if (!insertLine.success) return insertLine
|
|
65
|
+
return {
|
|
66
|
+
success: true,
|
|
67
|
+
operations: [
|
|
68
|
+
{
|
|
69
|
+
operation: 'insert',
|
|
70
|
+
insertLine: insertLine.value,
|
|
71
|
+
newString,
|
|
72
|
+
replace_all: input.replace_all ?? false,
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const oldString = input.old_string ?? input.oldStr
|
|
79
|
+
if (typeof oldString !== 'string') {
|
|
80
|
+
return {
|
|
81
|
+
success: false,
|
|
82
|
+
error: 'Either old_string/oldStr or insertLine is required.',
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
success: true,
|
|
87
|
+
operations: [
|
|
88
|
+
{
|
|
89
|
+
operation: 'replace',
|
|
90
|
+
oldString,
|
|
91
|
+
newString,
|
|
92
|
+
replace_all: input.replace_all ?? false,
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Spellings of "the end of the file" a model reaches for.
|
|
100
|
+
*
|
|
101
|
+
* Liberal here and strict in the schema, which is the right way round: the
|
|
102
|
+
* schema makes `"end"` the only emittable string for a provider that
|
|
103
|
+
* constrains, and this catches the rest for one that does not. None of these
|
|
104
|
+
* is ambiguous — accepting them is not guessing at intent, it is declining to
|
|
105
|
+
* spend a round trip on a synonym.
|
|
106
|
+
*/
|
|
107
|
+
const END_ALIASES = new Set(['end', 'eof', 'append', 'last', 'end_of_file', 'end-of-file'])
|
|
108
|
+
|
|
109
|
+
function normalizeInsertLine(
|
|
110
|
+
value: string | number,
|
|
111
|
+
): { success: true; value: number | 'end' } | { success: false; error: string } {
|
|
112
|
+
if (typeof value === 'string') {
|
|
113
|
+
const normalized = value.trim().toLowerCase()
|
|
114
|
+
// `"end"` is the only spelling the model-facing schema admits, so a
|
|
115
|
+
// constrained decoder cannot produce anything else. These aliases are
|
|
116
|
+
// for the providers that do not constrain: a model reading "appends to
|
|
117
|
+
// the file" reaches for the word it knows, and every one of these says
|
|
118
|
+
// the same unambiguous thing. Refusing them bought strictness and cost
|
|
119
|
+
// a full model round trip per occurrence — measured by a consuming host
|
|
120
|
+
// as the single largest source of tool-call waste in its runs.
|
|
121
|
+
if (END_ALIASES.has(normalized)) return { success: true, value: 'end' }
|
|
122
|
+
const parsed = Number(value)
|
|
123
|
+
if (Number.isInteger(parsed) && parsed >= 0) return { success: true, value: parsed }
|
|
124
|
+
return {
|
|
125
|
+
success: false,
|
|
126
|
+
error: `insertLine must be a non-negative line number or "end" (also accepted: ${[...END_ALIASES].filter((a) => a !== 'end').join(', ')}). Received ${JSON.stringify(value)}.`,
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { success: true, value }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Apply every operation in order, or none of them.
|
|
134
|
+
*
|
|
135
|
+
* "Or none" is the whole reason this takes a list. Four related changes sent
|
|
136
|
+
* as four calls are four chances to stop halfway, and the file left behind
|
|
137
|
+
* after the third succeeded and the fourth did not is in a state no one wrote
|
|
138
|
+
* and no one is looking at. Here the fold runs entirely in memory and the
|
|
139
|
+
* caller writes once, so a failure anywhere leaves the file exactly as it was.
|
|
140
|
+
*
|
|
141
|
+
* Each operation matches against the content as the ones before it left it,
|
|
142
|
+
* not against the original. That is what lets a later edit target text an
|
|
143
|
+
* earlier one produced — and it is also why a failure names the INDEX: by the
|
|
144
|
+
* time hunk 3 fails, the string it was looking for may have been consumed by
|
|
145
|
+
* hunk 1, and "old_string not found" without a position sends the model to
|
|
146
|
+
* re-check the wrong hunk.
|
|
147
|
+
*/
|
|
148
|
+
export function applyEdit(
|
|
149
|
+
content: string,
|
|
150
|
+
operations: readonly NormalizedEditInput[],
|
|
151
|
+
): { success: true; content: string; replacements: number } | { success: false; error: string } {
|
|
152
|
+
let current = content
|
|
153
|
+
let replacements = 0
|
|
154
|
+
|
|
155
|
+
for (const [index, operation] of operations.entries()) {
|
|
156
|
+
const result = applyOne(current, operation)
|
|
157
|
+
if (!result.success) {
|
|
158
|
+
return {
|
|
159
|
+
success: false,
|
|
160
|
+
error: framedError(result.error, index, operations.length),
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
current = result.content
|
|
164
|
+
replacements += result.replacements
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return { success: true, content: current, replacements }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function applyOne(
|
|
171
|
+
content: string,
|
|
172
|
+
input: NormalizedEditInput,
|
|
173
|
+
): { success: true; content: string; replacements: number } | { success: false; error: string } {
|
|
174
|
+
if (input.operation === 'insert') {
|
|
175
|
+
return applyLineInsert(content, input)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const replacement = normalizeLineEndings(content, input)
|
|
179
|
+
|
|
180
|
+
if (!content.includes(replacement.oldString)) {
|
|
181
|
+
return {
|
|
182
|
+
success: false,
|
|
183
|
+
error:
|
|
184
|
+
'old_string/oldStr not found in file. Make sure the string matches exactly, including whitespace and indentation.',
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (replacement.replace_all) {
|
|
189
|
+
const parts = content.split(replacement.oldString)
|
|
190
|
+
const replacements = parts.length - 1
|
|
191
|
+
return {
|
|
192
|
+
success: true,
|
|
193
|
+
content: parts.join(replacement.newString),
|
|
194
|
+
replacements,
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Uniqueness check: old_string/oldStr must appear exactly once
|
|
199
|
+
const firstIndex = content.indexOf(replacement.oldString)
|
|
200
|
+
const secondIndex = content.indexOf(replacement.oldString, firstIndex + 1)
|
|
201
|
+
|
|
202
|
+
if (secondIndex !== -1) {
|
|
203
|
+
const lineNumber = content.slice(0, firstIndex).split('\n').length
|
|
204
|
+
const secondLine = content.slice(0, secondIndex).split('\n').length
|
|
205
|
+
return {
|
|
206
|
+
success: false,
|
|
207
|
+
error: `old_string/oldStr is not unique — found at lines ${lineNumber} and ${secondLine}. Provide more surrounding context to make it unique, or use replace_all: true.`,
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
success: true,
|
|
213
|
+
content:
|
|
214
|
+
content.slice(0, firstIndex) +
|
|
215
|
+
replacement.newString +
|
|
216
|
+
content.slice(firstIndex + replacement.oldString.length),
|
|
217
|
+
replacements: 1,
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function applyLineInsert(
|
|
222
|
+
content: string,
|
|
223
|
+
input: Extract<NormalizedEditInput, { operation: 'insert' }>,
|
|
224
|
+
): { success: true; content: string; replacements: number } {
|
|
225
|
+
const hasTrailingNewline = content.endsWith('\n')
|
|
226
|
+
const lines = content.split('\n')
|
|
227
|
+
if (hasTrailingNewline) lines.pop()
|
|
228
|
+
|
|
229
|
+
const line =
|
|
230
|
+
input.insertLine === 'end'
|
|
231
|
+
? lines.length
|
|
232
|
+
: Math.min(Math.max(input.insertLine, 0), lines.length)
|
|
233
|
+
const inserted = input.newString.endsWith('\n')
|
|
234
|
+
? input.newString.slice(0, -1).split('\n')
|
|
235
|
+
: input.newString.split('\n')
|
|
236
|
+
lines.splice(line, 0, ...inserted)
|
|
237
|
+
return {
|
|
238
|
+
success: true,
|
|
239
|
+
content: `${lines.join('\n')}${hasTrailingNewline ? '\n' : ''}`,
|
|
240
|
+
replacements: 1,
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Reconcile the caller's line endings with the file's.
|
|
246
|
+
*
|
|
247
|
+
* A model reading a CRLF file and writing back LF (or the reverse) produces
|
|
248
|
+
* an `old_string` that is correct in every visible way and matches nothing.
|
|
249
|
+
* The failure reads as "your text is wrong" when the text is right and only
|
|
250
|
+
* the invisible half of each line break differs.
|
|
251
|
+
*
|
|
252
|
+
* Only applied when the file is CONSISTENT. A mixed-ending file has no
|
|
253
|
+
* single right answer, and rewriting boundaries there would corrupt the
|
|
254
|
+
* half that was already correct.
|
|
255
|
+
*/
|
|
256
|
+
function normalizeLineEndings(
|
|
257
|
+
content: string,
|
|
258
|
+
input: Extract<NormalizedEditInput, { operation: 'replace' }>,
|
|
259
|
+
): Extract<NormalizedEditInput, { operation: 'replace' }> {
|
|
260
|
+
const withoutCrlf = content.replaceAll('\r\n', '')
|
|
261
|
+
const usesOnlyCrlf = content.includes('\r\n') && !withoutCrlf.includes('\n')
|
|
262
|
+
if (usesOnlyCrlf) {
|
|
263
|
+
return {
|
|
264
|
+
...input,
|
|
265
|
+
oldString: content.includes(input.oldString)
|
|
266
|
+
? input.oldString
|
|
267
|
+
: input.oldString.replaceAll('\r\n', '\n').replaceAll('\n', '\r\n'),
|
|
268
|
+
newString: input.newString.replaceAll('\r\n', '\n').replaceAll('\n', '\r\n'),
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const usesOnlyLf = content.includes('\n') && !content.includes('\r\n')
|
|
273
|
+
if (usesOnlyLf) {
|
|
274
|
+
return {
|
|
275
|
+
...input,
|
|
276
|
+
oldString: content.includes(input.oldString)
|
|
277
|
+
? input.oldString
|
|
278
|
+
: input.oldString.replaceAll('\r\n', '\n'),
|
|
279
|
+
newString: input.newString.replaceAll('\r\n', '\n'),
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return input
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** The batch framing an operation's own error is reported under. */
|
|
286
|
+
function framedError(error: string, index: number, total: number): string {
|
|
287
|
+
if (total === 1) return error
|
|
288
|
+
return `edits[${index}] of ${total}: ${error} Nothing was written — the whole batch is refused, so the file is exactly as it was.`
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* What a bounded replay did, and what it cost.
|
|
293
|
+
*
|
|
294
|
+
* A union rather than a throw because all three outcomes are ordinary answers
|
|
295
|
+
* to a caller replaying somebody else's call: it applied, it would have built
|
|
296
|
+
* more than the caller has room for, or it no longer applies to the content it
|
|
297
|
+
* was handed. Only the first carries a body; the other two carry the charge so
|
|
298
|
+
* the caller can settle the room the attempt actually used.
|
|
299
|
+
*/
|
|
300
|
+
export type BoundedReplay =
|
|
301
|
+
| {
|
|
302
|
+
readonly outcome: 'replayed'
|
|
303
|
+
readonly content: string
|
|
304
|
+
readonly charged: number
|
|
305
|
+
readonly replacements: number
|
|
306
|
+
}
|
|
307
|
+
| { readonly outcome: 'refused'; readonly charged: number }
|
|
308
|
+
| {
|
|
309
|
+
readonly outcome: 'failed'
|
|
310
|
+
readonly charged: number
|
|
311
|
+
readonly error: string
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The single door a caller outside this module uses.
|
|
316
|
+
*
|
|
317
|
+
* Runs a visible call's arguments through the same normalize-then-apply path
|
|
318
|
+
* `EditTool.execute` runs at mutation time, against a content string the
|
|
319
|
+
* caller already has in hand — never the filesystem — and under an
|
|
320
|
+
* `allowance`: the largest string the caller is willing to have built on its
|
|
321
|
+
* behalf.
|
|
322
|
+
*
|
|
323
|
+
* The allowance is honoured one OPERATION at a time. Each operation's
|
|
324
|
+
* post-image length is worked out exactly from the content it is about to be
|
|
325
|
+
* applied to, compared against the allowance, and only then applied — so
|
|
326
|
+
* nothing over the ceiling is ever materialised, and nothing under it is
|
|
327
|
+
* refused for a bound that guessed high. An earlier shape predicted the whole
|
|
328
|
+
* call up front, which meant folding operations after the first against a
|
|
329
|
+
* string it had never seen: a rename hunk at index 1 was charged one match per
|
|
330
|
+
* anchor-length window of the file, and batches that would have fitted were
|
|
331
|
+
* turned away for a number nothing had built.
|
|
332
|
+
*
|
|
333
|
+
* `charged` is the longest string this call actually materialised, which is
|
|
334
|
+
* the one the caller paid for holding. It is the post-image length exactly for
|
|
335
|
+
* the single-operation shape almost every call has; for a batch it is the
|
|
336
|
+
* largest intermediate the fold built rather than the body it ends on, because
|
|
337
|
+
* a batch that grows a file to twenty megabytes and then deletes every
|
|
338
|
+
* character has still built the twenty megabytes. An operation that is refused
|
|
339
|
+
* or fails is charged nothing — it built nothing — while the ones before it in
|
|
340
|
+
* the same batch are charged, having run.
|
|
341
|
+
*/
|
|
342
|
+
export function replayEditCallWithin(
|
|
343
|
+
content: string,
|
|
344
|
+
rawArguments: unknown,
|
|
345
|
+
allowance: number,
|
|
346
|
+
): BoundedReplay {
|
|
347
|
+
const normalized = normalizeEditInput(rawArguments as EditInput)
|
|
348
|
+
// A shape this cannot normalize is one the tool itself would have refused.
|
|
349
|
+
// Reported as a failure rather than a throw, and charged nothing: no
|
|
350
|
+
// operation ran, so nothing was built.
|
|
351
|
+
if (!normalized.success) return { outcome: 'failed', charged: 0, error: normalized.error }
|
|
352
|
+
return replayOperationsWithin(content, normalized.operations, allowance)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* The same walk, entered with the operations already normalized.
|
|
357
|
+
*
|
|
358
|
+
* Separate from the entry point above so the equivalence with {@link applyEdit}
|
|
359
|
+
* can be exercised operation by operation — `replayOperationsWithin(c, [op], ∞)`
|
|
360
|
+
* is `applyOne(c, op)` plus its exact predicted length — rather than only in
|
|
361
|
+
* the aggregate, where a prediction that is wrong in two places by the same
|
|
362
|
+
* amount would pass.
|
|
363
|
+
*/
|
|
364
|
+
export function replayOperationsWithin(
|
|
365
|
+
content: string,
|
|
366
|
+
operations: readonly NormalizedEditInput[],
|
|
367
|
+
allowance: number,
|
|
368
|
+
): BoundedReplay {
|
|
369
|
+
let current = content
|
|
370
|
+
let replacements = 0
|
|
371
|
+
// The largest body this call has built so far, which is what it has cost
|
|
372
|
+
// the caller. Zero until an operation applies: a call refused at its first
|
|
373
|
+
// operation built nothing and owes nothing.
|
|
374
|
+
let charged = 0
|
|
375
|
+
|
|
376
|
+
for (const [index, operation] of operations.entries()) {
|
|
377
|
+
const predicted = predictOne(current, operation, allowance)
|
|
378
|
+
if (predicted > allowance) return { outcome: 'refused', charged }
|
|
379
|
+
const applied = applyOne(current, operation)
|
|
380
|
+
if (!applied.success) {
|
|
381
|
+
return {
|
|
382
|
+
outcome: 'failed',
|
|
383
|
+
charged,
|
|
384
|
+
error: framedError(applied.error, index, operations.length),
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
current = applied.content
|
|
388
|
+
replacements += applied.replacements
|
|
389
|
+
charged = Math.max(charged, current.length)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return { outcome: 'replayed', content: current, charged, replacements }
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* The exact length `applyOne` would produce, without producing it.
|
|
397
|
+
*
|
|
398
|
+
* Exact and not a bound, because the content it is measured against is the
|
|
399
|
+
* real one the operation is about to be applied to. The one place it stops
|
|
400
|
+
* short is the occurrence scan for `replace_all`: counting out every match of
|
|
401
|
+
* a one-character anchor in a large file is itself the work the allowance
|
|
402
|
+
* exists to avoid, so the scan stops as soon as one more match would carry the
|
|
403
|
+
* result past `allowance`. The number returned from a stopped scan is a lower
|
|
404
|
+
* bound on the real length and above the allowance, which is all a refusal
|
|
405
|
+
* needs.
|
|
406
|
+
*
|
|
407
|
+
* Here rather than in the caller because the prediction has to see the same
|
|
408
|
+
* line-ending reconciliation `applyOne` sees. An `old_string` written with LF
|
|
409
|
+
* against a CRLF file matches after normalization and not before, and a
|
|
410
|
+
* predictor blind to that would count zero occurrences for a replacement that
|
|
411
|
+
* was going to succeed.
|
|
412
|
+
*
|
|
413
|
+
* An operation that is not going to apply at all — an anchor that is missing,
|
|
414
|
+
* or matches twice where one match was required — gets a number that means
|
|
415
|
+
* nothing, and it is never used for anything but the comparison above: the
|
|
416
|
+
* apply immediately after this reports the real refusal.
|
|
417
|
+
*/
|
|
418
|
+
function predictOne(content: string, operation: NormalizedEditInput, allowance: number): number {
|
|
419
|
+
if (operation.operation === 'insert') {
|
|
420
|
+
// `applyLineInsert` splits the inserted text into lines and joins it back
|
|
421
|
+
// with the rest, which costs the text itself plus the one separator that
|
|
422
|
+
// joins it to its neighbour — and a trailing newline in the text is
|
|
423
|
+
// consumed as that separator rather than added to it.
|
|
424
|
+
return (
|
|
425
|
+
content.length + operation.newString.length + (operation.newString.endsWith('\n') ? 0 : 1)
|
|
426
|
+
)
|
|
427
|
+
}
|
|
428
|
+
const { oldString, newString, replace_all } = normalizeLineEndings(content, operation)
|
|
429
|
+
const growth = newString.length - oldString.length
|
|
430
|
+
// A single replacement, because `applyOne` refuses a second match. The
|
|
431
|
+
// clamp is for an anchor longer than the whole content: that operation is
|
|
432
|
+
// about to fail, and a negative length would be a charge handing the caller
|
|
433
|
+
// back room it never had.
|
|
434
|
+
if (!replace_all) return Math.max(content.length + growth, 0)
|
|
435
|
+
const room = Math.max(allowance - content.length, 0)
|
|
436
|
+
// One past what fits is already a refusal, so the scan never needs to see
|
|
437
|
+
// the match after that. A replacement no longer than its anchor cannot grow
|
|
438
|
+
// the string however often it matches, so there is nothing to stop for.
|
|
439
|
+
const cap = growth > 0 ? Math.floor(room / growth) + 1 : content.length
|
|
440
|
+
return Math.max(content.length + countOccurrences(content, oldString, cap) * growth, 0)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Non-overlapping matches, up to `cap`, counted the way `split` counts them. */
|
|
444
|
+
function countOccurrences(content: string, needle: string, cap: number): number {
|
|
445
|
+
// `split('')` yields one part per character; scanning for it would never
|
|
446
|
+
// advance. Neither shape reaches here through the tool's schema, which
|
|
447
|
+
// requires a non-empty `old_string`.
|
|
448
|
+
if (needle.length === 0) return Math.min(Math.max(content.length - 1, 0), cap)
|
|
449
|
+
let count = 0
|
|
450
|
+
let index = content.indexOf(needle)
|
|
451
|
+
while (index !== -1 && count < cap) {
|
|
452
|
+
count += 1
|
|
453
|
+
index = content.indexOf(needle, index + needle.length)
|
|
454
|
+
}
|
|
455
|
+
return count
|
|
456
|
+
}
|