Files
kotej/Sources/KotejEngine/ComparisonExport.swift
alexandrev-tibco 83d61df13b Exportar la comparación en un formato pensado para pegar a un LLM
Pedido: poder llevar la comparativa a una sesión abierta con un modelo para que
la use. Eso, y no un informe para imprimir, decide el formato: los ficheros
iguales se resumen en una linea en vez de listarse, los binarios solo se
mencionan, y las diferencias van en diff unificado, que los modelos leen de
forma nativa. Lo que se omite se dice en voz alta — un informe que se deja la
mitad en silencio es peor que ninguno, porque se lee como completo.

Las cabeceras `--- a/ +++ b/` cuestan dos lineas y convierten cada bloque en un
parche de verdad, asi que el modelo puede devolverlo por `git apply` en vez de
reescribir el cambio a mano. Que lo sea de verdad esta comprobado ejecutando
git sobre la salida en 10 escenarios (sin salto final, insercion al principio,
borrado al final...); dos fallos aparecieron asi: interlineaba `-` y `+` en vez
de agrupar, y contaba la linea fantasma que deja el ultimo `\n`.

Implementado en los dos motores (Swift y TypeScript) con la misma semantica, y
verificado que producen el mismo texto **byte a byte** para un mismo caso.
Tambien hay salida JSON, para alimentar una herramienta en vez de una charla.

UI en las dos apps: copiar al portapapeles primero (que es el gesto real), y
guardar/descargar despues. En web, si el portapapeles se niega, se descarga y
se avisa: un fallo silencioso ahi acaba en pegar contenido viejo sin saberlo.

De paso, un bug que esto destapó: el menu contextual de la web se cerraba en
`pointerdown`, quitando los botones antes de que su click llegara — ninguna
accion del menu funcionaba (tampoco "Set as base" ni "Expand all"). El smoke
test nunca habia pulsado una; ahora si.

Closes #9

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hEAYuHRKMYz9sSa9zmbzz
2026-08-02 09:19:59 +02:00

374 lines
16 KiB
Swift

