hivecode-mcp 0.12.4 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HIVE_RULES.md +11 -40
- package/README.md +421 -346
- package/docs/ALWAYS_ON.md +310 -0
- package/docs/GITHUB_WORKFLOW.md +334 -0
- package/docs/MCP.md +157 -118
- package/docs/QUICKSTART.md +266 -0
- package/docs/SELF_HOSTING.md +2 -2
- package/docs/SLACK_SETUP.md +450 -0
- package/docs/TROUBLESHOOTING.md +87 -0
- package/hive-await.js +25 -5
- package/hive-branch.js +55 -25
- package/hive-github.js +1904 -0
- package/hive-listen.js +81 -81
- package/hive-mailbox.js +5 -2
- package/hive-mcp-http.js +580 -0
- package/hive-mcp.js +109 -24
- package/hive-notify.js +233 -0
- package/hive-pr.js +17 -4
- package/hive-relay-health.js +230 -230
- package/hive-room-key.js +209 -0
- package/hive-session.js +114 -0
- package/hive-setup.js +14 -4
- package/hive-slack-agent.js +396 -0
- package/hive-slack.js +1809 -0
- package/hive-stop-hook.js +7 -4
- package/hive-time.js +71 -0
- package/hive-webhook.js +54 -0
- package/icr.js +851 -851
- package/limits.js +3 -3
- package/package.json +189 -172
- package/slack-app-manifest-agent.json +61 -0
- package/slack-app-manifest-inbound.json +36 -0
- package/slack-app-manifest.json +27 -0
- package/sync.js +4790 -4691
package/icr.js
CHANGED
|
@@ -1,851 +1,851 @@
|
|
|
1
|
-
// ICR — Intent-aware Code Replication (v1 proof-of-concept).
|
|
2
|
-
//
|
|
3
|
-
// The first real step from Hivecode-the-tool toward a FOUNDATIONAL primitive:
|
|
4
|
-
// merge code by STRUCTURE and INTENT, not characters, with a hard guarantee — never
|
|
5
|
-
// emit code more broken than its inputs.
|
|
6
|
-
//
|
|
7
|
-
// Why this matters: CRDTs (Yjs) merge characters. They will happily fuse two edits
|
|
8
|
-
// into syntactically-valid garbage, because they have no idea what code MEANS. ICR
|
|
9
|
-
// parses the code, merges at the level of declarations (functions / classes / vars),
|
|
10
|
-
// descends INTO a declaration both sides touched (so disjoint edits inside one function
|
|
11
|
-
// still merge), detects when two authors changed the SAME thing (a real conflict), and
|
|
12
|
-
// validates that the merged result still parses — falling back safely if it wouldn't.
|
|
13
|
-
//
|
|
14
|
-
// On top of structure it adds an INTENT layer:
|
|
15
|
-
// • RENAME DETECTION — a declaration gone from base whose identical-bodied twin appears
|
|
16
|
-
// under a new name is recognized as a rename; stale call sites are rewritten so the
|
|
17
|
-
// other agent's fresh calls keep working. Nothing else does this.
|
|
18
|
-
// • DANGLING REFERENCE — a declaration removed/renamed but still referenced is flagged
|
|
19
|
-
// as a semantic conflict even though the code parses. CRDTs/git/plain merge miss it.
|
|
20
|
-
//
|
|
21
|
-
// LANGUAGE-AGNOSTIC BY DESIGN: this file is the engine. Everything that knows about a
|
|
22
|
-
// specific language (how to parse, what a declaration is, how to find references) lives
|
|
23
|
-
// behind a provider (see lang-js.js). Adding Python/Go/Rust = writing another provider
|
|
24
|
-
// and registerLanguage()-ing it; the merge logic below never changes. JavaScript via
|
|
25
|
-
// `acorn` is simply the first provider. The production path swaps in tree-sitter
|
|
26
|
-
// providers for every language behind this same interface.
|
|
27
|
-
|
|
28
|
-
import { javascript } from './lang-js.js'
|
|
29
|
-
import { braceLanguages } from './lang-brace.js'
|
|
30
|
-
import { python } from './lang-python.js'
|
|
31
|
-
import { ruby } from './lang-ruby.js'
|
|
32
|
-
import { json } from './lang-json.js'
|
|
33
|
-
import { yaml } from './lang-yaml.js'
|
|
34
|
-
import { toml } from './lang-toml.js'
|
|
35
|
-
|
|
36
|
-
// --- language registry ----------------------------------------------------------
|
|
37
|
-
// JavaScript (acorn, full intent layer) first; then structural providers for the
|
|
38
|
-
// C-family (TypeScript, Go, Rust, Java, C/C++, C#, Swift, Kotlin, …), Python, and
|
|
39
|
-
// Ruby; data languages: JSON merges as parsed DATA (real parser, value-wise 3-way,
|
|
40
|
-
// perfect guarantee), YAML/TOML merge by keyed structure (sections/keys).
|
|
41
|
-
// These cover disjoint extensions, so order only matters on clashes (none today).
|
|
42
|
-
const LANGUAGES = [javascript, ...braceLanguages, python, ruby, json, yaml, toml]
|
|
43
|
-
|
|
44
|
-
// Register an additional language provider. Must expose the provider contract:
|
|
45
|
-
// { id, exts:[...], parses, units, declaredNames, usedIdentifiers, declBody,
|
|
46
|
-
// renameRefs, fnParts }. Newest wins on extension clashes.
|
|
47
|
-
export function registerLanguage(provider) { LANGUAGES.unshift(provider) }
|
|
48
|
-
|
|
49
|
-
// The live provider list (the actual registered objects, not copies) — used by the
|
|
50
|
-
// optional tree-sitter oracle to upgrade providers' parse checks in place.
|
|
51
|
-
export function registeredLanguages() { return LANGUAGES }
|
|
52
|
-
|
|
53
|
-
function extname(p) { const m = /\.[^.\/\\]+$/.exec(p || ''); return m ? m[0].toLowerCase() : '' }
|
|
54
|
-
|
|
55
|
-
// The provider for a filename, or null if no registered language claims its extension.
|
|
56
|
-
export function languageFor(filename) {
|
|
57
|
-
const e = extname(filename)
|
|
58
|
-
return LANGUAGES.find((l) => l.exts.includes(e)) || null
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Can ICR merge this file structurally? (Callers gate on this before trying.)
|
|
62
|
-
export function supports(filename) { return languageFor(filename) != null }
|
|
63
|
-
|
|
64
|
-
// --- generic merge primitives (language-independent: operate on {key,text}) -----
|
|
65
|
-
const mapOf = (us) => new Map(us.map((u) => [u.key, u.text]))
|
|
66
|
-
function dedupeOrder(keys) { const seen = new Set(), out = []; for (const k of keys) if (!seen.has(k)) { seen.add(k); out.push(k) } return out }
|
|
67
|
-
|
|
68
|
-
// For a given unit, choose which version survives: whoever CHANGED it wins; if neither
|
|
69
|
-
// changed it, keep the base.
|
|
70
|
-
function pick(k, B, A, Bb) {
|
|
71
|
-
const base = B.has(k) ? B.get(k) : null
|
|
72
|
-
const a = A.has(k) ? A.get(k) : null
|
|
73
|
-
const b = Bb.has(k) ? Bb.get(k) : null
|
|
74
|
-
if (a != null && a !== base) return a
|
|
75
|
-
if (b != null && b !== base) return b
|
|
76
|
-
return base != null ? base : (a != null ? a : b)
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// SIMILARITY RE-PAIRING (the matcher): a unit that "disappeared" on one side while a
|
|
80
|
-
// near-identical unit "appeared" on the SAME side is the same unit rewritten — e.g.
|
|
81
|
-
// `app.use(...)` becoming `app1.use(...)` when a variable is renamed changes the unit's
|
|
82
|
-
// content-derived key, and key matching alone would call that a delete + an add. Ground-
|
|
83
|
-
// truth replay of real repos showed this is the #1 source of false delete-vs-edit
|
|
84
|
-
// conflicts. Pair them back so the engine sees ONE unit changed on that side and the
|
|
85
|
-
// normal same-key tiers (inner merge → token splice) get their shot.
|
|
86
|
-
// Safety: same key KIND only (call: with call:, fn: with fn:), a high similarity bar,
|
|
87
|
-
// and the best match must be UNIQUE (with margin) and MUTUAL — any ambiguity means no
|
|
88
|
-
// pairing. Side-local and deterministic, so merge(a,b) === merge(b,a) is preserved.
|
|
89
|
-
const keyKind = (k) => k.slice(0, k.indexOf(':') + 1)
|
|
90
|
-
function shingleSet(t) {
|
|
91
|
-
const s = t.replace(/\s+/g, ' ').trim()
|
|
92
|
-
const out = new Set()
|
|
93
|
-
for (let i = 0; i + 4 <= s.length; i++) out.add(s.slice(i, i + 4))
|
|
94
|
-
return out
|
|
95
|
-
}
|
|
96
|
-
function shingleSim(sa, sb) {
|
|
97
|
-
if (!sa.size || !sb.size) return 0
|
|
98
|
-
let inter = 0
|
|
99
|
-
const [small, big] = sa.size <= sb.size ? [sa, sb] : [sb, sa]
|
|
100
|
-
for (const g of small) if (big.has(g)) inter++
|
|
101
|
-
return (2 * inter) / (sa.size + sb.size)
|
|
102
|
-
}
|
|
103
|
-
const SIM_FLOOR = 0.75, SIM_MARGIN = 0.04
|
|
104
|
-
function repairSide(baseU, sideU, otherKeys) {
|
|
105
|
-
const baseKeys = new Set(baseU.map((u) => u.key))
|
|
106
|
-
const sideKeys = new Set(sideU.map((u) => u.key))
|
|
107
|
-
const deleted = baseU.filter((u) => !sideKeys.has(u.key))
|
|
108
|
-
// An added key that ALSO appears on the other side is a genuine concurrent addition
|
|
109
|
-
// (both sides wrote it) — pairing it away would fabricate a duplicate and silently
|
|
110
|
-
// absorb a same-key add/add clash the engine must surface. Never pair those.
|
|
111
|
-
const added = sideU.filter((u) => !baseKeys.has(u.key) && !otherKeys.has(u.key))
|
|
112
|
-
if (!deleted.length || !added.length || deleted.length * added.length > 900) return sideU
|
|
113
|
-
const shD = deleted.map((u) => shingleSet(u.text)), shA = added.map((u) => shingleSet(u.text))
|
|
114
|
-
const bestFor = (sh, kind, pool, shPool) => {
|
|
115
|
-
let best = -1, bestS = 0, second = 0
|
|
116
|
-
for (let j = 0; j < pool.length; j++) {
|
|
117
|
-
if (keyKind(pool[j].key) !== kind) continue
|
|
118
|
-
const s = shingleSim(sh, shPool[j])
|
|
119
|
-
if (s > bestS) { second = bestS; bestS = s; best = j } else if (s > second) second = s
|
|
120
|
-
}
|
|
121
|
-
return { best, bestS, second }
|
|
122
|
-
}
|
|
123
|
-
const rekey = new Map(), claimed = new Set()
|
|
124
|
-
for (let i = 0; i < deleted.length; i++) {
|
|
125
|
-
const kind = keyKind(deleted[i].key)
|
|
126
|
-
const f = bestFor(shD[i], kind, added, shA)
|
|
127
|
-
// Short units are mostly boilerplate (`def f(): return N`), so shingle similarity
|
|
128
|
-
// runs hot on them — demand near-identity before calling two short units "the same".
|
|
129
|
-
const floor = Math.min(shD[i].size, f.best >= 0 ? shA[f.best].size : Infinity) < 24 ? 0.9 : SIM_FLOOR
|
|
130
|
-
if (f.best < 0 || f.bestS < floor || f.bestS - f.second < SIM_MARGIN) continue
|
|
131
|
-
const back = bestFor(shA[f.best], keyKind(added[f.best].key), deleted, shD)
|
|
132
|
-
if (back.best !== i || back.bestS - back.second < SIM_MARGIN) continue
|
|
133
|
-
if (claimed.has(added[f.best].key)) continue
|
|
134
|
-
claimed.add(added[f.best].key)
|
|
135
|
-
rekey.set(added[f.best].key, deleted[i].key)
|
|
136
|
-
}
|
|
137
|
-
if (!rekey.size) return sideU
|
|
138
|
-
return sideU.map((u) => (rekey.has(u.key) ? { ...u, key: rekey.get(u.key) } : u))
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// Core 3-way merge over a KEYED list of units (works at file level AND, recursively,
|
|
142
|
-
// inside a single declaration). Returns { conflicts:[...], parts:[...text] }.
|
|
143
|
-
// When both sides change the SAME key into different things, it first tries to descend
|
|
144
|
-
// INTO that unit (finer granularity) — only a clash it can't resolve becomes a conflict.
|
|
145
|
-
function mergeKeyed(lang, baseU, aU, bU, mopts = {}) {
|
|
146
|
-
const aKeys = new Set(aU.map((u) => u.key)), bKeys = new Set(bU.map((u) => u.key))
|
|
147
|
-
aU = repairSide(baseU, aU, bKeys)
|
|
148
|
-
bU = repairSide(baseU, bU, aKeys)
|
|
149
|
-
const B = mapOf(baseU), A = mapOf(aU), Bb = mapOf(bU)
|
|
150
|
-
const keys = dedupeOrder([...baseU.map((u) => u.key), ...aU.map((u) => u.key), ...bU.map((u) => u.key)])
|
|
151
|
-
|
|
152
|
-
const conflicts = [], conflictUnits = [], resolved = new Map()
|
|
153
|
-
for (const k of keys) {
|
|
154
|
-
const bs = B.has(k) ? B.get(k) : null
|
|
155
|
-
const as_ = A.has(k) ? A.get(k) : null
|
|
156
|
-
const bbs = Bb.has(k) ? Bb.get(k) : null
|
|
157
|
-
const changedA = as_ !== bs, changedB = bbs !== bs
|
|
158
|
-
if (changedA && changedB && as_ !== bbs) {
|
|
159
|
-
// Both touched the same unit, differently. Try finer-grained inner merge first.
|
|
160
|
-
if (bs != null && as_ != null && bbs != null) {
|
|
161
|
-
const inner = tryInnerMerge(lang, bs, as_, bbs, mopts)
|
|
162
|
-
if (inner != null) { resolved.set(k, inner); continue }
|
|
163
|
-
}
|
|
164
|
-
conflicts.push(k)
|
|
165
|
-
// Carry the three exact versions of the conflicting unit, so a conflict is a
|
|
166
|
-
// MACHINE-RESOLVABLE object (base/ours/theirs of just this declaration) — not a wall
|
|
167
|
-
// of text markers. This is what an intent-aware resolver (a judge agent) reconciles.
|
|
168
|
-
conflictUnits.push({ key: k, base: bs, ours: as_, theirs: bbs })
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
if (conflicts.length) return { conflicts, conflictUnits, parts: [] }
|
|
172
|
-
|
|
173
|
-
// No unresolved clashes → decide which keys survive, honoring one-sided deletions.
|
|
174
|
-
const survives = new Set()
|
|
175
|
-
for (const k of keys) {
|
|
176
|
-
const inBase = B.has(k), inA = A.has(k), inB2 = Bb.has(k)
|
|
177
|
-
if (inBase && (!inA || !inB2)) {
|
|
178
|
-
const deletedByA = !inA, deletedByB = !inB2
|
|
179
|
-
const otherUnchangedFromBase =
|
|
180
|
-
(deletedByA && (!inB2 || Bb.get(k) === B.get(k))) ||
|
|
181
|
-
(deletedByB && (!inA || A.get(k) === B.get(k)))
|
|
182
|
-
if (otherUnchangedFromBase) continue // accept the deletion
|
|
183
|
-
}
|
|
184
|
-
survives.add(k)
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// CONVERGENCE + TOPOLOGY: new units are inserted where their author put them — anchored
|
|
188
|
-
// to the base unit that immediately preceded them on the side that added them — while
|
|
189
|
-
// staying SYMMETRIC: merge(a,b) must equal merge(b,a) byte-for-byte or two live peers
|
|
190
|
-
// never settle. Symmetry rules: (1) a unit added by BOTH sides at different anchors gets
|
|
191
|
-
// the anchor earlier in base order (side-independent); (2) when both sides insert runs at
|
|
192
|
-
// the SAME anchor, each side's run keeps its own internal order, and the two runs are
|
|
193
|
-
// ordered by comparing their joined keys (content, not side).
|
|
194
|
-
const baseOrder = dedupeOrder(baseU.map((u) => u.key))
|
|
195
|
-
const baseSet = new Set(baseOrder)
|
|
196
|
-
const basePos = new Map(baseOrder.map((k, i) => [k, i]))
|
|
197
|
-
|
|
198
|
-
const anchorsFor = (units) => { // newKey -> base key it followed (null = top of file)
|
|
199
|
-
const m = new Map()
|
|
200
|
-
let lastBase = null
|
|
201
|
-
for (const u of units) {
|
|
202
|
-
if (baseSet.has(u.key)) lastBase = u.key
|
|
203
|
-
else if (survives.has(u.key) && !m.has(u.key)) m.set(u.key, lastBase)
|
|
204
|
-
}
|
|
205
|
-
return m
|
|
206
|
-
}
|
|
207
|
-
const aAnch = anchorsFor(aU), bAnch = anchorsFor(bU)
|
|
208
|
-
const anchorOf = (k) => {
|
|
209
|
-
const inA2 = aAnch.has(k), inB3 = bAnch.has(k)
|
|
210
|
-
if (inA2 && inB3) {
|
|
211
|
-
const x = aAnch.get(k), y = bAnch.get(k)
|
|
212
|
-
if (x === y) return x
|
|
213
|
-
const px = x == null ? -1 : basePos.get(x), py = y == null ? -1 : basePos.get(y)
|
|
214
|
-
return px <= py ? x : y
|
|
215
|
-
}
|
|
216
|
-
return inA2 ? aAnch.get(k) : bAnch.get(k)
|
|
217
|
-
}
|
|
218
|
-
const runsA = new Map(), runsB = new Map() // anchor -> that side's new keys, in its order
|
|
219
|
-
for (const k of aAnch.keys()) { const an = anchorOf(k); if (!runsA.has(an)) runsA.set(an, []); runsA.get(an).push(k) }
|
|
220
|
-
for (const k of bAnch.keys()) { const an = anchorOf(k); if (!runsB.has(an)) runsB.set(an, []); runsB.get(an).push(k) }
|
|
221
|
-
const newAt = (anchor) => {
|
|
222
|
-
const ra = runsA.get(anchor) || [], rb = runsB.get(anchor) || []
|
|
223
|
-
const [first, second] = ra.join('') <= rb.join('') ? [ra, rb] : [rb, ra]
|
|
224
|
-
const firstSet = new Set(first)
|
|
225
|
-
return [...first, ...second.filter((k) => !firstSet.has(k))]
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
const final = []
|
|
229
|
-
final.push(...newAt(null))
|
|
230
|
-
for (const k of baseOrder) {
|
|
231
|
-
if (survives.has(k)) final.push(k)
|
|
232
|
-
final.push(...newAt(k))
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// Assemble, recording PROVENANCE per surviving unit: who authored the version we kept
|
|
236
|
-
// ('a' / 'b' changed it, 'both' if we merged inside it, 'base' if unchanged).
|
|
237
|
-
const parts = [], provenance = [], order = [], textByKey = new Map()
|
|
238
|
-
for (const k of final) {
|
|
239
|
-
let text, from
|
|
240
|
-
if (resolved.has(k)) { text = resolved.get(k); from = 'both' }
|
|
241
|
-
else {
|
|
242
|
-
text = pick(k, B, A, Bb)
|
|
243
|
-
const bs = B.has(k) ? B.get(k) : null, as_ = A.has(k) ? A.get(k) : null, bbs = Bb.has(k) ? Bb.get(k) : null
|
|
244
|
-
from = (as_ != null && as_ !== bs) ? 'a' : (bbs != null && bbs !== bs) ? 'b' : 'base'
|
|
245
|
-
}
|
|
246
|
-
if (text != null) { parts.push(text); provenance.push({ key: k, from }); order.push(k); textByKey.set(k, text) }
|
|
247
|
-
}
|
|
248
|
-
// aU/bU are the sides AFTER repairSide re-keyed them: a caller that splices into a side's own
|
|
249
|
-
// text must use these keys, or a re-keyed unit reads as deleted-plus-new.
|
|
250
|
-
return { conflicts: [], parts, provenance, order, textByKey, aU, bU }
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// FINER GRANULARITY: both sides edited the same function OR class. If the signature
|
|
254
|
-
// matches and they edited DIFFERENT inner units (statements / class members), merge those
|
|
255
|
-
// recursively rather than declaring the whole declaration a conflict. Returns merged
|
|
256
|
-
// text, or null meaning "couldn't safely merge inside — treat as a real conflict."
|
|
257
|
-
function tryInnerMerge(lang, baseText, aText, bText, mopts = {}) {
|
|
258
|
-
// Language-specific same-key merges first (e.g. unioning import specifiers).
|
|
259
|
-
if (lang.mergeUnit) {
|
|
260
|
-
const t = lang.mergeUnit(baseText, aText, bText)
|
|
261
|
-
if (t != null && lang.parses(t)) return t
|
|
262
|
-
}
|
|
263
|
-
// Structural inner merge (declaration → statements, class → members): format-preserving
|
|
264
|
-
// and well-tested, so it goes first. Returns null when it can't safely resolve.
|
|
265
|
-
const structural = tryStructuralInner(lang, baseText, aText, bText, mopts)
|
|
266
|
-
if (structural != null) return structural
|
|
267
|
-
// Unit-level oracle: providers with a fragment validator (parsesUnit) get every finer-
|
|
268
|
-
// grained result checked HERE, so an invalid inner merge becomes an honest per-unit
|
|
269
|
-
// conflict (judge-resolvable) instead of poisoning the whole file into fallback.
|
|
270
|
-
// Providers without one (JS: a bare `return …` fragment is valid only in context) keep
|
|
271
|
-
// deferring to the whole-file gate, as before.
|
|
272
|
-
const unitOk = lang.parsesUnit ? (t) => t != null && lang.parsesUnit(t) : (t) => t != null
|
|
273
|
-
// LINE-LEVEL diff3: whole lines as atoms, so indentation travels with its line — the
|
|
274
|
-
// safe multi-hunk tier, and the workhorse for "both sides edited different lines of the
|
|
275
|
-
// same declaration" (the most common same-unit case in real repos).
|
|
276
|
-
const lm = lineMerge3(baseText, aText, bText, mopts)
|
|
277
|
-
if (unitOk(lm)) return lm
|
|
278
|
-
// FINEST GRANULARITY: a single line/statement both sides edited — token-level 3-way, so
|
|
279
|
-
// two edits INSIDE one line (`foo(1, 2)` → one side edits each argument) merge the way a
|
|
280
|
-
// tree-sitter merge does, instead of conflicting on the whole statement. For providers
|
|
281
|
-
// without parsesUnit, validity is enforced where the result lands (whole-file re-parse).
|
|
282
|
-
const tm = tokenMerge(lang, baseText, aText, bText, mopts)
|
|
283
|
-
if (tm != null && lang.parsesUnit && !lang.parsesUnit(tm)) return null
|
|
284
|
-
return tm
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
function tryStructuralInner(lang, baseText, aText, bText, mopts = {}) {
|
|
288
|
-
if (!lang.splitUnit) return null
|
|
289
|
-
const pb = lang.splitUnit(baseText), pa = lang.splitUnit(aText), pbb = lang.splitUnit(bText)
|
|
290
|
-
if (!pb || !pa || !pbb) return null
|
|
291
|
-
if (pa.sig.trim() !== pbb.sig.trim()) return null
|
|
292
|
-
// THE TEXT AROUND THE BODY IS MERGED TOO. open/close are the declaration's own bytes either
|
|
293
|
-
// side of its body, and they were always taken from the FIRST argument: what the second side
|
|
294
|
-
// wrote there was dropped, so a result depended on argument order. Three-way instead: the
|
|
295
|
-
// side that changed it wins, and both changing it differently is not ours to pick.
|
|
296
|
-
const open = pick3(pb.open, pa.open, pbb.open), close = pick3(pb.close, pa.close, pbb.close)
|
|
297
|
-
if (open == null || close == null) return null
|
|
298
|
-
const m = mergeKeyed(lang, pb.units, pa.units, pbb.units, mopts)
|
|
299
|
-
if (m.conflicts.length) return null
|
|
300
|
-
// FORMAT-PRESERVING inner assembly: when the provider exposes the body text + byte-ranged
|
|
301
|
-
// units (spliceable), rebuild the body by splicing merged units back into the BASE body,
|
|
302
|
-
// so everything BETWEEN statements — comments, blank lines, indentation — survives verbatim.
|
|
303
|
-
// (Join-based reassembly with a fixed separator silently drops inner comments and reflows
|
|
304
|
-
// whitespace; found in the wild merging ICR's own source, and the C-family class-brace glue.)
|
|
305
|
-
// Objects and any non-spliceable provider fall back to the separator join.
|
|
306
|
-
let text
|
|
307
|
-
const spliceable = pb.spliceable && typeof pb.body === 'string' && pb.units.every((u) => typeof u.start === 'number' && typeof u.end === 'number')
|
|
308
|
-
if (spliceable) {
|
|
309
|
-
// COMMENT-EDIT PRESERVATION: gaps (comments/blank lines between statements) come from
|
|
310
|
-
// the splice BASIS. Splicing from base loses a side's comment edit. So: if exactly ONE
|
|
311
|
-
// side changed the gaps, splice into THAT side's body — its comment edit survives and the
|
|
312
|
-
// other side's code edits land on top. If BOTH did, refuse (null), and the line tier
|
|
313
|
-
// below keeps both or reports a conflict, instead of silently dropping either side's.
|
|
314
|
-
// A side counts as having changed the gaps when it changed nothing else (the original
|
|
315
|
-
// rule, which keeps a whitespace-only edit), and ALSO when the comment text itself
|
|
316
|
-
// changed: the original rule alone lost a comment whenever the same side had also
|
|
317
|
-
// edited a statement (2 Oct 2026, tests/icr-gap-comments-test.mjs).
|
|
318
|
-
const sameUnits = (u1, u2) => u1.length === u2.length && u1.every((u, i) => u.key === u2[i].key && u.text === u2[i].text)
|
|
319
|
-
const gBase = gapLines(pb.body, pb.units)
|
|
320
|
-
const aGap = (pa.body !== pb.body && sameUnits(pa.units, pb.units)) || !sameLines(gapLines(pa.body, m.aU), gBase)
|
|
321
|
-
const bGap = (pbb.body !== pb.body && sameUnits(pbb.units, pb.units)) || !sameLines(gapLines(pbb.body, m.bU), gBase)
|
|
322
|
-
if (aGap && bGap) return null
|
|
323
|
-
const [basisBody, basisUnits] = aGap ? [pa.body, m.aU] : bGap ? [pbb.body, m.bU] : [pb.body, pb.units]
|
|
324
|
-
const body = spliceUnits(basisBody, basisUnits, m.order, m.textByKey, { trimLeadingBlank: false })
|
|
325
|
-
text = pa.sig + open + body + close
|
|
326
|
-
// ...and the same no-silent-loss check the top level runs (addedLinesKept).
|
|
327
|
-
const pm = lang.splitUnit(text)
|
|
328
|
-
if (pm && hasRanges(pm.units) &&
|
|
329
|
-
!addedLinesKept(gBase, gapLines(pa.body, m.aU), gapLines(pbb.body, m.bU), gapLines(pm.body, pm.units))) return null
|
|
330
|
-
} else {
|
|
331
|
-
text = pa.sig + open + m.parts.join(pa.join) + close
|
|
332
|
-
}
|
|
333
|
-
const valid = lang.parsesUnit || lang.parses // accept class-member fragments when supported
|
|
334
|
-
return valid(text) ? text : null
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
// TOKEN-LEVEL 3-way merge of one unit. base/a/b are the unit texts, both differing from base
|
|
338
|
-
// (the caller only asks when both sides changed the same unit). Diff each side against base at
|
|
339
|
-
// TOKEN granularity — one changed span via common prefix + common suffix — map each span to a
|
|
340
|
-
// base CHARACTER range, and, if the two base spans are DISJOINT, splice both sides' replacement
|
|
341
|
-
// text into base. Everything OUTSIDE the two edits comes from base verbatim (whitespace,
|
|
342
|
-
// comments); inside, each side's exact bytes. Overlapping or ambiguously-adjacent edits → null
|
|
343
|
-
// (a real conflict). Symmetric by construction: the result is base with two position-disjoint
|
|
344
|
-
// substring replacements, independent of which side is a or b — so live peers converge.
|
|
345
|
-
function tokenDiffRange(base, other) {
|
|
346
|
-
const n = base.length, m = other.length
|
|
347
|
-
let eq = n === m
|
|
348
|
-
if (eq) { for (let i = 0; i < n; i++) if (base[i] !== other[i]) { eq = false; break } }
|
|
349
|
-
if (eq) return null
|
|
350
|
-
let p = 0
|
|
351
|
-
const maxp = Math.min(n, m)
|
|
352
|
-
while (p < maxp && base[p] === other[p]) p++
|
|
353
|
-
let s = 0
|
|
354
|
-
while (s < n - p && s < m - p && base[n - 1 - s] === other[m - 1 - s]) s++
|
|
355
|
-
return { bStart: p, bEnd: n - s, oStart: p, oEnd: m - s }
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
function tokenMerge(lang, baseText, aText, bText, mopts = {}) {
|
|
359
|
-
if (!lang.tokenize) return null
|
|
360
|
-
const B = lang.tokenize(baseText), A = lang.tokenize(aText), Bb = lang.tokenize(bText)
|
|
361
|
-
if (!B || !A || !Bb) return null
|
|
362
|
-
const ra = tokenDiffRange(B.map((t) => t.k), A.map((t) => t.k))
|
|
363
|
-
const rb = tokenDiffRange(B.map((t) => t.k), Bb.map((t) => t.k))
|
|
364
|
-
if (!ra && !rb) return null // both sides changed only whitespace/comments → can't rank them
|
|
365
|
-
// REFORMAT vs CONTENT: one side changed only whitespace/comments (token stream identical
|
|
366
|
-
// to base), the other changed tokens. Apply the content side's token edit ONTO the
|
|
367
|
-
// reformatting side's text — its token stream matches base's, so token indices map its
|
|
368
|
-
// own byte ranges exactly. Both intents survive: the reformat/comment edit AND the code
|
|
369
|
-
// change. (Ground-truth replay: this was a top false-conflict source on real repos.)
|
|
370
|
-
// Symmetric because the roles are decided by CONTENT (which side has the token diff).
|
|
371
|
-
if (!ra || !rb) {
|
|
372
|
-
const [wsToks, wsText, r, toks, text] = !ra ? [A, aText, rb, Bb, bText] : [Bb, bText, ra, A, aText]
|
|
373
|
-
const cStart = r.bStart < wsToks.length ? wsToks[r.bStart].start : wsText.length
|
|
374
|
-
const cEnd = r.bEnd > r.bStart ? wsToks[r.bEnd - 1].end : cStart
|
|
375
|
-
const ins = r.oEnd > r.oStart ? text.slice(toks[r.oStart].start, toks[r.oEnd - 1].end) : ''
|
|
376
|
-
return wsText.slice(0, cStart) + ins + wsText.slice(cEnd)
|
|
377
|
-
}
|
|
378
|
-
const charRange = (r) => {
|
|
379
|
-
const cStart = r.bStart < B.length ? B[r.bStart].start : baseText.length
|
|
380
|
-
const cEnd = r.bEnd > r.bStart ? B[r.bEnd - 1].end : cStart
|
|
381
|
-
return [cStart, cEnd]
|
|
382
|
-
}
|
|
383
|
-
const repl = (toks, text, r) => (r.oEnd > r.oStart ? text.slice(toks[r.oStart].start, toks[r.oEnd - 1].end) : '')
|
|
384
|
-
const [aS, aE] = charRange(ra), [bS, bE] = charRange(rb)
|
|
385
|
-
// Single contiguous spans that don't collide: splice both (the cheap common case).
|
|
386
|
-
if (!(aS < bE && bS < aE) && aS !== bS) {
|
|
387
|
-
const edits = [{ s: aS, e: aE, t: repl(A, aText, ra) }, { s: bS, e: bE, t: repl(Bb, bText, rb) }].sort((x, y) => y.s - x.s)
|
|
388
|
-
let out = baseText
|
|
389
|
-
for (const ed of edits) out = out.slice(0, ed.s) + ed.t + out.slice(ed.e)
|
|
390
|
-
return out
|
|
391
|
-
}
|
|
392
|
-
// The prefix/suffix spans collide — but each is the BOUNDING BOX of that side's edits,
|
|
393
|
-
// and boxes overlap whenever a side edited in two places (a wrapper rewrite touches both
|
|
394
|
-
// ends, so its box swallows the whole unit). Re-diff each side at REAL multi-hunk
|
|
395
|
-
// granularity (Myers) and do token-level diff3: disjoint hunks interleave, identical
|
|
396
|
-
// hunks dedupe, truly overlapping hunks are the only conflict. Ground-truth replay:
|
|
397
|
-
// "one side rewrote the wrapper, the other edited inside" is a real-repo staple.
|
|
398
|
-
return tokenMerge3(baseText, aText, bText, B, A, Bb, mopts)
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
// Myers O(ND) diff on token-key arrays → edit hunks {bs,be,os,oe} (base/other token ranges).
|
|
402
|
-
function tokenHunks(bk, ok, maxD) {
|
|
403
|
-
const N = bk.length, M = ok.length
|
|
404
|
-
const MAX = Math.min(N + M, maxD)
|
|
405
|
-
const V = new Map([[1, 0]])
|
|
406
|
-
const trace = []
|
|
407
|
-
let D = -1
|
|
408
|
-
outer: for (let d = 0; d <= MAX; d++) {
|
|
409
|
-
trace.push(new Map(V))
|
|
410
|
-
for (let k = -d; k <= d; k += 2) {
|
|
411
|
-
let x = (k === -d || (k !== d && (V.get(k - 1) ?? 0) < (V.get(k + 1) ?? 0))) ? (V.get(k + 1) ?? 0) : (V.get(k - 1) ?? 0) + 1
|
|
412
|
-
let y = x - k
|
|
413
|
-
while (x < N && y < M && bk[x] === ok[y]) { x++; y++ }
|
|
414
|
-
V.set(k, x)
|
|
415
|
-
if (x >= N && y >= M) { D = d; break outer }
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
if (D < 0) return null // too different for the budget → caller falls back
|
|
419
|
-
// backtrack into hunks
|
|
420
|
-
const ops = [] // 1 = del base token, 2 = ins other token, stored back-to-front
|
|
421
|
-
let x = N, y = M
|
|
422
|
-
for (let d = D; d > 0; d--) {
|
|
423
|
-
const Vp = trace[d]
|
|
424
|
-
const k = x - y
|
|
425
|
-
const down = k === -d || (k !== d && (Vp.get(k - 1) ?? 0) < (Vp.get(k + 1) ?? 0))
|
|
426
|
-
const pk = down ? k + 1 : k - 1
|
|
427
|
-
const px = Vp.get(pk) ?? 0
|
|
428
|
-
const py = px - pk
|
|
429
|
-
while (x > px + (down ? 0 : 1) && y > py + (down ? 1 : 0)) { x--; y-- } // snake
|
|
430
|
-
if (down) { y--; ops.push([2, x, y]) } else { x--; ops.push([1, x, y]) }
|
|
431
|
-
}
|
|
432
|
-
ops.reverse()
|
|
433
|
-
const hunks = []
|
|
434
|
-
for (const [op, bx, oy] of ops) {
|
|
435
|
-
const last = hunks[hunks.length - 1]
|
|
436
|
-
if (op === 1) {
|
|
437
|
-
if (last && last.be === bx && last.oe === oy) last.be = bx + 1
|
|
438
|
-
else hunks.push({ bs: bx, be: bx + 1, os: oy, oe: oy })
|
|
439
|
-
} else {
|
|
440
|
-
if (last && last.be === bx && last.oe === oy) last.oe = oy + 1
|
|
441
|
-
else hunks.push({ bs: bx, be: bx, os: oy, oe: oy + 1 })
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
return hunks
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
// diff3 over two hunk lists: identical hunks dedupe; disjoint hunks both apply; overlap
|
|
448
|
-
// → conflict (null). Two INSERTIONS at the same base point are ambiguous (order
|
|
449
|
-
// unknowable) → conflict. `ak`/`bbk` are each side's atom arrays (token keys or lines),
|
|
450
|
-
// used to compare replacement content for the dedupe check. Returns [{h, side}] in base
|
|
451
|
-
// order, or null. Symmetric: ordering decisions are content/position-based, never
|
|
452
|
-
// side-identity-based, so merge(a,b) === merge(b,a) is preserved by construction.
|
|
453
|
-
function diff3Interleave(ha, hb, ak, bbk, opts = {}) {
|
|
454
|
-
const sameHunk = (x, y) => x.bs === y.bs && x.be === y.be && (x.oe - x.os) === (y.oe - y.os) &&
|
|
455
|
-
ak.slice(x.os, x.oe).every((k, i) => k === bbk[y.os + i])
|
|
456
|
-
const edits = []
|
|
457
|
-
let i = 0, j = 0
|
|
458
|
-
while (i < ha.length || j < hb.length) {
|
|
459
|
-
const x = ha[i], y = hb[j]
|
|
460
|
-
if (x && y) {
|
|
461
|
-
const overlap = x.bs < y.be && y.bs < x.be
|
|
462
|
-
const samePointInsert = x.bs === y.bs && x.be === x.bs && y.be === y.bs
|
|
463
|
-
if (overlap || samePointInsert) {
|
|
464
|
-
if (sameHunk(x, y)) { edits.push({ h: x, side: 'a' }); i++; j++; continue }
|
|
465
|
-
// UNION MODE (opt-in, for independent-intent merges — the multi-agent case where
|
|
466
|
-
// the medium KNOWS the two edits serve different tasks): two pure INSERTIONS at
|
|
467
|
-
// the same base point both survive, in content order (side-independent, so peers
|
|
468
|
-
// converge). Everything else (true overlaps, edit-vs-edit) still conflicts, and
|
|
469
|
-
// the parse gates downstream still refuse any union that breaks the code.
|
|
470
|
-
if (samePointInsert && opts.unionInserts) {
|
|
471
|
-
const ka = ak.slice(x.os, x.oe).join('\n'), kb = bbk.slice(y.os, y.oe).join('\n')
|
|
472
|
-
const [first, second] = ka <= kb ? [{ h: x, side: 'a' }, { h: y, side: 'b' }] : [{ h: y, side: 'b' }, { h: x, side: 'a' }]
|
|
473
|
-
edits.push(first, second); i++; j++; continue
|
|
474
|
-
}
|
|
475
|
-
return null
|
|
476
|
-
}
|
|
477
|
-
if (x.bs < y.bs || (x.bs === y.bs && x.be <= y.bs && x.be < y.be)) { edits.push({ h: x, side: 'a' }); i++ }
|
|
478
|
-
else { edits.push({ h: y, side: 'b' }); j++ }
|
|
479
|
-
} else if (x) { edits.push({ h: x, side: 'a' }); i++ }
|
|
480
|
-
else { edits.push({ h: y, side: 'b' }); j++ }
|
|
481
|
-
}
|
|
482
|
-
return edits
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
const TOKEN3_MAX_TOKENS = 20000, TOKEN3_MAX_D = 4000
|
|
486
|
-
function tokenMerge3(baseText, aText, bText, B, A, Bb, mopts = {}) {
|
|
487
|
-
if (B.length > TOKEN3_MAX_TOKENS || A.length > TOKEN3_MAX_TOKENS || Bb.length > TOKEN3_MAX_TOKENS) return null
|
|
488
|
-
const bk = B.map((t) => t.k)
|
|
489
|
-
const ha = tokenHunks(bk, A.map((t) => t.k), TOKEN3_MAX_D)
|
|
490
|
-
const hb = tokenHunks(bk, Bb.map((t) => t.k), TOKEN3_MAX_D)
|
|
491
|
-
if (!ha || !hb) return null
|
|
492
|
-
const edits = diff3Interleave(ha, hb, A.map((t) => t.k), Bb.map((t) => t.k), mopts)
|
|
493
|
-
if (!edits) return null
|
|
494
|
-
// splice back-to-front, mapping token hunks to base char ranges; replacement bytes come
|
|
495
|
-
// from the editing side's exact text between its tokens. A guard space prevents two word
|
|
496
|
-
// tokens fusing at a splice boundary (parse validation downstream still has final say).
|
|
497
|
-
const wordish = (c) => c && /[A-Za-z0-9_$]/.test(c)
|
|
498
|
-
let out = baseText
|
|
499
|
-
for (let e = edits.length - 1; e >= 0; e--) {
|
|
500
|
-
const { h, side } = edits[e]
|
|
501
|
-
const [toks, text] = side === 'a' ? [A, aText] : [Bb, bText]
|
|
502
|
-
const cS = h.bs < B.length ? B[h.bs].start : baseText.length
|
|
503
|
-
const cE = h.be > h.bs ? B[h.be - 1].end : cS
|
|
504
|
-
let ins = h.oe > h.os ? text.slice(toks[h.os].start, toks[h.oe - 1].end) : ''
|
|
505
|
-
if (ins && wordish(out[cS - 1]) && wordish(ins[0])) ins = ' ' + ins
|
|
506
|
-
if (ins && wordish(out[cE]) && wordish(ins[ins.length - 1])) ins = ins + ' '
|
|
507
|
-
out = out.slice(0, cS) + ins + out.slice(cE)
|
|
508
|
-
}
|
|
509
|
-
return out
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
// LINE-LEVEL 3-way merge of one unit: the same Myers + diff3 interleave with WHOLE LINES
|
|
513
|
-
// as atoms. Every replacement carries complete lines verbatim — indentation can never be
|
|
514
|
-
// stranded or doubled, which is what makes it the safe multi-hunk tier for indentation
|
|
515
|
-
// languages (the ConGra corpus showed token-splice arithmetic mangling Python indent when
|
|
516
|
-
// hunks straddle newline tokens: `conn = None` landing at column 0, a doubled 16-space
|
|
517
|
-
// indent). Token-level still runs after this for same-line micro-edits; this tier catches
|
|
518
|
-
// the far more common "both sides edited different LINES of the same unit" cleanly.
|
|
519
|
-
function splitLinesKeepEnds(text) {
|
|
520
|
-
const out = []
|
|
521
|
-
let i = 0
|
|
522
|
-
while (i < text.length) { let e = text.indexOf('\n', i); e = e < 0 ? text.length : e + 1; out.push(text.slice(i, e)); i = e }
|
|
523
|
-
return out
|
|
524
|
-
}
|
|
525
|
-
function lineMerge3(baseText, aText, bText, mopts = {}) {
|
|
526
|
-
const L = splitLinesKeepEnds(baseText), La = splitLinesKeepEnds(aText), Lb = splitLinesKeepEnds(bText)
|
|
527
|
-
if (L.length > TOKEN3_MAX_TOKENS) return null
|
|
528
|
-
const ha = tokenHunks(L, La, TOKEN3_MAX_D)
|
|
529
|
-
const hb = tokenHunks(L, Lb, TOKEN3_MAX_D)
|
|
530
|
-
if (!ha || !hb) return null
|
|
531
|
-
const edits = diff3Interleave(ha, hb, La, Lb, mopts)
|
|
532
|
-
if (!edits) return null
|
|
533
|
-
const out = L.slice()
|
|
534
|
-
for (let e = edits.length - 1; e >= 0; e--) {
|
|
535
|
-
const { h, side } = edits[e]
|
|
536
|
-
const src = side === 'a' ? La : Lb
|
|
537
|
-
out.splice(h.bs, h.be - h.bs, ...src.slice(h.os, h.oe))
|
|
538
|
-
}
|
|
539
|
-
return out.join('')
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
// THE TEXT BETWEEN UNITS, as the lines a reader would call content: every non-blank line
|
|
543
|
-
// outside the units' byte ranges, trimmed, in order. Comments, mostly. Trimmed so a CRLF file
|
|
544
|
-
// or an editor's trailing-space cleanup does not read as a comment edit, and blank lines are
|
|
545
|
-
// dropped so a reformat does not either.
|
|
546
|
-
function gapLines(text, units) {
|
|
547
|
-
const out = []
|
|
548
|
-
const take = (from, to) => {
|
|
549
|
-
if (to <= from) return
|
|
550
|
-
for (const line of text.slice(from, to).split('\n')) { const t = line.trim(); if (t) out.push(t) }
|
|
551
|
-
}
|
|
552
|
-
let pos = 0
|
|
553
|
-
for (const u of [...units].sort((x, y) => x.start - y.start)) { take(pos, u.start); pos = Math.max(pos, u.end) }
|
|
554
|
-
take(pos, text.length)
|
|
555
|
-
return out
|
|
556
|
-
}
|
|
557
|
-
const sameLines = (x, y) => x.length === y.length && x.every((l, i) => l === y[i])
|
|
558
|
-
const hasRanges = (units) => units.every((u) => typeof u.start === 'number')
|
|
559
|
-
// Three-way pick of one piece of text: the side that changed it wins; null when both did, differently.
|
|
560
|
-
const pick3 = (base, a, b) => (a === base ? b : b === base ? a : a === b ? a : null)
|
|
561
|
-
|
|
562
|
-
// EVERY COMMENT IN A FILE, as lines: a comment-only line, a block comment's ` * ` line, or the
|
|
563
|
-
// comment that ends a line of code. The style comes from the file name (# for Python, Ruby, YAML
|
|
564
|
-
// and TOML; // and /* for the rest; none for JSON). It is a heuristic and errs one way only: text
|
|
565
|
-
// that merely looks like a comment can make a merge refuse and fall back to the line merge,
|
|
566
|
-
// never make one ship without a side's comment.
|
|
567
|
-
const HASH_COMMENTS = /\.(py|pyw|pyi|rb|rake|ya?ml|toml)$/i
|
|
568
|
-
function commentLines(text, filename) {
|
|
569
|
-
if (/\.json$/i.test(filename || '')) return []
|
|
570
|
-
const hash = HASH_COMMENTS.test(filename || '')
|
|
571
|
-
const rx = hash ? /(?:^|\s)(#.*)$/ : /(?:^|\s)(\/\/.*|\/\*.*)$/
|
|
572
|
-
const out = []
|
|
573
|
-
for (const line of text.split('\n')) {
|
|
574
|
-
const t = line.trim()
|
|
575
|
-
if (!t) continue
|
|
576
|
-
if (!hash && t.startsWith('*')) { out.push(t); continue }
|
|
577
|
-
const m = rx.exec(line)
|
|
578
|
-
if (m) out.push(m[1].trim())
|
|
579
|
-
}
|
|
580
|
-
return out
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
// NO SILENT LOSS BETWEEN UNITS (2 Oct 2026). Every between-unit line a side ADDED must be in
|
|
584
|
-
// the merged result, counted: a side that added a second "// TODO" is not satisfied by base's
|
|
585
|
-
// first. What the other side deleted may go, so the bound for each line is what a side added
|
|
586
|
-
// net of the other side's removals. False means a merge would ship without a side's comment:
|
|
587
|
-
// the caller refuses it, and the line merge (which keeps both or conflicts) takes over.
|
|
588
|
-
// Found because a whole night of real merges came back 'structural', clean, and short of
|
|
589
|
-
// 17 comment lines that only one side had written.
|
|
590
|
-
function addedLinesKept(baseG, aG, bG, mergedG) {
|
|
591
|
-
const count = (lines) => { const m = new Map(); for (const l of lines) m.set(l, (m.get(l) || 0) + 1); return m }
|
|
592
|
-
const B = count(baseG), A = count(aG), Bb = count(bG), M = count(mergedG)
|
|
593
|
-
for (const line of new Set([...A.keys(), ...Bb.keys()])) {
|
|
594
|
-
const inBase = B.get(line) || 0, inA = A.get(line) || 0, inB = Bb.get(line) || 0
|
|
595
|
-
if (inA <= inBase && inB <= inBase) continue
|
|
596
|
-
const need = Math.max(inA - Math.max(0, inBase - inB), inB - Math.max(0, inBase - inA))
|
|
597
|
-
if ((M.get(line) || 0) < need) return false
|
|
598
|
-
}
|
|
599
|
-
return true
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
// FORMAT-PRESERVING assembly: rebuild the merged file by splicing the merged units back
|
|
603
|
-
// into the BASE text at their original byte ranges. Unchanged units — and all the
|
|
604
|
-
// whitespace/comments BETWEEN units — survive verbatim; only changed units carry the
|
|
605
|
-
// editing side's bytes, and genuinely new units are appended. This is what stops ICR from
|
|
606
|
-
// reformatting code it merges. Deterministic and symmetric, so peers converge.
|
|
607
|
-
function spliceUnits(baseText, baseUnits, order, textByKey, opts = {}) {
|
|
608
|
-
const baseKeySet = new Set(baseUnits.map((u) => u.key))
|
|
609
|
-
const baseByKey = new Map(baseUnits.map((u) => [u.key, u]))
|
|
610
|
-
const orderSet = new Set(order)
|
|
611
|
-
const deleted = baseUnits.filter((u) => !orderSet.has(u.key))
|
|
612
|
-
let out = '', pos = 0
|
|
613
|
-
|
|
614
|
-
function emitGap(from, to) {
|
|
615
|
-
let cursor = from
|
|
616
|
-
for (const d of deleted) {
|
|
617
|
-
if (d.start >= cursor && d.end <= to) {
|
|
618
|
-
out += baseText.slice(cursor, d.start)
|
|
619
|
-
cursor = d.end
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
out += baseText.slice(cursor, to)
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
// A KEY REPEATED IN `order` USED TO EMIT ITS UNIT AGAIN. `order` is assembled from both
|
|
626
|
-
// sides' unit lists, so any key present in both can appear twice, and each appearance
|
|
627
|
-
// wrote the declaration out. On a whole-file rewrite that is every declaration in the
|
|
628
|
-
// file, which is how a merge silently doubled a source file instead of merging it.
|
|
629
|
-
// Emit each unit exactly once, at its first ordered position.
|
|
630
|
-
const seenKeys = new Set()
|
|
631
|
-
for (const k of order) {
|
|
632
|
-
if (seenKeys.has(k)) continue
|
|
633
|
-
seenKeys.add(k)
|
|
634
|
-
if (baseKeySet.has(k)) {
|
|
635
|
-
const u = baseByKey.get(k)
|
|
636
|
-
if (u.start >= pos) {
|
|
637
|
-
emitGap(pos, u.start)
|
|
638
|
-
pos = u.end
|
|
639
|
-
}
|
|
640
|
-
// A missing key used to concatenate the string "undefined" into the source.
|
|
641
|
-
const val = textByKey.get(k)
|
|
642
|
-
if (val != null) out += val
|
|
643
|
-
} else {
|
|
644
|
-
// New unit: insert it exactly where the merge order dictated
|
|
645
|
-
const val = textByKey.get(k)
|
|
646
|
-
if (val != null) {
|
|
647
|
-
out += (out.endsWith('\n') ? '' : '\n') + val + '\n'
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
}
|
|
651
|
-
if (pos < baseText.length) {
|
|
652
|
-
emitGap(pos, baseText.length)
|
|
653
|
-
}
|
|
654
|
-
// Top-level: drop orphan leading blank lines a deleted first decl leaves behind. Inner
|
|
655
|
-
// splice (trimLeadingBlank:false): keep the body's own leading `\n ` so indentation holds.
|
|
656
|
-
return opts.trimLeadingBlank === false ? out : out.replace(/^\n+/, '')
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
// --- HyperAST Caching -----------------------------------------------------------
|
|
660
|
-
// A memory cache to bypass redundant AST parsing. This gives O(1) lookups for
|
|
661
|
-
// unchanged structures during interleaved diff3 merges, dropping engine execution
|
|
662
|
-
// time drastically on large files.
|
|
663
|
-
const providerCache = new WeakMap();
|
|
664
|
-
|
|
665
|
-
function withHyperAST(lang) {
|
|
666
|
-
if (providerCache.has(lang)) return providerCache.get(lang);
|
|
667
|
-
|
|
668
|
-
const cache = new Map();
|
|
669
|
-
const get = (op, text, fn) => {
|
|
670
|
-
if (typeof text !== 'string' || text.length > 500000) return fn(); // Bypass for massive strings
|
|
671
|
-
const key = op + ':' + text;
|
|
672
|
-
let res = cache.get(key);
|
|
673
|
-
if (res !== undefined) return res;
|
|
674
|
-
res = fn();
|
|
675
|
-
if (cache.size > 10000) cache.clear();
|
|
676
|
-
cache.set(key, res);
|
|
677
|
-
return res;
|
|
678
|
-
};
|
|
679
|
-
|
|
680
|
-
const wrapped = Object.create(lang);
|
|
681
|
-
if (lang.parses) wrapped.parses = (src) => get('parses', src, () => lang.parses(src));
|
|
682
|
-
if (lang.units) wrapped.units = (src) => get('units', src, () => lang.units(src));
|
|
683
|
-
if (lang.tokenize) wrapped.tokenize = (src) => get('tokenize', src, () => lang.tokenize(src));
|
|
684
|
-
if (lang.splitUnit) wrapped.splitUnit = (src) => get('splitUnit', src, () => lang.splitUnit(src));
|
|
685
|
-
if (lang.declaredNames) wrapped.declaredNames = (src) => get('declaredNames', src, () => lang.declaredNames(src));
|
|
686
|
-
|
|
687
|
-
providerCache.set(lang, wrapped);
|
|
688
|
-
return wrapped;
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
// --- public API -----------------------------------------------------------------
|
|
692
|
-
|
|
693
|
-
// Does this source parse under the given (or default JS) language?
|
|
694
|
-
export function parses(src, lang = javascript) { return withHyperAST(lang).parses(src) }
|
|
695
|
-
|
|
696
|
-
// 3-way STRUCTURAL + INTENT merge of `a` and `b` against common ancestor `base`.
|
|
697
|
-
// opts: { lang } a provider, or { filename } to pick one by extension (defaults to JS).
|
|
698
|
-
// Returns { status, text, conflicts, renames? }:
|
|
699
|
-
// 'auto' — clean merge; `text` is valid, parseable code.
|
|
700
|
-
// 'semantic-conflict' — same declaration changed both sides, or a dangling reference;
|
|
701
|
-
// `conflicts` names them (e.g. ['fn:login'] or ['ref:helper']).
|
|
702
|
-
// 'fallback' — couldn't merge safely (unparseable input, or the merge wouldn't
|
|
703
|
-
// parse, or unsupported language); caller keeps both. Never broken.
|
|
704
|
-
export function structuralMerge(base, a, b, opts = {}) {
|
|
705
|
-
let lang = opts.lang || (opts.filename ? languageFor(opts.filename) : javascript) || javascript
|
|
706
|
-
lang = withHyperAST(lang)
|
|
707
|
-
|
|
708
|
-
if (!lang.parses(base) || !lang.parses(a) || !lang.parses(b))
|
|
709
|
-
return { status: 'fallback', text: null, conflicts: [], reason: 'unparseable input' }
|
|
710
|
-
|
|
711
|
-
// FIXED-POINT fast path: once two peers agree on a text T, re-merging must return T
|
|
712
|
-
// unchanged. This is what makes the live sync settle. Both agree ⇒ nothing to check.
|
|
713
|
-
if (a === b) return { status: 'auto', text: a, conflicts: [], renames: [], provenance: [] }
|
|
714
|
-
|
|
715
|
-
const authors = opts.authors || {}
|
|
716
|
-
const attribute = (prov) => (prov || []).map((p) => ({ unit: p.key, author: authors[p.from] || p.from }))
|
|
717
|
-
|
|
718
|
-
// Determine the structurally-merged text + provenance. A one-sided change takes that
|
|
719
|
-
// side verbatim (format perfectly preserved); otherwise do the keyed 3-way merge and
|
|
720
|
-
// splice it back into base. Either way the INTENT layer below still runs, so a deletion
|
|
721
|
-
// that leaves a dangling reference is caught even when only one side changed.
|
|
722
|
-
let text, provenance, gapCheck = null
|
|
723
|
-
if (a === base) { text = b; provenance = [] }
|
|
724
|
-
else if (b === base) { text = a; provenance = [] }
|
|
725
|
-
else if (lang.mergeWhole) {
|
|
726
|
-
// WHOLE-DOCUMENT MERGE: a provider that understands the entire document as DATA
|
|
727
|
-
// (e.g. JSON) merges it directly — real-parser accuracy, value-wise 3-way semantics,
|
|
728
|
-
// conflicts named by path. Returns { text } on success, { conflicts, resolvable? } on
|
|
729
|
-
// a same-value clash, or null when it can't (engine falls back safely).
|
|
730
|
-
const w = lang.mergeWhole(base, a, b)
|
|
731
|
-
if (!w) return { status: 'fallback', text: null, conflicts: [], reason: 'whole-document merge failed' }
|
|
732
|
-
if (w.conflicts && w.conflicts.length) return { status: 'semantic-conflict', text: null, conflicts: w.conflicts, resolvable: w.resolvable || [] }
|
|
733
|
-
text = w.text; provenance = w.provenance || []
|
|
734
|
-
}
|
|
735
|
-
else {
|
|
736
|
-
const baseUnits = lang.units(base)
|
|
737
|
-
const merge = mergeKeyed(lang, baseUnits, lang.units(a), lang.units(b), { unionInserts: !!opts.unionInserts })
|
|
738
|
-
if (merge.conflicts.length) return { status: 'semantic-conflict', text: null, conflicts: merge.conflicts, resolvable: merge.conflictUnits }
|
|
739
|
-
// Prefer the format-preserving splice (units carry byte ranges); fall back to a plain
|
|
740
|
-
// join for languages whose units don't expose ranges.
|
|
741
|
-
if (hasRanges(baseUnits)) {
|
|
742
|
-
// THE SPLICE BASIS DECIDES WHOSE COMMENTS SURVIVE (2 Oct 2026). Everything between
|
|
743
|
-
// top-level units comes from the text we splice into, and this used to be base, always:
|
|
744
|
-
// a comment one side added above a function, after an import, or at the end of the file
|
|
745
|
-
// vanished whenever the other side changed anything else, and the merge still said
|
|
746
|
-
// 'auto'. Both real merges of that night lost comments this way, with code identical to
|
|
747
|
-
// git's. So, as the inner splice does: if exactly one side changed the between-unit
|
|
748
|
-
// lines, splice into that side's text; if both did, refuse, and the line merge keeps both
|
|
749
|
-
// or conflicts. addedLinesKept() then checks the result, which also covers any provider
|
|
750
|
-
// whose units leave a side's comment outside every range. (tests/icr-gap-comments-test.mjs)
|
|
751
|
-
let basisText = base, basisUnits = baseUnits
|
|
752
|
-
if (hasRanges(merge.aU) && hasRanges(merge.bU)) {
|
|
753
|
-
const gBase = gapLines(base, baseUnits), gA = gapLines(a, merge.aU), gB = gapLines(b, merge.bU)
|
|
754
|
-
const aGap = !sameLines(gA, gBase), bGap = !sameLines(gB, gBase)
|
|
755
|
-
if (aGap && bGap) return { status: 'fallback', text: null, conflicts: [], reason: 'both sides edited the comments between declarations' }
|
|
756
|
-
if (aGap) { basisText = a; basisUnits = merge.aU } else if (bGap) { basisText = b; basisUnits = merge.bU }
|
|
757
|
-
gapCheck = { gBase, gA, gB }
|
|
758
|
-
}
|
|
759
|
-
text = spliceUnits(basisText, basisUnits, merge.order, merge.textByKey)
|
|
760
|
-
} else text = merge.parts.join('\n\n') + '\n'
|
|
761
|
-
provenance = merge.provenance
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
// THE GUARANTEE: the merged result must parse, or we refuse it.
|
|
765
|
-
// THE GUARANTEE stands: a merge that would not parse is REFUSED — text stays null so no
|
|
766
|
-
// caller can ever mistake broken output for a merge. (Debugging wants the bytes? That is
|
|
767
|
-
// what `debugText` is for; it is deliberately not `text`.)
|
|
768
|
-
if (!lang.parses(text)) return { status: 'fallback', text: null, debugText: text, conflicts: [], reason: 'merge would not parse' }
|
|
769
|
-
// ...and it must not drop a comment one side wrote between declarations (see the splice basis).
|
|
770
|
-
if (gapCheck) {
|
|
771
|
-
const mu = lang.units(text)
|
|
772
|
-
if (!hasRanges(mu) || !addedLinesKept(gapCheck.gBase, gapCheck.gA, gapCheck.gB, gapLines(text, mu)))
|
|
773
|
-
return { status: 'fallback', text: null, debugText: text, conflicts: [], reason: 'merge would drop a comment one side added' }
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
// RENAME DETECTION (intent): a declaration gone from base whose identical-bodied twin
|
|
777
|
-
// appears under a new name is a rename — rewrite stale call sites to the new name.
|
|
778
|
-
let merged = text
|
|
779
|
-
const baseNames = lang.declaredNames(base)
|
|
780
|
-
let mergedNames = lang.declaredNames(merged)
|
|
781
|
-
let removed = [...baseNames].filter((n) => !mergedNames.has(n))
|
|
782
|
-
const renames = []
|
|
783
|
-
const rename = lang.renameFreeRefs || lang.renameRefs // prefer scope-aware rewriting
|
|
784
|
-
|
|
785
|
-
if (removed.length && lang.declBody && rename) {
|
|
786
|
-
const added = [...mergedNames].filter((n) => !baseNames.has(n))
|
|
787
|
-
const claimed = new Set()
|
|
788
|
-
for (const oldName of removed) {
|
|
789
|
-
const oldBody = lang.declBody(base, oldName)
|
|
790
|
-
if (oldBody == null) continue
|
|
791
|
-
const match = added.find((n) => !claimed.has(n) && lang.declBody(merged, n) === oldBody)
|
|
792
|
-
if (match) { renames.push([oldName, match]); claimed.add(match) }
|
|
793
|
-
}
|
|
794
|
-
let rewritten = merged
|
|
795
|
-
// Rewrite only references that resolve to the renamed binding (scope-aware) — a local
|
|
796
|
-
// variable that merely shares the old name is left untouched.
|
|
797
|
-
for (const [oldName, newName] of renames) rewritten = rename(rewritten, oldName, newName)
|
|
798
|
-
// only accept the rewrite if it still parses — never trade a parse error for intent.
|
|
799
|
-
if (renames.length && lang.parses(rewritten)) { merged = rewritten; mergedNames = lang.declaredNames(merged) }
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
// ASYNC/AWAIT SYNCHRONIZATION (intent):
|
|
803
|
-
// When a declaration was synchronous in base and became ASYNC in the merged result,
|
|
804
|
-
// upgrade call sites inside async callers with `await` so the contract holds.
|
|
805
|
-
const asyncUpgrades = []
|
|
806
|
-
if (lang.isAsyncDecl && lang.upgradeCallSitesToAwait) {
|
|
807
|
-
for (const name of mergedNames) {
|
|
808
|
-
const wasAsyncInBase = baseNames.has(name) ? lang.isAsyncDecl(base, name) : false
|
|
809
|
-
const isAsyncInMerged = lang.isAsyncDecl(merged, name)
|
|
810
|
-
if (!wasAsyncInBase && isAsyncInMerged) {
|
|
811
|
-
const upgraded = lang.upgradeCallSitesToAwait(merged, name)
|
|
812
|
-
if (upgraded !== merged && lang.parses(upgraded)) {
|
|
813
|
-
merged = upgraded
|
|
814
|
-
asyncUpgrades.push(name)
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
// DANGLING REFERENCE (intent): a declaration removed (and NOT explained by a rename)
|
|
821
|
-
// but still referenced is a semantic conflict even though the code parses cleanly.
|
|
822
|
-
// Uses the SCOPE-AWARE reference set when the language provides one (so a local binding
|
|
823
|
-
// that merely shares the deleted name is correctly NOT treated as a reference to it),
|
|
824
|
-
// falling back to the approximate identifier set otherwise.
|
|
825
|
-
removed = [...baseNames].filter((n) => !mergedNames.has(n))
|
|
826
|
-
const refs = lang.referencedFreeNames ? lang.referencedFreeNames(merged)
|
|
827
|
-
: lang.usedIdentifiers ? lang.usedIdentifiers(merged) : null
|
|
828
|
-
if (process.env.ICR_DEBUG_MERGED) console.error('--- ICR merged intermediate ---\n' + merged + '\n--- refs: ' + [...(refs || [])].join(',') + ' ---')
|
|
829
|
-
if (removed.length && refs) {
|
|
830
|
-
const dangling = removed.filter((n) => refs.has(n))
|
|
831
|
-
if (dangling.length)
|
|
832
|
-
return { status: 'semantic-conflict', text: null, conflicts: dangling.map((n) => 'ref:' + n), reason: 'dangling reference: a declaration was removed/renamed but is still used' }
|
|
833
|
-
}
|
|
834
|
-
// NO COMMENT EITHER SIDE ADDED MAY GO MISSING, wherever it sits and whichever path merged it
|
|
835
|
-
// (2 Oct 2026). The splice basis and the inner rules above handle the cases found so far; this
|
|
836
|
-
// is the net under all of them, because the defect class is a merge that is green and short.
|
|
837
|
-
// An independent fuzz found the Python case after the JS fix had passed its own tests.
|
|
838
|
-
if (!addedLinesKept(commentLines(base, opts.filename), commentLines(a, opts.filename), commentLines(b, opts.filename), commentLines(merged, opts.filename)))
|
|
839
|
-
return { status: 'fallback', text: null, debugText: merged, conflicts: [], reason: 'merge would drop a comment one side added' }
|
|
840
|
-
// PROVENANCE: attribute each surviving unit to the author whose version we kept. With
|
|
841
|
-
// opts.authors = { a, b, base } the labels are real names; otherwise they're 'a'/'b'/etc.
|
|
842
|
-
return {
|
|
843
|
-
status: 'auto', text: merged, conflicts: [],
|
|
844
|
-
renames: renames.map(([o, n]) => o + '->' + n),
|
|
845
|
-
asyncUpgrades,
|
|
846
|
-
provenance: attribute(provenance),
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
// Internal handles for diagnostic harnesses (not part of the public API).
|
|
851
|
-
export const _internals = { tokenMerge, tokenMerge3, tokenHunks, tryInnerMerge, lineMerge3 }
|
|
1
|
+
// ICR — Intent-aware Code Replication (v1 proof-of-concept).
|
|
2
|
+
//
|
|
3
|
+
// The first real step from Hivecode-the-tool toward a FOUNDATIONAL primitive:
|
|
4
|
+
// merge code by STRUCTURE and INTENT, not characters, with a hard guarantee — never
|
|
5
|
+
// emit code more broken than its inputs.
|
|
6
|
+
//
|
|
7
|
+
// Why this matters: CRDTs (Yjs) merge characters. They will happily fuse two edits
|
|
8
|
+
// into syntactically-valid garbage, because they have no idea what code MEANS. ICR
|
|
9
|
+
// parses the code, merges at the level of declarations (functions / classes / vars),
|
|
10
|
+
// descends INTO a declaration both sides touched (so disjoint edits inside one function
|
|
11
|
+
// still merge), detects when two authors changed the SAME thing (a real conflict), and
|
|
12
|
+
// validates that the merged result still parses — falling back safely if it wouldn't.
|
|
13
|
+
//
|
|
14
|
+
// On top of structure it adds an INTENT layer:
|
|
15
|
+
// • RENAME DETECTION — a declaration gone from base whose identical-bodied twin appears
|
|
16
|
+
// under a new name is recognized as a rename; stale call sites are rewritten so the
|
|
17
|
+
// other agent's fresh calls keep working. Nothing else does this.
|
|
18
|
+
// • DANGLING REFERENCE — a declaration removed/renamed but still referenced is flagged
|
|
19
|
+
// as a semantic conflict even though the code parses. CRDTs/git/plain merge miss it.
|
|
20
|
+
//
|
|
21
|
+
// LANGUAGE-AGNOSTIC BY DESIGN: this file is the engine. Everything that knows about a
|
|
22
|
+
// specific language (how to parse, what a declaration is, how to find references) lives
|
|
23
|
+
// behind a provider (see lang-js.js). Adding Python/Go/Rust = writing another provider
|
|
24
|
+
// and registerLanguage()-ing it; the merge logic below never changes. JavaScript via
|
|
25
|
+
// `acorn` is simply the first provider. The production path swaps in tree-sitter
|
|
26
|
+
// providers for every language behind this same interface.
|
|
27
|
+
|
|
28
|
+
import { javascript } from './lang-js.js'
|
|
29
|
+
import { braceLanguages } from './lang-brace.js'
|
|
30
|
+
import { python } from './lang-python.js'
|
|
31
|
+
import { ruby } from './lang-ruby.js'
|
|
32
|
+
import { json } from './lang-json.js'
|
|
33
|
+
import { yaml } from './lang-yaml.js'
|
|
34
|
+
import { toml } from './lang-toml.js'
|
|
35
|
+
|
|
36
|
+
// --- language registry ----------------------------------------------------------
|
|
37
|
+
// JavaScript (acorn, full intent layer) first; then structural providers for the
|
|
38
|
+
// C-family (TypeScript, Go, Rust, Java, C/C++, C#, Swift, Kotlin, …), Python, and
|
|
39
|
+
// Ruby; data languages: JSON merges as parsed DATA (real parser, value-wise 3-way,
|
|
40
|
+
// perfect guarantee), YAML/TOML merge by keyed structure (sections/keys).
|
|
41
|
+
// These cover disjoint extensions, so order only matters on clashes (none today).
|
|
42
|
+
const LANGUAGES = [javascript, ...braceLanguages, python, ruby, json, yaml, toml]
|
|
43
|
+
|
|
44
|
+
// Register an additional language provider. Must expose the provider contract:
|
|
45
|
+
// { id, exts:[...], parses, units, declaredNames, usedIdentifiers, declBody,
|
|
46
|
+
// renameRefs, fnParts }. Newest wins on extension clashes.
|
|
47
|
+
export function registerLanguage(provider) { LANGUAGES.unshift(provider) }
|
|
48
|
+
|
|
49
|
+
// The live provider list (the actual registered objects, not copies) — used by the
|
|
50
|
+
// optional tree-sitter oracle to upgrade providers' parse checks in place.
|
|
51
|
+
export function registeredLanguages() { return LANGUAGES }
|
|
52
|
+
|
|
53
|
+
function extname(p) { const m = /\.[^.\/\\]+$/.exec(p || ''); return m ? m[0].toLowerCase() : '' }
|
|
54
|
+
|
|
55
|
+
// The provider for a filename, or null if no registered language claims its extension.
|
|
56
|
+
export function languageFor(filename) {
|
|
57
|
+
const e = extname(filename)
|
|
58
|
+
return LANGUAGES.find((l) => l.exts.includes(e)) || null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Can ICR merge this file structurally? (Callers gate on this before trying.)
|
|
62
|
+
export function supports(filename) { return languageFor(filename) != null }
|
|
63
|
+
|
|
64
|
+
// --- generic merge primitives (language-independent: operate on {key,text}) -----
|
|
65
|
+
const mapOf = (us) => new Map(us.map((u) => [u.key, u.text]))
|
|
66
|
+
function dedupeOrder(keys) { const seen = new Set(), out = []; for (const k of keys) if (!seen.has(k)) { seen.add(k); out.push(k) } return out }
|
|
67
|
+
|
|
68
|
+
// For a given unit, choose which version survives: whoever CHANGED it wins; if neither
|
|
69
|
+
// changed it, keep the base.
|
|
70
|
+
function pick(k, B, A, Bb) {
|
|
71
|
+
const base = B.has(k) ? B.get(k) : null
|
|
72
|
+
const a = A.has(k) ? A.get(k) : null
|
|
73
|
+
const b = Bb.has(k) ? Bb.get(k) : null
|
|
74
|
+
if (a != null && a !== base) return a
|
|
75
|
+
if (b != null && b !== base) return b
|
|
76
|
+
return base != null ? base : (a != null ? a : b)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// SIMILARITY RE-PAIRING (the matcher): a unit that "disappeared" on one side while a
|
|
80
|
+
// near-identical unit "appeared" on the SAME side is the same unit rewritten — e.g.
|
|
81
|
+
// `app.use(...)` becoming `app1.use(...)` when a variable is renamed changes the unit's
|
|
82
|
+
// content-derived key, and key matching alone would call that a delete + an add. Ground-
|
|
83
|
+
// truth replay of real repos showed this is the #1 source of false delete-vs-edit
|
|
84
|
+
// conflicts. Pair them back so the engine sees ONE unit changed on that side and the
|
|
85
|
+
// normal same-key tiers (inner merge → token splice) get their shot.
|
|
86
|
+
// Safety: same key KIND only (call: with call:, fn: with fn:), a high similarity bar,
|
|
87
|
+
// and the best match must be UNIQUE (with margin) and MUTUAL — any ambiguity means no
|
|
88
|
+
// pairing. Side-local and deterministic, so merge(a,b) === merge(b,a) is preserved.
|
|
89
|
+
const keyKind = (k) => k.slice(0, k.indexOf(':') + 1)
|
|
90
|
+
function shingleSet(t) {
|
|
91
|
+
const s = t.replace(/\s+/g, ' ').trim()
|
|
92
|
+
const out = new Set()
|
|
93
|
+
for (let i = 0; i + 4 <= s.length; i++) out.add(s.slice(i, i + 4))
|
|
94
|
+
return out
|
|
95
|
+
}
|
|
96
|
+
function shingleSim(sa, sb) {
|
|
97
|
+
if (!sa.size || !sb.size) return 0
|
|
98
|
+
let inter = 0
|
|
99
|
+
const [small, big] = sa.size <= sb.size ? [sa, sb] : [sb, sa]
|
|
100
|
+
for (const g of small) if (big.has(g)) inter++
|
|
101
|
+
return (2 * inter) / (sa.size + sb.size)
|
|
102
|
+
}
|
|
103
|
+
const SIM_FLOOR = 0.75, SIM_MARGIN = 0.04
|
|
104
|
+
function repairSide(baseU, sideU, otherKeys) {
|
|
105
|
+
const baseKeys = new Set(baseU.map((u) => u.key))
|
|
106
|
+
const sideKeys = new Set(sideU.map((u) => u.key))
|
|
107
|
+
const deleted = baseU.filter((u) => !sideKeys.has(u.key))
|
|
108
|
+
// An added key that ALSO appears on the other side is a genuine concurrent addition
|
|
109
|
+
// (both sides wrote it) — pairing it away would fabricate a duplicate and silently
|
|
110
|
+
// absorb a same-key add/add clash the engine must surface. Never pair those.
|
|
111
|
+
const added = sideU.filter((u) => !baseKeys.has(u.key) && !otherKeys.has(u.key))
|
|
112
|
+
if (!deleted.length || !added.length || deleted.length * added.length > 900) return sideU
|
|
113
|
+
const shD = deleted.map((u) => shingleSet(u.text)), shA = added.map((u) => shingleSet(u.text))
|
|
114
|
+
const bestFor = (sh, kind, pool, shPool) => {
|
|
115
|
+
let best = -1, bestS = 0, second = 0
|
|
116
|
+
for (let j = 0; j < pool.length; j++) {
|
|
117
|
+
if (keyKind(pool[j].key) !== kind) continue
|
|
118
|
+
const s = shingleSim(sh, shPool[j])
|
|
119
|
+
if (s > bestS) { second = bestS; bestS = s; best = j } else if (s > second) second = s
|
|
120
|
+
}
|
|
121
|
+
return { best, bestS, second }
|
|
122
|
+
}
|
|
123
|
+
const rekey = new Map(), claimed = new Set()
|
|
124
|
+
for (let i = 0; i < deleted.length; i++) {
|
|
125
|
+
const kind = keyKind(deleted[i].key)
|
|
126
|
+
const f = bestFor(shD[i], kind, added, shA)
|
|
127
|
+
// Short units are mostly boilerplate (`def f(): return N`), so shingle similarity
|
|
128
|
+
// runs hot on them — demand near-identity before calling two short units "the same".
|
|
129
|
+
const floor = Math.min(shD[i].size, f.best >= 0 ? shA[f.best].size : Infinity) < 24 ? 0.9 : SIM_FLOOR
|
|
130
|
+
if (f.best < 0 || f.bestS < floor || f.bestS - f.second < SIM_MARGIN) continue
|
|
131
|
+
const back = bestFor(shA[f.best], keyKind(added[f.best].key), deleted, shD)
|
|
132
|
+
if (back.best !== i || back.bestS - back.second < SIM_MARGIN) continue
|
|
133
|
+
if (claimed.has(added[f.best].key)) continue
|
|
134
|
+
claimed.add(added[f.best].key)
|
|
135
|
+
rekey.set(added[f.best].key, deleted[i].key)
|
|
136
|
+
}
|
|
137
|
+
if (!rekey.size) return sideU
|
|
138
|
+
return sideU.map((u) => (rekey.has(u.key) ? { ...u, key: rekey.get(u.key) } : u))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Core 3-way merge over a KEYED list of units (works at file level AND, recursively,
|
|
142
|
+
// inside a single declaration). Returns { conflicts:[...], parts:[...text] }.
|
|
143
|
+
// When both sides change the SAME key into different things, it first tries to descend
|
|
144
|
+
// INTO that unit (finer granularity) — only a clash it can't resolve becomes a conflict.
|
|
145
|
+
function mergeKeyed(lang, baseU, aU, bU, mopts = {}) {
|
|
146
|
+
const aKeys = new Set(aU.map((u) => u.key)), bKeys = new Set(bU.map((u) => u.key))
|
|
147
|
+
aU = repairSide(baseU, aU, bKeys)
|
|
148
|
+
bU = repairSide(baseU, bU, aKeys)
|
|
149
|
+
const B = mapOf(baseU), A = mapOf(aU), Bb = mapOf(bU)
|
|
150
|
+
const keys = dedupeOrder([...baseU.map((u) => u.key), ...aU.map((u) => u.key), ...bU.map((u) => u.key)])
|
|
151
|
+
|
|
152
|
+
const conflicts = [], conflictUnits = [], resolved = new Map()
|
|
153
|
+
for (const k of keys) {
|
|
154
|
+
const bs = B.has(k) ? B.get(k) : null
|
|
155
|
+
const as_ = A.has(k) ? A.get(k) : null
|
|
156
|
+
const bbs = Bb.has(k) ? Bb.get(k) : null
|
|
157
|
+
const changedA = as_ !== bs, changedB = bbs !== bs
|
|
158
|
+
if (changedA && changedB && as_ !== bbs) {
|
|
159
|
+
// Both touched the same unit, differently. Try finer-grained inner merge first.
|
|
160
|
+
if (bs != null && as_ != null && bbs != null) {
|
|
161
|
+
const inner = tryInnerMerge(lang, bs, as_, bbs, mopts)
|
|
162
|
+
if (inner != null) { resolved.set(k, inner); continue }
|
|
163
|
+
}
|
|
164
|
+
conflicts.push(k)
|
|
165
|
+
// Carry the three exact versions of the conflicting unit, so a conflict is a
|
|
166
|
+
// MACHINE-RESOLVABLE object (base/ours/theirs of just this declaration) — not a wall
|
|
167
|
+
// of text markers. This is what an intent-aware resolver (a judge agent) reconciles.
|
|
168
|
+
conflictUnits.push({ key: k, base: bs, ours: as_, theirs: bbs })
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (conflicts.length) return { conflicts, conflictUnits, parts: [] }
|
|
172
|
+
|
|
173
|
+
// No unresolved clashes → decide which keys survive, honoring one-sided deletions.
|
|
174
|
+
const survives = new Set()
|
|
175
|
+
for (const k of keys) {
|
|
176
|
+
const inBase = B.has(k), inA = A.has(k), inB2 = Bb.has(k)
|
|
177
|
+
if (inBase && (!inA || !inB2)) {
|
|
178
|
+
const deletedByA = !inA, deletedByB = !inB2
|
|
179
|
+
const otherUnchangedFromBase =
|
|
180
|
+
(deletedByA && (!inB2 || Bb.get(k) === B.get(k))) ||
|
|
181
|
+
(deletedByB && (!inA || A.get(k) === B.get(k)))
|
|
182
|
+
if (otherUnchangedFromBase) continue // accept the deletion
|
|
183
|
+
}
|
|
184
|
+
survives.add(k)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// CONVERGENCE + TOPOLOGY: new units are inserted where their author put them — anchored
|
|
188
|
+
// to the base unit that immediately preceded them on the side that added them — while
|
|
189
|
+
// staying SYMMETRIC: merge(a,b) must equal merge(b,a) byte-for-byte or two live peers
|
|
190
|
+
// never settle. Symmetry rules: (1) a unit added by BOTH sides at different anchors gets
|
|
191
|
+
// the anchor earlier in base order (side-independent); (2) when both sides insert runs at
|
|
192
|
+
// the SAME anchor, each side's run keeps its own internal order, and the two runs are
|
|
193
|
+
// ordered by comparing their joined keys (content, not side).
|
|
194
|
+
const baseOrder = dedupeOrder(baseU.map((u) => u.key))
|
|
195
|
+
const baseSet = new Set(baseOrder)
|
|
196
|
+
const basePos = new Map(baseOrder.map((k, i) => [k, i]))
|
|
197
|
+
|
|
198
|
+
const anchorsFor = (units) => { // newKey -> base key it followed (null = top of file)
|
|
199
|
+
const m = new Map()
|
|
200
|
+
let lastBase = null
|
|
201
|
+
for (const u of units) {
|
|
202
|
+
if (baseSet.has(u.key)) lastBase = u.key
|
|
203
|
+
else if (survives.has(u.key) && !m.has(u.key)) m.set(u.key, lastBase)
|
|
204
|
+
}
|
|
205
|
+
return m
|
|
206
|
+
}
|
|
207
|
+
const aAnch = anchorsFor(aU), bAnch = anchorsFor(bU)
|
|
208
|
+
const anchorOf = (k) => {
|
|
209
|
+
const inA2 = aAnch.has(k), inB3 = bAnch.has(k)
|
|
210
|
+
if (inA2 && inB3) {
|
|
211
|
+
const x = aAnch.get(k), y = bAnch.get(k)
|
|
212
|
+
if (x === y) return x
|
|
213
|
+
const px = x == null ? -1 : basePos.get(x), py = y == null ? -1 : basePos.get(y)
|
|
214
|
+
return px <= py ? x : y
|
|
215
|
+
}
|
|
216
|
+
return inA2 ? aAnch.get(k) : bAnch.get(k)
|
|
217
|
+
}
|
|
218
|
+
const runsA = new Map(), runsB = new Map() // anchor -> that side's new keys, in its order
|
|
219
|
+
for (const k of aAnch.keys()) { const an = anchorOf(k); if (!runsA.has(an)) runsA.set(an, []); runsA.get(an).push(k) }
|
|
220
|
+
for (const k of bAnch.keys()) { const an = anchorOf(k); if (!runsB.has(an)) runsB.set(an, []); runsB.get(an).push(k) }
|
|
221
|
+
const newAt = (anchor) => {
|
|
222
|
+
const ra = runsA.get(anchor) || [], rb = runsB.get(anchor) || []
|
|
223
|
+
const [first, second] = ra.join('') <= rb.join('') ? [ra, rb] : [rb, ra]
|
|
224
|
+
const firstSet = new Set(first)
|
|
225
|
+
return [...first, ...second.filter((k) => !firstSet.has(k))]
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const final = []
|
|
229
|
+
final.push(...newAt(null))
|
|
230
|
+
for (const k of baseOrder) {
|
|
231
|
+
if (survives.has(k)) final.push(k)
|
|
232
|
+
final.push(...newAt(k))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Assemble, recording PROVENANCE per surviving unit: who authored the version we kept
|
|
236
|
+
// ('a' / 'b' changed it, 'both' if we merged inside it, 'base' if unchanged).
|
|
237
|
+
const parts = [], provenance = [], order = [], textByKey = new Map()
|
|
238
|
+
for (const k of final) {
|
|
239
|
+
let text, from
|
|
240
|
+
if (resolved.has(k)) { text = resolved.get(k); from = 'both' }
|
|
241
|
+
else {
|
|
242
|
+
text = pick(k, B, A, Bb)
|
|
243
|
+
const bs = B.has(k) ? B.get(k) : null, as_ = A.has(k) ? A.get(k) : null, bbs = Bb.has(k) ? Bb.get(k) : null
|
|
244
|
+
from = (as_ != null && as_ !== bs) ? 'a' : (bbs != null && bbs !== bs) ? 'b' : 'base'
|
|
245
|
+
}
|
|
246
|
+
if (text != null) { parts.push(text); provenance.push({ key: k, from }); order.push(k); textByKey.set(k, text) }
|
|
247
|
+
}
|
|
248
|
+
// aU/bU are the sides AFTER repairSide re-keyed them: a caller that splices into a side's own
|
|
249
|
+
// text must use these keys, or a re-keyed unit reads as deleted-plus-new.
|
|
250
|
+
return { conflicts: [], parts, provenance, order, textByKey, aU, bU }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// FINER GRANULARITY: both sides edited the same function OR class. If the signature
|
|
254
|
+
// matches and they edited DIFFERENT inner units (statements / class members), merge those
|
|
255
|
+
// recursively rather than declaring the whole declaration a conflict. Returns merged
|
|
256
|
+
// text, or null meaning "couldn't safely merge inside — treat as a real conflict."
|
|
257
|
+
function tryInnerMerge(lang, baseText, aText, bText, mopts = {}) {
|
|
258
|
+
// Language-specific same-key merges first (e.g. unioning import specifiers).
|
|
259
|
+
if (lang.mergeUnit) {
|
|
260
|
+
const t = lang.mergeUnit(baseText, aText, bText)
|
|
261
|
+
if (t != null && lang.parses(t)) return t
|
|
262
|
+
}
|
|
263
|
+
// Structural inner merge (declaration → statements, class → members): format-preserving
|
|
264
|
+
// and well-tested, so it goes first. Returns null when it can't safely resolve.
|
|
265
|
+
const structural = tryStructuralInner(lang, baseText, aText, bText, mopts)
|
|
266
|
+
if (structural != null) return structural
|
|
267
|
+
// Unit-level oracle: providers with a fragment validator (parsesUnit) get every finer-
|
|
268
|
+
// grained result checked HERE, so an invalid inner merge becomes an honest per-unit
|
|
269
|
+
// conflict (judge-resolvable) instead of poisoning the whole file into fallback.
|
|
270
|
+
// Providers without one (JS: a bare `return …` fragment is valid only in context) keep
|
|
271
|
+
// deferring to the whole-file gate, as before.
|
|
272
|
+
const unitOk = lang.parsesUnit ? (t) => t != null && lang.parsesUnit(t) : (t) => t != null
|
|
273
|
+
// LINE-LEVEL diff3: whole lines as atoms, so indentation travels with its line — the
|
|
274
|
+
// safe multi-hunk tier, and the workhorse for "both sides edited different lines of the
|
|
275
|
+
// same declaration" (the most common same-unit case in real repos).
|
|
276
|
+
const lm = lineMerge3(baseText, aText, bText, mopts)
|
|
277
|
+
if (unitOk(lm)) return lm
|
|
278
|
+
// FINEST GRANULARITY: a single line/statement both sides edited — token-level 3-way, so
|
|
279
|
+
// two edits INSIDE one line (`foo(1, 2)` → one side edits each argument) merge the way a
|
|
280
|
+
// tree-sitter merge does, instead of conflicting on the whole statement. For providers
|
|
281
|
+
// without parsesUnit, validity is enforced where the result lands (whole-file re-parse).
|
|
282
|
+
const tm = tokenMerge(lang, baseText, aText, bText, mopts)
|
|
283
|
+
if (tm != null && lang.parsesUnit && !lang.parsesUnit(tm)) return null
|
|
284
|
+
return tm
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function tryStructuralInner(lang, baseText, aText, bText, mopts = {}) {
|
|
288
|
+
if (!lang.splitUnit) return null
|
|
289
|
+
const pb = lang.splitUnit(baseText), pa = lang.splitUnit(aText), pbb = lang.splitUnit(bText)
|
|
290
|
+
if (!pb || !pa || !pbb) return null
|
|
291
|
+
if (pa.sig.trim() !== pbb.sig.trim()) return null
|
|
292
|
+
// THE TEXT AROUND THE BODY IS MERGED TOO. open/close are the declaration's own bytes either
|
|
293
|
+
// side of its body, and they were always taken from the FIRST argument: what the second side
|
|
294
|
+
// wrote there was dropped, so a result depended on argument order. Three-way instead: the
|
|
295
|
+
// side that changed it wins, and both changing it differently is not ours to pick.
|
|
296
|
+
const open = pick3(pb.open, pa.open, pbb.open), close = pick3(pb.close, pa.close, pbb.close)
|
|
297
|
+
if (open == null || close == null) return null
|
|
298
|
+
const m = mergeKeyed(lang, pb.units, pa.units, pbb.units, mopts)
|
|
299
|
+
if (m.conflicts.length) return null
|
|
300
|
+
// FORMAT-PRESERVING inner assembly: when the provider exposes the body text + byte-ranged
|
|
301
|
+
// units (spliceable), rebuild the body by splicing merged units back into the BASE body,
|
|
302
|
+
// so everything BETWEEN statements — comments, blank lines, indentation — survives verbatim.
|
|
303
|
+
// (Join-based reassembly with a fixed separator silently drops inner comments and reflows
|
|
304
|
+
// whitespace; found in the wild merging ICR's own source, and the C-family class-brace glue.)
|
|
305
|
+
// Objects and any non-spliceable provider fall back to the separator join.
|
|
306
|
+
let text
|
|
307
|
+
const spliceable = pb.spliceable && typeof pb.body === 'string' && pb.units.every((u) => typeof u.start === 'number' && typeof u.end === 'number')
|
|
308
|
+
if (spliceable) {
|
|
309
|
+
// COMMENT-EDIT PRESERVATION: gaps (comments/blank lines between statements) come from
|
|
310
|
+
// the splice BASIS. Splicing from base loses a side's comment edit. So: if exactly ONE
|
|
311
|
+
// side changed the gaps, splice into THAT side's body — its comment edit survives and the
|
|
312
|
+
// other side's code edits land on top. If BOTH did, refuse (null), and the line tier
|
|
313
|
+
// below keeps both or reports a conflict, instead of silently dropping either side's.
|
|
314
|
+
// A side counts as having changed the gaps when it changed nothing else (the original
|
|
315
|
+
// rule, which keeps a whitespace-only edit), and ALSO when the comment text itself
|
|
316
|
+
// changed: the original rule alone lost a comment whenever the same side had also
|
|
317
|
+
// edited a statement (2 Oct 2026, tests/icr-gap-comments-test.mjs).
|
|
318
|
+
const sameUnits = (u1, u2) => u1.length === u2.length && u1.every((u, i) => u.key === u2[i].key && u.text === u2[i].text)
|
|
319
|
+
const gBase = gapLines(pb.body, pb.units)
|
|
320
|
+
const aGap = (pa.body !== pb.body && sameUnits(pa.units, pb.units)) || !sameLines(gapLines(pa.body, m.aU), gBase)
|
|
321
|
+
const bGap = (pbb.body !== pb.body && sameUnits(pbb.units, pb.units)) || !sameLines(gapLines(pbb.body, m.bU), gBase)
|
|
322
|
+
if (aGap && bGap) return null
|
|
323
|
+
const [basisBody, basisUnits] = aGap ? [pa.body, m.aU] : bGap ? [pbb.body, m.bU] : [pb.body, pb.units]
|
|
324
|
+
const body = spliceUnits(basisBody, basisUnits, m.order, m.textByKey, { trimLeadingBlank: false })
|
|
325
|
+
text = pa.sig + open + body + close
|
|
326
|
+
// ...and the same no-silent-loss check the top level runs (addedLinesKept).
|
|
327
|
+
const pm = lang.splitUnit(text)
|
|
328
|
+
if (pm && hasRanges(pm.units) &&
|
|
329
|
+
!addedLinesKept(gBase, gapLines(pa.body, m.aU), gapLines(pbb.body, m.bU), gapLines(pm.body, pm.units))) return null
|
|
330
|
+
} else {
|
|
331
|
+
text = pa.sig + open + m.parts.join(pa.join) + close
|
|
332
|
+
}
|
|
333
|
+
const valid = lang.parsesUnit || lang.parses // accept class-member fragments when supported
|
|
334
|
+
return valid(text) ? text : null
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// TOKEN-LEVEL 3-way merge of one unit. base/a/b are the unit texts, both differing from base
|
|
338
|
+
// (the caller only asks when both sides changed the same unit). Diff each side against base at
|
|
339
|
+
// TOKEN granularity — one changed span via common prefix + common suffix — map each span to a
|
|
340
|
+
// base CHARACTER range, and, if the two base spans are DISJOINT, splice both sides' replacement
|
|
341
|
+
// text into base. Everything OUTSIDE the two edits comes from base verbatim (whitespace,
|
|
342
|
+
// comments); inside, each side's exact bytes. Overlapping or ambiguously-adjacent edits → null
|
|
343
|
+
// (a real conflict). Symmetric by construction: the result is base with two position-disjoint
|
|
344
|
+
// substring replacements, independent of which side is a or b — so live peers converge.
|
|
345
|
+
function tokenDiffRange(base, other) {
|
|
346
|
+
const n = base.length, m = other.length
|
|
347
|
+
let eq = n === m
|
|
348
|
+
if (eq) { for (let i = 0; i < n; i++) if (base[i] !== other[i]) { eq = false; break } }
|
|
349
|
+
if (eq) return null
|
|
350
|
+
let p = 0
|
|
351
|
+
const maxp = Math.min(n, m)
|
|
352
|
+
while (p < maxp && base[p] === other[p]) p++
|
|
353
|
+
let s = 0
|
|
354
|
+
while (s < n - p && s < m - p && base[n - 1 - s] === other[m - 1 - s]) s++
|
|
355
|
+
return { bStart: p, bEnd: n - s, oStart: p, oEnd: m - s }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function tokenMerge(lang, baseText, aText, bText, mopts = {}) {
|
|
359
|
+
if (!lang.tokenize) return null
|
|
360
|
+
const B = lang.tokenize(baseText), A = lang.tokenize(aText), Bb = lang.tokenize(bText)
|
|
361
|
+
if (!B || !A || !Bb) return null
|
|
362
|
+
const ra = tokenDiffRange(B.map((t) => t.k), A.map((t) => t.k))
|
|
363
|
+
const rb = tokenDiffRange(B.map((t) => t.k), Bb.map((t) => t.k))
|
|
364
|
+
if (!ra && !rb) return null // both sides changed only whitespace/comments → can't rank them
|
|
365
|
+
// REFORMAT vs CONTENT: one side changed only whitespace/comments (token stream identical
|
|
366
|
+
// to base), the other changed tokens. Apply the content side's token edit ONTO the
|
|
367
|
+
// reformatting side's text — its token stream matches base's, so token indices map its
|
|
368
|
+
// own byte ranges exactly. Both intents survive: the reformat/comment edit AND the code
|
|
369
|
+
// change. (Ground-truth replay: this was a top false-conflict source on real repos.)
|
|
370
|
+
// Symmetric because the roles are decided by CONTENT (which side has the token diff).
|
|
371
|
+
if (!ra || !rb) {
|
|
372
|
+
const [wsToks, wsText, r, toks, text] = !ra ? [A, aText, rb, Bb, bText] : [Bb, bText, ra, A, aText]
|
|
373
|
+
const cStart = r.bStart < wsToks.length ? wsToks[r.bStart].start : wsText.length
|
|
374
|
+
const cEnd = r.bEnd > r.bStart ? wsToks[r.bEnd - 1].end : cStart
|
|
375
|
+
const ins = r.oEnd > r.oStart ? text.slice(toks[r.oStart].start, toks[r.oEnd - 1].end) : ''
|
|
376
|
+
return wsText.slice(0, cStart) + ins + wsText.slice(cEnd)
|
|
377
|
+
}
|
|
378
|
+
const charRange = (r) => {
|
|
379
|
+
const cStart = r.bStart < B.length ? B[r.bStart].start : baseText.length
|
|
380
|
+
const cEnd = r.bEnd > r.bStart ? B[r.bEnd - 1].end : cStart
|
|
381
|
+
return [cStart, cEnd]
|
|
382
|
+
}
|
|
383
|
+
const repl = (toks, text, r) => (r.oEnd > r.oStart ? text.slice(toks[r.oStart].start, toks[r.oEnd - 1].end) : '')
|
|
384
|
+
const [aS, aE] = charRange(ra), [bS, bE] = charRange(rb)
|
|
385
|
+
// Single contiguous spans that don't collide: splice both (the cheap common case).
|
|
386
|
+
if (!(aS < bE && bS < aE) && aS !== bS) {
|
|
387
|
+
const edits = [{ s: aS, e: aE, t: repl(A, aText, ra) }, { s: bS, e: bE, t: repl(Bb, bText, rb) }].sort((x, y) => y.s - x.s)
|
|
388
|
+
let out = baseText
|
|
389
|
+
for (const ed of edits) out = out.slice(0, ed.s) + ed.t + out.slice(ed.e)
|
|
390
|
+
return out
|
|
391
|
+
}
|
|
392
|
+
// The prefix/suffix spans collide — but each is the BOUNDING BOX of that side's edits,
|
|
393
|
+
// and boxes overlap whenever a side edited in two places (a wrapper rewrite touches both
|
|
394
|
+
// ends, so its box swallows the whole unit). Re-diff each side at REAL multi-hunk
|
|
395
|
+
// granularity (Myers) and do token-level diff3: disjoint hunks interleave, identical
|
|
396
|
+
// hunks dedupe, truly overlapping hunks are the only conflict. Ground-truth replay:
|
|
397
|
+
// "one side rewrote the wrapper, the other edited inside" is a real-repo staple.
|
|
398
|
+
return tokenMerge3(baseText, aText, bText, B, A, Bb, mopts)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Myers O(ND) diff on token-key arrays → edit hunks {bs,be,os,oe} (base/other token ranges).
|
|
402
|
+
function tokenHunks(bk, ok, maxD) {
|
|
403
|
+
const N = bk.length, M = ok.length
|
|
404
|
+
const MAX = Math.min(N + M, maxD)
|
|
405
|
+
const V = new Map([[1, 0]])
|
|
406
|
+
const trace = []
|
|
407
|
+
let D = -1
|
|
408
|
+
outer: for (let d = 0; d <= MAX; d++) {
|
|
409
|
+
trace.push(new Map(V))
|
|
410
|
+
for (let k = -d; k <= d; k += 2) {
|
|
411
|
+
let x = (k === -d || (k !== d && (V.get(k - 1) ?? 0) < (V.get(k + 1) ?? 0))) ? (V.get(k + 1) ?? 0) : (V.get(k - 1) ?? 0) + 1
|
|
412
|
+
let y = x - k
|
|
413
|
+
while (x < N && y < M && bk[x] === ok[y]) { x++; y++ }
|
|
414
|
+
V.set(k, x)
|
|
415
|
+
if (x >= N && y >= M) { D = d; break outer }
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
if (D < 0) return null // too different for the budget → caller falls back
|
|
419
|
+
// backtrack into hunks
|
|
420
|
+
const ops = [] // 1 = del base token, 2 = ins other token, stored back-to-front
|
|
421
|
+
let x = N, y = M
|
|
422
|
+
for (let d = D; d > 0; d--) {
|
|
423
|
+
const Vp = trace[d]
|
|
424
|
+
const k = x - y
|
|
425
|
+
const down = k === -d || (k !== d && (Vp.get(k - 1) ?? 0) < (Vp.get(k + 1) ?? 0))
|
|
426
|
+
const pk = down ? k + 1 : k - 1
|
|
427
|
+
const px = Vp.get(pk) ?? 0
|
|
428
|
+
const py = px - pk
|
|
429
|
+
while (x > px + (down ? 0 : 1) && y > py + (down ? 1 : 0)) { x--; y-- } // snake
|
|
430
|
+
if (down) { y--; ops.push([2, x, y]) } else { x--; ops.push([1, x, y]) }
|
|
431
|
+
}
|
|
432
|
+
ops.reverse()
|
|
433
|
+
const hunks = []
|
|
434
|
+
for (const [op, bx, oy] of ops) {
|
|
435
|
+
const last = hunks[hunks.length - 1]
|
|
436
|
+
if (op === 1) {
|
|
437
|
+
if (last && last.be === bx && last.oe === oy) last.be = bx + 1
|
|
438
|
+
else hunks.push({ bs: bx, be: bx + 1, os: oy, oe: oy })
|
|
439
|
+
} else {
|
|
440
|
+
if (last && last.be === bx && last.oe === oy) last.oe = oy + 1
|
|
441
|
+
else hunks.push({ bs: bx, be: bx, os: oy, oe: oy + 1 })
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return hunks
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// diff3 over two hunk lists: identical hunks dedupe; disjoint hunks both apply; overlap
|
|
448
|
+
// → conflict (null). Two INSERTIONS at the same base point are ambiguous (order
|
|
449
|
+
// unknowable) → conflict. `ak`/`bbk` are each side's atom arrays (token keys or lines),
|
|
450
|
+
// used to compare replacement content for the dedupe check. Returns [{h, side}] in base
|
|
451
|
+
// order, or null. Symmetric: ordering decisions are content/position-based, never
|
|
452
|
+
// side-identity-based, so merge(a,b) === merge(b,a) is preserved by construction.
|
|
453
|
+
function diff3Interleave(ha, hb, ak, bbk, opts = {}) {
|
|
454
|
+
const sameHunk = (x, y) => x.bs === y.bs && x.be === y.be && (x.oe - x.os) === (y.oe - y.os) &&
|
|
455
|
+
ak.slice(x.os, x.oe).every((k, i) => k === bbk[y.os + i])
|
|
456
|
+
const edits = []
|
|
457
|
+
let i = 0, j = 0
|
|
458
|
+
while (i < ha.length || j < hb.length) {
|
|
459
|
+
const x = ha[i], y = hb[j]
|
|
460
|
+
if (x && y) {
|
|
461
|
+
const overlap = x.bs < y.be && y.bs < x.be
|
|
462
|
+
const samePointInsert = x.bs === y.bs && x.be === x.bs && y.be === y.bs
|
|
463
|
+
if (overlap || samePointInsert) {
|
|
464
|
+
if (sameHunk(x, y)) { edits.push({ h: x, side: 'a' }); i++; j++; continue }
|
|
465
|
+
// UNION MODE (opt-in, for independent-intent merges — the multi-agent case where
|
|
466
|
+
// the medium KNOWS the two edits serve different tasks): two pure INSERTIONS at
|
|
467
|
+
// the same base point both survive, in content order (side-independent, so peers
|
|
468
|
+
// converge). Everything else (true overlaps, edit-vs-edit) still conflicts, and
|
|
469
|
+
// the parse gates downstream still refuse any union that breaks the code.
|
|
470
|
+
if (samePointInsert && opts.unionInserts) {
|
|
471
|
+
const ka = ak.slice(x.os, x.oe).join('\n'), kb = bbk.slice(y.os, y.oe).join('\n')
|
|
472
|
+
const [first, second] = ka <= kb ? [{ h: x, side: 'a' }, { h: y, side: 'b' }] : [{ h: y, side: 'b' }, { h: x, side: 'a' }]
|
|
473
|
+
edits.push(first, second); i++; j++; continue
|
|
474
|
+
}
|
|
475
|
+
return null
|
|
476
|
+
}
|
|
477
|
+
if (x.bs < y.bs || (x.bs === y.bs && x.be <= y.bs && x.be < y.be)) { edits.push({ h: x, side: 'a' }); i++ }
|
|
478
|
+
else { edits.push({ h: y, side: 'b' }); j++ }
|
|
479
|
+
} else if (x) { edits.push({ h: x, side: 'a' }); i++ }
|
|
480
|
+
else { edits.push({ h: y, side: 'b' }); j++ }
|
|
481
|
+
}
|
|
482
|
+
return edits
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const TOKEN3_MAX_TOKENS = 20000, TOKEN3_MAX_D = 4000
|
|
486
|
+
function tokenMerge3(baseText, aText, bText, B, A, Bb, mopts = {}) {
|
|
487
|
+
if (B.length > TOKEN3_MAX_TOKENS || A.length > TOKEN3_MAX_TOKENS || Bb.length > TOKEN3_MAX_TOKENS) return null
|
|
488
|
+
const bk = B.map((t) => t.k)
|
|
489
|
+
const ha = tokenHunks(bk, A.map((t) => t.k), TOKEN3_MAX_D)
|
|
490
|
+
const hb = tokenHunks(bk, Bb.map((t) => t.k), TOKEN3_MAX_D)
|
|
491
|
+
if (!ha || !hb) return null
|
|
492
|
+
const edits = diff3Interleave(ha, hb, A.map((t) => t.k), Bb.map((t) => t.k), mopts)
|
|
493
|
+
if (!edits) return null
|
|
494
|
+
// splice back-to-front, mapping token hunks to base char ranges; replacement bytes come
|
|
495
|
+
// from the editing side's exact text between its tokens. A guard space prevents two word
|
|
496
|
+
// tokens fusing at a splice boundary (parse validation downstream still has final say).
|
|
497
|
+
const wordish = (c) => c && /[A-Za-z0-9_$]/.test(c)
|
|
498
|
+
let out = baseText
|
|
499
|
+
for (let e = edits.length - 1; e >= 0; e--) {
|
|
500
|
+
const { h, side } = edits[e]
|
|
501
|
+
const [toks, text] = side === 'a' ? [A, aText] : [Bb, bText]
|
|
502
|
+
const cS = h.bs < B.length ? B[h.bs].start : baseText.length
|
|
503
|
+
const cE = h.be > h.bs ? B[h.be - 1].end : cS
|
|
504
|
+
let ins = h.oe > h.os ? text.slice(toks[h.os].start, toks[h.oe - 1].end) : ''
|
|
505
|
+
if (ins && wordish(out[cS - 1]) && wordish(ins[0])) ins = ' ' + ins
|
|
506
|
+
if (ins && wordish(out[cE]) && wordish(ins[ins.length - 1])) ins = ins + ' '
|
|
507
|
+
out = out.slice(0, cS) + ins + out.slice(cE)
|
|
508
|
+
}
|
|
509
|
+
return out
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// LINE-LEVEL 3-way merge of one unit: the same Myers + diff3 interleave with WHOLE LINES
|
|
513
|
+
// as atoms. Every replacement carries complete lines verbatim — indentation can never be
|
|
514
|
+
// stranded or doubled, which is what makes it the safe multi-hunk tier for indentation
|
|
515
|
+
// languages (the ConGra corpus showed token-splice arithmetic mangling Python indent when
|
|
516
|
+
// hunks straddle newline tokens: `conn = None` landing at column 0, a doubled 16-space
|
|
517
|
+
// indent). Token-level still runs after this for same-line micro-edits; this tier catches
|
|
518
|
+
// the far more common "both sides edited different LINES of the same unit" cleanly.
|
|
519
|
+
function splitLinesKeepEnds(text) {
|
|
520
|
+
const out = []
|
|
521
|
+
let i = 0
|
|
522
|
+
while (i < text.length) { let e = text.indexOf('\n', i); e = e < 0 ? text.length : e + 1; out.push(text.slice(i, e)); i = e }
|
|
523
|
+
return out
|
|
524
|
+
}
|
|
525
|
+
function lineMerge3(baseText, aText, bText, mopts = {}) {
|
|
526
|
+
const L = splitLinesKeepEnds(baseText), La = splitLinesKeepEnds(aText), Lb = splitLinesKeepEnds(bText)
|
|
527
|
+
if (L.length > TOKEN3_MAX_TOKENS) return null
|
|
528
|
+
const ha = tokenHunks(L, La, TOKEN3_MAX_D)
|
|
529
|
+
const hb = tokenHunks(L, Lb, TOKEN3_MAX_D)
|
|
530
|
+
if (!ha || !hb) return null
|
|
531
|
+
const edits = diff3Interleave(ha, hb, La, Lb, mopts)
|
|
532
|
+
if (!edits) return null
|
|
533
|
+
const out = L.slice()
|
|
534
|
+
for (let e = edits.length - 1; e >= 0; e--) {
|
|
535
|
+
const { h, side } = edits[e]
|
|
536
|
+
const src = side === 'a' ? La : Lb
|
|
537
|
+
out.splice(h.bs, h.be - h.bs, ...src.slice(h.os, h.oe))
|
|
538
|
+
}
|
|
539
|
+
return out.join('')
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// THE TEXT BETWEEN UNITS, as the lines a reader would call content: every non-blank line
|
|
543
|
+
// outside the units' byte ranges, trimmed, in order. Comments, mostly. Trimmed so a CRLF file
|
|
544
|
+
// or an editor's trailing-space cleanup does not read as a comment edit, and blank lines are
|
|
545
|
+
// dropped so a reformat does not either.
|
|
546
|
+
function gapLines(text, units) {
|
|
547
|
+
const out = []
|
|
548
|
+
const take = (from, to) => {
|
|
549
|
+
if (to <= from) return
|
|
550
|
+
for (const line of text.slice(from, to).split('\n')) { const t = line.trim(); if (t) out.push(t) }
|
|
551
|
+
}
|
|
552
|
+
let pos = 0
|
|
553
|
+
for (const u of [...units].sort((x, y) => x.start - y.start)) { take(pos, u.start); pos = Math.max(pos, u.end) }
|
|
554
|
+
take(pos, text.length)
|
|
555
|
+
return out
|
|
556
|
+
}
|
|
557
|
+
const sameLines = (x, y) => x.length === y.length && x.every((l, i) => l === y[i])
|
|
558
|
+
const hasRanges = (units) => units.every((u) => typeof u.start === 'number')
|
|
559
|
+
// Three-way pick of one piece of text: the side that changed it wins; null when both did, differently.
|
|
560
|
+
const pick3 = (base, a, b) => (a === base ? b : b === base ? a : a === b ? a : null)
|
|
561
|
+
|
|
562
|
+
// EVERY COMMENT IN A FILE, as lines: a comment-only line, a block comment's ` * ` line, or the
|
|
563
|
+
// comment that ends a line of code. The style comes from the file name (# for Python, Ruby, YAML
|
|
564
|
+
// and TOML; // and /* for the rest; none for JSON). It is a heuristic and errs one way only: text
|
|
565
|
+
// that merely looks like a comment can make a merge refuse and fall back to the line merge,
|
|
566
|
+
// never make one ship without a side's comment.
|
|
567
|
+
const HASH_COMMENTS = /\.(py|pyw|pyi|rb|rake|ya?ml|toml)$/i
|
|
568
|
+
function commentLines(text, filename) {
|
|
569
|
+
if (/\.json$/i.test(filename || '')) return []
|
|
570
|
+
const hash = HASH_COMMENTS.test(filename || '')
|
|
571
|
+
const rx = hash ? /(?:^|\s)(#.*)$/ : /(?:^|\s)(\/\/.*|\/\*.*)$/
|
|
572
|
+
const out = []
|
|
573
|
+
for (const line of text.split('\n')) {
|
|
574
|
+
const t = line.trim()
|
|
575
|
+
if (!t) continue
|
|
576
|
+
if (!hash && t.startsWith('*')) { out.push(t); continue }
|
|
577
|
+
const m = rx.exec(line)
|
|
578
|
+
if (m) out.push(m[1].trim())
|
|
579
|
+
}
|
|
580
|
+
return out
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// NO SILENT LOSS BETWEEN UNITS (2 Oct 2026). Every between-unit line a side ADDED must be in
|
|
584
|
+
// the merged result, counted: a side that added a second "// TODO" is not satisfied by base's
|
|
585
|
+
// first. What the other side deleted may go, so the bound for each line is what a side added
|
|
586
|
+
// net of the other side's removals. False means a merge would ship without a side's comment:
|
|
587
|
+
// the caller refuses it, and the line merge (which keeps both or conflicts) takes over.
|
|
588
|
+
// Found because a whole night of real merges came back 'structural', clean, and short of
|
|
589
|
+
// 17 comment lines that only one side had written.
|
|
590
|
+
function addedLinesKept(baseG, aG, bG, mergedG) {
|
|
591
|
+
const count = (lines) => { const m = new Map(); for (const l of lines) m.set(l, (m.get(l) || 0) + 1); return m }
|
|
592
|
+
const B = count(baseG), A = count(aG), Bb = count(bG), M = count(mergedG)
|
|
593
|
+
for (const line of new Set([...A.keys(), ...Bb.keys()])) {
|
|
594
|
+
const inBase = B.get(line) || 0, inA = A.get(line) || 0, inB = Bb.get(line) || 0
|
|
595
|
+
if (inA <= inBase && inB <= inBase) continue
|
|
596
|
+
const need = Math.max(inA - Math.max(0, inBase - inB), inB - Math.max(0, inBase - inA))
|
|
597
|
+
if ((M.get(line) || 0) < need) return false
|
|
598
|
+
}
|
|
599
|
+
return true
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// FORMAT-PRESERVING assembly: rebuild the merged file by splicing the merged units back
|
|
603
|
+
// into the BASE text at their original byte ranges. Unchanged units — and all the
|
|
604
|
+
// whitespace/comments BETWEEN units — survive verbatim; only changed units carry the
|
|
605
|
+
// editing side's bytes, and genuinely new units are appended. This is what stops ICR from
|
|
606
|
+
// reformatting code it merges. Deterministic and symmetric, so peers converge.
|
|
607
|
+
function spliceUnits(baseText, baseUnits, order, textByKey, opts = {}) {
|
|
608
|
+
const baseKeySet = new Set(baseUnits.map((u) => u.key))
|
|
609
|
+
const baseByKey = new Map(baseUnits.map((u) => [u.key, u]))
|
|
610
|
+
const orderSet = new Set(order)
|
|
611
|
+
const deleted = baseUnits.filter((u) => !orderSet.has(u.key))
|
|
612
|
+
let out = '', pos = 0
|
|
613
|
+
|
|
614
|
+
function emitGap(from, to) {
|
|
615
|
+
let cursor = from
|
|
616
|
+
for (const d of deleted) {
|
|
617
|
+
if (d.start >= cursor && d.end <= to) {
|
|
618
|
+
out += baseText.slice(cursor, d.start)
|
|
619
|
+
cursor = d.end
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
out += baseText.slice(cursor, to)
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// A KEY REPEATED IN `order` USED TO EMIT ITS UNIT AGAIN. `order` is assembled from both
|
|
626
|
+
// sides' unit lists, so any key present in both can appear twice, and each appearance
|
|
627
|
+
// wrote the declaration out. On a whole-file rewrite that is every declaration in the
|
|
628
|
+
// file, which is how a merge silently doubled a source file instead of merging it.
|
|
629
|
+
// Emit each unit exactly once, at its first ordered position.
|
|
630
|
+
const seenKeys = new Set()
|
|
631
|
+
for (const k of order) {
|
|
632
|
+
if (seenKeys.has(k)) continue
|
|
633
|
+
seenKeys.add(k)
|
|
634
|
+
if (baseKeySet.has(k)) {
|
|
635
|
+
const u = baseByKey.get(k)
|
|
636
|
+
if (u.start >= pos) {
|
|
637
|
+
emitGap(pos, u.start)
|
|
638
|
+
pos = u.end
|
|
639
|
+
}
|
|
640
|
+
// A missing key used to concatenate the string "undefined" into the source.
|
|
641
|
+
const val = textByKey.get(k)
|
|
642
|
+
if (val != null) out += val
|
|
643
|
+
} else {
|
|
644
|
+
// New unit: insert it exactly where the merge order dictated
|
|
645
|
+
const val = textByKey.get(k)
|
|
646
|
+
if (val != null) {
|
|
647
|
+
out += (out.endsWith('\n') ? '' : '\n') + val + '\n'
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
if (pos < baseText.length) {
|
|
652
|
+
emitGap(pos, baseText.length)
|
|
653
|
+
}
|
|
654
|
+
// Top-level: drop orphan leading blank lines a deleted first decl leaves behind. Inner
|
|
655
|
+
// splice (trimLeadingBlank:false): keep the body's own leading `\n ` so indentation holds.
|
|
656
|
+
return opts.trimLeadingBlank === false ? out : out.replace(/^\n+/, '')
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// --- HyperAST Caching -----------------------------------------------------------
|
|
660
|
+
// A memory cache to bypass redundant AST parsing. This gives O(1) lookups for
|
|
661
|
+
// unchanged structures during interleaved diff3 merges, dropping engine execution
|
|
662
|
+
// time drastically on large files.
|
|
663
|
+
const providerCache = new WeakMap();
|
|
664
|
+
|
|
665
|
+
function withHyperAST(lang) {
|
|
666
|
+
if (providerCache.has(lang)) return providerCache.get(lang);
|
|
667
|
+
|
|
668
|
+
const cache = new Map();
|
|
669
|
+
const get = (op, text, fn) => {
|
|
670
|
+
if (typeof text !== 'string' || text.length > 500000) return fn(); // Bypass for massive strings
|
|
671
|
+
const key = op + ':' + text;
|
|
672
|
+
let res = cache.get(key);
|
|
673
|
+
if (res !== undefined) return res;
|
|
674
|
+
res = fn();
|
|
675
|
+
if (cache.size > 10000) cache.clear();
|
|
676
|
+
cache.set(key, res);
|
|
677
|
+
return res;
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
const wrapped = Object.create(lang);
|
|
681
|
+
if (lang.parses) wrapped.parses = (src) => get('parses', src, () => lang.parses(src));
|
|
682
|
+
if (lang.units) wrapped.units = (src) => get('units', src, () => lang.units(src));
|
|
683
|
+
if (lang.tokenize) wrapped.tokenize = (src) => get('tokenize', src, () => lang.tokenize(src));
|
|
684
|
+
if (lang.splitUnit) wrapped.splitUnit = (src) => get('splitUnit', src, () => lang.splitUnit(src));
|
|
685
|
+
if (lang.declaredNames) wrapped.declaredNames = (src) => get('declaredNames', src, () => lang.declaredNames(src));
|
|
686
|
+
|
|
687
|
+
providerCache.set(lang, wrapped);
|
|
688
|
+
return wrapped;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// --- public API -----------------------------------------------------------------
|
|
692
|
+
|
|
693
|
+
// Does this source parse under the given (or default JS) language?
|
|
694
|
+
export function parses(src, lang = javascript) { return withHyperAST(lang).parses(src) }
|
|
695
|
+
|
|
696
|
+
// 3-way STRUCTURAL + INTENT merge of `a` and `b` against common ancestor `base`.
|
|
697
|
+
// opts: { lang } a provider, or { filename } to pick one by extension (defaults to JS).
|
|
698
|
+
// Returns { status, text, conflicts, renames? }:
|
|
699
|
+
// 'auto' — clean merge; `text` is valid, parseable code.
|
|
700
|
+
// 'semantic-conflict' — same declaration changed both sides, or a dangling reference;
|
|
701
|
+
// `conflicts` names them (e.g. ['fn:login'] or ['ref:helper']).
|
|
702
|
+
// 'fallback' — couldn't merge safely (unparseable input, or the merge wouldn't
|
|
703
|
+
// parse, or unsupported language); caller keeps both. Never broken.
|
|
704
|
+
export function structuralMerge(base, a, b, opts = {}) {
|
|
705
|
+
let lang = opts.lang || (opts.filename ? languageFor(opts.filename) : javascript) || javascript
|
|
706
|
+
lang = withHyperAST(lang)
|
|
707
|
+
|
|
708
|
+
if (!lang.parses(base) || !lang.parses(a) || !lang.parses(b))
|
|
709
|
+
return { status: 'fallback', text: null, conflicts: [], reason: 'unparseable input' }
|
|
710
|
+
|
|
711
|
+
// FIXED-POINT fast path: once two peers agree on a text T, re-merging must return T
|
|
712
|
+
// unchanged. This is what makes the live sync settle. Both agree ⇒ nothing to check.
|
|
713
|
+
if (a === b) return { status: 'auto', text: a, conflicts: [], renames: [], provenance: [] }
|
|
714
|
+
|
|
715
|
+
const authors = opts.authors || {}
|
|
716
|
+
const attribute = (prov) => (prov || []).map((p) => ({ unit: p.key, author: authors[p.from] || p.from }))
|
|
717
|
+
|
|
718
|
+
// Determine the structurally-merged text + provenance. A one-sided change takes that
|
|
719
|
+
// side verbatim (format perfectly preserved); otherwise do the keyed 3-way merge and
|
|
720
|
+
// splice it back into base. Either way the INTENT layer below still runs, so a deletion
|
|
721
|
+
// that leaves a dangling reference is caught even when only one side changed.
|
|
722
|
+
let text, provenance, gapCheck = null
|
|
723
|
+
if (a === base) { text = b; provenance = [] }
|
|
724
|
+
else if (b === base) { text = a; provenance = [] }
|
|
725
|
+
else if (lang.mergeWhole) {
|
|
726
|
+
// WHOLE-DOCUMENT MERGE: a provider that understands the entire document as DATA
|
|
727
|
+
// (e.g. JSON) merges it directly — real-parser accuracy, value-wise 3-way semantics,
|
|
728
|
+
// conflicts named by path. Returns { text } on success, { conflicts, resolvable? } on
|
|
729
|
+
// a same-value clash, or null when it can't (engine falls back safely).
|
|
730
|
+
const w = lang.mergeWhole(base, a, b)
|
|
731
|
+
if (!w) return { status: 'fallback', text: null, conflicts: [], reason: 'whole-document merge failed' }
|
|
732
|
+
if (w.conflicts && w.conflicts.length) return { status: 'semantic-conflict', text: null, conflicts: w.conflicts, resolvable: w.resolvable || [] }
|
|
733
|
+
text = w.text; provenance = w.provenance || []
|
|
734
|
+
}
|
|
735
|
+
else {
|
|
736
|
+
const baseUnits = lang.units(base)
|
|
737
|
+
const merge = mergeKeyed(lang, baseUnits, lang.units(a), lang.units(b), { unionInserts: !!opts.unionInserts })
|
|
738
|
+
if (merge.conflicts.length) return { status: 'semantic-conflict', text: null, conflicts: merge.conflicts, resolvable: merge.conflictUnits }
|
|
739
|
+
// Prefer the format-preserving splice (units carry byte ranges); fall back to a plain
|
|
740
|
+
// join for languages whose units don't expose ranges.
|
|
741
|
+
if (hasRanges(baseUnits)) {
|
|
742
|
+
// THE SPLICE BASIS DECIDES WHOSE COMMENTS SURVIVE (2 Oct 2026). Everything between
|
|
743
|
+
// top-level units comes from the text we splice into, and this used to be base, always:
|
|
744
|
+
// a comment one side added above a function, after an import, or at the end of the file
|
|
745
|
+
// vanished whenever the other side changed anything else, and the merge still said
|
|
746
|
+
// 'auto'. Both real merges of that night lost comments this way, with code identical to
|
|
747
|
+
// git's. So, as the inner splice does: if exactly one side changed the between-unit
|
|
748
|
+
// lines, splice into that side's text; if both did, refuse, and the line merge keeps both
|
|
749
|
+
// or conflicts. addedLinesKept() then checks the result, which also covers any provider
|
|
750
|
+
// whose units leave a side's comment outside every range. (tests/icr-gap-comments-test.mjs)
|
|
751
|
+
let basisText = base, basisUnits = baseUnits
|
|
752
|
+
if (hasRanges(merge.aU) && hasRanges(merge.bU)) {
|
|
753
|
+
const gBase = gapLines(base, baseUnits), gA = gapLines(a, merge.aU), gB = gapLines(b, merge.bU)
|
|
754
|
+
const aGap = !sameLines(gA, gBase), bGap = !sameLines(gB, gBase)
|
|
755
|
+
if (aGap && bGap) return { status: 'fallback', text: null, conflicts: [], reason: 'both sides edited the comments between declarations' }
|
|
756
|
+
if (aGap) { basisText = a; basisUnits = merge.aU } else if (bGap) { basisText = b; basisUnits = merge.bU }
|
|
757
|
+
gapCheck = { gBase, gA, gB }
|
|
758
|
+
}
|
|
759
|
+
text = spliceUnits(basisText, basisUnits, merge.order, merge.textByKey)
|
|
760
|
+
} else text = merge.parts.join('\n\n') + '\n'
|
|
761
|
+
provenance = merge.provenance
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// THE GUARANTEE: the merged result must parse, or we refuse it.
|
|
765
|
+
// THE GUARANTEE stands: a merge that would not parse is REFUSED — text stays null so no
|
|
766
|
+
// caller can ever mistake broken output for a merge. (Debugging wants the bytes? That is
|
|
767
|
+
// what `debugText` is for; it is deliberately not `text`.)
|
|
768
|
+
if (!lang.parses(text)) return { status: 'fallback', text: null, debugText: text, conflicts: [], reason: 'merge would not parse' }
|
|
769
|
+
// ...and it must not drop a comment one side wrote between declarations (see the splice basis).
|
|
770
|
+
if (gapCheck) {
|
|
771
|
+
const mu = lang.units(text)
|
|
772
|
+
if (!hasRanges(mu) || !addedLinesKept(gapCheck.gBase, gapCheck.gA, gapCheck.gB, gapLines(text, mu)))
|
|
773
|
+
return { status: 'fallback', text: null, debugText: text, conflicts: [], reason: 'merge would drop a comment one side added' }
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// RENAME DETECTION (intent): a declaration gone from base whose identical-bodied twin
|
|
777
|
+
// appears under a new name is a rename — rewrite stale call sites to the new name.
|
|
778
|
+
let merged = text
|
|
779
|
+
const baseNames = lang.declaredNames(base)
|
|
780
|
+
let mergedNames = lang.declaredNames(merged)
|
|
781
|
+
let removed = [...baseNames].filter((n) => !mergedNames.has(n))
|
|
782
|
+
const renames = []
|
|
783
|
+
const rename = lang.renameFreeRefs || lang.renameRefs // prefer scope-aware rewriting
|
|
784
|
+
|
|
785
|
+
if (removed.length && lang.declBody && rename) {
|
|
786
|
+
const added = [...mergedNames].filter((n) => !baseNames.has(n))
|
|
787
|
+
const claimed = new Set()
|
|
788
|
+
for (const oldName of removed) {
|
|
789
|
+
const oldBody = lang.declBody(base, oldName)
|
|
790
|
+
if (oldBody == null) continue
|
|
791
|
+
const match = added.find((n) => !claimed.has(n) && lang.declBody(merged, n) === oldBody)
|
|
792
|
+
if (match) { renames.push([oldName, match]); claimed.add(match) }
|
|
793
|
+
}
|
|
794
|
+
let rewritten = merged
|
|
795
|
+
// Rewrite only references that resolve to the renamed binding (scope-aware) — a local
|
|
796
|
+
// variable that merely shares the old name is left untouched.
|
|
797
|
+
for (const [oldName, newName] of renames) rewritten = rename(rewritten, oldName, newName)
|
|
798
|
+
// only accept the rewrite if it still parses — never trade a parse error for intent.
|
|
799
|
+
if (renames.length && lang.parses(rewritten)) { merged = rewritten; mergedNames = lang.declaredNames(merged) }
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// ASYNC/AWAIT SYNCHRONIZATION (intent):
|
|
803
|
+
// When a declaration was synchronous in base and became ASYNC in the merged result,
|
|
804
|
+
// upgrade call sites inside async callers with `await` so the contract holds.
|
|
805
|
+
const asyncUpgrades = []
|
|
806
|
+
if (lang.isAsyncDecl && lang.upgradeCallSitesToAwait) {
|
|
807
|
+
for (const name of mergedNames) {
|
|
808
|
+
const wasAsyncInBase = baseNames.has(name) ? lang.isAsyncDecl(base, name) : false
|
|
809
|
+
const isAsyncInMerged = lang.isAsyncDecl(merged, name)
|
|
810
|
+
if (!wasAsyncInBase && isAsyncInMerged) {
|
|
811
|
+
const upgraded = lang.upgradeCallSitesToAwait(merged, name)
|
|
812
|
+
if (upgraded !== merged && lang.parses(upgraded)) {
|
|
813
|
+
merged = upgraded
|
|
814
|
+
asyncUpgrades.push(name)
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// DANGLING REFERENCE (intent): a declaration removed (and NOT explained by a rename)
|
|
821
|
+
// but still referenced is a semantic conflict even though the code parses cleanly.
|
|
822
|
+
// Uses the SCOPE-AWARE reference set when the language provides one (so a local binding
|
|
823
|
+
// that merely shares the deleted name is correctly NOT treated as a reference to it),
|
|
824
|
+
// falling back to the approximate identifier set otherwise.
|
|
825
|
+
removed = [...baseNames].filter((n) => !mergedNames.has(n))
|
|
826
|
+
const refs = lang.referencedFreeNames ? lang.referencedFreeNames(merged)
|
|
827
|
+
: lang.usedIdentifiers ? lang.usedIdentifiers(merged) : null
|
|
828
|
+
if (process.env.ICR_DEBUG_MERGED) console.error('--- ICR merged intermediate ---\n' + merged + '\n--- refs: ' + [...(refs || [])].join(',') + ' ---')
|
|
829
|
+
if (removed.length && refs) {
|
|
830
|
+
const dangling = removed.filter((n) => refs.has(n))
|
|
831
|
+
if (dangling.length)
|
|
832
|
+
return { status: 'semantic-conflict', text: null, conflicts: dangling.map((n) => 'ref:' + n), reason: 'dangling reference: a declaration was removed/renamed but is still used' }
|
|
833
|
+
}
|
|
834
|
+
// NO COMMENT EITHER SIDE ADDED MAY GO MISSING, wherever it sits and whichever path merged it
|
|
835
|
+
// (2 Oct 2026). The splice basis and the inner rules above handle the cases found so far; this
|
|
836
|
+
// is the net under all of them, because the defect class is a merge that is green and short.
|
|
837
|
+
// An independent fuzz found the Python case after the JS fix had passed its own tests.
|
|
838
|
+
if (!addedLinesKept(commentLines(base, opts.filename), commentLines(a, opts.filename), commentLines(b, opts.filename), commentLines(merged, opts.filename)))
|
|
839
|
+
return { status: 'fallback', text: null, debugText: merged, conflicts: [], reason: 'merge would drop a comment one side added' }
|
|
840
|
+
// PROVENANCE: attribute each surviving unit to the author whose version we kept. With
|
|
841
|
+
// opts.authors = { a, b, base } the labels are real names; otherwise they're 'a'/'b'/etc.
|
|
842
|
+
return {
|
|
843
|
+
status: 'auto', text: merged, conflicts: [],
|
|
844
|
+
renames: renames.map(([o, n]) => o + '->' + n),
|
|
845
|
+
asyncUpgrades,
|
|
846
|
+
provenance: attribute(provenance),
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// Internal handles for diagnostic harnesses (not part of the public API).
|
|
851
|
+
export const _internals = { tokenMerge, tokenMerge3, tokenHunks, tryInnerMerge, lineMerge3 }
|