| 1 | #!/usr/bin/env node |
| 2 | /** |
| 3 | * CLI helper: apply pending live copy edits as one AI-owned batch. |
| 4 | * |
| 5 | * The browser Save path stages copy edits in .impeccable/live. This script is |
| 6 | * called by /manual-edit-commit when the user clicks Apply copy edits. It gives |
| 7 | * the local AI runner the full staged batch plus evidence, validates the files |
| 8 | * the runner reports touching, and clears only entries reported as applied. |
| 9 | * |
| 10 | * Usage: |
| 11 | * node live-commit-manual-edits.mjs |
| 12 | * node live-commit-manual-edits.mjs --page-url=/ |
| 13 | * |
| 14 | * Output JSON: |
| 15 | * { applied, failed, files, cleared, count, pageUrl } |
| 16 | */ |
| 17 | |
| 18 | import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs'; |
| 19 | import { readBuffer, readBufferStrict, writeBuffer, countByPage } from './live-manual-edits-buffer.mjs'; |
| 20 | import { isGeneratedFile } from './is-generated.mjs'; |
| 21 | import { |
| 22 | runCopyEditBatchAgent, |
| 23 | runCopyEditPostApplyChecks, |
| 24 | } from './live-copy-edit-agent.mjs'; |
| 25 | import fs from 'node:fs'; |
| 26 | import path from 'node:path'; |
| 27 | |
| 28 | const ROLLBACK_EXTENSIONS = new Set([ |
| 29 | '.astro', |
| 30 | '.cjs', |
| 31 | '.css', |
| 32 | '.htm', |
| 33 | '.html', |
| 34 | '.js', |
| 35 | '.json', |
| 36 | '.jsx', |
| 37 | '.md', |
| 38 | '.mdx', |
| 39 | '.mjs', |
| 40 | '.scss', |
| 41 | '.svelte', |
| 42 | '.svg', |
| 43 | '.ts', |
| 44 | '.tsx', |
| 45 | '.txt', |
| 46 | '.vue', |
| 47 | '.yaml', |
| 48 | '.yml', |
| 49 | ]); |
| 50 | const ROLLBACK_SKIP_DIRS = new Set([ |
| 51 | '.astro', |
| 52 | '.git', |
| 53 | '.impeccable', |
| 54 | '.next', |
| 55 | '.nuxt', |
| 56 | '.svelte-kit', |
| 57 | 'build', |
| 58 | 'coverage', |
| 59 | 'dist', |
| 60 | 'node_modules', |
| 61 | 'out', |
| 62 | ]); |
| 63 | const DEFAULT_REPAIR_ATTEMPTS = 3; |
| 64 | |
| 65 | function argVal(args, name) { |
| 66 | const prefix = name + '='; |
| 67 | for (const arg of args) { |
| 68 | if (arg === name) return true; |
| 69 | if (arg.startsWith(prefix)) return arg.slice(prefix.length); |
| 70 | } |
| 71 | return null; |
| 72 | } |
| 73 | |
| 74 | function countOps(entries) { |
| 75 | let count = 0; |
| 76 | for (const entry of entries || []) count += Array.isArray(entry.ops) ? entry.ops.length : 0; |
| 77 | return count; |
| 78 | } |
| 79 | |
| 80 | function summarizeAppliedEntries(entries, appliedEntryIds) { |
| 81 | const ids = new Set(appliedEntryIds); |
| 82 | const out = []; |
| 83 | for (const entry of entries || []) { |
| 84 | if (!ids.has(entry.id)) continue; |
| 85 | for (const op of entry.ops || []) { |
| 86 | out.push({ |
| 87 | id: entry.id, |
| 88 | ref: op.ref, |
| 89 | originalText: op.originalText, |
| 90 | newText: op.newText, |
| 91 | }); |
| 92 | } |
| 93 | } |
| 94 | return out; |
| 95 | } |
| 96 | |
| 97 | function normalizeFailedEntries(batch, result, fallbackReason) { |
| 98 | const failed = []; |
| 99 | const failedByEntryId = new Map(); |
| 100 | for (const item of result?.failed || []) { |
| 101 | const entryId = item.entryId || item.id || null; |
| 102 | if (!entryId) continue; |
| 103 | failedByEntryId.set(entryId, item); |
| 104 | } |
| 105 | |
| 106 | for (const entry of batch.entries || []) { |
| 107 | const item = failedByEntryId.get(entry.id); |
| 108 | if (!item) continue; |
| 109 | failed.push({ |
| 110 | id: entry.id, |
| 111 | reason: item.reason || item.message || fallbackReason || 'failed', |
| 112 | candidates: Array.isArray(item.candidates) && item.candidates.length > 0 |
| 113 | ? item.candidates |
| 114 | : candidatesForEntry(batch, entry.id), |
| 115 | }); |
| 116 | } |
| 117 | return failed; |
| 118 | } |
| 119 | |
| 120 | function mergeFailedEntries(...groups) { |
| 121 | const out = []; |
| 122 | const indexById = new Map(); |
| 123 | for (const item of groups.flatMap((group) => Array.isArray(group) ? group : [])) { |
| 124 | if (!item || typeof item !== 'object') continue; |
| 125 | const id = typeof item.id === 'string' && item.id ? item.id : null; |
| 126 | if (!id) { |
| 127 | out.push(item); |
| 128 | continue; |
| 129 | } |
| 130 | const existingIndex = indexById.get(id); |
| 131 | if (existingIndex === undefined) { |
| 132 | indexById.set(id, out.length); |
| 133 | out.push(item); |
| 134 | continue; |
| 135 | } |
| 136 | out[existingIndex] = { |
| 137 | ...out[existingIndex], |
| 138 | ...item, |
| 139 | candidates: item.candidates || out[existingIndex].candidates, |
| 140 | checks: item.checks || out[existingIndex].checks, |
| 141 | }; |
| 142 | } |
| 143 | return out; |
| 144 | } |
| 145 | |
| 146 | function candidatesForEntry(batch, entryId) { |
| 147 | return (batch.candidates || []) |
| 148 | .filter((candidate) => candidate.entryId === entryId) |
| 149 | .flatMap((candidate) => [ |
| 150 | ...(candidate.sourceHint ? [candidate.sourceHint] : []), |
| 151 | ...(candidate.textMatches || []), |
| 152 | ...(candidate.objectKeyMatches || []), |
| 153 | ...(candidate.locatorMatches || []), |
| 154 | ...(candidate.contextTextMatches || []), |
| 155 | ]) |
| 156 | .slice(0, 12); |
| 157 | } |
| 158 | |
| 159 | function uniqueStrings(values) { |
| 160 | return [...new Set(values.filter((value) => typeof value === 'string' && value.trim()))]; |
| 161 | } |
| 162 | |
| 163 | function allEntryIds(batch) { |
| 164 | return (batch?.entries || []).map((entry) => entry.id).filter(Boolean); |
| 165 | } |
| 166 | |
| 167 | function mergeUniqueStrings(...groups) { |
| 168 | return uniqueStrings(groups.flatMap((group) => Array.isArray(group) ? group : [])); |
| 169 | } |
| 170 | |
| 171 | function repairAttemptLimit(env = process.env) { |
| 172 | const value = Number(env.IMPECCABLE_LIVE_MANUAL_EDIT_REPAIR_ATTEMPTS || DEFAULT_REPAIR_ATTEMPTS); |
| 173 | if (!Number.isFinite(value)) return DEFAULT_REPAIR_ATTEMPTS; |
| 174 | return Math.max(1, Math.min(10, Math.trunc(value))); |
| 175 | } |
| 176 | |
| 177 | function summarizeRepairFailures(failures = []) { |
| 178 | return failures.map((failure) => { |
| 179 | const out = { |
| 180 | reason: failure.reason || failure.detail || 'validation_failed', |
| 181 | }; |
| 182 | if (failure.id || failure.entryId) out.entryId = failure.id || failure.entryId; |
| 183 | if (failure.ref) out.ref = failure.ref; |
| 184 | if (failure.detail) out.detail = failure.detail; |
| 185 | if (failure.file) out.file = failure.file; |
| 186 | if (failure.message) out.message = failure.message; |
| 187 | if (failure.marker) out.marker = failure.marker; |
| 188 | if (Array.isArray(failure.files)) out.files = failure.files.slice(0, 8); |
| 189 | if (Array.isArray(failure.candidates)) { |
| 190 | out.candidates = failure.candidates.slice(0, 8).map((candidate) => ({ |
| 191 | file: candidate.file, |
| 192 | line: candidate.line, |
| 193 | kind: candidate.kind, |
| 194 | reason: candidate.reason, |
| 195 | })); |
| 196 | } |
| 197 | if (Array.isArray(failure.failures)) { |
| 198 | out.failures = failure.failures.slice(0, 8).map((item) => ({ |
| 199 | ref: item.ref, |
| 200 | reason: item.reason || item.detail, |
| 201 | detail: item.detail, |
| 202 | candidates: Array.isArray(item.candidates) |
| 203 | ? item.candidates.slice(0, 6).map((candidate) => ({ |
| 204 | file: candidate.file, |
| 205 | line: candidate.line, |
| 206 | kind: candidate.kind, |
| 207 | reason: candidate.reason, |
| 208 | })) |
| 209 | : undefined, |
| 210 | })); |
| 211 | } |
| 212 | if (failure.checks) out.checks = failure.checks; |
| 213 | return out; |
| 214 | }).slice(0, 20); |
| 215 | } |
| 216 | |
| 217 | function buildRepairBatch(batch, repair) { |
| 218 | return { |
| 219 | ...batch, |
| 220 | repair, |
| 221 | }; |
| 222 | } |
| 223 | |
| 224 | function normalizeProjectSourcePath(cwd, file, opts = {}) { |
| 225 | if (!file || typeof file !== 'string') return null; |
| 226 | const absolute = path.isAbsolute(file) ? file : path.resolve(cwd, file); |
| 227 | const relative = path.relative(cwd, absolute); |
| 228 | if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null; |
| 229 | if (opts.requireExists && !fs.existsSync(absolute)) return null; |
| 230 | if (isGeneratedFile(absolute, { cwd })) return null; |
| 231 | return relative; |
| 232 | } |
| 233 | |
| 234 | function normalizeRelativeFile(cwd, file) { |
| 235 | return normalizeProjectSourcePath(cwd, file, { requireExists: true }); |
| 236 | } |
| 237 | |
| 238 | function sourceHintWindowFailure(cwd, op) { |
| 239 | const hint = op?.sourceHint; |
| 240 | if (!hint?.file || !hint.line) return null; |
| 241 | const relative = normalizeRelativeFile(cwd, hint.file); |
| 242 | if (!relative) return null; |
| 243 | const absolute = path.resolve(cwd, relative); |
| 244 | let content; |
| 245 | try { content = fs.readFileSync(absolute, 'utf-8'); } catch { return null; } |
| 246 | const lines = content.split('\n'); |
| 247 | const line = Math.max(1, Number(hint.line) || 1); |
| 248 | const lineText = lines[line - 1] || ''; |
| 249 | const start = Math.max(0, line - 5); |
| 250 | const end = Math.min(lines.length, line + 4); |
| 251 | if ( |
| 252 | typeof op.originalText === 'string' |
| 253 | && op.originalText |
| 254 | && lineText.includes(op.originalText) |
| 255 | && !lineShowsAppliedOp(lineText, op) |
| 256 | ) { |
| 257 | return { |
| 258 | file: relative, |
| 259 | line, |
| 260 | reason: 'source_hint_still_contains_original_text', |
| 261 | }; |
| 262 | } |
| 263 | if (lines.slice(start, end).some((candidateLine) => lineShowsAppliedOp(candidateLine, op))) return null; |
| 264 | return null; |
| 265 | } |
| 266 | |
| 267 | function verificationTargetsForOp(batch, op, reportedFiles, cwd) { |
| 268 | const candidate = (batch.candidates || []).find((item) => item.entryId === op.entryId && item.ref === op.ref); |
| 269 | const out = []; |
| 270 | const reportedFileSet = new Set(reportedFiles || []); |
| 271 | const add = (file, line, kind) => { |
| 272 | const relativeFile = normalizeRelativeFile(cwd, file); |
| 273 | const lineNumber = Number(line); |
| 274 | if (!relativeFile || !Number.isFinite(lineNumber) || lineNumber < 1) return; |
| 275 | out.push({ file: relativeFile, line: lineNumber, kind, reported: reportedFileSet.has(relativeFile) }); |
| 276 | }; |
| 277 | |
| 278 | add(op.sourceHint?.file, op.sourceHint?.line, 'source_hint'); |
| 279 | add(candidate?.sourceHint?.relativeFile || candidate?.sourceHint?.file, candidate?.sourceHint?.line, 'candidate_source_hint'); |
| 280 | for (const item of candidate?.textMatches || []) add(item.file, item.line, 'text_match'); |
| 281 | for (const item of candidate?.objectKeyMatches || []) add(item.file, item.line, 'object_key_match'); |
| 282 | for (const item of candidate?.locatorMatches || []) add(item.file, item.line, 'locator_match'); |
| 283 | for (const item of candidate?.contextTextMatches || []) add(item.file, item.line, 'context_text_match'); |
| 284 | |
| 285 | // Manual copy edits often stage coupled leaves from the same UI object, e.g. |
| 286 | // a card label plus its count. Dynamic source stores both on the label/key |
| 287 | // line, so the count op may need the sibling label's data candidates. |
| 288 | for (const siblingCandidate of siblingCandidatesForEntry(batch, op)) { |
| 289 | add(siblingCandidate.sourceHint?.relativeFile || siblingCandidate.sourceHint?.file, siblingCandidate.sourceHint?.line, 'entry_source_hint'); |
| 290 | for (const item of siblingCandidate.textMatches || []) add(item.file, item.line, 'entry_text_match'); |
| 291 | for (const item of siblingCandidate.objectKeyMatches || []) add(item.file, item.line, 'entry_object_key_match'); |
| 292 | for (const item of siblingCandidate.contextTextMatches || []) add(item.file, item.line, 'entry_context_text_match'); |
| 293 | } |
| 294 | |
| 295 | for (const relativeFile of reportedFiles || []) { |
| 296 | for (const target of locatorTargetsInFile(cwd, relativeFile, op)) { |
| 297 | out.push(target); |
| 298 | } |
| 299 | } |
| 300 | |
| 301 | const seen = new Set(); |
| 302 | return out.filter((target) => { |
| 303 | const key = target.file + ':' + target.line + ':' + target.kind; |
| 304 | if (seen.has(key)) return false; |
| 305 | seen.add(key); |
| 306 | return true; |
| 307 | }); |
| 308 | } |
| 309 | |
| 310 | function objectKeyCandidatesForOp(batch, op) { |
| 311 | const candidates = (batch.candidates || []) |
| 312 | .filter((item) => item.entryId === op.entryId && item.ref === op.ref); |
| 313 | return candidates.flatMap((candidate) => candidate.objectKeyMatches || []); |
| 314 | } |
| 315 | |
| 316 | function lineHasObjectKey(line, text) { |
| 317 | if (typeof text !== 'string' || text.length === 0) return false; |
| 318 | const quotedKey = new RegExp('(^|[\\s,{])([\'"`])' + escapeRegExp(text) + '\\2\\s*:'); |
| 319 | if (quotedKey.test(line)) return true; |
| 320 | const identifierSafe = /^[A-Za-z_$][\w$]*$/.test(text); |
| 321 | if (!identifierSafe) return false; |
| 322 | const bareKey = new RegExp('(^|[\\s,{])' + escapeRegExp(text) + '\\s*:'); |
| 323 | return bareKey.test(line); |
| 324 | } |
| 325 | |
| 326 | function objectKeyMatchStillUsesOriginal(cwd, match, op) { |
| 327 | const relative = normalizeRelativeFile(cwd, match?.file); |
| 328 | const lineNumber = Number(match?.line); |
| 329 | if (!relative || !Number.isFinite(lineNumber) || lineNumber < 1) return false; |
| 330 | let lines; |
| 331 | try { lines = fs.readFileSync(path.resolve(cwd, relative), 'utf-8').split('\n'); } catch { return false; } |
| 332 | const start = Math.max(0, lineNumber - 4); |
| 333 | const end = Math.min(lines.length, lineNumber + 3); |
| 334 | const windowLines = lines.slice(start, end); |
| 335 | if (windowLines.some((line) => lineHasObjectKey(line, op.newText))) return false; |
| 336 | return windowLines.some((line) => lineHasObjectKey(line, op.originalText)); |
| 337 | } |
| 338 | |
| 339 | function coupledObjectKeyFailuresForOp(batch, op, cwd) { |
| 340 | if ( |
| 341 | typeof op?.originalText !== 'string' |
| 342 | || typeof op?.newText !== 'string' |
| 343 | || op.originalText === op.newText |
| 344 | ) return []; |
| 345 | return objectKeyCandidatesForOp(batch, op) |
| 346 | .filter((match) => objectKeyMatchStillUsesOriginal(cwd, match, op)) |
| 347 | .map((match) => ({ |
| 348 | ref: op.ref, |
| 349 | reason: 'source_verification_failed', |
| 350 | detail: 'edited_text_source_key_dependency_not_updated', |
| 351 | candidates: [{ |
| 352 | file: normalizeRelativeFile(cwd, match.file) || match.file, |
| 353 | line: match.line, |
| 354 | kind: 'object_key_match', |
| 355 | reason: 'edited text is also a source key; update the coupled key to newText or fail the entry', |
| 356 | }], |
| 357 | })); |
| 358 | } |
| 359 | |
| 360 | function siblingCandidatesForEntry(batch, op) { |
| 361 | if (!op?.entryId) return []; |
| 362 | return (batch.candidates || []).filter((item) => item.entryId === op.entryId && item.ref !== op.ref); |
| 363 | } |
| 364 | |
| 365 | function locatorTargetsInFile(cwd, relativeFile, op) { |
| 366 | if (!opHasLocator(op)) return []; |
| 367 | const absolute = path.resolve(cwd, relativeFile); |
| 368 | let lines; |
| 369 | try { lines = fs.readFileSync(absolute, 'utf-8').split('\n'); } catch { return []; } |
| 370 | const out = []; |
| 371 | for (let index = 0; index < lines.length; index += 1) { |
| 372 | if (!lineMatchesManualEditLocator(lines[index], op)) continue; |
| 373 | out.push({ file: relativeFile, line: index + 1, kind: 'reported_locator_match' }); |
| 374 | if (out.length >= 20) break; |
| 375 | } |
| 376 | return out; |
| 377 | } |
| 378 | |
| 379 | function verificationTargetPasses(cwd, target, op) { |
| 380 | let lines; |
| 381 | try { lines = fs.readFileSync(path.resolve(cwd, target.file), 'utf-8').split('\n'); } catch { return false; } |
| 382 | return verificationTargetPassesLines(lines, target, op); |
| 383 | } |
| 384 | |
| 385 | function verificationTargetPassesLines(lines, target, op) { |
| 386 | const line = lines[target.line - 1] || ''; |
| 387 | if (lineShowsAppliedOp(line, op)) return true; |
| 388 | const originalText = typeof op?.originalText === 'string' ? op.originalText : ''; |
| 389 | if (originalText && line.includes(originalText)) return false; |
| 390 | const kind = String(target.kind || ''); |
| 391 | const canSearchWindow = target.reported |
| 392 | || kind.includes('context_text_match') |
| 393 | || kind.includes('object_key_match') |
| 394 | || kind.includes('text_match'); |
| 395 | if (!canSearchWindow) return false; |
| 396 | const radius = kind.includes('context_text_match') ? 20 : 4; |
| 397 | const start = Math.max(0, target.line - radius - 1); |
| 398 | const end = Math.min(lines.length, target.line + radius); |
| 399 | const windowLines = lines.slice(start, end); |
| 400 | if (windowLines.some((candidateLine) => lineShowsAppliedOp(candidateLine, op))) return true; |
| 401 | if (windowShowsAppliedOp(windowLines, op)) return true; |
| 402 | return false; |
| 403 | } |
| 404 | |
| 405 | function windowShowsAppliedOp(lines, op) { |
| 406 | const newText = typeof op?.newText === 'string' ? op.newText : ''; |
| 407 | if (!newText) return false; |
| 408 | const originalText = typeof op?.originalText === 'string' ? op.originalText : ''; |
| 409 | const normalizedNew = normalizeVerificationText(newText); |
| 410 | const normalizedOriginal = normalizeVerificationText(originalText); |
| 411 | const normalizedWindow = normalizeVerificationText(lines.join('\n')); |
| 412 | if (!normalizedNew || !normalizedWindow.includes(normalizedNew)) return false; |
| 413 | if (normalizedOriginal && !normalizedNew.includes(normalizedOriginal) && normalizedWindow.includes(normalizedOriginal)) return false; |
| 414 | return true; |
| 415 | } |
| 416 | |
| 417 | function normalizeVerificationText(text) { |
| 418 | return String(text || '').replace(/\s+/g, ' ').trim(); |
| 419 | } |
| 420 | |
| 421 | function lineShowsAppliedOp(line, op) { |
| 422 | const originalText = typeof op?.originalText === 'string' ? op.originalText : ''; |
| 423 | const newText = typeof op?.newText === 'string' ? op.newText : ''; |
| 424 | const deletion = op?.deleted === true || newText.length === 0; |
| 425 | if (deletion) return !!originalText && !line.includes(originalText); |
| 426 | if (!line.includes(newText)) return false; |
| 427 | if (originalText && !newText.includes(originalText) && line.includes(originalText)) return false; |
| 428 | return true; |
| 429 | } |
| 430 | |
| 431 | function opHasLocator(op) { |
| 432 | return !!( |
| 433 | op?.tag |
| 434 | || op?.elementId |
| 435 | || (Array.isArray(op?.classes) && op.classes.filter(Boolean).length > 0) |
| 436 | ); |
| 437 | } |
| 438 | |
| 439 | function lineMatchesManualEditLocator(line, op) { |
| 440 | if (op.tag) { |
| 441 | const tagRe = new RegExp('<\\s*' + escapeRegExp(op.tag) + '(?=[\\s>/]|$)', 'i'); |
| 442 | if (!tagRe.test(line)) return false; |
| 443 | } |
| 444 | |
| 445 | if (op.elementId) { |
| 446 | const idRe = new RegExp('\\bid\\s*=\\s*["\']' + escapeRegExp(op.elementId) + '["\']'); |
| 447 | if (!idRe.test(line)) return false; |
| 448 | } |
| 449 | |
| 450 | const classes = Array.isArray(op.classes) ? op.classes.filter(Boolean) : []; |
| 451 | for (const className of classes) { |
| 452 | if (!line.includes(className)) return false; |
| 453 | } |
| 454 | |
| 455 | return true; |
| 456 | } |
| 457 | |
| 458 | function verifyAppliedEntry({ batch, entry, reportedFiles, cwd }) { |
| 459 | const failures = []; |
| 460 | for (const rawOp of entry.ops || []) { |
| 461 | const op = { ...rawOp, entryId: entry.id }; |
| 462 | if (op.deleted === true && typeof op.newText !== 'string') op.newText = ''; |
| 463 | if (typeof op.newText !== 'string') { |
| 464 | failures.push({ |
| 465 | ref: op.ref, |
| 466 | reason: 'source_verification_failed', |
| 467 | detail: 'missing_newText', |
| 468 | candidates: candidatesForEntry(batch, entry.id).slice(0, 12), |
| 469 | }); |
| 470 | continue; |
| 471 | } |
| 472 | const targets = verificationTargetsForOp(batch, op, reportedFiles, cwd); |
| 473 | const coupledObjectKeyFailures = coupledObjectKeyFailuresForOp(batch, op, cwd); |
| 474 | if ( |
| 475 | coupledObjectKeyFailures.length === 0 |
| 476 | && targets.some((target) => verificationTargetPasses(cwd, target, op)) |
| 477 | ) continue; |
| 478 | |
| 479 | if (coupledObjectKeyFailures.length > 0) { |
| 480 | failures.push(...coupledObjectKeyFailures.map((failure) => ({ |
| 481 | ...failure, |
| 482 | candidates: [ |
| 483 | ...(failure.candidates || []), |
| 484 | ...targets.map((target) => ({ file: target.file, line: target.line, kind: target.kind })), |
| 485 | ...candidatesForEntry(batch, entry.id), |
| 486 | ].slice(0, 12), |
| 487 | }))); |
| 488 | continue; |
| 489 | } |
| 490 | |
| 491 | const hintedOldText = sourceHintWindowFailure(cwd, op); |
| 492 | if (hintedOldText) { |
| 493 | failures.push({ |
| 494 | ref: op.ref, |
| 495 | reason: 'source_verification_failed', |
| 496 | detail: hintedOldText.reason, |
| 497 | candidates: [hintedOldText, ...targets.map((target) => ({ file: target.file, line: target.line, kind: target.kind })), ...candidatesForEntry(batch, entry.id)].slice(0, 12), |
| 498 | }); |
| 499 | continue; |
| 500 | } |
| 501 | |
| 502 | failures.push({ |
| 503 | ref: op.ref, |
| 504 | reason: 'source_verification_failed', |
| 505 | detail: op.newText.length === 0 ? 'originalText_still_present_in_plausible_source_location' : 'newText_not_found_in_plausible_source_location', |
| 506 | candidates: targets.map((target) => ({ file: target.file, line: target.line, kind: target.kind })).concat(candidatesForEntry(batch, entry.id)).slice(0, 12), |
| 507 | }); |
| 508 | } |
| 509 | return failures; |
| 510 | } |
| 511 | |
| 512 | function snapshotTargetPasses(snapshot, target, op) { |
| 513 | const before = snapshot.get(target.file)?.content; |
| 514 | if (typeof before !== 'string') return false; |
| 515 | return verificationTargetPassesLines(before.split('\n'), target, op); |
| 516 | } |
| 517 | |
| 518 | function findUnappliedEntrySourceChanges({ batch, entries, reportedFiles, cwd, rollbackSnapshot }) { |
| 519 | const failures = []; |
| 520 | for (const entry of entries || []) { |
| 521 | for (const rawOp of entry.ops || []) { |
| 522 | const op = { ...rawOp, entryId: entry.id }; |
| 523 | if (typeof op.newText !== 'string' || op.newText.length === 0) continue; |
| 524 | const targets = verificationTargetsForOp(batch, op, reportedFiles, cwd); |
| 525 | const leakedTargets = targets.filter((target) => |
| 526 | verificationTargetPasses(cwd, target, op) |
| 527 | && !snapshotTargetPasses(rollbackSnapshot, target, op) |
| 528 | ); |
| 529 | if (leakedTargets.length === 0) continue; |
| 530 | failures.push({ |
| 531 | id: entry.id, |
| 532 | reason: 'failed_entry_source_changed', |
| 533 | ref: op.ref, |
| 534 | newText: op.newText, |
| 535 | candidates: leakedTargets |
| 536 | .map((target) => ({ file: target.file, line: target.line, kind: target.kind })) |
| 537 | .concat(candidatesForEntry(batch, entry.id)) |
| 538 | .slice(0, 12), |
| 539 | }); |
| 540 | break; |
| 541 | } |
| 542 | } |
| 543 | return failures; |
| 544 | } |
| 545 | |
| 546 | function verificationFailuresForEntries(batch, entries, reason, extra = {}) { |
| 547 | return entries.map((entry) => ({ |
| 548 | id: entry.id, |
| 549 | reason, |
| 550 | candidates: candidatesForEntry(batch, entry.id), |
| 551 | ...extra, |
| 552 | })); |
| 553 | } |
| 554 | |
| 555 | function clearAppliedEntries(cwd, appliedEntryIds) { |
| 556 | const ids = new Set(appliedEntryIds); |
| 557 | if (ids.size === 0) return 0; |
| 558 | const buffer = readBuffer(cwd); |
| 559 | let cleared = 0; |
| 560 | const kept = []; |
| 561 | for (const entry of buffer.entries || []) { |
| 562 | if (ids.has(entry.id)) { |
| 563 | cleared += Array.isArray(entry.ops) ? entry.ops.length : 0; |
| 564 | } else { |
| 565 | kept.push(entry); |
| 566 | } |
| 567 | } |
| 568 | writeBuffer(cwd, { version: buffer.version || 1, entries: kept }); |
| 569 | return cleared; |
| 570 | } |
| 571 | |
| 572 | function snapshotRollbackFiles(cwd, files = null) { |
| 573 | const snapshot = new Map(); |
| 574 | const rollbackFiles = Array.isArray(files) && files.length > 0 |
| 575 | ? uniqueStrings(files).map((file) => normalizeRollbackPath(cwd, file)).filter(Boolean) |
| 576 | : collectRollbackFiles(cwd); |
| 577 | for (const relativeFile of rollbackFiles) { |
| 578 | const absolute = path.resolve(cwd, relativeFile); |
| 579 | try { |
| 580 | snapshot.set(relativeFile, { |
| 581 | existed: true, |
| 582 | content: fs.readFileSync(absolute, 'utf-8'), |
| 583 | }); |
| 584 | } catch (err) { |
| 585 | if (err?.code === 'ENOENT') { |
| 586 | snapshot.set(relativeFile, { existed: false }); |
| 587 | } |
| 588 | // Other read failures are not safe to roll back. |
| 589 | } |
| 590 | } |
| 591 | return snapshot; |
| 592 | } |
| 593 | |
| 594 | function collectRollbackFiles(cwd) { |
| 595 | const out = []; |
| 596 | const seenDirs = new Set(); |
| 597 | const seenFiles = new Set(); |
| 598 | scanRollbackDir(cwd, cwd, out, seenDirs, seenFiles, 0); |
| 599 | return out; |
| 600 | } |
| 601 | |
| 602 | function scanRollbackDir(dir, cwd, out, seenDirs, seenFiles, depth) { |
| 603 | if (depth > 10) return; |
| 604 | let realDir; |
| 605 | try { realDir = fs.realpathSync(dir); } catch { return; } |
| 606 | if (seenDirs.has(realDir)) return; |
| 607 | seenDirs.add(realDir); |
| 608 | |
| 609 | let entries; |
| 610 | try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } |
| 611 | for (const entry of entries) { |
| 612 | if (entry.isDirectory()) { |
| 613 | if (ROLLBACK_SKIP_DIRS.has(entry.name)) continue; |
| 614 | scanRollbackDir(path.join(dir, entry.name), cwd, out, seenDirs, seenFiles, depth + 1); |
| 615 | continue; |
| 616 | } |
| 617 | if (!entry.isFile()) continue; |
| 618 | if (!ROLLBACK_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) continue; |
| 619 | const absolute = path.join(dir, entry.name); |
| 620 | if (isGeneratedFile(absolute, { cwd })) continue; |
| 621 | let realFile; |
| 622 | try { realFile = fs.realpathSync(absolute); } catch { continue; } |
| 623 | if (seenFiles.has(realFile)) continue; |
| 624 | seenFiles.add(realFile); |
| 625 | const relative = path.relative(cwd, absolute); |
| 626 | if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) continue; |
| 627 | out.push(relative); |
| 628 | } |
| 629 | } |
| 630 | |
| 631 | function changedFilesSinceSnapshot(cwd, snapshot, scopeFiles = null) { |
| 632 | const changed = new Map(); |
| 633 | const scopedFiles = Array.isArray(scopeFiles) && scopeFiles.length > 0 |
| 634 | ? scopeFiles.map((file) => normalizeRollbackPath(cwd, file)).filter(Boolean) |
| 635 | : null; |
| 636 | const currentFiles = new Set(scopedFiles || collectRollbackFiles(cwd)); |
| 637 | for (const [relativeFile, before] of snapshot.entries()) { |
| 638 | if (scopedFiles && !currentFiles.has(relativeFile)) continue; |
| 639 | const absolute = path.resolve(cwd, relativeFile); |
| 640 | if (before?.existed === false) { |
| 641 | if (fs.existsSync(absolute)) changed.set(relativeFile, { file: relativeFile, kind: 'added' }); |
| 642 | continue; |
| 643 | } |
| 644 | if (!fs.existsSync(absolute)) { |
| 645 | changed.set(relativeFile, { file: relativeFile, kind: 'deleted' }); |
| 646 | continue; |
| 647 | } |
| 648 | let content; |
| 649 | try { content = fs.readFileSync(absolute, 'utf-8'); } catch { continue; } |
| 650 | if (content !== before.content) { |
| 651 | changed.set(relativeFile, { file: relativeFile, kind: 'modified' }); |
| 652 | } |
| 653 | } |
| 654 | for (const relativeFile of currentFiles) { |
| 655 | if (!snapshot.has(relativeFile)) { |
| 656 | changed.set(relativeFile, { file: relativeFile, kind: 'unknown' }); |
| 657 | } |
| 658 | } |
| 659 | return [...changed.values()]; |
| 660 | } |
| 661 | |
| 662 | function rollbackChangedFiles(cwd, snapshot, extraFiles = [], scopeFiles = []) { |
| 663 | const scope = new Set( |
| 664 | [...(scopeFiles || []), ...(extraFiles || [])] |
| 665 | .map((file) => normalizeRollbackPath(cwd, file)) |
| 666 | .filter(Boolean), |
| 667 | ); |
| 668 | const changed = changedFilesSinceSnapshot(cwd, snapshot, [...scope]); |
| 669 | const byFile = new Map(changed.map((item) => [item.file, item])); |
| 670 | for (const file of extraFiles || []) { |
| 671 | const relative = normalizeRollbackPath(cwd, file); |
| 672 | if (relative && !byFile.has(relative)) { |
| 673 | byFile.set(relative, { file: relative, kind: snapshot.has(relative) ? 'reported' : 'unknown' }); |
| 674 | } |
| 675 | } |
| 676 | |
| 677 | const rolledBackFiles = []; |
| 678 | const rollbackFailures = []; |
| 679 | for (const item of byFile.values()) { |
| 680 | if (!scope.has(item.file)) continue; |
| 681 | const absolute = path.resolve(cwd, item.file); |
| 682 | const before = snapshot.get(item.file); |
| 683 | try { |
| 684 | if (before?.existed !== false && typeof before?.content === 'string') { |
| 685 | fs.mkdirSync(path.dirname(absolute), { recursive: true }); |
| 686 | fs.writeFileSync(absolute, before.content, 'utf-8'); |
| 687 | } else if (before?.existed === false && item.kind === 'added' && fs.existsSync(absolute)) { |
| 688 | fs.rmSync(absolute); |
| 689 | } else { |
| 690 | rollbackFailures.push({ file: item.file, reason: 'no_snapshot' }); |
| 691 | continue; |
| 692 | } |
| 693 | rolledBackFiles.push(item.file); |
| 694 | } catch (err) { |
| 695 | rollbackFailures.push({ file: item.file, reason: 'restore_failed', message: err.message || String(err) }); |
| 696 | } |
| 697 | } |
| 698 | return { rolledBackFiles, rollbackFailures }; |
| 699 | } |
| 700 | |
| 701 | function collectApplyOwnedFiles(batch, cwd, extraFiles = []) { |
| 702 | const files = []; |
| 703 | for (const entry of batch?.entries || []) { |
| 704 | for (const op of entry.ops || []) files.push(op.sourceHint?.file); |
| 705 | } |
| 706 | for (const candidate of batch?.candidates || []) { |
| 707 | files.push(candidate.sourceHint?.relativeFile, candidate.sourceHint?.file); |
| 708 | for (const item of candidate.textMatches || []) files.push(item.file); |
| 709 | for (const item of candidate.objectKeyMatches || []) files.push(item.file); |
| 710 | for (const item of candidate.locatorMatches || []) files.push(item.file); |
| 711 | for (const item of candidate.contextTextMatches || []) files.push(item.file); |
| 712 | } |
| 713 | files.push(...(extraFiles || [])); |
| 714 | return uniqueStrings(files) |
| 715 | .map((file) => normalizeRollbackPath(cwd, file)) |
| 716 | .filter(Boolean); |
| 717 | } |
| 718 | |
| 719 | function unreportedChangedFiles(cwd, snapshot, reportedFiles, scopeFiles = []) { |
| 720 | const reported = new Set( |
| 721 | (reportedFiles || []) |
| 722 | .map((file) => normalizeRollbackPath(cwd, file)) |
| 723 | .filter(Boolean), |
| 724 | ); |
| 725 | const scope = new Set( |
| 726 | (scopeFiles || []) |
| 727 | .map((file) => normalizeRollbackPath(cwd, file)) |
| 728 | .filter(Boolean), |
| 729 | ); |
| 730 | return changedFilesSinceSnapshot(cwd, snapshot, [...scope]) |
| 731 | .map((item) => item.file) |
| 732 | .filter((file) => scope.has(file)) |
| 733 | .filter((file) => !reported.has(file)); |
| 734 | } |
| 735 | |
| 736 | function normalizeRollbackPath(cwd, file) { |
| 737 | return normalizeProjectSourcePath(cwd, file); |
| 738 | } |
| 739 | |
| 740 | function verifyEntriesAfterRepair({ batch, appliedEntryIds, files, cwd }) { |
| 741 | const reportedFiles = uniqueStrings(files || []) |
| 742 | .map((file) => normalizeRelativeFile(cwd, file)) |
| 743 | .filter(Boolean); |
| 744 | const entries = (batch.entries || []).filter((entry) => appliedEntryIds.includes(entry.id)); |
| 745 | const verifiedIds = []; |
| 746 | const failed = []; |
| 747 | for (const entry of entries) { |
| 748 | const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd }); |
| 749 | if (failures.length === 0) { |
| 750 | verifiedIds.push(entry.id); |
| 751 | } else { |
| 752 | failed.push({ |
| 753 | id: entry.id, |
| 754 | reason: 'source_verification_failed', |
| 755 | failures, |
| 756 | candidates: candidatesForEntry(batch, entry.id), |
| 757 | }); |
| 758 | } |
| 759 | } |
| 760 | return { verifiedIds, failed, reportedFiles }; |
| 761 | } |
| 762 | |
| 763 | async function repairPostApplyValidation({ |
| 764 | batch, |
| 765 | cwd, |
| 766 | pageUrl, |
| 767 | count, |
| 768 | provider, |
| 769 | env, |
| 770 | timeoutMs, |
| 771 | applyBatchToSource, |
| 772 | chatAvailable, |
| 773 | transactionId, |
| 774 | appliedEntryIds, |
| 775 | files, |
| 776 | failed, |
| 777 | notes, |
| 778 | warnings, |
| 779 | postChecks, |
| 780 | repairReason = 'post_apply_validation_failed', |
| 781 | repairFailures = null, |
| 782 | }) { |
| 783 | const maxAttempts = repairAttemptLimit(env); |
| 784 | let currentFiles = mergeUniqueStrings(files || []); |
| 785 | let currentAppliedIds = mergeUniqueStrings(appliedEntryIds || []); |
| 786 | let currentFailed = Array.isArray(failed) ? failed : []; |
| 787 | let currentNotes = Array.isArray(notes) ? notes : []; |
| 788 | let currentWarnings = Array.isArray(warnings) ? warnings : []; |
| 789 | let currentFailures = Array.isArray(repairFailures) ? repairFailures : (postChecks?.failures || []); |
| 790 | |
| 791 | for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { |
| 792 | const repair = { |
| 793 | attempt, |
| 794 | maxAttempts, |
| 795 | transactionId: transactionId || null, |
| 796 | reason: repairReason, |
| 797 | failures: summarizeRepairFailures(currentFailures), |
| 798 | files: currentFiles, |
| 799 | pageUrl, |
| 800 | }; |
| 801 | let repairResult; |
| 802 | try { |
| 803 | repairResult = await runCopyEditBatchAgent(buildRepairBatch(batch, repair), { |
| 804 | cwd, |
| 805 | provider, |
| 806 | env, |
| 807 | timeoutMs, |
| 808 | applyBatchToSource, |
| 809 | chatAvailable, |
| 810 | }); |
| 811 | } catch (err) { |
| 812 | currentFailures = [{ |
| 813 | reason: 'repair_agent_failed', |
| 814 | message: err.message || String(err), |
| 815 | }]; |
| 816 | continue; |
| 817 | } |
| 818 | |
| 819 | currentFiles = mergeUniqueStrings(currentFiles, repairResult.files || []); |
| 820 | currentNotes = [...currentNotes, ...(repairResult.notes || [])]; |
| 821 | currentWarnings = [...currentWarnings, ...(repairResult.warnings || [])]; |
| 822 | currentAppliedIds = mergeUniqueStrings(currentAppliedIds, repairResult.appliedEntryIds || []); |
| 823 | currentFailed = mergeFailedEntries( |
| 824 | currentFailed, |
| 825 | normalizeFailedEntries(batch, repairResult, 'repair_failed'), |
| 826 | ); |
| 827 | |
| 828 | const verified = verifyEntriesAfterRepair({ |
| 829 | batch, |
| 830 | appliedEntryIds: currentAppliedIds, |
| 831 | files: currentFiles, |
| 832 | cwd, |
| 833 | }); |
| 834 | if (verified.failed.length > 0) { |
| 835 | currentFailures = verified.failed; |
| 836 | continue; |
| 837 | } |
| 838 | |
| 839 | const repairedChecks = runCopyEditPostApplyChecks({ cwd, files: currentFiles }); |
| 840 | currentWarnings = [...currentWarnings, ...(repairedChecks.warnings || [])]; |
| 841 | if (!repairedChecks.ok) { |
| 842 | currentFailures = repairedChecks.failures || []; |
| 843 | continue; |
| 844 | } |
| 845 | |
| 846 | const cleared = clearAppliedEntries(cwd, verified.verifiedIds); |
| 847 | const counts = countByPage(cwd); |
| 848 | const verifiedIdSet = new Set(verified.verifiedIds); |
| 849 | return { |
| 850 | applied: summarizeAppliedEntries(batch.entries, verified.verifiedIds), |
| 851 | failed: mergeFailedEntries(currentFailed).filter((item) => !verifiedIdSet.has(item.id)), |
| 852 | files: currentFiles, |
| 853 | cleared, |
| 854 | count, |
| 855 | pageUrl, |
| 856 | warnings: currentWarnings, |
| 857 | notes: currentNotes, |
| 858 | repair: { |
| 859 | status: 'repaired', |
| 860 | attempts: attempt, |
| 861 | maxAttempts, |
| 862 | transactionId: transactionId || null, |
| 863 | }, |
| 864 | ...counts, |
| 865 | }; |
| 866 | } |
| 867 | |
| 868 | const decisionFailedEntries = currentAppliedIds.length > 0 |
| 869 | ? (batch.entries || []) |
| 870 | .filter((entry) => currentAppliedIds.includes(entry.id)) |
| 871 | .map((entry) => ({ |
| 872 | id: entry.id, |
| 873 | reason: repairReason, |
| 874 | checks: currentFailures, |
| 875 | candidates: candidatesForEntry(batch, entry.id), |
| 876 | })) |
| 877 | : verificationFailuresForEntries(batch, batch.entries || [], repairReason, { checks: currentFailures }); |
| 878 | return { |
| 879 | applied: [], |
| 880 | failed: mergeFailedEntries(decisionFailedEntries, currentFailed), |
| 881 | files: currentFiles, |
| 882 | cleared: 0, |
| 883 | count, |
| 884 | pageUrl, |
| 885 | warnings: currentWarnings, |
| 886 | notes: currentNotes, |
| 887 | reason: 'manual_edit_repair_needs_decision', |
| 888 | needsManualDecision: true, |
| 889 | repair: { |
| 890 | status: 'needs_decision', |
| 891 | attempts: maxAttempts, |
| 892 | maxAttempts, |
| 893 | transactionId: transactionId || null, |
| 894 | failures: summarizeRepairFailures(currentFailures), |
| 895 | files: currentFiles, |
| 896 | }, |
| 897 | ...countByPage(cwd), |
| 898 | }; |
| 899 | } |
| 900 | |
| 901 | export async function commitManualEdits({ |
| 902 | cwd = process.cwd(), |
| 903 | pageUrl = null, |
| 904 | provider = undefined, |
| 905 | env = process.env, |
| 906 | timeoutMs = undefined, |
| 907 | applyBatchToSource = undefined, |
| 908 | chatAvailable = undefined, |
| 909 | repairOnly = false, |
| 910 | transactionId = null, |
| 911 | batch: providedBatch = null, |
| 912 | } = {}) { |
| 913 | try { |
| 914 | readBufferStrict(cwd); |
| 915 | } catch (err) { |
| 916 | return { |
| 917 | applied: [], |
| 918 | failed: [], |
| 919 | files: [], |
| 920 | cleared: 0, |
| 921 | count: 0, |
| 922 | pageUrl, |
| 923 | reason: 'manual_edit_buffer_invalid', |
| 924 | message: err.message || String(err), |
| 925 | ...countByPage(cwd), |
| 926 | }; |
| 927 | } |
| 928 | |
| 929 | const batch = providedBatch || buildManualEditEvidence({ cwd, pageUrl }); |
| 930 | const count = countOps(batch.entries); |
| 931 | if (count === 0) { |
| 932 | return { |
| 933 | applied: [], |
| 934 | failed: [], |
| 935 | files: [], |
| 936 | cleared: 0, |
| 937 | count: 0, |
| 938 | pageUrl, |
| 939 | reason: 'no_pending_edits', |
| 940 | ...countByPage(cwd), |
| 941 | }; |
| 942 | } |
| 943 | |
| 944 | const baseRollbackScope = collectApplyOwnedFiles(batch, cwd); |
| 945 | const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope); |
| 946 | let result; |
| 947 | try { |
| 948 | result = repairOnly |
| 949 | ? { |
| 950 | status: 'done', |
| 951 | appliedEntryIds: allEntryIds(batch), |
| 952 | failed: [], |
| 953 | files: collectApplyOwnedFiles(batch, cwd), |
| 954 | notes: ['repair-only validation pass'], |
| 955 | } |
| 956 | : await runCopyEditBatchAgent(batch, { |
| 957 | cwd, |
| 958 | provider, |
| 959 | env, |
| 960 | timeoutMs, |
| 961 | applyBatchToSource, |
| 962 | chatAvailable, |
| 963 | }); |
| 964 | } catch (err) { |
| 965 | const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope); |
| 966 | return { |
| 967 | applied: [], |
| 968 | failed: batch.entries.map((entry) => ({ |
| 969 | id: entry.id, |
| 970 | reason: err.message || String(err), |
| 971 | candidates: candidatesForEntry(batch, entry.id), |
| 972 | })), |
| 973 | files: [], |
| 974 | cleared: 0, |
| 975 | count, |
| 976 | pageUrl, |
| 977 | rolledBackFiles: rollback.rolledBackFiles, |
| 978 | rollbackFailures: rollback.rollbackFailures, |
| 979 | ...countByPage(cwd), |
| 980 | }; |
| 981 | } |
| 982 | |
| 983 | if (result.status === 'error') { |
| 984 | const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []); |
| 985 | const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope); |
| 986 | const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed'); |
| 987 | return { |
| 988 | applied: [], |
| 989 | failed: failed.length > 0 |
| 990 | ? failed |
| 991 | : verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'), |
| 992 | files: result.files || [], |
| 993 | cleared: 0, |
| 994 | count, |
| 995 | pageUrl, |
| 996 | notes: result.notes || [], |
| 997 | rolledBackFiles: rollback.rolledBackFiles, |
| 998 | rollbackFailures: rollback.rollbackFailures, |
| 999 | ...countByPage(cwd), |
| 1000 | }; |
| 1001 | } |
| 1002 | |
| 1003 | const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []); |
| 1004 | const reportedFiles = uniqueStrings(result.files || []) |
| 1005 | .map((file) => normalizeRelativeFile(cwd, file)) |
| 1006 | .filter(Boolean); |
| 1007 | const aiFailed = normalizeFailedEntries(batch, result, 'AI copy edit failed'); |
| 1008 | const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []); |
| 1009 | const failedIds = new Set(aiFailed.map((item) => item.id).filter(Boolean)); |
| 1010 | const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id)); |
| 1011 | |
| 1012 | if (conflictingAppliedIds.length > 0) { |
| 1013 | const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope); |
| 1014 | const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id)); |
| 1015 | return { |
| 1016 | applied: [], |
| 1017 | failed: [ |
| 1018 | ...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'), |
| 1019 | ...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)), |
| 1020 | ], |
| 1021 | files: result.files || [], |
| 1022 | cleared: 0, |
| 1023 | count, |
| 1024 | pageUrl, |
| 1025 | notes: result.notes || [], |
| 1026 | rolledBackFiles: rollback.rolledBackFiles, |
| 1027 | rollbackFailures: rollback.rollbackFailures, |
| 1028 | ...countByPage(cwd), |
| 1029 | }; |
| 1030 | } |
| 1031 | |
| 1032 | const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope); |
| 1033 | if (unreportedFiles.length > 0) { |
| 1034 | const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]); |
| 1035 | return { |
| 1036 | applied: [], |
| 1037 | failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }), |
| 1038 | files: result.files || [], |
| 1039 | unreportedFiles, |
| 1040 | cleared: 0, |
| 1041 | count, |
| 1042 | pageUrl, |
| 1043 | notes: result.notes || [], |
| 1044 | rolledBackFiles: rollback.rolledBackFiles, |
| 1045 | rollbackFailures: rollback.rollbackFailures, |
| 1046 | ...countByPage(cwd), |
| 1047 | }; |
| 1048 | } |
| 1049 | |
| 1050 | if (result.status === 'done' && reportedAppliedIds.length === 0) { |
| 1051 | const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope); |
| 1052 | return { |
| 1053 | applied: [], |
| 1054 | failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'), |
| 1055 | files: result.files || [], |
| 1056 | cleared: 0, |
| 1057 | count, |
| 1058 | pageUrl, |
| 1059 | notes: result.notes || [], |
| 1060 | rolledBackFiles: rollback.rolledBackFiles, |
| 1061 | rollbackFailures: rollback.rollbackFailures, |
| 1062 | ...countByPage(cwd), |
| 1063 | }; |
| 1064 | } |
| 1065 | |
| 1066 | const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id)); |
| 1067 | if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) { |
| 1068 | return repairPostApplyValidation({ |
| 1069 | batch, |
| 1070 | cwd, |
| 1071 | pageUrl, |
| 1072 | count, |
| 1073 | provider, |
| 1074 | env, |
| 1075 | timeoutMs, |
| 1076 | applyBatchToSource, |
| 1077 | chatAvailable, |
| 1078 | transactionId, |
| 1079 | appliedEntryIds: reportedAppliedIds, |
| 1080 | files: result.files || [], |
| 1081 | failed: aiFailed, |
| 1082 | notes: result.notes || [], |
| 1083 | warnings: result.warnings || [], |
| 1084 | repairReason: 'missing_touched_files', |
| 1085 | repairFailures: verificationFailuresForEntries(batch, reportedAppliedEntries, 'missing_touched_files'), |
| 1086 | }); |
| 1087 | } |
| 1088 | |
| 1089 | const verifiedAppliedIds = []; |
| 1090 | const verificationFailed = []; |
| 1091 | for (const entry of reportedAppliedEntries) { |
| 1092 | const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd }); |
| 1093 | if (failures.length === 0) { |
| 1094 | verifiedAppliedIds.push(entry.id); |
| 1095 | } else { |
| 1096 | verificationFailed.push({ |
| 1097 | id: entry.id, |
| 1098 | reason: 'source_verification_failed', |
| 1099 | failures, |
| 1100 | candidates: candidatesForEntry(batch, entry.id), |
| 1101 | }); |
| 1102 | } |
| 1103 | } |
| 1104 | const unreportedEntries = result.status === 'done' || result.status === 'partial' |
| 1105 | ? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id)) |
| 1106 | : []; |
| 1107 | const nonRepairFailed = [ |
| 1108 | ...verificationFailuresForEntries(batch, unreportedEntries, 'not_reported_applied'), |
| 1109 | ...aiFailed, |
| 1110 | ]; |
| 1111 | const failed = [ |
| 1112 | ...verificationFailed, |
| 1113 | ...nonRepairFailed, |
| 1114 | ]; |
| 1115 | |
| 1116 | const unappliedEntries = batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id)); |
| 1117 | const leakedUnapplied = findUnappliedEntrySourceChanges({ |
| 1118 | batch, |
| 1119 | entries: unappliedEntries, |
| 1120 | reportedFiles, |
| 1121 | cwd, |
| 1122 | rollbackSnapshot, |
| 1123 | }); |
| 1124 | if (leakedUnapplied.length > 0) { |
| 1125 | const leakedIds = new Set(leakedUnapplied.map((item) => item.id).filter(Boolean)); |
| 1126 | const rolledBackVerified = reportedAppliedEntries |
| 1127 | .filter((entry) => verifiedAppliedIds.includes(entry.id)) |
| 1128 | .map((entry) => ({ |
| 1129 | id: entry.id, |
| 1130 | reason: 'rolled_back_due_to_failed_entry_source_changed', |
| 1131 | candidates: candidatesForEntry(batch, entry.id), |
| 1132 | })); |
| 1133 | const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope); |
| 1134 | return { |
| 1135 | applied: [], |
| 1136 | failed: [ |
| 1137 | ...leakedUnapplied, |
| 1138 | ...failed.filter((item) => !leakedIds.has(item.id)), |
| 1139 | ...rolledBackVerified, |
| 1140 | ], |
| 1141 | files: result.files || [], |
| 1142 | cleared: 0, |
| 1143 | count, |
| 1144 | pageUrl, |
| 1145 | rolledBackFiles: rollback.rolledBackFiles, |
| 1146 | rollbackFailures: rollback.rollbackFailures, |
| 1147 | notes: result.notes || [], |
| 1148 | ...countByPage(cwd), |
| 1149 | }; |
| 1150 | } |
| 1151 | |
| 1152 | if (verificationFailed.length > 0) { |
| 1153 | return repairPostApplyValidation({ |
| 1154 | batch, |
| 1155 | cwd, |
| 1156 | pageUrl, |
| 1157 | count, |
| 1158 | provider, |
| 1159 | env, |
| 1160 | timeoutMs, |
| 1161 | applyBatchToSource, |
| 1162 | chatAvailable, |
| 1163 | transactionId, |
| 1164 | appliedEntryIds: reportedAppliedIds, |
| 1165 | files: result.files || [], |
| 1166 | failed: nonRepairFailed, |
| 1167 | notes: result.notes || [], |
| 1168 | warnings: result.warnings || [], |
| 1169 | repairReason: 'source_verification_failed', |
| 1170 | repairFailures: verificationFailed, |
| 1171 | }); |
| 1172 | } |
| 1173 | |
| 1174 | const postChecks = runCopyEditPostApplyChecks({ cwd, files: result.files || [] }); |
| 1175 | if (!postChecks.ok) { |
| 1176 | const postCheckEntries = verifiedAppliedIds.length > 0 |
| 1177 | ? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id)) |
| 1178 | : batch.entries; |
| 1179 | return repairPostApplyValidation({ |
| 1180 | batch, |
| 1181 | cwd, |
| 1182 | pageUrl, |
| 1183 | count, |
| 1184 | provider, |
| 1185 | env, |
| 1186 | timeoutMs, |
| 1187 | applyBatchToSource, |
| 1188 | chatAvailable, |
| 1189 | transactionId, |
| 1190 | appliedEntryIds: verifiedAppliedIds.length > 0 |
| 1191 | ? verifiedAppliedIds |
| 1192 | : postCheckEntries.map((entry) => entry.id).filter(Boolean), |
| 1193 | files: result.files || [], |
| 1194 | failed, |
| 1195 | notes: result.notes || [], |
| 1196 | warnings: [...(result.warnings || []), ...(postChecks.warnings || [])], |
| 1197 | postChecks, |
| 1198 | }); |
| 1199 | } |
| 1200 | |
| 1201 | const cleared = clearAppliedEntries(cwd, verifiedAppliedIds); |
| 1202 | const counts = countByPage(cwd); |
| 1203 | return { |
| 1204 | applied: summarizeAppliedEntries(batch.entries, verifiedAppliedIds), |
| 1205 | failed, |
| 1206 | files: result.files || [], |
| 1207 | cleared, |
| 1208 | count, |
| 1209 | pageUrl, |
| 1210 | warnings: [...(result.warnings || []), ...(postChecks.warnings || [])], |
| 1211 | notes: result.notes || [], |
| 1212 | ...counts, |
| 1213 | }; |
| 1214 | } |
| 1215 | |
| 1216 | async function main() { |
| 1217 | const args = process.argv.slice(2); |
| 1218 | if (args.includes('--help') || args.includes('-h')) { |
| 1219 | console.log('Usage: node live-commit-manual-edits.mjs [--page-url=<url>] [--provider=auto|codex|claude|mock]'); |
| 1220 | process.exit(0); |
| 1221 | } |
| 1222 | |
| 1223 | const result = await commitManualEdits({ |
| 1224 | cwd: process.cwd(), |
| 1225 | pageUrl: argVal(args, '--page-url'), |
| 1226 | provider: argVal(args, '--provider') || undefined, |
| 1227 | timeoutMs: Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000), |
| 1228 | }); |
| 1229 | console.log(JSON.stringify(result)); |
| 1230 | } |
| 1231 | |
| 1232 | if (process.argv[1]?.endsWith('live-commit-manual-edits.mjs')) { |
| 1233 | main().catch((err) => { |
| 1234 | console.error(JSON.stringify({ error: 'commit_failed', message: err.message || String(err) })); |
| 1235 | process.exit(1); |
| 1236 | }); |
| 1237 | } |
| 1238 | |
| 1239 | function escapeRegExp(value) { |
| 1240 | return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
| 1241 | } |
| 1242 |