/** * AttributionTracker — pure span algebra over offset ranges (spec §6.2/§6.5). * Char-honest (INV-7): attributes exactly the characters each author produced — * split character-precisely, coalesce only adjacent same-author/same-turn * spans. vscode-free; the controller converts vscode change events to TextEdit. */ import type { TextEdit } from "./anchorer"; import type { Provenance } from "./model"; export interface LiveSpan { id: string; /** half-open [start, end) character offsets in the live document. */ start: number; end: number; author: Provenance; turnId?: string; /** ISO-8601; survives splits/merges (earliest wins on merge). */ createdAt: string; } /** Injected effects so the algebra stays pure and deterministic in tests. */ export interface TrackerCtx { newId: () => string; now: () => string; turnId?: string; } function sameAuthor(a: LiveSpan, b: LiveSpan): boolean { return JSON.stringify(a.author) === JSON.stringify(b.author) && a.turnId === b.turnId; } /** Merge adjacent same-author/same-turn spans; drop empties; sort by start. */ export function coalesce(spans: LiveSpan[]): LiveSpan[] { const sorted = spans.filter((s) => s.end > s.start).sort((a, b) => a.start - b.start); const out: LiveSpan[] = []; for (const s of sorted) { const last = out[out.length - 1]; if (last && last.end === s.start && sameAuthor(last, s)) { last.end = s.end; if (s.createdAt < last.createdAt) last.createdAt = s.createdAt; } else { out.push({ ...s }); } } return out; } /** * Apply one document edit (the half-open range [start,end) replaced by * newLength chars) authored by `author`. Existing spans shift/split/clip; * inserted chars become a new span of `author` (INV-7). * * `attributeInserted` (default true) controls whether inserted chars get a new * span. Pass `false` for an UNDO/REDO change (#38): the geometry of existing * spans is still reconciled, but re-inserted text is NOT freshly attributed — * an undo is history navigation, not authorship, so restored text stays neutral * rather than being falsely claimed by the current author. */ export function applyChange( spans: LiveSpan[], edit: TextEdit, author: Provenance, ctx: TrackerCtx, attributeInserted = true, ): LiveSpan[] { const delta = edit.newLength - (edit.end - edit.start); const out: LiveSpan[] = []; for (const s of spans) { if (s.end <= edit.start) { out.push({ ...s }); } else if (s.start >= edit.end) { out.push({ ...s, start: s.start + delta, end: s.end + delta }); } else { const left = s.start < edit.start ? { ...s, end: edit.start } : null; const right = s.end > edit.end ? { ...s, start: edit.end + delta, end: s.end + delta } : null; if (left) out.push(left); if (right) out.push(left ? { ...right, id: ctx.newId() } : right); } } if (attributeInserted && edit.newLength > 0) { out.push({ id: ctx.newId(), start: edit.start, end: edit.start + edit.newLength, author, turnId: ctx.turnId, createdAt: ctx.now(), }); } return coalesce(out); }