hivecode-mcp 0.14.1 → 0.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/HIVE_RULES.md +98 -98
  2. package/LICENSE +21 -21
  3. package/README.md +421 -421
  4. package/core.js +91 -91
  5. package/docs/ALWAYS_ON.md +310 -310
  6. package/docs/GITHUB_WORKFLOW.md +394 -394
  7. package/docs/HIVE_RULES.md +41 -41
  8. package/docs/MCP.md +157 -157
  9. package/docs/QUICKSTART.md +266 -266
  10. package/docs/SELF_HOSTING.md +256 -256
  11. package/docs/SLACK_SETUP.md +474 -474
  12. package/docs/TROUBLESHOOTING.md +105 -105
  13. package/hive-activity.js +336 -336
  14. package/hive-authors.js +46 -44
  15. package/hive-await.js +230 -230
  16. package/hive-branch.js +608 -607
  17. package/hive-busy.js +88 -88
  18. package/hive-coord.js +110 -110
  19. package/hive-gate.js +135 -135
  20. package/hive-github.js +3 -1
  21. package/hive-hook-install.js +179 -179
  22. package/hive-instructions.js +74 -74
  23. package/hive-land.js +695 -691
  24. package/hive-listen.js +81 -81
  25. package/hive-mailbox.js +520 -520
  26. package/hive-mcp-http.js +591 -591
  27. package/hive-mcp.js +33 -6
  28. package/hive-mux-proto.js +58 -58
  29. package/hive-mux.js +351 -351
  30. package/hive-notify.js +234 -233
  31. package/hive-obligations.js +247 -247
  32. package/hive-poll-proto.js +50 -50
  33. package/hive-pr-staleness.js +189 -189
  34. package/hive-pr.js +600 -512
  35. package/hive-privacy.js +327 -327
  36. package/hive-receipts.js +233 -233
  37. package/hive-relay-health.js +234 -234
  38. package/hive-repos.js +430 -430
  39. package/hive-room-key.js +210 -210
  40. package/hive-room-mode.js +1668 -1668
  41. package/hive-room-watcher.js +562 -561
  42. package/hive-secrets.js +89 -89
  43. package/hive-session.js +114 -114
  44. package/hive-setup.js +560 -560
  45. package/hive-slack-agent.js +408 -408
  46. package/hive-slack.js +2384 -2367
  47. package/hive-stdio.js +172 -172
  48. package/hive-stop-hook.js +293 -293
  49. package/hive-throttle.js +118 -118
  50. package/hive-time.js +71 -71
  51. package/hive-transport.js +578 -578
  52. package/hive-update.js +58 -58
  53. package/hive-version.js +110 -110
  54. package/hive-webhook.js +55 -54
  55. package/icr-merge.js +58 -58
  56. package/icr.js +851 -851
  57. package/lang-brace.js +305 -305
  58. package/lang-js.js +651 -651
  59. package/lang-json.js +211 -211
  60. package/lang-python.js +588 -588
  61. package/lang-ruby.js +230 -230
  62. package/lang-toml.js +136 -136
  63. package/lang-yaml.js +183 -183
  64. package/limits.js +413 -413
  65. package/package.json +1 -1
  66. package/reconcile.cjs +621 -621
  67. package/slack-app-manifest-agent.json +61 -61
  68. package/slack-app-manifest-inbound.json +36 -36
  69. package/slack-app-manifest.json +27 -27
  70. package/substrate.js +252 -252
  71. package/sync.js +169 -11
  72. package/token.js +201 -201
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 }