import Foundation
/// Turns a comparison into text meant to be pasted into an open conversation with
/// a language model, so it can reason about what changed.
///
/// That goal drives every decision here. The output has to fit in a message, so
/// identical files are summarised on one line instead of listed one by one, and
/// binaries only get a mention. Differences use **unified diff** with context,
/// which models read natively and which `git apply` accepts, so a change can be
/// handed straight back. Anything left out is stated out loud: a report that
/// silently drops half the changes is worse than no report, because it reads as
/// complete.
///
/// Kept in step with `web/src/engine/export.ts`; both must produce the same text.
public enum ComparisonExport {
public struct Options: Sendable {
/// List the identical files too. Off by default: it's noise for this purpose.
public var includeIdentical: Bool
/// Unchanged lines kept around each change.
public var contextLines: Int
/// Diff lines per file before truncating (and saying so).
public var maxLinesPerFile: Int
/// Files with a diff body before the rest are only named (and said so).
public var maxFiles: Int
public init(includeIdentical: Bool = false, contextLines: Int = 3,
maxLinesPerFile: Int = 200, maxFiles: Int = 40) {
self.includeIdentical = includeIdentical
self.contextLines = contextLines
self.maxLinesPerFile = maxLinesPerFile
self.maxFiles = maxFiles
}
}
static func modeName(_ mode: ComparisonMode) -> String {
switch mode {
case .binary: return "byte for byte"
case .text: return "text"
case .semantic: return "semantic (JSON/XML)"
}
}
private static func flatten(_ root: DiffNode) -> [DiffNode] {
var out: [DiffNode] = []
func walk(_ node: DiffNode) {
if node.isDirectory {
node.children.forEach(walk)
return
}
out.append(node)
}
walk(root)
return out
}
private static func formatSize(_ size: UInt64) -> String {
size < 1024 ? "\(size) B" : String(format: "%.1f KB", Double(size) / 1024)
}
// MARK: - Markdown
/// The comparison as Markdown, ready to paste into a chat.
public static func markdown(root: DiffNode, leftName: String, rightName: String,
mode: ComparisonMode?, options: Options = Options()) -> String {
let files = flatten(root)
let changed = files.filter { $0.status == .different || $0.status == .pending }
let onlyLeft = files.filter { $0.status == .onlyLeft }
let onlyRight = files.filter { $0.status == .onlyRight }
let same = files.filter { $0.status == .identical || $0.status == .equivalent }
var out: [String] = []
out.append("# Comparison: \(leftName)\(rightName)")
out.append("")
// Spelling out the convention costs two lines and removes any doubt about
// which side is which when the model reads the diffs below.
out.append("Generated by Kotej. **A** is the left side (`\(leftName)`), "
+ "**B** is the right side (`\(rightName)`). "
+ "In the diffs, `-` is A and `+` is B.")
out.append("")
out.append("- Comparison mode: " + (mode.map(modeName) ?? "automatic (per file type)"))
out.append("- \(same.count) same, \(changed.count) different, "
+ "\(onlyLeft.count) only in A, \(onlyRight.count) only in B")
out.append("")
if !onlyLeft.isEmpty {
out.append("## Only in A (\(leftName))")
out.append("")
for file in onlyLeft { out.append("- `\(file.path)` (\(formatSize(file.left?.size ?? 0)))") }
out.append("")
}
if !onlyRight.isEmpty {
out.append("## Only in B (\(rightName))")
out.append("")
for file in onlyRight { out.append("- `\(file.path)` (\(formatSize(file.right?.size ?? 0)))") }
out.append("")
}
if !changed.isEmpty {
out.append("## Different (\(changed.count))")
out.append("")
let detailed = Array(changed.prefix(options.maxFiles))
for file in detailed { out.append(contentsOf: section(file, options: options)) }
if changed.count > detailed.count {
let rest = changed.dropFirst(detailed.count)
out.append("### \(rest.count) more differing files, listed without their diffs")
out.append("")
for file in rest { out.append("- `\(file.path)`") }
out.append("")
}
}
if !same.isEmpty {
out.append("## Same (\(same.count))")
out.append("")
if options.includeIdentical {
for file in same {
out.append("- `\(file.path)`"
+ (file.status == .equivalent ? " (equivalent, not byte-identical)" : ""))
}
} else {
// One line rather than a list: the model rarely needs the names,
// but it does need to know they were compared and came out equal.
let equivalent = same.filter { $0.status == .equivalent }.count
out.append("\(same.count) files matched and are not listed"
+ (equivalent > 0
? ", of which \(equivalent) are equivalent rather than byte-identical "
+ "(same meaning, different bytes — e.g. reordered JSON keys or reformatted XML)."
: "."))
}
out.append("")
}
var text = out.joined(separator: "\n")
while text.contains("\n\n\n") { text = text.replacingOccurrences(of: "\n\n\n", with: "\n\n") }
return text.trimmingCharacters(in: .whitespacesAndNewlines) + "\n"
}
/// One differing file: a heading, what settled it, and the diff itself.
private static func section(_ file: DiffNode, options: Options) -> [String] {
var out: [String] = []
out.append("### `\(file.path)`")
out.append("")
guard let left = file.left, let right = file.right else { return out }
guard let leftData = try? left.data(), let rightData = try? right.data() else {
out.append("Contents could not be read.")
out.append("")
return out
}
if ContentKind.detect(path: file.name, data: leftData) == .binary {
out.append("Binary file, differs. A: \(formatSize(UInt64(leftData.count))), "
+ "B: \(formatSize(UInt64(rightData.count))).")
out.append("")
return out
}
let leftText = String(decoding: leftData, as: UTF8.self)
let rightText = String(decoding: rightData, as: UTF8.self)
let rows = TextDiff.rows(left: leftText, right: rightText,
syntax: CommentSyntax.forPath(file.name))
let summary = describeChanges(rows)
out.append(file.appliedMode.map { "Compared as \(modeName($0)). \(summary)" } ?? summary)
out.append("")
let diff = unified(rows: rows, options: options,
leftEndsWithNewline: leftText.hasSuffix("\n"),
rightEndsWithNewline: rightText.hasSuffix("\n"))
out.append("```diff")
// The a/ b/ headers cost two lines and make the block a real patch, so the
// model can hand it straight to `git apply` instead of retyping the change.
out.append("--- a/\(file.path)")
out.append("+++ b/\(file.path)")
out.append(diff.text)
out.append("```")
if diff.omitted > 0 { out.append("_\(diff.omitted) further diff lines omitted for length._") }
out.append("")
return out
}
/// A one-line summary of what kind of change this file holds.
static func describeChanges(_ rows: [TextDiff.Row]) -> String {
let changed = rows.filter { $0.kind == .changed }
var parts: [String] = []
let content = changed.filter { $0.changeKind == .content }.count
let comments = changed.filter { $0.changeKind == .comment }.count
let spacing = changed.filter { $0.changeKind == .whitespace }.count
let added = rows.filter { $0.kind == .added }.count
let removed = rows.filter { $0.kind == .removed }.count
if content > 0 { parts.append("\(content) changed") }
if comments > 0 { parts.append("\(comments) comment-only") }
if spacing > 0 { parts.append("\(spacing) whitespace-only") }
if added > 0 { parts.append("\(added) added") }
if removed > 0 { parts.append("\(removed) removed") }
return parts.isEmpty ? "No line-level differences." : parts.joined(separator: ", ") + " lines."
}
// MARK: - Unified diff
/// Unified diff built from the aligned rows, keeping `contextLines` around each
/// change and collapsing the untouched stretches into `@@` hunks.
public static func unified(rows allRows: [TextDiff.Row], options: Options = Options(),
leftEndsWithNewline: Bool = true,
rightEndsWithNewline: Bool = true) -> (text: String, omitted: Int) {
// Splitting on "\n" leaves a phantom empty row after a trailing newline.
// The viewer can show it, but a patch must not count it: the file has 8
// lines, and claiming 9 makes git reject the whole thing.
var rows = allRows
if let last = rows.last, last.kind == .equal, last.left.isEmpty, last.right.isEmpty {
rows.removeLast()
}
let interesting = rows.indices.filter { rows[$0].kind != .equal }
guard !interesting.isEmpty else { return ("", 0) }
// Group changes that are close enough to share one hunk.
var groups: [(Int, Int)] = []
var start = interesting[0]
var end = interesting[0]
for index in interesting.dropFirst() {
if index - end <= options.contextLines * 2 {
end = index
continue
}
groups.append((start, end))
start = index
end = index
}
groups.append((start, end))
var lines: [String] = []
var budget = options.maxLinesPerFile
var omitted = 0
// A file that doesn't end in a newline needs git's marker after its last
// line; without it git reads the line as newline-terminated and the patch
// won't apply.
let noNewline = "\\ No newline at end of file"
let lastIndex = rows.count - 1
for (from, to) in groups {
let first = max(0, from - options.contextLines)
let last = min(rows.count - 1, to + options.contextLines)
let slice = Array(rows[first...last])
let leftStart = slice.first(where: { $0.leftNumber != nil })?.leftNumber ?? 0
let rightStart = slice.first(where: { $0.rightNumber != nil })?.rightNumber ?? 0
let leftCount = slice.filter { $0.leftNumber != nil }.count
let rightCount = slice.filter { $0.rightNumber != nil }.count
// Real unified diff groups a run of removals before its additions,
// rather than interleaving them pair by pair otherwise `git apply`
// and friends reject it, and it stops being a format anything can
// consume.
var body: [String] = []
var removals: [String] = []
var additions: [String] = []
func flush() {
body.append(contentsOf: removals)
body.append(contentsOf: additions)
removals.removeAll()
additions.removeAll()
}
for (offset, row) in slice.enumerated() {
let isLast = first + offset == lastIndex
switch row.kind {
case .equal:
flush()
body.append(" \(row.left)")
if isLast && !(leftEndsWithNewline && rightEndsWithNewline) { body.append(noNewline) }
case .added:
additions.append("+\(row.right)")
if isLast && !rightEndsWithNewline { additions.append(noNewline) }
case .removed:
removals.append("-\(row.left)")
if isLast && !leftEndsWithNewline { removals.append(noNewline) }
case .changed:
removals.append("-\(row.left)")
if isLast && !leftEndsWithNewline { removals.append(noNewline) }
additions.append("+\(row.right)")
if isLast && !rightEndsWithNewline { additions.append(noNewline) }
}
}
flush()
if budget <= 0 {
omitted += body.count
continue
}
lines.append("@@ -\(leftStart),\(leftCount) +\(rightStart),\(rightCount) @@")
if body.count > budget {
lines.append(contentsOf: body.prefix(budget))
omitted += body.count - budget
budget = 0
} else {
lines.append(contentsOf: body)
budget -= body.count
}
}
return (lines.joined(separator: "\n"), omitted)
}
// MARK: - JSON
/// The same data as JSON, for feeding a tool rather than a conversation.
public static func json(root: DiffNode, leftName: String, rightName: String,
mode: ComparisonMode?, options: Options = Options()) -> String {
let files = flatten(root)
var entries: [[String: Any]] = []
for file in files {
var entry: [String: Any] = [
"path": file.path,
"status": statusName(file.status),
"appliedMode": file.appliedMode.map(modeName) ?? NSNull(),
"leftSize": file.left.map { Int($0.size) } ?? NSNull(),
"rightSize": file.right.map { Int($0.size) } ?? NSNull(),
]
if file.status == .different, let left = file.left, let right = file.right {
if let leftData = try? left.data(), let rightData = try? right.data() {
if ContentKind.detect(path: file.name, data: leftData) == .binary {
entry["binary"] = true
} else {
let leftText = String(decoding: leftData, as: UTF8.self)
let rightText = String(decoding: rightData, as: UTF8.self)
let rows = TextDiff.rows(left: leftText, right: rightText,
syntax: CommentSyntax.forPath(file.name))
entry["diff"] = unified(rows: rows, options: options,
leftEndsWithNewline: leftText.hasSuffix("\n"),
rightEndsWithNewline: rightText.hasSuffix("\n")).text
}
} else {
entry["unreadable"] = true
}
}
entries.append(entry)
}
let payload: [String: Any] = [
"tool": "Kotej",
"left": leftName,
"right": rightName,
"mode": mode.map(modeName) ?? "automatic",
"totals": [
"same": files.filter { $0.status == .identical || $0.status == .equivalent }.count,
"different": files.filter { $0.status == .different || $0.status == .pending }.count,
"onlyLeft": files.filter { $0.status == .onlyLeft }.count,
"onlyRight": files.filter { $0.status == .onlyRight }.count,
],
"files": entries,
]
guard let data = try? JSONSerialization.data(withJSONObject: payload,
options: [.prettyPrinted, .sortedKeys]),
let text = String(data: data, encoding: .utf8) else { return "{}" }
return text
}
private static func statusName(_ status: DiffStatus) -> String {
switch status {
case .identical: return "identical"
case .equivalent: return "equivalent"
case .different: return "different"
case .onlyLeft: return "onlyLeft"
case .onlyRight: return "onlyRight"
case .pending: return "pending"
}
}
}