@qijenchen/governance 0.1.0-beta.94
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 +89 -0
- package/bin/attest.mjs +4 -0
- package/bin/check.mjs +4 -0
- package/bin/doctor.mjs +4 -0
- package/bin/generate.mjs +4 -0
- package/bin/governance.mjs +4 -0
- package/bin/hook.mjs +4 -0
- package/bin/upgrade.mjs +4 -0
- package/canonical/gates.json +42 -0
- package/canonical/manifest.json +476 -0
- package/canonical/plugin-aliases.json +25 -0
- package/canonical/provider-lifecycle.json +48 -0
- package/canonical/providers.json +455 -0
- package/canonical/roles.json +12 -0
- package/canonical/rules.json +81 -0
- package/canonical/schemas/attestation.schema.json +61 -0
- package/canonical/schemas/diagnostic.schema.json +37 -0
- package/canonical/schemas/gates.schema.json +27 -0
- package/canonical/schemas/lock.schema.json +189 -0
- package/canonical/schemas/manifest.schema.json +103 -0
- package/canonical/schemas/plugin-aliases.schema.json +59 -0
- package/canonical/schemas/provider-hook-coverage.schema.json +173 -0
- package/canonical/schemas/provider-lifecycle.schema.json +126 -0
- package/canonical/schemas/providers.schema.json +917 -0
- package/canonical/schemas/roles.schema.json +27 -0
- package/canonical/schemas/rules.schema.json +46 -0
- package/canonical/schemas/upgrade-plan.schema.json +31 -0
- package/package.json +42 -0
- package/src/authority-decision-evidence.mjs +413 -0
- package/src/canonical-order.mjs +8 -0
- package/src/carrier-projection.mjs +407 -0
- package/src/cli.mjs +114 -0
- package/src/closed-tool-execution.mjs +1001 -0
- package/src/common.mjs +278 -0
- package/src/contract.mjs +760 -0
- package/src/hook-api.mjs +107 -0
- package/src/index.mjs +14 -0
- package/src/provider-hook-normalization.mjs +1646 -0
- package/src/provider-review-binding.mjs +2377 -0
- package/src/snapshot.mjs +520 -0
|
@@ -0,0 +1,1646 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
constants as fsConstants,
|
|
4
|
+
fstatSync,
|
|
5
|
+
lstatSync,
|
|
6
|
+
openSync,
|
|
7
|
+
readSync,
|
|
8
|
+
} from 'node:fs'
|
|
9
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path'
|
|
10
|
+
import { compareUtf8Bytes } from './canonical-order.mjs'
|
|
11
|
+
|
|
12
|
+
const WRITE_TOOLS = new Set(['edit', 'write', 'multiedit'])
|
|
13
|
+
const EXECUTE_TOOLS = new Set(['bash', 'execute', 'exec'])
|
|
14
|
+
const WRITE_EVENT_KEYS = new Set(['beforewrite', 'prewrite', 'afterwrite', 'postwrite', 'write'])
|
|
15
|
+
const PATH_EXPECTATION = 'repository-contained path without symbolic-link components'
|
|
16
|
+
const MAX_PROPOSED_TEXT_BYTES = 16 * 1024 * 1024
|
|
17
|
+
const MAX_PROPOSED_TEXT_LINES = 16_384
|
|
18
|
+
const MAX_PROVIDER_PATH_BYTES = 4096
|
|
19
|
+
const MAX_PROVIDER_PATH_SEGMENTS = 256
|
|
20
|
+
const DEFAULT_NORMALIZATION_LIMITS = Object.freeze({
|
|
21
|
+
maximumPathCandidates: 1024,
|
|
22
|
+
maximumMutationOperations: 1024,
|
|
23
|
+
maximumMutationEdits: 1024,
|
|
24
|
+
maximumPatchLines: 16_384,
|
|
25
|
+
maximumAggregateTextBytes: MAX_PROPOSED_TEXT_BYTES,
|
|
26
|
+
})
|
|
27
|
+
const STRICT_UTF8 = new TextDecoder('utf-8', { fatal: true })
|
|
28
|
+
|
|
29
|
+
export class ProviderWriteStateError extends Error {
|
|
30
|
+
constructor(code, message, { path = '<unknown>', pathIssue = null } = {}) {
|
|
31
|
+
super(`provider proposed state blocked:${code}:${message}`)
|
|
32
|
+
this.name = 'ProviderWriteStateError'
|
|
33
|
+
this.code = code
|
|
34
|
+
this.path = path
|
|
35
|
+
this.pathIssue = pathIssue
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function stateFail(code, message, options) {
|
|
40
|
+
throw new ProviderWriteStateError(code, message, options)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizationLimits(value = {}) {
|
|
44
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
45
|
+
stateFail('MUTATION_INVALID', 'normalization limits must be an object')
|
|
46
|
+
}
|
|
47
|
+
return Object.fromEntries(Object.entries(DEFAULT_NORMALIZATION_LIMITS).map(([name, fallback]) => {
|
|
48
|
+
const candidate = value[name] ?? fallback
|
|
49
|
+
if (!Number.isSafeInteger(candidate) || candidate < 1) {
|
|
50
|
+
stateFail('MUTATION_INVALID', `${name} must be a positive safe integer`)
|
|
51
|
+
}
|
|
52
|
+
return [name, candidate]
|
|
53
|
+
}))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizationCheckpoint(checkpoint, label) {
|
|
57
|
+
if (checkpoint === null || checkpoint === undefined) return
|
|
58
|
+
if (typeof checkpoint !== 'function') stateFail('MUTATION_INVALID', 'normalization checkpoint must be a function')
|
|
59
|
+
checkpoint(label)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function enforceCountLimit(count, maximum, code, label) {
|
|
63
|
+
if (!Number.isSafeInteger(count) || count > maximum) {
|
|
64
|
+
stateFail(code, `${label} exceeds ${maximum}`)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function boundedPatchLines(body, { limits, checkpoint, label }) {
|
|
69
|
+
let count = 1
|
|
70
|
+
let cursor = 0
|
|
71
|
+
while (cursor < body.length) {
|
|
72
|
+
const newline = body.indexOf('\n', cursor)
|
|
73
|
+
if (newline < 0) break
|
|
74
|
+
count += 1
|
|
75
|
+
enforceCountLimit(count, limits.maximumPatchLines, 'MUTATION_EDIT_COUNT_EXCEEDED', `${label} line count`)
|
|
76
|
+
if ((count & 255) === 0) normalizationCheckpoint(checkpoint, `${label}:lines:${count}`)
|
|
77
|
+
cursor = newline + 1
|
|
78
|
+
}
|
|
79
|
+
normalizationCheckpoint(checkpoint, `${label}:lines-complete`)
|
|
80
|
+
return body.split('\n')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getAtPath(value, path) {
|
|
84
|
+
return String(path).split('.').reduce(
|
|
85
|
+
(current, key) => current && typeof current === 'object' ? current[key] : undefined,
|
|
86
|
+
value,
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function firstString(input, fields) {
|
|
91
|
+
for (const field of fields || []) {
|
|
92
|
+
const value = getAtPath(input, field)
|
|
93
|
+
if (typeof value === 'string' && value.trim()) return value.trim()
|
|
94
|
+
}
|
|
95
|
+
return ''
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function aliasKey(value) {
|
|
99
|
+
return String(value || '').trim().toLowerCase()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function compactAliasKey(value) {
|
|
103
|
+
return aliasKey(value).replaceAll(/[^a-z0-9]/g, '')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function aliasValue(value, aliases, { compact = false } = {}) {
|
|
107
|
+
const raw = String(value || '').trim()
|
|
108
|
+
if (!raw) return ''
|
|
109
|
+
const directKey = aliasKey(raw)
|
|
110
|
+
if (typeof aliases?.[directKey] === 'string') return aliases[directKey].trim()
|
|
111
|
+
if (compact) {
|
|
112
|
+
const wanted = compactAliasKey(raw)
|
|
113
|
+
if (typeof aliases?.[wanted] === 'string') return aliases[wanted].trim()
|
|
114
|
+
for (const [key, candidate] of Object.entries(aliases || {})) {
|
|
115
|
+
if (compactAliasKey(key) === wanted && typeof candidate === 'string') return candidate.trim()
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return raw
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function issue(reasonCode, path = '<invalid>') {
|
|
122
|
+
return {
|
|
123
|
+
reasonCode,
|
|
124
|
+
evidence: {
|
|
125
|
+
kind: 'hook-path-invalid',
|
|
126
|
+
path,
|
|
127
|
+
expected: PATH_EXPECTATION,
|
|
128
|
+
actual: reasonCode,
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function mutationIssue(reasonCode, path = '<write-mutation>') {
|
|
134
|
+
return {
|
|
135
|
+
reasonCode,
|
|
136
|
+
evidence: {
|
|
137
|
+
kind: 'hook-write-mutation-invalid',
|
|
138
|
+
path,
|
|
139
|
+
expected: 'registry-declared write transport with an exact in-memory after-image',
|
|
140
|
+
actual: reasonCode,
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function providerHookIssueMessage(value) {
|
|
146
|
+
switch (value?.reasonCode) {
|
|
147
|
+
case 'NUL_BYTE': return 'provider hook adapter blocked:changed path contains NUL'
|
|
148
|
+
case 'CONTROL_CHARACTER': return 'provider hook adapter blocked:changed path contains a control character'
|
|
149
|
+
case 'OUTSIDE_REPOSITORY': return 'provider hook adapter blocked:changed path escapes repository root'
|
|
150
|
+
case 'REPOSITORY_ROOT': return 'provider hook adapter blocked:changed path must identify a file below the repository root'
|
|
151
|
+
case 'SYMLINK_COMPONENT': return `provider hook adapter blocked:changed path crosses a symbolic link:${value.evidence.path}`
|
|
152
|
+
case 'PATH_INSPECTION_FAILED': return `provider hook adapter blocked:changed path cannot be safely inspected:${value.evidence.path}`
|
|
153
|
+
case 'EMPTY_PATH': return 'provider hook adapter blocked:changed path is empty'
|
|
154
|
+
case 'INVALID_PATH_TYPE': return 'provider hook adapter blocked:changed path must be a string'
|
|
155
|
+
case 'PATH_ARRAY_INVALID': return 'provider hook adapter blocked:changed path collection must be a string or array'
|
|
156
|
+
case 'PATH_ENTRY_INVALID': return 'provider hook adapter blocked:changed path entry must contain a string path or file_path'
|
|
157
|
+
case 'QUOTED_PATCH_PATH': return 'provider hook adapter blocked:quoted patch paths require an explicit canonical decoder'
|
|
158
|
+
case 'SURROUNDING_WHITESPACE': return 'provider hook adapter blocked:changed path has ambiguous surrounding whitespace'
|
|
159
|
+
case 'AMBIGUOUS_SEPARATOR': return 'provider hook adapter blocked:changed path uses a platform-ambiguous separator'
|
|
160
|
+
case 'PATCH_INVALID': return 'provider hook adapter blocked:write patch syntax is invalid'
|
|
161
|
+
case 'PATCH_TOO_LARGE': return 'provider hook adapter blocked:write patch exceeds the bounded payload limit'
|
|
162
|
+
case 'TEXT_INVALID': return 'provider hook adapter blocked:write payload text is invalid'
|
|
163
|
+
case 'TEXT_TOO_LARGE': return 'provider hook adapter blocked:write payload text exceeds the bounded payload limit'
|
|
164
|
+
case 'EDIT_INVALID': return 'provider hook adapter blocked:write edit payload is invalid'
|
|
165
|
+
case 'MUTATION_INVALID': return 'provider hook adapter blocked:write mutation transport is invalid'
|
|
166
|
+
case 'PATH_COUNT_EXCEEDED': return 'provider hook adapter blocked:changed path candidate count exceeds the bounded limit'
|
|
167
|
+
case 'PATH_TOO_LONG': return `provider hook adapter blocked:changed path exceeds ${MAX_PROVIDER_PATH_BYTES} bytes`
|
|
168
|
+
case 'PATH_SEGMENT_COUNT_EXCEEDED': return `provider hook adapter blocked:changed path exceeds ${MAX_PROVIDER_PATH_SEGMENTS} segments`
|
|
169
|
+
case 'MUTATION_OPERATION_COUNT_EXCEEDED': return 'provider hook adapter blocked:write mutation operation count exceeds the bounded limit'
|
|
170
|
+
case 'MUTATION_EDIT_COUNT_EXCEEDED': return 'provider hook adapter blocked:write mutation edit count exceeds the bounded limit'
|
|
171
|
+
default: return 'provider hook adapter blocked:changed path is invalid'
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function inspectPath(repoRoot, value) {
|
|
176
|
+
if (typeof value !== 'string') return { issue: issue('INVALID_PATH_TYPE') }
|
|
177
|
+
if (Buffer.byteLength(value, 'utf8') > MAX_PROVIDER_PATH_BYTES) {
|
|
178
|
+
return { issue: issue('PATH_TOO_LONG', '<oversized-path>') }
|
|
179
|
+
}
|
|
180
|
+
const trimmed = value.trim()
|
|
181
|
+
if (!trimmed) return { issue: issue('EMPTY_PATH') }
|
|
182
|
+
if (trimmed.includes('\0')) return { issue: issue('NUL_BYTE', trimmed.replaceAll('\0', '\\0')) }
|
|
183
|
+
if (/[\u0001-\u001f\u007f]/.test(trimmed)) return { issue: issue('CONTROL_CHARACTER', '<control-character>') }
|
|
184
|
+
if (trimmed !== value) return { issue: issue('SURROUNDING_WHITESPACE', '<surrounding-whitespace>') }
|
|
185
|
+
|
|
186
|
+
// Treat both slash spellings as separators. Otherwise `..\\outside` would be a harmless-looking
|
|
187
|
+
// in-repository filename on POSIX but an escape on a Windows consumer.
|
|
188
|
+
if (process.platform !== 'win32' && trimmed.includes('\\')) {
|
|
189
|
+
return { issue: issue('AMBIGUOUS_SEPARATOR', '<backslash-path>') }
|
|
190
|
+
}
|
|
191
|
+
const portable = trimmed.replaceAll('\\', '/')
|
|
192
|
+
if (portable.split('/').filter(Boolean).length > MAX_PROVIDER_PATH_SEGMENTS) {
|
|
193
|
+
return { issue: issue('PATH_SEGMENT_COUNT_EXCEEDED', '<excessive-path-segments>') }
|
|
194
|
+
}
|
|
195
|
+
if (/^[A-Za-z]:\//.test(portable) && process.platform !== 'win32') {
|
|
196
|
+
return { issue: issue('OUTSIDE_REPOSITORY', portable) }
|
|
197
|
+
}
|
|
198
|
+
const root = resolve(repoRoot)
|
|
199
|
+
const absolute = resolve(isAbsolute(portable) ? portable : resolve(root, portable))
|
|
200
|
+
const rel = relative(root, absolute)
|
|
201
|
+
if (!rel) return { issue: issue('REPOSITORY_ROOT', '.') }
|
|
202
|
+
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
203
|
+
return { issue: issue('OUTSIDE_REPOSITORY', portable) }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let cursor = root
|
|
207
|
+
for (const segment of rel.split(sep).filter(Boolean)) {
|
|
208
|
+
cursor = resolve(cursor, segment)
|
|
209
|
+
try {
|
|
210
|
+
const info = lstatSync(cursor)
|
|
211
|
+
if (info.isSymbolicLink()) {
|
|
212
|
+
return { issue: issue('SYMLINK_COMPONENT', relative(root, cursor).replaceAll('\\', '/')) }
|
|
213
|
+
}
|
|
214
|
+
} catch (error) {
|
|
215
|
+
if (error?.code === 'ENOENT') break
|
|
216
|
+
return { issue: issue('PATH_INSPECTION_FAILED', rel.replaceAll('\\', '/')) }
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return { path: rel.replaceAll('\\', '/') }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function requiredRepositoryPath(repoRoot, value, label) {
|
|
223
|
+
const inspected = inspectPath(repoRoot, value)
|
|
224
|
+
if (inspected.issue) {
|
|
225
|
+
stateFail(inspected.issue.reasonCode, `${label} is unsafe`, {
|
|
226
|
+
path: inspected.issue.evidence.path,
|
|
227
|
+
pathIssue: inspected.issue,
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
return inspected.path
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function patchText(value, label) {
|
|
234
|
+
if (typeof value !== 'string' || !value) stateFail('PATCH_INVALID', `${label} must be a non-empty string`)
|
|
235
|
+
if (value.includes('\0')) stateFail('PATCH_INVALID', `${label} contains NUL`)
|
|
236
|
+
if (Buffer.byteLength(value, 'utf8') > MAX_PROPOSED_TEXT_BYTES) {
|
|
237
|
+
stateFail('PATCH_TOO_LARGE', `${label} exceeds ${MAX_PROPOSED_TEXT_BYTES} bytes`)
|
|
238
|
+
}
|
|
239
|
+
return value.replaceAll('\r\n', '\n')
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function assertText(value, label) {
|
|
243
|
+
if (typeof value !== 'string') stateFail('TEXT_INVALID', `${label} must be a string`)
|
|
244
|
+
if (value.includes('\0')) stateFail('TEXT_INVALID', `${label} contains NUL`)
|
|
245
|
+
if (Buffer.byteLength(value, 'utf8') > MAX_PROPOSED_TEXT_BYTES) {
|
|
246
|
+
stateFail('TEXT_TOO_LARGE', `${label} exceeds ${MAX_PROPOSED_TEXT_BYTES} bytes`)
|
|
247
|
+
}
|
|
248
|
+
return value
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function portablePatchPath(value, prefix, repoRoot) {
|
|
252
|
+
if (typeof value !== 'string' || !value || /^["']/.test(value)) {
|
|
253
|
+
stateFail('PATCH_INVALID', `${prefix} path is missing or quoted`)
|
|
254
|
+
}
|
|
255
|
+
const withoutPrefix = value.replace(/^[ab]\//, '')
|
|
256
|
+
return requiredRepositoryPath(repoRoot, withoutPrefix, prefix)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function parseCodexChunk(lines, header, budget) {
|
|
260
|
+
const changes = []
|
|
261
|
+
let endOfFile = false
|
|
262
|
+
for (const line of lines) {
|
|
263
|
+
budget.edits += 1
|
|
264
|
+
enforceCountLimit(
|
|
265
|
+
budget.edits,
|
|
266
|
+
budget.limits.maximumMutationEdits,
|
|
267
|
+
'MUTATION_EDIT_COUNT_EXCEEDED',
|
|
268
|
+
'Codex patch edit count',
|
|
269
|
+
)
|
|
270
|
+
if ((budget.edits & 255) === 0) {
|
|
271
|
+
normalizationCheckpoint(budget.checkpoint, `codex-patch:edits:${budget.edits}`)
|
|
272
|
+
}
|
|
273
|
+
if (line === '*** End of File') {
|
|
274
|
+
if (endOfFile) stateFail('PATCH_INVALID', 'duplicate End of File marker')
|
|
275
|
+
endOfFile = true
|
|
276
|
+
continue
|
|
277
|
+
}
|
|
278
|
+
if (endOfFile) stateFail('PATCH_INVALID', 'content follows End of File marker')
|
|
279
|
+
if (!/^[ +\-]/.test(line)) stateFail('PATCH_INVALID', `invalid update line prefix:${line.slice(0, 40)}`)
|
|
280
|
+
changes.push({ kind: line[0], text: line.slice(1) })
|
|
281
|
+
}
|
|
282
|
+
if (!changes.length && !endOfFile) stateFail('PATCH_INVALID', 'empty update chunk')
|
|
283
|
+
return { header, changes, endOfFile }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Parse Codex's native `*** Begin Patch` transport without executing it.
|
|
288
|
+
* The returned AST preserves file and hunk order so one later operation cannot
|
|
289
|
+
* be hidden by an earlier valid path.
|
|
290
|
+
*/
|
|
291
|
+
export function parseCodexApplyPatch({
|
|
292
|
+
command,
|
|
293
|
+
repoRoot = process.cwd(),
|
|
294
|
+
limits: limitOptions = {},
|
|
295
|
+
checkpoint = null,
|
|
296
|
+
} = {}) {
|
|
297
|
+
const limits = normalizationLimits(limitOptions)
|
|
298
|
+
const body = patchText(command, 'Codex apply_patch command')
|
|
299
|
+
const lines = boundedPatchLines(body, {
|
|
300
|
+
limits,
|
|
301
|
+
checkpoint,
|
|
302
|
+
label: 'codex-patch',
|
|
303
|
+
})
|
|
304
|
+
if (lines.at(-1) === '') lines.pop()
|
|
305
|
+
if (lines[0] !== '*** Begin Patch' || lines.at(-1) !== '*** End Patch') {
|
|
306
|
+
stateFail('PATCH_INVALID', 'Codex patch must have exact Begin/End markers')
|
|
307
|
+
}
|
|
308
|
+
const operations = []
|
|
309
|
+
const budget = { edits: 0, limits, checkpoint }
|
|
310
|
+
let index = 1
|
|
311
|
+
while (index < lines.length - 1) {
|
|
312
|
+
enforceCountLimit(
|
|
313
|
+
operations.length + 1,
|
|
314
|
+
limits.maximumMutationOperations,
|
|
315
|
+
'MUTATION_OPERATION_COUNT_EXCEEDED',
|
|
316
|
+
'Codex patch operation count',
|
|
317
|
+
)
|
|
318
|
+
normalizationCheckpoint(checkpoint, `codex-patch:operation:${operations.length + 1}`)
|
|
319
|
+
const header = lines[index]
|
|
320
|
+
const match = header.match(/^\*\*\* (Add|Update|Delete) File: (.+)$/)
|
|
321
|
+
if (!match) stateFail('PATCH_INVALID', `unexpected patch control line:${header.slice(0, 80)}`)
|
|
322
|
+
const kind = match[1].toLowerCase()
|
|
323
|
+
const path = portablePatchPath(match[2], `${kind} file`, repoRoot)
|
|
324
|
+
index += 1
|
|
325
|
+
|
|
326
|
+
if (kind === 'add') {
|
|
327
|
+
const added = []
|
|
328
|
+
while (index < lines.length - 1 && !/^\*\*\* (?:Add|Update|Delete) File: /.test(lines[index])) {
|
|
329
|
+
const line = lines[index]
|
|
330
|
+
if (!line.startsWith('+')) stateFail('PATCH_INVALID', 'Add File body must contain only + lines', { path })
|
|
331
|
+
budget.edits += 1
|
|
332
|
+
enforceCountLimit(
|
|
333
|
+
budget.edits,
|
|
334
|
+
limits.maximumMutationEdits,
|
|
335
|
+
'MUTATION_EDIT_COUNT_EXCEEDED',
|
|
336
|
+
'Codex patch edit count',
|
|
337
|
+
)
|
|
338
|
+
added.push(line.slice(1))
|
|
339
|
+
index += 1
|
|
340
|
+
}
|
|
341
|
+
operations.push({ kind: 'add', path, content: `${added.join('\n')}${added.length ? '\n' : ''}` })
|
|
342
|
+
continue
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (kind === 'delete') {
|
|
346
|
+
if (index < lines.length - 1 && !/^\*\*\* (?:Add|Update|Delete) File: /.test(lines[index])) {
|
|
347
|
+
stateFail('PATCH_INVALID', 'Delete File must not contain a body', { path })
|
|
348
|
+
}
|
|
349
|
+
operations.push({ kind: 'delete', path })
|
|
350
|
+
continue
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
let moveTo = null
|
|
354
|
+
if (lines[index]?.startsWith('*** Move to: ')) {
|
|
355
|
+
moveTo = portablePatchPath(lines[index].slice('*** Move to: '.length), 'move destination', repoRoot)
|
|
356
|
+
index += 1
|
|
357
|
+
}
|
|
358
|
+
const chunks = []
|
|
359
|
+
let chunkHeader = null
|
|
360
|
+
let chunkLines = []
|
|
361
|
+
const flushChunk = () => {
|
|
362
|
+
if (!chunkLines.length && chunkHeader === null) return
|
|
363
|
+
chunks.push(parseCodexChunk(chunkLines, chunkHeader, budget))
|
|
364
|
+
chunkHeader = null
|
|
365
|
+
chunkLines = []
|
|
366
|
+
}
|
|
367
|
+
while (index < lines.length - 1 && !/^\*\*\* (?:Add|Update|Delete) File: /.test(lines[index])) {
|
|
368
|
+
const line = lines[index]
|
|
369
|
+
if (line === '@@' || line.startsWith('@@ ')) {
|
|
370
|
+
flushChunk()
|
|
371
|
+
chunkHeader = line === '@@' ? '' : line.slice(3)
|
|
372
|
+
} else if (line === '*** End of File') {
|
|
373
|
+
chunkLines.push(line)
|
|
374
|
+
} else if (line.startsWith('*** ')) {
|
|
375
|
+
stateFail('PATCH_INVALID', `unexpected update control line:${line.slice(0, 80)}`, { path })
|
|
376
|
+
} else {
|
|
377
|
+
chunkLines.push(line)
|
|
378
|
+
}
|
|
379
|
+
index += 1
|
|
380
|
+
}
|
|
381
|
+
flushChunk()
|
|
382
|
+
if (!chunks.length) stateFail('PATCH_INVALID', 'Update File requires at least one non-empty chunk', { path })
|
|
383
|
+
operations.push({ kind: 'update', path, ...(moveTo ? { moveTo } : {}), chunks })
|
|
384
|
+
}
|
|
385
|
+
if (!operations.length) stateFail('PATCH_INVALID', 'Codex patch contains no file operation')
|
|
386
|
+
return { schemaVersion: 1, engine: 'codex-apply-patch-v1', operations }
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function unifiedPath(value, prefix, repoRoot) {
|
|
390
|
+
const raw = String(value || '').split('\t', 1)[0]
|
|
391
|
+
if (raw === '/dev/null') return null
|
|
392
|
+
return portablePatchPath(raw, prefix, repoRoot)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Parse ordinary text unified diffs into the same ordered mutation AST.
|
|
397
|
+
* Binary/combined/rename-metadata forms are deliberately rejected instead of
|
|
398
|
+
* guessed; a future provider can add a separately tested engine.
|
|
399
|
+
*/
|
|
400
|
+
export function parseUnifiedDiff({
|
|
401
|
+
diff,
|
|
402
|
+
repoRoot = process.cwd(),
|
|
403
|
+
limits: limitOptions = {},
|
|
404
|
+
checkpoint = null,
|
|
405
|
+
} = {}) {
|
|
406
|
+
const limits = normalizationLimits(limitOptions)
|
|
407
|
+
const body = patchText(diff, 'unified diff')
|
|
408
|
+
const lines = boundedPatchLines(body, {
|
|
409
|
+
limits,
|
|
410
|
+
checkpoint,
|
|
411
|
+
label: 'unified-diff',
|
|
412
|
+
})
|
|
413
|
+
if (lines.at(-1) === '') lines.pop()
|
|
414
|
+
const operations = []
|
|
415
|
+
let editCount = 0
|
|
416
|
+
let index = 0
|
|
417
|
+
while (index < lines.length) {
|
|
418
|
+
enforceCountLimit(
|
|
419
|
+
operations.length + 1,
|
|
420
|
+
limits.maximumMutationOperations,
|
|
421
|
+
'MUTATION_OPERATION_COUNT_EXCEEDED',
|
|
422
|
+
'unified diff operation count',
|
|
423
|
+
)
|
|
424
|
+
normalizationCheckpoint(checkpoint, `unified-diff:operation:${operations.length + 1}`)
|
|
425
|
+
if (/^(?:diff --git|index |new file mode|deleted file mode|similarity index|rename from|rename to|Binary files|GIT binary patch)/.test(lines[index])) {
|
|
426
|
+
stateFail('PATCH_INVALID', `unsupported unified diff metadata:${lines[index].slice(0, 80)}`)
|
|
427
|
+
}
|
|
428
|
+
const oldHeader = lines[index]?.match(/^--- (.+)$/)
|
|
429
|
+
const newHeader = lines[index + 1]?.match(/^\+\+\+ (.+)$/)
|
|
430
|
+
if (!oldHeader || !newHeader) {
|
|
431
|
+
stateFail('PATCH_INVALID', `expected ---/+++ file headers:${String(lines[index] || '').slice(0, 80)}`)
|
|
432
|
+
}
|
|
433
|
+
const oldPath = unifiedPath(oldHeader[1], 'unified old file', repoRoot)
|
|
434
|
+
const newPath = unifiedPath(newHeader[1], 'unified new file', repoRoot)
|
|
435
|
+
if (!oldPath && !newPath) stateFail('PATCH_INVALID', 'unified diff cannot map /dev/null to /dev/null')
|
|
436
|
+
index += 2
|
|
437
|
+
const chunks = []
|
|
438
|
+
while (index < lines.length && !lines[index].startsWith('--- ')) {
|
|
439
|
+
const hunk = lines[index]?.match(/^@@ -([0-9]+)(?:,([0-9]+))? \+([0-9]+)(?:,([0-9]+))? @@(?: .*)?$/)
|
|
440
|
+
if (!hunk) stateFail('PATCH_INVALID', `expected unified hunk header:${String(lines[index] || '').slice(0, 80)}`)
|
|
441
|
+
index += 1
|
|
442
|
+
const changes = []
|
|
443
|
+
let oldCount = 0
|
|
444
|
+
let newCount = 0
|
|
445
|
+
while (index < lines.length && !lines[index].startsWith('@@ ') && !lines[index].startsWith('--- ')) {
|
|
446
|
+
const line = lines[index]
|
|
447
|
+
editCount += 1
|
|
448
|
+
enforceCountLimit(
|
|
449
|
+
editCount,
|
|
450
|
+
limits.maximumMutationEdits,
|
|
451
|
+
'MUTATION_EDIT_COUNT_EXCEEDED',
|
|
452
|
+
'unified diff edit count',
|
|
453
|
+
)
|
|
454
|
+
if ((editCount & 255) === 0) {
|
|
455
|
+
normalizationCheckpoint(checkpoint, `unified-diff:edits:${editCount}`)
|
|
456
|
+
}
|
|
457
|
+
if (line === '\') {
|
|
458
|
+
const previous = changes.at(-1)
|
|
459
|
+
if (!previous) stateFail('PATCH_INVALID', 'no-newline marker must follow a unified diff line')
|
|
460
|
+
if (previous.oldNoNewline || previous.newNoNewline) {
|
|
461
|
+
stateFail('PATCH_INVALID', 'duplicate no-newline marker')
|
|
462
|
+
}
|
|
463
|
+
if (previous.kind !== '+') previous.oldNoNewline = true
|
|
464
|
+
if (previous.kind !== '-') previous.newNoNewline = true
|
|
465
|
+
index += 1
|
|
466
|
+
continue
|
|
467
|
+
}
|
|
468
|
+
if (!/^[ +\-]/.test(line)) stateFail('PATCH_INVALID', `invalid unified line prefix:${line.slice(0, 40)}`)
|
|
469
|
+
if (line[0] !== '+') oldCount += 1
|
|
470
|
+
if (line[0] !== '-') newCount += 1
|
|
471
|
+
changes.push({ kind: line[0], text: line.slice(1) })
|
|
472
|
+
index += 1
|
|
473
|
+
}
|
|
474
|
+
const declaredOld = Number(hunk[2] ?? 1)
|
|
475
|
+
const declaredNew = Number(hunk[4] ?? 1)
|
|
476
|
+
if (oldCount !== declaredOld || newCount !== declaredNew) {
|
|
477
|
+
stateFail('PATCH_INVALID', `unified hunk count mismatch:${oldCount}/${declaredOld}:${newCount}/${declaredNew}`)
|
|
478
|
+
}
|
|
479
|
+
const oldChanges = changes.filter((change) => change.kind !== '+')
|
|
480
|
+
const newChanges = changes.filter((change) => change.kind !== '-')
|
|
481
|
+
if (oldChanges.some((change, changeIndex) => change.oldNoNewline && changeIndex !== oldChanges.length - 1)) {
|
|
482
|
+
stateFail('PATCH_INVALID', 'old-side no-newline marker must describe the final old-side hunk line')
|
|
483
|
+
}
|
|
484
|
+
if (newChanges.some((change, changeIndex) => change.newNoNewline && changeIndex !== newChanges.length - 1)) {
|
|
485
|
+
stateFail('PATCH_INVALID', 'new-side no-newline marker must describe the final new-side hunk line')
|
|
486
|
+
}
|
|
487
|
+
chunks.push({
|
|
488
|
+
oldStart: Number(hunk[1]),
|
|
489
|
+
newStart: Number(hunk[3]),
|
|
490
|
+
changes,
|
|
491
|
+
})
|
|
492
|
+
}
|
|
493
|
+
if (!chunks.length) stateFail('PATCH_INVALID', 'unified file section contains no hunk')
|
|
494
|
+
if (!oldPath) operations.push({ kind: 'add-unified', path: newPath, chunks })
|
|
495
|
+
else if (!newPath) operations.push({ kind: 'delete-unified', path: oldPath, chunks })
|
|
496
|
+
else operations.push({
|
|
497
|
+
kind: 'update-unified',
|
|
498
|
+
path: oldPath,
|
|
499
|
+
...(oldPath !== newPath ? { moveTo: newPath } : {}),
|
|
500
|
+
chunks,
|
|
501
|
+
})
|
|
502
|
+
}
|
|
503
|
+
if (!operations.length) stateFail('PATCH_INVALID', 'unified diff contains no file operation')
|
|
504
|
+
return { schemaVersion: 1, engine: 'unified-diff-v1', operations }
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function exactOccurrenceSummary(body, needle, {
|
|
508
|
+
countAll,
|
|
509
|
+
checkpoint = null,
|
|
510
|
+
label,
|
|
511
|
+
}) {
|
|
512
|
+
if (!needle) return { count: 0, first: -1 }
|
|
513
|
+
let count = 0
|
|
514
|
+
let first = -1
|
|
515
|
+
let cursor = 0
|
|
516
|
+
while (cursor <= body.length) {
|
|
517
|
+
const offset = body.indexOf(needle, cursor)
|
|
518
|
+
if (offset === -1) break
|
|
519
|
+
if (first === -1) first = offset
|
|
520
|
+
count += 1
|
|
521
|
+
if ((count & 4095) === 0) normalizationCheckpoint(checkpoint, `${label}:matches:${count}`)
|
|
522
|
+
if (!countAll && count === 2) break
|
|
523
|
+
cursor = offset + Math.max(needle.length, 1)
|
|
524
|
+
}
|
|
525
|
+
normalizationCheckpoint(checkpoint, `${label}:matches-complete`)
|
|
526
|
+
return { count, first }
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function applyExactReplacement(content, edit, label, checkpoint = null) {
|
|
530
|
+
const oldString = assertText(edit?.oldString, `${label}.oldString`)
|
|
531
|
+
const newString = assertText(edit?.newString, `${label}.newString`)
|
|
532
|
+
if (!oldString) stateFail('EDIT_INVALID', `${label}.oldString must not be empty`)
|
|
533
|
+
if (edit.replaceAll !== undefined && edit.replaceAll !== false) {
|
|
534
|
+
if (edit.replaceAll !== true) stateFail('EDIT_INVALID', `${label}.replaceAll must be boolean`)
|
|
535
|
+
}
|
|
536
|
+
const matches = exactOccurrenceSummary(content, oldString, {
|
|
537
|
+
countAll: edit.replaceAll === true,
|
|
538
|
+
checkpoint,
|
|
539
|
+
label,
|
|
540
|
+
})
|
|
541
|
+
if (matches.count === 0) stateFail('EDIT_CONTEXT_MISMATCH', `${label}.oldString was not found`)
|
|
542
|
+
if (edit.replaceAll === true) {
|
|
543
|
+
const projectedBytes = BigInt(Buffer.byteLength(content, 'utf8'))
|
|
544
|
+
+ (BigInt(matches.count) * (
|
|
545
|
+
BigInt(Buffer.byteLength(newString, 'utf8'))
|
|
546
|
+
- BigInt(Buffer.byteLength(oldString, 'utf8'))
|
|
547
|
+
))
|
|
548
|
+
if (projectedBytes > BigInt(MAX_PROPOSED_TEXT_BYTES)) {
|
|
549
|
+
stateFail('TEXT_TOO_LARGE', `${label} after-image exceeds ${MAX_PROPOSED_TEXT_BYTES} bytes`)
|
|
550
|
+
}
|
|
551
|
+
return assertText(content.replaceAll(oldString, () => newString), `${label} after-image`)
|
|
552
|
+
}
|
|
553
|
+
if (matches.count !== 1) {
|
|
554
|
+
stateFail('EDIT_CONTEXT_AMBIGUOUS', `${label}.oldString matched more than once without replaceAll`)
|
|
555
|
+
}
|
|
556
|
+
const offset = matches.first
|
|
557
|
+
return assertText(
|
|
558
|
+
`${content.slice(0, offset)}${newString}${content.slice(offset + oldString.length)}`,
|
|
559
|
+
`${label} after-image`,
|
|
560
|
+
)
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function splitTextState(content, label, checkpoint = null) {
|
|
564
|
+
if (content === '') return { lines: [], trailingNewline: false }
|
|
565
|
+
let newlineCount = 0
|
|
566
|
+
let cursor = 0
|
|
567
|
+
while (cursor < content.length) {
|
|
568
|
+
const newline = content.indexOf('\n', cursor)
|
|
569
|
+
if (newline < 0) break
|
|
570
|
+
newlineCount += 1
|
|
571
|
+
if ((newlineCount & 4095) === 0) normalizationCheckpoint(checkpoint, `${label}:lines:${newlineCount}`)
|
|
572
|
+
cursor = newline + 1
|
|
573
|
+
}
|
|
574
|
+
const lineCount = newlineCount + (content.endsWith('\n') ? 0 : 1)
|
|
575
|
+
if (lineCount > MAX_PROPOSED_TEXT_LINES) {
|
|
576
|
+
stateFail('SOURCE_LINE_COUNT_EXCEEDED', `${label} exceeds ${MAX_PROPOSED_TEXT_LINES} lines`)
|
|
577
|
+
}
|
|
578
|
+
normalizationCheckpoint(checkpoint, `${label}:lines-complete`)
|
|
579
|
+
const trailingNewline = content.endsWith('\n')
|
|
580
|
+
const lines = content.split('\n')
|
|
581
|
+
if (trailingNewline) lines.pop()
|
|
582
|
+
return { lines, trailingNewline }
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function joinTextState({ lines, trailingNewline }) {
|
|
586
|
+
return `${lines.join('\n')}${trailingNewline ? '\n' : ''}`
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function sequenceMatches(lines, at, expected) {
|
|
590
|
+
if (at < 0 || at + expected.length > lines.length) return false
|
|
591
|
+
return expected.every((line, index) => lines[at + index] === line)
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function findExactSequence(lines, expected, start, {
|
|
595
|
+
header = null,
|
|
596
|
+
endOfFile = false,
|
|
597
|
+
checkpoint = null,
|
|
598
|
+
} = {}) {
|
|
599
|
+
if (!expected.length) {
|
|
600
|
+
// Codex apply_patch treats every addition-only update chunk as an append,
|
|
601
|
+
// including bare `@@`, `@@ <label>`, and an explicit End of File marker.
|
|
602
|
+
return lines.length
|
|
603
|
+
}
|
|
604
|
+
let searchStart = start
|
|
605
|
+
if (header !== null && header !== '') {
|
|
606
|
+
const headerIndex = lines.findIndex((line, index) =>
|
|
607
|
+
index >= start && (line === header || line.trim() === header.trim()))
|
|
608
|
+
if (headerIndex === -1) stateFail('PATCH_CONTEXT_MISMATCH', `hunk header was not found:${header.slice(0, 80)}`)
|
|
609
|
+
searchStart = headerIndex
|
|
610
|
+
}
|
|
611
|
+
let match = -1
|
|
612
|
+
for (let index = searchStart; index <= lines.length - expected.length; index += 1) {
|
|
613
|
+
if ((index & 1023) === 0) normalizationCheckpoint(checkpoint, `patch-context:line:${index + 1}`)
|
|
614
|
+
if (!sequenceMatches(lines, index, expected)) continue
|
|
615
|
+
if (match === -1) {
|
|
616
|
+
match = index
|
|
617
|
+
if (header !== null && header !== '') return match
|
|
618
|
+
continue
|
|
619
|
+
}
|
|
620
|
+
stateFail('PATCH_CONTEXT_AMBIGUOUS', 'patch hunk context matched more than one location')
|
|
621
|
+
}
|
|
622
|
+
normalizationCheckpoint(checkpoint, 'patch-context:complete')
|
|
623
|
+
if (match === -1) stateFail('PATCH_CONTEXT_MISMATCH', 'patch hunk context was not found')
|
|
624
|
+
return match
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function applyCodexChunks(content, chunks, checkpoint = null) {
|
|
628
|
+
const state = splitTextState(content, 'Codex patch source', checkpoint)
|
|
629
|
+
let cursor = 0
|
|
630
|
+
for (const chunk of chunks) {
|
|
631
|
+
const oldLines = chunk.changes.filter((change) => change.kind !== '+').map((change) => change.text)
|
|
632
|
+
const newLines = chunk.changes.filter((change) => change.kind !== '-').map((change) => change.text)
|
|
633
|
+
const offset = findExactSequence(state.lines, oldLines, cursor, {
|
|
634
|
+
header: chunk.header,
|
|
635
|
+
endOfFile: chunk.endOfFile,
|
|
636
|
+
checkpoint,
|
|
637
|
+
})
|
|
638
|
+
state.lines.splice(offset, oldLines.length, ...newLines)
|
|
639
|
+
cursor = offset + newLines.length
|
|
640
|
+
}
|
|
641
|
+
// Codex's Update File engine rewrites accepted text as line-joined content
|
|
642
|
+
// with one final LF. It canonicalizes even a pre-existing no-LF source and
|
|
643
|
+
// context-only or move updates; an empty after-image remains zero bytes.
|
|
644
|
+
state.trailingNewline = state.lines.length > 0
|
|
645
|
+
return assertText(joinTextState(state), 'Codex patch after-image')
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function applyUnifiedChunks(content, chunks, checkpoint = null) {
|
|
649
|
+
const state = splitTextState(content, 'unified diff source', checkpoint)
|
|
650
|
+
let lineDelta = 0
|
|
651
|
+
for (const chunk of chunks) {
|
|
652
|
+
const oldChanges = chunk.changes.filter((change) => change.kind !== '+')
|
|
653
|
+
const newChanges = chunk.changes.filter((change) => change.kind !== '-')
|
|
654
|
+
const oldLines = oldChanges.map((change) => change.text)
|
|
655
|
+
const newLines = newChanges.map((change) => change.text)
|
|
656
|
+
const expectedOffset = chunk.oldStart === 0 ? 0 : chunk.oldStart - 1 + lineDelta
|
|
657
|
+
if (!sequenceMatches(state.lines, expectedOffset, oldLines)) {
|
|
658
|
+
stateFail('PATCH_CONTEXT_MISMATCH', `unified hunk context mismatch at old line ${chunk.oldStart}`)
|
|
659
|
+
}
|
|
660
|
+
const oldTouchesEnd = expectedOffset + oldLines.length === state.lines.length
|
|
661
|
+
const oldNoNewline = oldChanges.at(-1)?.oldNoNewline === true
|
|
662
|
+
if (oldNoNewline && (!oldTouchesEnd || state.trailingNewline)) {
|
|
663
|
+
stateFail('PATCH_CONTEXT_MISMATCH', 'old-side no-newline marker contradicts the source after-image')
|
|
664
|
+
}
|
|
665
|
+
if (
|
|
666
|
+
oldTouchesEnd
|
|
667
|
+
&& oldLines.length
|
|
668
|
+
&& !oldNoNewline
|
|
669
|
+
&& !state.trailingNewline
|
|
670
|
+
) {
|
|
671
|
+
stateFail('PATCH_CONTEXT_MISMATCH', 'source lacks the unified diff old-side no-newline marker')
|
|
672
|
+
}
|
|
673
|
+
state.lines.splice(expectedOffset, oldLines.length, ...newLines)
|
|
674
|
+
lineDelta += newLines.length - oldLines.length
|
|
675
|
+
const newTouchesEnd = expectedOffset + newLines.length === state.lines.length
|
|
676
|
+
const newNoNewline = newChanges.at(-1)?.newNoNewline === true
|
|
677
|
+
if (newNoNewline && !newTouchesEnd) {
|
|
678
|
+
stateFail('PATCH_CONTEXT_MISMATCH', 'new-side no-newline marker does not describe the final after-image line')
|
|
679
|
+
}
|
|
680
|
+
if (newTouchesEnd) {
|
|
681
|
+
state.trailingNewline = state.lines.length > 0 && !newNoNewline
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
return assertText(joinTextState(state), 'unified diff after-image')
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function sameOpenedIdentity(left, right) {
|
|
688
|
+
return ['dev', 'ino', 'mode'].every((key) => left[key] === right[key])
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function repositoryChainSnapshot(repoRoot, path) {
|
|
692
|
+
const root = resolve(repoRoot)
|
|
693
|
+
const segments = path.split('/').filter(Boolean)
|
|
694
|
+
const chain = []
|
|
695
|
+
let cursor = root
|
|
696
|
+
for (let index = -1; index < segments.length; index += 1) {
|
|
697
|
+
if (index >= 0) cursor = resolve(cursor, segments[index])
|
|
698
|
+
let info
|
|
699
|
+
try {
|
|
700
|
+
info = lstatSync(cursor, { bigint: true })
|
|
701
|
+
} catch (error) {
|
|
702
|
+
if (error?.code === 'ENOENT') return { missing: true, chain }
|
|
703
|
+
stateFail('SOURCE_UNSAFE', `cannot inspect source component:${path}`, { path })
|
|
704
|
+
}
|
|
705
|
+
if (info.isSymbolicLink()) {
|
|
706
|
+
stateFail('SOURCE_UNSAFE', `source component became a symbolic link:${path}`, { path })
|
|
707
|
+
}
|
|
708
|
+
const isFinal = index === segments.length - 1
|
|
709
|
+
if (!isFinal && !info.isDirectory()) {
|
|
710
|
+
stateFail('SOURCE_UNSAFE', `source parent is not a directory:${path}`, { path })
|
|
711
|
+
}
|
|
712
|
+
chain.push({
|
|
713
|
+
path: index < 0 ? '.' : segments.slice(0, index + 1).join('/'),
|
|
714
|
+
dev: info.dev,
|
|
715
|
+
ino: info.ino,
|
|
716
|
+
mode: info.mode,
|
|
717
|
+
...(isFinal ? {
|
|
718
|
+
size: info.size,
|
|
719
|
+
mtimeNs: info.mtimeNs,
|
|
720
|
+
ctimeNs: info.ctimeNs,
|
|
721
|
+
nlink: info.nlink,
|
|
722
|
+
} : {}),
|
|
723
|
+
})
|
|
724
|
+
}
|
|
725
|
+
return { missing: false, chain }
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function sameRepositoryChain(left, right) {
|
|
729
|
+
return left.length === right.length && left.every((entry, index) =>
|
|
730
|
+
entry.path === right[index].path
|
|
731
|
+
&& sameOpenedIdentity(entry, right[index])
|
|
732
|
+
&& (
|
|
733
|
+
index !== left.length - 1
|
|
734
|
+
|| ['size', 'mtimeNs', 'ctimeNs', 'nlink'].every((key) => entry[key] === right[index][key])
|
|
735
|
+
))
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function readStableRepositoryText(
|
|
739
|
+
repoRoot,
|
|
740
|
+
path,
|
|
741
|
+
sourceOpenBarrier = null,
|
|
742
|
+
maximumRetainedBytes = MAX_PROPOSED_TEXT_BYTES,
|
|
743
|
+
) {
|
|
744
|
+
const inspected = requiredRepositoryPath(repoRoot, path, 'materialized file')
|
|
745
|
+
const absolute = resolve(repoRoot, inspected)
|
|
746
|
+
const preOpen = repositoryChainSnapshot(repoRoot, inspected)
|
|
747
|
+
if (preOpen.missing) return { path: inspected, kind: 'deleted' }
|
|
748
|
+
if (typeof sourceOpenBarrier === 'function') {
|
|
749
|
+
sourceOpenBarrier({ phase: 'after-source-chain-snapshot', path: inspected })
|
|
750
|
+
}
|
|
751
|
+
let handle = null
|
|
752
|
+
try {
|
|
753
|
+
const noFollow = typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0
|
|
754
|
+
handle = openSync(absolute, fsConstants.O_RDONLY | noFollow)
|
|
755
|
+
} catch (error) {
|
|
756
|
+
stateFail(
|
|
757
|
+
error?.code === 'ENOENT' ? 'SOURCE_RACED' : 'SOURCE_UNSAFE',
|
|
758
|
+
`cannot open the pre-inspected no-follow source:${inspected}`,
|
|
759
|
+
{ path: inspected },
|
|
760
|
+
)
|
|
761
|
+
}
|
|
762
|
+
try {
|
|
763
|
+
const before = fstatSync(handle, { bigint: true })
|
|
764
|
+
const preFinal = preOpen.chain.at(-1)
|
|
765
|
+
if (!preFinal || !sameOpenedIdentity(preFinal, before)) {
|
|
766
|
+
stateFail('SOURCE_RACED', `opened source identity differs from its inspected repository path:${inspected}`, { path: inspected })
|
|
767
|
+
}
|
|
768
|
+
if (!before.isFile() || before.nlink !== 1n) {
|
|
769
|
+
stateFail('SOURCE_UNSAFE', `source must be one regular unaliased file:${inspected}`, { path: inspected })
|
|
770
|
+
}
|
|
771
|
+
if (before.size > BigInt(MAX_PROPOSED_TEXT_BYTES)) {
|
|
772
|
+
stateFail('SOURCE_TOO_LARGE', `source exceeds ${MAX_PROPOSED_TEXT_BYTES} bytes:${inspected}`, { path: inspected })
|
|
773
|
+
}
|
|
774
|
+
if (before.size > BigInt(maximumRetainedBytes)) {
|
|
775
|
+
stateFail(
|
|
776
|
+
'AGGREGATE_TEXT_TOO_LARGE',
|
|
777
|
+
`materialized text states exceed ${maximumRetainedBytes} remaining bytes:${inspected}`,
|
|
778
|
+
{ path: inspected },
|
|
779
|
+
)
|
|
780
|
+
}
|
|
781
|
+
const bytes = Buffer.alloc(Number(before.size))
|
|
782
|
+
let offset = 0
|
|
783
|
+
while (offset < bytes.length) {
|
|
784
|
+
const count = readSync(handle, bytes, offset, bytes.length - offset, offset)
|
|
785
|
+
if (count <= 0) stateFail('SOURCE_RACED', `source truncated while reading:${inspected}`, { path: inspected })
|
|
786
|
+
offset += count
|
|
787
|
+
}
|
|
788
|
+
const after = fstatSync(handle, { bigint: true })
|
|
789
|
+
for (const key of ['dev', 'ino', 'size', 'mtimeNs', 'ctimeNs', 'nlink']) {
|
|
790
|
+
if (before[key] !== after[key]) {
|
|
791
|
+
stateFail('SOURCE_RACED', `source changed while reading:${inspected}`, { path: inspected })
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
const postOpen = repositoryChainSnapshot(repoRoot, inspected)
|
|
795
|
+
if (postOpen.missing || !sameRepositoryChain(preOpen.chain, postOpen.chain)) {
|
|
796
|
+
stateFail('SOURCE_RACED', `source repository chain changed while reading:${inspected}`, { path: inspected })
|
|
797
|
+
}
|
|
798
|
+
const postFinal = postOpen.chain.at(-1)
|
|
799
|
+
if (!postFinal || !sameOpenedIdentity(postFinal, after)) {
|
|
800
|
+
stateFail('SOURCE_RACED', `source path no longer names the opened file:${inspected}`, { path: inspected })
|
|
801
|
+
}
|
|
802
|
+
let content
|
|
803
|
+
try {
|
|
804
|
+
content = STRICT_UTF8.decode(bytes)
|
|
805
|
+
} catch {
|
|
806
|
+
stateFail('SOURCE_NOT_UTF8', `source is not strict UTF-8:${inspected}`, { path: inspected })
|
|
807
|
+
}
|
|
808
|
+
return { path: inspected, kind: 'text', content }
|
|
809
|
+
} finally {
|
|
810
|
+
if (handle !== null) closeSync(handle)
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function retainedTextBytes(state) {
|
|
815
|
+
return state?.kind === 'text' ? Buffer.byteLength(state.content, 'utf8') : 0
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function setBoundedState(states, path, state, budget) {
|
|
819
|
+
const previousBytes = retainedTextBytes(states.get(path))
|
|
820
|
+
const nextBytes = retainedTextBytes(state)
|
|
821
|
+
const projected = budget.bytes - previousBytes + nextBytes
|
|
822
|
+
if (!Number.isSafeInteger(projected) || projected > budget.maximum) {
|
|
823
|
+
stateFail(
|
|
824
|
+
'AGGREGATE_TEXT_TOO_LARGE',
|
|
825
|
+
`materialized text states exceed ${budget.maximum} bytes`,
|
|
826
|
+
{ path },
|
|
827
|
+
)
|
|
828
|
+
}
|
|
829
|
+
states.set(path, state)
|
|
830
|
+
budget.bytes = projected
|
|
831
|
+
return state
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function stateMapEntry(states, repoRoot, path, sourceOpenBarrier, budget) {
|
|
835
|
+
if (!states.has(path)) {
|
|
836
|
+
const remaining = budget.maximum - budget.bytes
|
|
837
|
+
const state = readStableRepositoryText(repoRoot, path, sourceOpenBarrier, remaining)
|
|
838
|
+
setBoundedState(states, path, state, budget)
|
|
839
|
+
}
|
|
840
|
+
return states.get(path)
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function requireTextState(states, repoRoot, path, label, sourceOpenBarrier, budget) {
|
|
844
|
+
const current = stateMapEntry(states, repoRoot, path, sourceOpenBarrier, budget)
|
|
845
|
+
if (current.kind !== 'text') stateFail('SOURCE_MISSING', `${label} source does not exist:${path}`, { path })
|
|
846
|
+
return current
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function boundedMaterializationChunks(
|
|
850
|
+
operation,
|
|
851
|
+
label,
|
|
852
|
+
limits,
|
|
853
|
+
checkpoint,
|
|
854
|
+
currentEditCount,
|
|
855
|
+
) {
|
|
856
|
+
if (!Array.isArray(operation.chunks) || !operation.chunks.length) {
|
|
857
|
+
stateFail('MUTATION_INVALID', `${label}.chunks must be a non-empty array`)
|
|
858
|
+
}
|
|
859
|
+
enforceCountLimit(
|
|
860
|
+
operation.chunks.length,
|
|
861
|
+
limits.maximumPatchLines,
|
|
862
|
+
'MUTATION_EDIT_COUNT_EXCEEDED',
|
|
863
|
+
`${label}.chunk count`,
|
|
864
|
+
)
|
|
865
|
+
let editCount = currentEditCount
|
|
866
|
+
for (const [chunkIndex, chunk] of operation.chunks.entries()) {
|
|
867
|
+
if (!chunk || typeof chunk !== 'object' || !Array.isArray(chunk.changes)) {
|
|
868
|
+
stateFail('MUTATION_INVALID', `${label}.chunks[${chunkIndex}].changes must be an array`)
|
|
869
|
+
}
|
|
870
|
+
enforceCountLimit(
|
|
871
|
+
editCount + chunk.changes.length,
|
|
872
|
+
limits.maximumMutationEdits,
|
|
873
|
+
'MUTATION_EDIT_COUNT_EXCEEDED',
|
|
874
|
+
'mutation edit count',
|
|
875
|
+
)
|
|
876
|
+
editCount += chunk.changes.length
|
|
877
|
+
if ((chunkIndex & 255) === 0) {
|
|
878
|
+
normalizationCheckpoint(checkpoint, `materialize:chunk:${chunkIndex + 1}`)
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return { chunks: operation.chunks, editCount }
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Materialize an ordered provider-neutral mutation AST entirely in memory,
|
|
886
|
+
* once for every requested path. Replaying one multi-file patch separately per
|
|
887
|
+
* path would be O(paths × patch); this batch API keeps it O(patch + paths).
|
|
888
|
+
* This function never writes either the checkout or a temporary patch target.
|
|
889
|
+
*/
|
|
890
|
+
export function materializeProviderWriteStates({
|
|
891
|
+
repoRoot = process.cwd(),
|
|
892
|
+
mutation,
|
|
893
|
+
paths,
|
|
894
|
+
sourceOpenBarrier = null,
|
|
895
|
+
limits: limitOptions = {},
|
|
896
|
+
checkpoint = null,
|
|
897
|
+
} = {}) {
|
|
898
|
+
const limits = normalizationLimits(limitOptions)
|
|
899
|
+
if (!mutation || mutation.schemaVersion !== 1 || !Array.isArray(mutation.operations)) {
|
|
900
|
+
stateFail('MUTATION_INVALID', 'mutation AST is missing or unsupported')
|
|
901
|
+
}
|
|
902
|
+
if (!Array.isArray(paths) || !paths.length) {
|
|
903
|
+
stateFail('MUTATION_INVALID', 'requested materialized paths must be a non-empty array')
|
|
904
|
+
}
|
|
905
|
+
enforceCountLimit(
|
|
906
|
+
paths.length,
|
|
907
|
+
limits.maximumPathCandidates,
|
|
908
|
+
'PATH_COUNT_EXCEEDED',
|
|
909
|
+
'requested materialized path count',
|
|
910
|
+
)
|
|
911
|
+
enforceCountLimit(
|
|
912
|
+
mutation.operations.length,
|
|
913
|
+
limits.maximumMutationOperations,
|
|
914
|
+
'MUTATION_OPERATION_COUNT_EXCEEDED',
|
|
915
|
+
'mutation operation count',
|
|
916
|
+
)
|
|
917
|
+
normalizationCheckpoint(checkpoint, 'materialize:start')
|
|
918
|
+
const requestedPaths = [...new Set(paths.map((path, index) =>
|
|
919
|
+
requiredRepositoryPath(repoRoot, path, `requested materialized paths[${index}]`)))]
|
|
920
|
+
const states = new Map()
|
|
921
|
+
const retainedBudget = {
|
|
922
|
+
bytes: 0,
|
|
923
|
+
maximum: limits.maximumAggregateTextBytes,
|
|
924
|
+
}
|
|
925
|
+
let editCount = 0
|
|
926
|
+
for (const [operationIndex, operation] of mutation.operations.entries()) {
|
|
927
|
+
normalizationCheckpoint(checkpoint, `materialize:operation:${operationIndex + 1}`)
|
|
928
|
+
const label = `operation[${operationIndex}]`
|
|
929
|
+
const operationPath = requiredRepositoryPath(repoRoot, operation.path, `${label}.path`)
|
|
930
|
+
if (operation.kind === 'write') {
|
|
931
|
+
setBoundedState(states, operationPath, {
|
|
932
|
+
path: operationPath,
|
|
933
|
+
kind: 'text',
|
|
934
|
+
content: assertText(operation.content, `${label}.content`),
|
|
935
|
+
}, retainedBudget)
|
|
936
|
+
continue
|
|
937
|
+
}
|
|
938
|
+
if (operation.kind === 'edit') {
|
|
939
|
+
const current = requireTextState(
|
|
940
|
+
states,
|
|
941
|
+
repoRoot,
|
|
942
|
+
operationPath,
|
|
943
|
+
label,
|
|
944
|
+
sourceOpenBarrier,
|
|
945
|
+
retainedBudget,
|
|
946
|
+
)
|
|
947
|
+
let content = current.content
|
|
948
|
+
if (!Array.isArray(operation.edits) || !operation.edits.length) {
|
|
949
|
+
stateFail('EDIT_INVALID', `${label}.edits must be a non-empty array`, { path: operationPath })
|
|
950
|
+
}
|
|
951
|
+
for (const [editIndex, edit] of operation.edits.entries()) {
|
|
952
|
+
editCount += 1
|
|
953
|
+
enforceCountLimit(
|
|
954
|
+
editCount,
|
|
955
|
+
limits.maximumMutationEdits,
|
|
956
|
+
'MUTATION_EDIT_COUNT_EXCEEDED',
|
|
957
|
+
'mutation edit count',
|
|
958
|
+
)
|
|
959
|
+
content = applyExactReplacement(content, edit, `${label}.edits[${editIndex}]`, checkpoint)
|
|
960
|
+
}
|
|
961
|
+
setBoundedState(
|
|
962
|
+
states,
|
|
963
|
+
operationPath,
|
|
964
|
+
{ path: operationPath, kind: 'text', content },
|
|
965
|
+
retainedBudget,
|
|
966
|
+
)
|
|
967
|
+
continue
|
|
968
|
+
}
|
|
969
|
+
if (operation.kind === 'add') {
|
|
970
|
+
const current = stateMapEntry(states, repoRoot, operationPath, sourceOpenBarrier, retainedBudget)
|
|
971
|
+
if (current.kind !== 'deleted') stateFail('ADD_TARGET_EXISTS', `${label} target already exists`, { path: operationPath })
|
|
972
|
+
setBoundedState(states, operationPath, {
|
|
973
|
+
path: operationPath,
|
|
974
|
+
kind: 'text',
|
|
975
|
+
content: assertText(operation.content, `${label}.content`),
|
|
976
|
+
}, retainedBudget)
|
|
977
|
+
continue
|
|
978
|
+
}
|
|
979
|
+
if (operation.kind === 'delete') {
|
|
980
|
+
requireTextState(states, repoRoot, operationPath, label, sourceOpenBarrier, retainedBudget)
|
|
981
|
+
setBoundedState(states, operationPath, { path: operationPath, kind: 'deleted' }, retainedBudget)
|
|
982
|
+
continue
|
|
983
|
+
}
|
|
984
|
+
if (operation.kind === 'update') {
|
|
985
|
+
const bounded = boundedMaterializationChunks(
|
|
986
|
+
operation,
|
|
987
|
+
label,
|
|
988
|
+
limits,
|
|
989
|
+
checkpoint,
|
|
990
|
+
editCount,
|
|
991
|
+
)
|
|
992
|
+
editCount = bounded.editCount
|
|
993
|
+
const current = requireTextState(
|
|
994
|
+
states,
|
|
995
|
+
repoRoot,
|
|
996
|
+
operationPath,
|
|
997
|
+
label,
|
|
998
|
+
sourceOpenBarrier,
|
|
999
|
+
retainedBudget,
|
|
1000
|
+
)
|
|
1001
|
+
const content = applyCodexChunks(current.content, bounded.chunks, checkpoint)
|
|
1002
|
+
if (operation.moveTo) {
|
|
1003
|
+
const moveTo = requiredRepositoryPath(repoRoot, operation.moveTo, `${label}.moveTo`)
|
|
1004
|
+
if (stateMapEntry(states, repoRoot, moveTo, sourceOpenBarrier, retainedBudget).kind !== 'deleted') {
|
|
1005
|
+
stateFail('MOVE_TARGET_EXISTS', `${label} move target already exists`, { path: moveTo })
|
|
1006
|
+
}
|
|
1007
|
+
setBoundedState(states, operationPath, { path: operationPath, kind: 'deleted' }, retainedBudget)
|
|
1008
|
+
setBoundedState(states, moveTo, { path: moveTo, kind: 'text', content }, retainedBudget)
|
|
1009
|
+
} else {
|
|
1010
|
+
setBoundedState(states, operationPath, { path: operationPath, kind: 'text', content }, retainedBudget)
|
|
1011
|
+
}
|
|
1012
|
+
continue
|
|
1013
|
+
}
|
|
1014
|
+
if (['add-unified', 'delete-unified', 'update-unified'].includes(operation.kind)) {
|
|
1015
|
+
const bounded = boundedMaterializationChunks(
|
|
1016
|
+
operation,
|
|
1017
|
+
label,
|
|
1018
|
+
limits,
|
|
1019
|
+
checkpoint,
|
|
1020
|
+
editCount,
|
|
1021
|
+
)
|
|
1022
|
+
editCount = bounded.editCount
|
|
1023
|
+
const isAdd = operation.kind === 'add-unified'
|
|
1024
|
+
const current = isAdd
|
|
1025
|
+
? stateMapEntry(states, repoRoot, operationPath, sourceOpenBarrier, retainedBudget)
|
|
1026
|
+
: requireTextState(
|
|
1027
|
+
states,
|
|
1028
|
+
repoRoot,
|
|
1029
|
+
operationPath,
|
|
1030
|
+
label,
|
|
1031
|
+
sourceOpenBarrier,
|
|
1032
|
+
retainedBudget,
|
|
1033
|
+
)
|
|
1034
|
+
if (isAdd && current.kind !== 'deleted') {
|
|
1035
|
+
stateFail('ADD_TARGET_EXISTS', `${label} target already exists`, { path: operationPath })
|
|
1036
|
+
}
|
|
1037
|
+
const source = current.kind === 'text' ? current.content : ''
|
|
1038
|
+
const content = applyUnifiedChunks(source, bounded.chunks, checkpoint)
|
|
1039
|
+
if (operation.kind === 'delete-unified') {
|
|
1040
|
+
setBoundedState(states, operationPath, { path: operationPath, kind: 'deleted' }, retainedBudget)
|
|
1041
|
+
} else if (operation.moveTo) {
|
|
1042
|
+
const moveTo = requiredRepositoryPath(repoRoot, operation.moveTo, `${label}.moveTo`)
|
|
1043
|
+
if (stateMapEntry(states, repoRoot, moveTo, sourceOpenBarrier, retainedBudget).kind !== 'deleted') {
|
|
1044
|
+
stateFail('MOVE_TARGET_EXISTS', `${label} move target already exists`, { path: moveTo })
|
|
1045
|
+
}
|
|
1046
|
+
setBoundedState(states, operationPath, { path: operationPath, kind: 'deleted' }, retainedBudget)
|
|
1047
|
+
setBoundedState(states, moveTo, { path: moveTo, kind: 'text', content }, retainedBudget)
|
|
1048
|
+
} else {
|
|
1049
|
+
setBoundedState(states, operationPath, { path: operationPath, kind: 'text', content }, retainedBudget)
|
|
1050
|
+
}
|
|
1051
|
+
continue
|
|
1052
|
+
}
|
|
1053
|
+
stateFail('MUTATION_INVALID', `${label} has unsupported kind:${operation.kind || '<missing>'}`)
|
|
1054
|
+
}
|
|
1055
|
+
normalizationCheckpoint(checkpoint, 'materialize:complete')
|
|
1056
|
+
return requestedPaths.map((requestedPath) => {
|
|
1057
|
+
const result = stateMapEntry(
|
|
1058
|
+
states,
|
|
1059
|
+
repoRoot,
|
|
1060
|
+
requestedPath,
|
|
1061
|
+
sourceOpenBarrier,
|
|
1062
|
+
retainedBudget,
|
|
1063
|
+
)
|
|
1064
|
+
return {
|
|
1065
|
+
schemaVersion: 1,
|
|
1066
|
+
path: requestedPath,
|
|
1067
|
+
kind: result.kind,
|
|
1068
|
+
...(result.kind === 'text' ? { content: result.content } : {}),
|
|
1069
|
+
}
|
|
1070
|
+
})
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
export function materializeProviderWriteState({
|
|
1074
|
+
repoRoot = process.cwd(),
|
|
1075
|
+
mutation,
|
|
1076
|
+
path,
|
|
1077
|
+
sourceOpenBarrier = null,
|
|
1078
|
+
limits = {},
|
|
1079
|
+
checkpoint = null,
|
|
1080
|
+
} = {}) {
|
|
1081
|
+
return materializeProviderWriteStates({
|
|
1082
|
+
repoRoot,
|
|
1083
|
+
mutation,
|
|
1084
|
+
paths: [path],
|
|
1085
|
+
sourceOpenBarrier,
|
|
1086
|
+
limits,
|
|
1087
|
+
checkpoint,
|
|
1088
|
+
})[0]
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function transportField(input, field, label) {
|
|
1092
|
+
const value = getAtPath(input, field)
|
|
1093
|
+
if (value === undefined) stateFail('MUTATION_INVALID', `${label} field is missing:${field}`)
|
|
1094
|
+
return value
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
function mutationPath(repoRoot, value, label) {
|
|
1098
|
+
return requiredRepositoryPath(repoRoot, value, label)
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function matchingWriteTransports(provider, input) {
|
|
1102
|
+
const normalization = provider?.normalization || {}
|
|
1103
|
+
const rawTool = firstString(input || {}, normalization.toolFields || [])
|
|
1104
|
+
if (!rawTool) return { rawTool: '', matches: [] }
|
|
1105
|
+
const transports = Array.isArray(normalization.writeTransports)
|
|
1106
|
+
? normalization.writeTransports
|
|
1107
|
+
: []
|
|
1108
|
+
return {
|
|
1109
|
+
rawTool,
|
|
1110
|
+
matches: transports.filter((transport) =>
|
|
1111
|
+
Array.isArray(transport.rawTools)
|
|
1112
|
+
&& transport.rawTools.some((candidate) => aliasKey(candidate) === aliasKey(rawTool))),
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function postEventCarriesDeclaredTransport(input, transport, { limits, checkpoint }) {
|
|
1117
|
+
const requiredFieldIndexes = {
|
|
1118
|
+
'full-file-v1': [0, 1],
|
|
1119
|
+
'exact-replace-v1': [0, 1, 2],
|
|
1120
|
+
'exact-replace-list-v1': [1],
|
|
1121
|
+
'codex-apply-patch-v1': [0],
|
|
1122
|
+
'unified-diff-v1': [0],
|
|
1123
|
+
}[transport?.engine]
|
|
1124
|
+
if (!requiredFieldIndexes) return true
|
|
1125
|
+
const rootFieldsPresent = requiredFieldIndexes.every((index) =>
|
|
1126
|
+
typeof transport.payloadFields?.[index] === 'string'
|
|
1127
|
+
&& getAtPath(input, transport.payloadFields[index]) !== undefined)
|
|
1128
|
+
if (!rootFieldsPresent || transport.engine !== 'exact-replace-list-v1') {
|
|
1129
|
+
return rootFieldsPresent
|
|
1130
|
+
}
|
|
1131
|
+
const edits = getAtPath(input, transport.payloadFields[1])
|
|
1132
|
+
const outerPath = getAtPath(input, transport.payloadFields[0])
|
|
1133
|
+
if (!Array.isArray(edits) || edits.length === 0) return false
|
|
1134
|
+
// An oversized PostToolUse MultiEdit must reach the bounded parser without first
|
|
1135
|
+
// walking the entire provider array merely to decide whether it carries a transport.
|
|
1136
|
+
if (edits.length > limits.maximumMutationEdits
|
|
1137
|
+
|| edits.length > limits.maximumMutationOperations) return true
|
|
1138
|
+
for (const [index, edit] of edits.entries()) {
|
|
1139
|
+
if ((index & 255) === 0) normalizationCheckpoint(checkpoint, `post-write-transport:edit:${index + 1}`)
|
|
1140
|
+
if (!edit
|
|
1141
|
+
|| typeof edit !== 'object'
|
|
1142
|
+
|| Array.isArray(edit)
|
|
1143
|
+
|| typeof (edit.file_path ?? edit.path ?? outerPath) !== 'string'
|
|
1144
|
+
|| getAtPath(edit, transport.payloadFields[2]) === undefined
|
|
1145
|
+
|| getAtPath(edit, transport.payloadFields[3]) === undefined) return false
|
|
1146
|
+
}
|
|
1147
|
+
return true
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* Parse a provider-native write payload according to registry data. Selection
|
|
1152
|
+
* uses the raw tool name before aliases so `apply_patch` cannot collapse into
|
|
1153
|
+
* the unrelated legacy `MultiEdit` transport.
|
|
1154
|
+
*/
|
|
1155
|
+
export function parseProviderWriteMutation({
|
|
1156
|
+
provider,
|
|
1157
|
+
input,
|
|
1158
|
+
repoRoot = process.cwd(),
|
|
1159
|
+
limits: limitOptions = {},
|
|
1160
|
+
checkpoint = null,
|
|
1161
|
+
} = {}) {
|
|
1162
|
+
const limits = normalizationLimits(limitOptions)
|
|
1163
|
+
normalizationCheckpoint(checkpoint, 'write-transport:start')
|
|
1164
|
+
const { rawTool, matches } = matchingWriteTransports(provider, input)
|
|
1165
|
+
if (!rawTool) return null
|
|
1166
|
+
if (!matches.length) return null
|
|
1167
|
+
if (matches.length !== 1) stateFail('MUTATION_INVALID', `raw tool matches multiple write transports:${rawTool}`)
|
|
1168
|
+
const transport = matches[0]
|
|
1169
|
+
const fields = transport.payloadFields
|
|
1170
|
+
if (!Array.isArray(fields)) stateFail('MUTATION_INVALID', `${transport.id}.payloadFields must be an array`)
|
|
1171
|
+
|
|
1172
|
+
if (transport.engine === 'full-file-v1') {
|
|
1173
|
+
const path = mutationPath(repoRoot, transportField(input, fields[0], transport.id), `${transport.id}.path`)
|
|
1174
|
+
const content = assertText(transportField(input, fields[1], transport.id), `${transport.id}.content`)
|
|
1175
|
+
return {
|
|
1176
|
+
schemaVersion: 1,
|
|
1177
|
+
provider: provider?.id || null,
|
|
1178
|
+
rawTool,
|
|
1179
|
+
transportId: transport.id,
|
|
1180
|
+
engine: transport.engine,
|
|
1181
|
+
operations: [{ kind: 'write', path, content }],
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
if (transport.engine === 'exact-replace-v1') {
|
|
1186
|
+
const path = mutationPath(repoRoot, transportField(input, fields[0], transport.id), `${transport.id}.path`)
|
|
1187
|
+
const oldString = assertText(transportField(input, fields[1], transport.id), `${transport.id}.oldString`)
|
|
1188
|
+
const newString = assertText(transportField(input, fields[2], transport.id), `${transport.id}.newString`)
|
|
1189
|
+
const replaceAll = getAtPath(input, fields[3])
|
|
1190
|
+
if (replaceAll !== undefined && typeof replaceAll !== 'boolean') {
|
|
1191
|
+
stateFail('EDIT_INVALID', `${transport.id}.replaceAll must be boolean`)
|
|
1192
|
+
}
|
|
1193
|
+
return {
|
|
1194
|
+
schemaVersion: 1,
|
|
1195
|
+
provider: provider?.id || null,
|
|
1196
|
+
rawTool,
|
|
1197
|
+
transportId: transport.id,
|
|
1198
|
+
engine: transport.engine,
|
|
1199
|
+
operations: [{
|
|
1200
|
+
kind: 'edit',
|
|
1201
|
+
path,
|
|
1202
|
+
edits: [{ oldString, newString, ...(replaceAll !== undefined ? { replaceAll } : {}) }],
|
|
1203
|
+
}],
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
if (transport.engine === 'exact-replace-list-v1') {
|
|
1208
|
+
const outerPathValue = getAtPath(input, fields[0])
|
|
1209
|
+
const edits = transportField(input, fields[1], transport.id)
|
|
1210
|
+
if (!Array.isArray(edits) || !edits.length) stateFail('EDIT_INVALID', `${transport.id}.edits must be a non-empty array`)
|
|
1211
|
+
enforceCountLimit(
|
|
1212
|
+
edits.length,
|
|
1213
|
+
limits.maximumMutationEdits,
|
|
1214
|
+
'MUTATION_EDIT_COUNT_EXCEEDED',
|
|
1215
|
+
`${transport.id}.edits`,
|
|
1216
|
+
)
|
|
1217
|
+
enforceCountLimit(
|
|
1218
|
+
edits.length,
|
|
1219
|
+
limits.maximumMutationOperations,
|
|
1220
|
+
'MUTATION_OPERATION_COUNT_EXCEEDED',
|
|
1221
|
+
`${transport.id}.operations`,
|
|
1222
|
+
)
|
|
1223
|
+
const operations = edits.map((edit, index) => {
|
|
1224
|
+
if ((index & 255) === 0) normalizationCheckpoint(checkpoint, `${transport.id}:edit:${index + 1}`)
|
|
1225
|
+
if (!edit || typeof edit !== 'object' || Array.isArray(edit)) {
|
|
1226
|
+
stateFail('EDIT_INVALID', `${transport.id}.edits[${index}] must be an object`)
|
|
1227
|
+
}
|
|
1228
|
+
const editPath = edit.file_path ?? edit.path ?? outerPathValue
|
|
1229
|
+
const path = mutationPath(repoRoot, editPath, `${transport.id}.edits[${index}].path`)
|
|
1230
|
+
const oldString = assertText(getAtPath(edit, fields[2]), `${transport.id}.edits[${index}].oldString`)
|
|
1231
|
+
const newString = assertText(getAtPath(edit, fields[3]), `${transport.id}.edits[${index}].newString`)
|
|
1232
|
+
const replaceAll = getAtPath(edit, fields[4])
|
|
1233
|
+
if (replaceAll !== undefined && typeof replaceAll !== 'boolean') {
|
|
1234
|
+
stateFail('EDIT_INVALID', `${transport.id}.edits[${index}].replaceAll must be boolean`)
|
|
1235
|
+
}
|
|
1236
|
+
return {
|
|
1237
|
+
kind: 'edit',
|
|
1238
|
+
path,
|
|
1239
|
+
edits: [{ oldString, newString, ...(replaceAll !== undefined ? { replaceAll } : {}) }],
|
|
1240
|
+
}
|
|
1241
|
+
})
|
|
1242
|
+
return {
|
|
1243
|
+
schemaVersion: 1,
|
|
1244
|
+
provider: provider?.id || null,
|
|
1245
|
+
rawTool,
|
|
1246
|
+
transportId: transport.id,
|
|
1247
|
+
engine: transport.engine,
|
|
1248
|
+
operations,
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
if (transport.engine === 'codex-apply-patch-v1') {
|
|
1253
|
+
return {
|
|
1254
|
+
...parseCodexApplyPatch({
|
|
1255
|
+
command: transportField(input, fields[0], transport.id),
|
|
1256
|
+
repoRoot,
|
|
1257
|
+
limits,
|
|
1258
|
+
checkpoint,
|
|
1259
|
+
}),
|
|
1260
|
+
provider: provider?.id || null,
|
|
1261
|
+
rawTool,
|
|
1262
|
+
transportId: transport.id,
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
if (transport.engine === 'unified-diff-v1') {
|
|
1267
|
+
return {
|
|
1268
|
+
...parseUnifiedDiff({
|
|
1269
|
+
diff: transportField(input, fields[0], transport.id),
|
|
1270
|
+
repoRoot,
|
|
1271
|
+
limits,
|
|
1272
|
+
checkpoint,
|
|
1273
|
+
}),
|
|
1274
|
+
provider: provider?.id || null,
|
|
1275
|
+
rawTool,
|
|
1276
|
+
transportId: transport.id,
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
stateFail('MUTATION_INVALID', `unsupported write transport engine:${transport.engine || '<missing>'}`)
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
function entryContent(value) {
|
|
1284
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return ''
|
|
1285
|
+
return [value.content, value.new_string, value.text]
|
|
1286
|
+
.filter((item) => typeof item === 'string' && item)
|
|
1287
|
+
.join('\n')
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
function operationChangedText(operation) {
|
|
1291
|
+
if (!operation || typeof operation !== 'object') return ''
|
|
1292
|
+
if (operation.kind === 'write' || operation.kind === 'add') {
|
|
1293
|
+
return typeof operation.content === 'string' ? operation.content : ''
|
|
1294
|
+
}
|
|
1295
|
+
if (operation.kind === 'edit') {
|
|
1296
|
+
return (operation.edits || [])
|
|
1297
|
+
.map((edit) => typeof edit?.newString === 'string' ? edit.newString : '')
|
|
1298
|
+
.filter(Boolean)
|
|
1299
|
+
.join('\n')
|
|
1300
|
+
}
|
|
1301
|
+
if (['update', 'add-unified', 'update-unified'].includes(operation.kind)) {
|
|
1302
|
+
return (operation.chunks || [])
|
|
1303
|
+
.flatMap((chunk) => chunk.changes || [])
|
|
1304
|
+
.filter((change) => change.kind === '+')
|
|
1305
|
+
.map((change) => change.text)
|
|
1306
|
+
.join('\n')
|
|
1307
|
+
}
|
|
1308
|
+
return ''
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
function uniqueSortedIssues(values) {
|
|
1312
|
+
const byEvidence = new Map()
|
|
1313
|
+
for (const value of values) {
|
|
1314
|
+
const key = `${value.reasonCode}:${value.evidence.path}`
|
|
1315
|
+
if (!byEvidence.has(key)) byEvidence.set(key, value)
|
|
1316
|
+
}
|
|
1317
|
+
return [...byEvidence.values()].sort((left, right) =>
|
|
1318
|
+
compareUtf8Bytes(left.reasonCode, right.reasonCode)
|
|
1319
|
+
|| compareUtf8Bytes(left.evidence.path, right.evidence.path))
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
function normalizeOperation({ input, rawEvent, tool, normalization }) {
|
|
1323
|
+
const aliases = normalization.toolAliases || {}
|
|
1324
|
+
const writeTools = new Set((normalization.writeTools || []).map((value) =>
|
|
1325
|
+
aliasValue(value, aliases).toLowerCase()))
|
|
1326
|
+
const lowerTool = tool.toLowerCase()
|
|
1327
|
+
if (WRITE_TOOLS.has(lowerTool) || writeTools.has(lowerTool)) return 'write'
|
|
1328
|
+
if (EXECUTE_TOOLS.has(lowerTool)) return 'execute'
|
|
1329
|
+
|
|
1330
|
+
const explicit = typeof input.operation === 'string'
|
|
1331
|
+
? aliasValue(input.operation, aliases).toLowerCase()
|
|
1332
|
+
: ''
|
|
1333
|
+
if (WRITE_TOOLS.has(explicit) || writeTools.has(explicit) || explicit === 'write') return 'write'
|
|
1334
|
+
if (EXECUTE_TOOLS.has(explicit)) return 'execute'
|
|
1335
|
+
if (WRITE_EVENT_KEYS.has(compactAliasKey(rawEvent))) return 'write'
|
|
1336
|
+
return 'read'
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
function patchValues(input, toolInput) {
|
|
1340
|
+
const values = [toolInput.patch, input.patch, toolInput.diff, input.diff]
|
|
1341
|
+
.filter((value) => typeof value === 'string' && value)
|
|
1342
|
+
return [...new Set(values)]
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/**
|
|
1346
|
+
* Normalize a provider payload into one provider-neutral semantic record.
|
|
1347
|
+
*
|
|
1348
|
+
* `normalized.paths` are always sorted repository-relative POSIX paths. `records` retains the
|
|
1349
|
+
* per-file content needed by the shell-hook runner, while `issues` is the fail-closed path verdict
|
|
1350
|
+
* consumed by both the package API and the repository runner.
|
|
1351
|
+
*/
|
|
1352
|
+
export function normalizeProviderHookInput({
|
|
1353
|
+
provider,
|
|
1354
|
+
input,
|
|
1355
|
+
repoRoot = process.cwd(),
|
|
1356
|
+
limits: limitOptions = {},
|
|
1357
|
+
checkpoint = null,
|
|
1358
|
+
} = {}) {
|
|
1359
|
+
const limits = normalizationLimits(limitOptions)
|
|
1360
|
+
normalizationCheckpoint(checkpoint, 'normalize:start')
|
|
1361
|
+
const normalization = provider?.normalization || {}
|
|
1362
|
+
const rawEvent = firstString(input || {}, normalization.eventFields || [])
|
|
1363
|
+
const rawTool = firstString(input || {}, normalization.toolFields || [])
|
|
1364
|
+
const event = aliasValue(rawEvent, normalization.eventAliases, { compact: true })
|
|
1365
|
+
const tool = aliasValue(rawTool, normalization.toolAliases)
|
|
1366
|
+
const operation = normalizeOperation({ input: input || {}, rawEvent, tool, normalization })
|
|
1367
|
+
const toolInput = input?.tool_input && typeof input.tool_input === 'object' && !Array.isArray(input.tool_input)
|
|
1368
|
+
? input.tool_input
|
|
1369
|
+
: {}
|
|
1370
|
+
// Provider payloads may describe many paths while carrying one shared content body. Keep that
|
|
1371
|
+
// body exactly once; callers materialize it only for the single file currently being dispatched.
|
|
1372
|
+
const sharedContent = [...new Set([entryContent(toolInput), entryContent(input)]
|
|
1373
|
+
.filter((value) => typeof value === 'string' && value))]
|
|
1374
|
+
.join('\n')
|
|
1375
|
+
const candidates = []
|
|
1376
|
+
const issues = []
|
|
1377
|
+
let mutation = null
|
|
1378
|
+
let fatalMutationLimit = false
|
|
1379
|
+
|
|
1380
|
+
const selectedWriteTransports = matchingWriteTransports(provider, input)
|
|
1381
|
+
const shouldParseMutation = selectedWriteTransports.matches.length > 0
|
|
1382
|
+
&& (
|
|
1383
|
+
event === 'PreToolUse'
|
|
1384
|
+
|| (
|
|
1385
|
+
event === 'PostToolUse'
|
|
1386
|
+
&& (
|
|
1387
|
+
selectedWriteTransports.matches.length !== 1
|
|
1388
|
+
|| postEventCarriesDeclaredTransport(input, selectedWriteTransports.matches[0], {
|
|
1389
|
+
limits,
|
|
1390
|
+
checkpoint,
|
|
1391
|
+
})
|
|
1392
|
+
)
|
|
1393
|
+
)
|
|
1394
|
+
)
|
|
1395
|
+
if (shouldParseMutation) {
|
|
1396
|
+
try {
|
|
1397
|
+
mutation = parseProviderWriteMutation({
|
|
1398
|
+
provider,
|
|
1399
|
+
input,
|
|
1400
|
+
repoRoot,
|
|
1401
|
+
limits,
|
|
1402
|
+
checkpoint,
|
|
1403
|
+
})
|
|
1404
|
+
} catch (error) {
|
|
1405
|
+
if (!(error instanceof ProviderWriteStateError)) throw error
|
|
1406
|
+
fatalMutationLimit = [
|
|
1407
|
+
'PATH_COUNT_EXCEEDED',
|
|
1408
|
+
'MUTATION_OPERATION_COUNT_EXCEEDED',
|
|
1409
|
+
'MUTATION_EDIT_COUNT_EXCEEDED',
|
|
1410
|
+
].includes(error.code)
|
|
1411
|
+
if (error.pathIssue) {
|
|
1412
|
+
issues.push(error.pathIssue)
|
|
1413
|
+
} else {
|
|
1414
|
+
issues.push(mutationIssue(
|
|
1415
|
+
error.code || 'MUTATION_INVALID',
|
|
1416
|
+
error.path === '<unknown>' ? '<write-mutation>' : error.path,
|
|
1417
|
+
))
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
if (fatalMutationLimit) {
|
|
1423
|
+
const canonicalIssues = uniqueSortedIssues(issues)
|
|
1424
|
+
normalizationCheckpoint(checkpoint, 'normalize:bounded-rejection')
|
|
1425
|
+
return {
|
|
1426
|
+
normalized: {
|
|
1427
|
+
provider: provider?.id || null,
|
|
1428
|
+
event,
|
|
1429
|
+
tool,
|
|
1430
|
+
operation,
|
|
1431
|
+
paths: [],
|
|
1432
|
+
blocked: true,
|
|
1433
|
+
evidence: canonicalIssues.map((value) => value.evidence),
|
|
1434
|
+
},
|
|
1435
|
+
records: [],
|
|
1436
|
+
sharedContent,
|
|
1437
|
+
issues: canonicalIssues,
|
|
1438
|
+
mutation: null,
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
let pathCandidateCount = 0
|
|
1443
|
+
let pathCandidateLimitReached = false
|
|
1444
|
+
const addCandidate = (path, source = null, pathLocal = false) => {
|
|
1445
|
+
pathCandidateCount += 1
|
|
1446
|
+
if (pathCandidateCount > limits.maximumPathCandidates) {
|
|
1447
|
+
if (!pathCandidateLimitReached) {
|
|
1448
|
+
issues.push(mutationIssue('PATH_COUNT_EXCEEDED', '<changed-path-candidates>'))
|
|
1449
|
+
}
|
|
1450
|
+
pathCandidateLimitReached = true
|
|
1451
|
+
return false
|
|
1452
|
+
}
|
|
1453
|
+
if ((pathCandidateCount & 255) === 0) {
|
|
1454
|
+
normalizationCheckpoint(checkpoint, `normalize:path-candidate:${pathCandidateCount}`)
|
|
1455
|
+
}
|
|
1456
|
+
const inspected = inspectPath(repoRoot, path)
|
|
1457
|
+
if (inspected.issue) {
|
|
1458
|
+
issues.push(inspected.issue)
|
|
1459
|
+
return false
|
|
1460
|
+
}
|
|
1461
|
+
candidates.push({
|
|
1462
|
+
path: inspected.path,
|
|
1463
|
+
content: pathLocal ? entryContent(source) : '',
|
|
1464
|
+
source: pathLocal && source && typeof source === 'object' && !Array.isArray(source)
|
|
1465
|
+
? { ...source }
|
|
1466
|
+
: {},
|
|
1467
|
+
})
|
|
1468
|
+
return true
|
|
1469
|
+
}
|
|
1470
|
+
const addPatchCandidate = (path, source = null, pathLocal = false) => {
|
|
1471
|
+
if (typeof path === 'string' && /^["']/.test(path.trim())) {
|
|
1472
|
+
issues.push(issue('QUOTED_PATCH_PATH', '<quoted-patch-path>'))
|
|
1473
|
+
return false
|
|
1474
|
+
}
|
|
1475
|
+
return addCandidate(path, source, pathLocal)
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
for (const [operationIndex, operation] of (mutation?.operations || []).entries()) {
|
|
1479
|
+
normalizationCheckpoint(checkpoint, `normalize:mutation-operation:${operationIndex + 1}`)
|
|
1480
|
+
if (operation.kind === 'write') {
|
|
1481
|
+
addCandidate(operation.path, { content: operation.content }, true)
|
|
1482
|
+
} else if (operation.kind === 'edit') {
|
|
1483
|
+
for (const edit of operation.edits || []) {
|
|
1484
|
+
addCandidate(operation.path, {
|
|
1485
|
+
old_string: edit.oldString,
|
|
1486
|
+
new_string: edit.newString,
|
|
1487
|
+
...(edit.replaceAll !== undefined ? { replace_all: edit.replaceAll } : {}),
|
|
1488
|
+
}, true)
|
|
1489
|
+
}
|
|
1490
|
+
} else {
|
|
1491
|
+
const changedText = operationChangedText(operation)
|
|
1492
|
+
addCandidate(
|
|
1493
|
+
operation.path,
|
|
1494
|
+
changedText ? { new_string: changedText } : null,
|
|
1495
|
+
Boolean(changedText),
|
|
1496
|
+
)
|
|
1497
|
+
}
|
|
1498
|
+
if (operation.moveTo) {
|
|
1499
|
+
const changedText = operationChangedText(operation)
|
|
1500
|
+
addCandidate(
|
|
1501
|
+
operation.moveTo,
|
|
1502
|
+
changedText ? { new_string: changedText } : null,
|
|
1503
|
+
Boolean(changedText),
|
|
1504
|
+
)
|
|
1505
|
+
}
|
|
1506
|
+
if (pathCandidateLimitReached) break
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
const mutationTransport = mutation
|
|
1510
|
+
? (normalization.writeTransports || []).find((transport) => transport.id === mutation.transportId)
|
|
1511
|
+
: null
|
|
1512
|
+
const mutationEditCollectionField = mutation?.engine === 'exact-replace-list-v1'
|
|
1513
|
+
? mutationTransport?.payloadFields?.[1]
|
|
1514
|
+
: null
|
|
1515
|
+
|
|
1516
|
+
for (const field of normalization.pathFields || []) {
|
|
1517
|
+
if (pathCandidateLimitReached) break
|
|
1518
|
+
const value = getAtPath(input || {}, field)
|
|
1519
|
+
if (value === undefined) continue
|
|
1520
|
+
// A path field points into the provider's overall envelope. Its content is shared rather than
|
|
1521
|
+
// path-local so repeated path aliases cannot duplicate one large body into every record.
|
|
1522
|
+
addCandidate(value)
|
|
1523
|
+
}
|
|
1524
|
+
for (const field of normalization.pathArrayFields || []) {
|
|
1525
|
+
if (pathCandidateLimitReached) break
|
|
1526
|
+
if (field === mutationEditCollectionField) continue
|
|
1527
|
+
const value = getAtPath(input || {}, field)
|
|
1528
|
+
if (value === undefined) continue
|
|
1529
|
+
if (typeof value !== 'string' && !Array.isArray(value)) {
|
|
1530
|
+
issues.push(issue('PATH_ARRAY_INVALID'))
|
|
1531
|
+
continue
|
|
1532
|
+
}
|
|
1533
|
+
const entries = typeof value === 'string' ? [value] : value
|
|
1534
|
+
if (pathCandidateCount + entries.length > limits.maximumPathCandidates) {
|
|
1535
|
+
issues.push(mutationIssue('PATH_COUNT_EXCEEDED', field))
|
|
1536
|
+
pathCandidateLimitReached = true
|
|
1537
|
+
break
|
|
1538
|
+
}
|
|
1539
|
+
for (const entry of entries) {
|
|
1540
|
+
if (typeof entry === 'string') {
|
|
1541
|
+
addCandidate(entry)
|
|
1542
|
+
continue
|
|
1543
|
+
}
|
|
1544
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
1545
|
+
issues.push(issue('PATH_ENTRY_INVALID'))
|
|
1546
|
+
continue
|
|
1547
|
+
}
|
|
1548
|
+
const supplied = ['file_path', 'path'].filter((key) => entry[key] !== undefined)
|
|
1549
|
+
if (!supplied.length) {
|
|
1550
|
+
issues.push(issue('PATH_ENTRY_INVALID'))
|
|
1551
|
+
continue
|
|
1552
|
+
}
|
|
1553
|
+
for (const key of supplied) addCandidate(entry[key], entry, true)
|
|
1554
|
+
if (pathCandidateLimitReached) break
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
// Apply-patch and unified-diff transports carry paths outside the ordinary provider fields.
|
|
1559
|
+
// Parse every declared old/new path so one valid header cannot conceal a later escaping path.
|
|
1560
|
+
for (const patch of mutation?.engine === 'unified-diff-v1' || mutation?.engine === 'codex-apply-patch-v1'
|
|
1561
|
+
? []
|
|
1562
|
+
: patchValues(input || {}, toolInput)) {
|
|
1563
|
+
if (pathCandidateLimitReached) break
|
|
1564
|
+
if (Buffer.byteLength(patch, 'utf8') > MAX_PROPOSED_TEXT_BYTES) {
|
|
1565
|
+
issues.push(mutationIssue('PATCH_TOO_LARGE', '<write-patch>'))
|
|
1566
|
+
continue
|
|
1567
|
+
}
|
|
1568
|
+
let patchRecord = null
|
|
1569
|
+
const flushPatchRecord = () => {
|
|
1570
|
+
if (!patchRecord) return
|
|
1571
|
+
addPatchCandidate(patchRecord.path, { new_string: patchRecord.added.join('\n') }, true)
|
|
1572
|
+
patchRecord = null
|
|
1573
|
+
}
|
|
1574
|
+
let lines
|
|
1575
|
+
try {
|
|
1576
|
+
lines = boundedPatchLines(patch.replaceAll('\r\n', '\n'), {
|
|
1577
|
+
limits,
|
|
1578
|
+
checkpoint,
|
|
1579
|
+
label: 'fallback-patch',
|
|
1580
|
+
})
|
|
1581
|
+
} catch (error) {
|
|
1582
|
+
if (!(error instanceof ProviderWriteStateError)) throw error
|
|
1583
|
+
issues.push(mutationIssue(error.code || 'MUTATION_EDIT_COUNT_EXCEEDED', '<write-patch>'))
|
|
1584
|
+
continue
|
|
1585
|
+
}
|
|
1586
|
+
for (const [index, line] of lines.entries()) {
|
|
1587
|
+
if ((index & 255) === 0) normalizationCheckpoint(checkpoint, `fallback-patch:line:${index + 1}`)
|
|
1588
|
+
const applyHeader = line.match(/^\*\*\* (?:Add|Update|Delete) File: ?(.*)$/)
|
|
1589
|
+
const moveHeader = line.match(/^\*\*\* Move to: ?(.*)$/)
|
|
1590
|
+
const oldHeader = lines[index + 1]?.startsWith('+++') ? line.match(/^--- ?(.*)$/) : null
|
|
1591
|
+
const newHeader = lines[index - 1]?.startsWith('---') ? line.match(/^\+\+\+ ?(.*)$/) : null
|
|
1592
|
+
if (applyHeader || moveHeader) {
|
|
1593
|
+
flushPatchRecord()
|
|
1594
|
+
patchRecord = { path: (applyHeader || moveHeader)[1], added: [] }
|
|
1595
|
+
} else if (oldHeader) {
|
|
1596
|
+
flushPatchRecord()
|
|
1597
|
+
const path = oldHeader[1].split('\t', 1)[0].replace(/^a\//, '')
|
|
1598
|
+
if (path !== '/dev/null') addPatchCandidate(path)
|
|
1599
|
+
} else if (newHeader) {
|
|
1600
|
+
flushPatchRecord()
|
|
1601
|
+
const path = newHeader[1].split('\t', 1)[0].replace(/^b\//, '')
|
|
1602
|
+
if (path !== '/dev/null') patchRecord = { path, added: [] }
|
|
1603
|
+
} else if (patchRecord && line.startsWith('+') && !line.startsWith('+++')) {
|
|
1604
|
+
patchRecord.added.push(line.slice(1))
|
|
1605
|
+
}
|
|
1606
|
+
if (pathCandidateLimitReached) break
|
|
1607
|
+
}
|
|
1608
|
+
flushPatchRecord()
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
const recordsByPath = new Map()
|
|
1612
|
+
for (const candidate of candidates) {
|
|
1613
|
+
const record = recordsByPath.get(candidate.path) || {
|
|
1614
|
+
path: candidate.path,
|
|
1615
|
+
specificContent: [],
|
|
1616
|
+
sources: [],
|
|
1617
|
+
}
|
|
1618
|
+
if (candidate.content) record.specificContent.push(candidate.content)
|
|
1619
|
+
if (Object.keys(candidate.source).length) record.sources.push(candidate.source)
|
|
1620
|
+
recordsByPath.set(candidate.path, record)
|
|
1621
|
+
}
|
|
1622
|
+
const records = [...recordsByPath.values()]
|
|
1623
|
+
.sort((left, right) => compareUtf8Bytes(left.path, right.path))
|
|
1624
|
+
.map((record) => ({
|
|
1625
|
+
path: record.path,
|
|
1626
|
+
content: [...new Set(record.specificContent)].join('\n'),
|
|
1627
|
+
source: record.sources.find((item) => entryContent(item)) || record.sources[0] || {},
|
|
1628
|
+
}))
|
|
1629
|
+
const canonicalIssues = uniqueSortedIssues(issues)
|
|
1630
|
+
normalizationCheckpoint(checkpoint, 'normalize:complete')
|
|
1631
|
+
return {
|
|
1632
|
+
normalized: {
|
|
1633
|
+
provider: provider?.id || null,
|
|
1634
|
+
event,
|
|
1635
|
+
tool,
|
|
1636
|
+
operation,
|
|
1637
|
+
paths: records.map((record) => record.path),
|
|
1638
|
+
blocked: canonicalIssues.length > 0,
|
|
1639
|
+
evidence: canonicalIssues.map((value) => value.evidence),
|
|
1640
|
+
},
|
|
1641
|
+
records,
|
|
1642
|
+
sharedContent,
|
|
1643
|
+
issues: canonicalIssues,
|
|
1644
|
+
mutation,
|
|
1645
|
+
}
|
|
1646
|
+
}
|