@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.
Files changed (40) hide show
  1. package/README.md +89 -0
  2. package/bin/attest.mjs +4 -0
  3. package/bin/check.mjs +4 -0
  4. package/bin/doctor.mjs +4 -0
  5. package/bin/generate.mjs +4 -0
  6. package/bin/governance.mjs +4 -0
  7. package/bin/hook.mjs +4 -0
  8. package/bin/upgrade.mjs +4 -0
  9. package/canonical/gates.json +42 -0
  10. package/canonical/manifest.json +476 -0
  11. package/canonical/plugin-aliases.json +25 -0
  12. package/canonical/provider-lifecycle.json +48 -0
  13. package/canonical/providers.json +455 -0
  14. package/canonical/roles.json +12 -0
  15. package/canonical/rules.json +81 -0
  16. package/canonical/schemas/attestation.schema.json +61 -0
  17. package/canonical/schemas/diagnostic.schema.json +37 -0
  18. package/canonical/schemas/gates.schema.json +27 -0
  19. package/canonical/schemas/lock.schema.json +189 -0
  20. package/canonical/schemas/manifest.schema.json +103 -0
  21. package/canonical/schemas/plugin-aliases.schema.json +59 -0
  22. package/canonical/schemas/provider-hook-coverage.schema.json +173 -0
  23. package/canonical/schemas/provider-lifecycle.schema.json +126 -0
  24. package/canonical/schemas/providers.schema.json +917 -0
  25. package/canonical/schemas/roles.schema.json +27 -0
  26. package/canonical/schemas/rules.schema.json +46 -0
  27. package/canonical/schemas/upgrade-plan.schema.json +31 -0
  28. package/package.json +42 -0
  29. package/src/authority-decision-evidence.mjs +413 -0
  30. package/src/canonical-order.mjs +8 -0
  31. package/src/carrier-projection.mjs +407 -0
  32. package/src/cli.mjs +114 -0
  33. package/src/closed-tool-execution.mjs +1001 -0
  34. package/src/common.mjs +278 -0
  35. package/src/contract.mjs +760 -0
  36. package/src/hook-api.mjs +107 -0
  37. package/src/index.mjs +14 -0
  38. package/src/provider-hook-normalization.mjs +1646 -0
  39. package/src/provider-review-binding.mjs +2377 -0
  40. 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
+ }