hivecode-mcp 0.8.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -68
- package/core.js +62 -13
- package/hive-mcp.js +458 -48
- package/icr.js +502 -35
- package/lang-brace.js +240 -174
- package/lang-js.js +642 -418
- package/lang-json.js +184 -0
- package/lang-python.js +360 -149
- package/lang-ruby.js +230 -0
- package/lang-toml.js +136 -0
- package/lang-yaml.js +151 -0
- package/package.json +97 -70
- package/sync.js +220 -10
- package/MCP.md +0 -77
package/icr.js
CHANGED
|
@@ -28,18 +28,28 @@
|
|
|
28
28
|
import { javascript } from './lang-js.js'
|
|
29
29
|
import { braceLanguages } from './lang-brace.js'
|
|
30
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'
|
|
31
35
|
|
|
32
36
|
// --- language registry ----------------------------------------------------------
|
|
33
37
|
// JavaScript (acorn, full intent layer) first; then structural providers for the
|
|
34
|
-
// C-family (TypeScript, Go, Rust, Java, C/C++, C#, Swift, Kotlin, …) and
|
|
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).
|
|
35
41
|
// These cover disjoint extensions, so order only matters on clashes (none today).
|
|
36
|
-
const LANGUAGES = [javascript, ...braceLanguages, python]
|
|
42
|
+
const LANGUAGES = [javascript, ...braceLanguages, python, ruby, json, yaml, toml]
|
|
37
43
|
|
|
38
44
|
// Register an additional language provider. Must expose the provider contract:
|
|
39
45
|
// { id, exts:[...], parses, units, declaredNames, usedIdentifiers, declBody,
|
|
40
46
|
// renameRefs, fnParts }. Newest wins on extension clashes.
|
|
41
47
|
export function registerLanguage(provider) { LANGUAGES.unshift(provider) }
|
|
42
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
|
+
|
|
43
53
|
function extname(p) { const m = /\.[^.\/\\]+$/.exec(p || ''); return m ? m[0].toLowerCase() : '' }
|
|
44
54
|
|
|
45
55
|
// The provider for a filename, or null if no registered language claims its extension.
|
|
@@ -66,15 +76,80 @@ function pick(k, B, A, Bb) {
|
|
|
66
76
|
return base != null ? base : (a != null ? a : b)
|
|
67
77
|
}
|
|
68
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
|
+
|
|
69
141
|
// Core 3-way merge over a KEYED list of units (works at file level AND, recursively,
|
|
70
142
|
// inside a single declaration). Returns { conflicts:[...], parts:[...text] }.
|
|
71
143
|
// When both sides change the SAME key into different things, it first tries to descend
|
|
72
144
|
// INTO that unit (finer granularity) — only a clash it can't resolve becomes a conflict.
|
|
73
|
-
function mergeKeyed(lang, baseU, aU, bU) {
|
|
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)
|
|
74
149
|
const B = mapOf(baseU), A = mapOf(aU), Bb = mapOf(bU)
|
|
75
150
|
const keys = dedupeOrder([...baseU.map((u) => u.key), ...aU.map((u) => u.key), ...bU.map((u) => u.key)])
|
|
76
151
|
|
|
77
|
-
const conflicts = [], resolved = new Map()
|
|
152
|
+
const conflicts = [], conflictUnits = [], resolved = new Map()
|
|
78
153
|
for (const k of keys) {
|
|
79
154
|
const bs = B.has(k) ? B.get(k) : null
|
|
80
155
|
const as_ = A.has(k) ? A.get(k) : null
|
|
@@ -83,13 +158,17 @@ function mergeKeyed(lang, baseU, aU, bU) {
|
|
|
83
158
|
if (changedA && changedB && as_ !== bbs) {
|
|
84
159
|
// Both touched the same unit, differently. Try finer-grained inner merge first.
|
|
85
160
|
if (bs != null && as_ != null && bbs != null) {
|
|
86
|
-
const inner = tryInnerMerge(lang, bs, as_, bbs)
|
|
161
|
+
const inner = tryInnerMerge(lang, bs, as_, bbs, mopts)
|
|
87
162
|
if (inner != null) { resolved.set(k, inner); continue }
|
|
88
163
|
}
|
|
89
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 })
|
|
90
169
|
}
|
|
91
170
|
}
|
|
92
|
-
if (conflicts.length) return { conflicts, parts: [] }
|
|
171
|
+
if (conflicts.length) return { conflicts, conflictUnits, parts: [] }
|
|
93
172
|
|
|
94
173
|
// No unresolved clashes → decide which keys survive, honoring one-sided deletions.
|
|
95
174
|
const survives = new Set()
|
|
@@ -105,15 +184,53 @@ function mergeKeyed(lang, baseU, aU, bU) {
|
|
|
105
184
|
survives.add(k)
|
|
106
185
|
}
|
|
107
186
|
|
|
108
|
-
// CONVERGENCE
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
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).
|
|
112
194
|
const baseOrder = dedupeOrder(baseU.map((u) => u.key))
|
|
113
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
|
+
|
|
114
228
|
const final = []
|
|
115
|
-
|
|
116
|
-
for (const k of
|
|
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
|
+
}
|
|
117
234
|
|
|
118
235
|
// Assemble, recording PROVENANCE per surviving unit: who authored the version we kept
|
|
119
236
|
// ('a' / 'b' changed it, 'both' if we merged inside it, 'base' if unchanged).
|
|
@@ -135,52 +252,364 @@ function mergeKeyed(lang, baseU, aU, bU) {
|
|
|
135
252
|
// matches and they edited DIFFERENT inner units (statements / class members), merge those
|
|
136
253
|
// recursively rather than declaring the whole declaration a conflict. Returns merged
|
|
137
254
|
// text, or null meaning "couldn't safely merge inside — treat as a real conflict."
|
|
138
|
-
function tryInnerMerge(lang, baseText, aText, bText) {
|
|
255
|
+
function tryInnerMerge(lang, baseText, aText, bText, mopts = {}) {
|
|
139
256
|
// Language-specific same-key merges first (e.g. unioning import specifiers).
|
|
140
257
|
if (lang.mergeUnit) {
|
|
141
258
|
const t = lang.mergeUnit(baseText, aText, bText)
|
|
142
259
|
if (t != null && lang.parses(t)) return t
|
|
143
260
|
}
|
|
261
|
+
// Structural inner merge (declaration → statements, class → members): format-preserving
|
|
262
|
+
// and well-tested, so it goes first. Returns null when it can't safely resolve.
|
|
263
|
+
const structural = tryStructuralInner(lang, baseText, aText, bText, mopts)
|
|
264
|
+
if (structural != null) return structural
|
|
265
|
+
// Unit-level oracle: providers with a fragment validator (parsesUnit) get every finer-
|
|
266
|
+
// grained result checked HERE, so an invalid inner merge becomes an honest per-unit
|
|
267
|
+
// conflict (judge-resolvable) instead of poisoning the whole file into fallback.
|
|
268
|
+
// Providers without one (JS: a bare `return …` fragment is valid only in context) keep
|
|
269
|
+
// deferring to the whole-file gate, as before.
|
|
270
|
+
const unitOk = lang.parsesUnit ? (t) => t != null && lang.parsesUnit(t) : (t) => t != null
|
|
271
|
+
// LINE-LEVEL diff3: whole lines as atoms, so indentation travels with its line — the
|
|
272
|
+
// safe multi-hunk tier, and the workhorse for "both sides edited different lines of the
|
|
273
|
+
// same declaration" (the most common same-unit case in real repos).
|
|
274
|
+
const lm = lineMerge3(baseText, aText, bText, mopts)
|
|
275
|
+
if (unitOk(lm)) return lm
|
|
276
|
+
// FINEST GRANULARITY: a single line/statement both sides edited — token-level 3-way, so
|
|
277
|
+
// two edits INSIDE one line (`foo(1, 2)` → one side edits each argument) merge the way a
|
|
278
|
+
// tree-sitter merge does, instead of conflicting on the whole statement. For providers
|
|
279
|
+
// without parsesUnit, validity is enforced where the result lands (whole-file re-parse).
|
|
280
|
+
const tm = tokenMerge(lang, baseText, aText, bText, mopts)
|
|
281
|
+
if (tm != null && lang.parsesUnit && !lang.parsesUnit(tm)) return null
|
|
282
|
+
return tm
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function tryStructuralInner(lang, baseText, aText, bText, mopts = {}) {
|
|
144
286
|
if (!lang.splitUnit) return null
|
|
145
287
|
const pb = lang.splitUnit(baseText), pa = lang.splitUnit(aText), pbb = lang.splitUnit(bText)
|
|
146
288
|
if (!pb || !pa || !pbb) return null
|
|
147
|
-
if (pa.sig.trim() !== pbb.sig.trim()) return null
|
|
148
|
-
const m = mergeKeyed(lang, pb.units, pa.units, pbb.units)
|
|
289
|
+
if (pa.sig.trim() !== pbb.sig.trim()) return null
|
|
290
|
+
const m = mergeKeyed(lang, pb.units, pa.units, pbb.units, mopts)
|
|
149
291
|
if (m.conflicts.length) return null
|
|
150
|
-
|
|
292
|
+
// FORMAT-PRESERVING inner assembly: when the provider exposes the body text + byte-ranged
|
|
293
|
+
// units (spliceable), rebuild the body by splicing merged units back into the BASE body,
|
|
294
|
+
// so everything BETWEEN statements — comments, blank lines, indentation — survives verbatim.
|
|
295
|
+
// (Join-based reassembly with a fixed separator silently drops inner comments and reflows
|
|
296
|
+
// whitespace; found in the wild merging ICR's own source, and the C-family class-brace glue.)
|
|
297
|
+
// Objects and any non-spliceable provider fall back to the separator join.
|
|
298
|
+
let text
|
|
299
|
+
const spliceable = pb.spliceable && typeof pb.body === 'string' && pb.units.every((u) => typeof u.start === 'number' && typeof u.end === 'number')
|
|
300
|
+
if (spliceable) {
|
|
301
|
+
// COMMENT-EDIT PRESERVATION: gaps (comments/blank lines between statements) come from
|
|
302
|
+
// the splice BASIS. Splicing from base loses a side's comment-only edit. So: if exactly
|
|
303
|
+
// ONE side changed only gaps (its units are byte-identical to base's), splice into THAT
|
|
304
|
+
// side's body — its comment edit survives and the other side's code edits land on top.
|
|
305
|
+
// If BOTH sides changed only gaps, refuse (null) so the unit surfaces as a conflict
|
|
306
|
+
// instead of silently dropping either side's comment.
|
|
307
|
+
const sameUnits = (u1, u2) => u1.length === u2.length && u1.every((u, i) => u.key === u2[i].key && u.text === u2[i].text)
|
|
308
|
+
const aGapOnly = pa.body !== pb.body && sameUnits(pa.units, pb.units)
|
|
309
|
+
const bGapOnly = pbb.body !== pb.body && sameUnits(pbb.units, pb.units)
|
|
310
|
+
if (aGapOnly && bGapOnly) return null
|
|
311
|
+
const [basisBody, basisUnits] = aGapOnly ? [pa.body, pa.units] : bGapOnly ? [pbb.body, pbb.units] : [pb.body, pb.units]
|
|
312
|
+
const body = spliceUnits(basisBody, basisUnits, m.order, m.textByKey, { trimLeadingBlank: false })
|
|
313
|
+
text = pa.sig + pa.open + body + pa.close
|
|
314
|
+
} else {
|
|
315
|
+
text = pa.sig + pa.open + m.parts.join(pa.join) + pa.close
|
|
316
|
+
}
|
|
151
317
|
const valid = lang.parsesUnit || lang.parses // accept class-member fragments when supported
|
|
152
318
|
return valid(text) ? text : null
|
|
153
319
|
}
|
|
154
320
|
|
|
321
|
+
// TOKEN-LEVEL 3-way merge of one unit. base/a/b are the unit texts, both differing from base
|
|
322
|
+
// (the caller only asks when both sides changed the same unit). Diff each side against base at
|
|
323
|
+
// TOKEN granularity — one changed span via common prefix + common suffix — map each span to a
|
|
324
|
+
// base CHARACTER range, and, if the two base spans are DISJOINT, splice both sides' replacement
|
|
325
|
+
// text into base. Everything OUTSIDE the two edits comes from base verbatim (whitespace,
|
|
326
|
+
// comments); inside, each side's exact bytes. Overlapping or ambiguously-adjacent edits → null
|
|
327
|
+
// (a real conflict). Symmetric by construction: the result is base with two position-disjoint
|
|
328
|
+
// substring replacements, independent of which side is a or b — so live peers converge.
|
|
329
|
+
function tokenDiffRange(base, other) {
|
|
330
|
+
const n = base.length, m = other.length
|
|
331
|
+
let eq = n === m
|
|
332
|
+
if (eq) { for (let i = 0; i < n; i++) if (base[i] !== other[i]) { eq = false; break } }
|
|
333
|
+
if (eq) return null
|
|
334
|
+
let p = 0
|
|
335
|
+
const maxp = Math.min(n, m)
|
|
336
|
+
while (p < maxp && base[p] === other[p]) p++
|
|
337
|
+
let s = 0
|
|
338
|
+
while (s < n - p && s < m - p && base[n - 1 - s] === other[m - 1 - s]) s++
|
|
339
|
+
return { bStart: p, bEnd: n - s, oStart: p, oEnd: m - s }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function tokenMerge(lang, baseText, aText, bText, mopts = {}) {
|
|
343
|
+
if (!lang.tokenize) return null
|
|
344
|
+
const B = lang.tokenize(baseText), A = lang.tokenize(aText), Bb = lang.tokenize(bText)
|
|
345
|
+
if (!B || !A || !Bb) return null
|
|
346
|
+
const ra = tokenDiffRange(B.map((t) => t.k), A.map((t) => t.k))
|
|
347
|
+
const rb = tokenDiffRange(B.map((t) => t.k), Bb.map((t) => t.k))
|
|
348
|
+
if (!ra && !rb) return null // both sides changed only whitespace/comments → can't rank them
|
|
349
|
+
// REFORMAT vs CONTENT: one side changed only whitespace/comments (token stream identical
|
|
350
|
+
// to base), the other changed tokens. Apply the content side's token edit ONTO the
|
|
351
|
+
// reformatting side's text — its token stream matches base's, so token indices map its
|
|
352
|
+
// own byte ranges exactly. Both intents survive: the reformat/comment edit AND the code
|
|
353
|
+
// change. (Ground-truth replay: this was a top false-conflict source on real repos.)
|
|
354
|
+
// Symmetric because the roles are decided by CONTENT (which side has the token diff).
|
|
355
|
+
if (!ra || !rb) {
|
|
356
|
+
const [wsToks, wsText, r, toks, text] = !ra ? [A, aText, rb, Bb, bText] : [Bb, bText, ra, A, aText]
|
|
357
|
+
const cStart = r.bStart < wsToks.length ? wsToks[r.bStart].start : wsText.length
|
|
358
|
+
const cEnd = r.bEnd > r.bStart ? wsToks[r.bEnd - 1].end : cStart
|
|
359
|
+
const ins = r.oEnd > r.oStart ? text.slice(toks[r.oStart].start, toks[r.oEnd - 1].end) : ''
|
|
360
|
+
return wsText.slice(0, cStart) + ins + wsText.slice(cEnd)
|
|
361
|
+
}
|
|
362
|
+
const charRange = (r) => {
|
|
363
|
+
const cStart = r.bStart < B.length ? B[r.bStart].start : baseText.length
|
|
364
|
+
const cEnd = r.bEnd > r.bStart ? B[r.bEnd - 1].end : cStart
|
|
365
|
+
return [cStart, cEnd]
|
|
366
|
+
}
|
|
367
|
+
const repl = (toks, text, r) => (r.oEnd > r.oStart ? text.slice(toks[r.oStart].start, toks[r.oEnd - 1].end) : '')
|
|
368
|
+
const [aS, aE] = charRange(ra), [bS, bE] = charRange(rb)
|
|
369
|
+
// Single contiguous spans that don't collide: splice both (the cheap common case).
|
|
370
|
+
if (!(aS < bE && bS < aE) && aS !== bS) {
|
|
371
|
+
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)
|
|
372
|
+
let out = baseText
|
|
373
|
+
for (const ed of edits) out = out.slice(0, ed.s) + ed.t + out.slice(ed.e)
|
|
374
|
+
return out
|
|
375
|
+
}
|
|
376
|
+
// The prefix/suffix spans collide — but each is the BOUNDING BOX of that side's edits,
|
|
377
|
+
// and boxes overlap whenever a side edited in two places (a wrapper rewrite touches both
|
|
378
|
+
// ends, so its box swallows the whole unit). Re-diff each side at REAL multi-hunk
|
|
379
|
+
// granularity (Myers) and do token-level diff3: disjoint hunks interleave, identical
|
|
380
|
+
// hunks dedupe, truly overlapping hunks are the only conflict. Ground-truth replay:
|
|
381
|
+
// "one side rewrote the wrapper, the other edited inside" is a real-repo staple.
|
|
382
|
+
return tokenMerge3(baseText, aText, bText, B, A, Bb, mopts)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Myers O(ND) diff on token-key arrays → edit hunks {bs,be,os,oe} (base/other token ranges).
|
|
386
|
+
function tokenHunks(bk, ok, maxD) {
|
|
387
|
+
const N = bk.length, M = ok.length
|
|
388
|
+
const MAX = Math.min(N + M, maxD)
|
|
389
|
+
const V = new Map([[1, 0]])
|
|
390
|
+
const trace = []
|
|
391
|
+
let D = -1
|
|
392
|
+
outer: for (let d = 0; d <= MAX; d++) {
|
|
393
|
+
trace.push(new Map(V))
|
|
394
|
+
for (let k = -d; k <= d; k += 2) {
|
|
395
|
+
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
|
|
396
|
+
let y = x - k
|
|
397
|
+
while (x < N && y < M && bk[x] === ok[y]) { x++; y++ }
|
|
398
|
+
V.set(k, x)
|
|
399
|
+
if (x >= N && y >= M) { D = d; break outer }
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (D < 0) return null // too different for the budget → caller falls back
|
|
403
|
+
// backtrack into hunks
|
|
404
|
+
const ops = [] // 1 = del base token, 2 = ins other token, stored back-to-front
|
|
405
|
+
let x = N, y = M
|
|
406
|
+
for (let d = D; d > 0; d--) {
|
|
407
|
+
const Vp = trace[d]
|
|
408
|
+
const k = x - y
|
|
409
|
+
const down = k === -d || (k !== d && (Vp.get(k - 1) ?? 0) < (Vp.get(k + 1) ?? 0))
|
|
410
|
+
const pk = down ? k + 1 : k - 1
|
|
411
|
+
const px = Vp.get(pk) ?? 0
|
|
412
|
+
const py = px - pk
|
|
413
|
+
while (x > px + (down ? 0 : 1) && y > py + (down ? 1 : 0)) { x--; y-- } // snake
|
|
414
|
+
if (down) { y--; ops.push([2, x, y]) } else { x--; ops.push([1, x, y]) }
|
|
415
|
+
}
|
|
416
|
+
ops.reverse()
|
|
417
|
+
const hunks = []
|
|
418
|
+
for (const [op, bx, oy] of ops) {
|
|
419
|
+
const last = hunks[hunks.length - 1]
|
|
420
|
+
if (op === 1) {
|
|
421
|
+
if (last && last.be === bx && last.oe === oy) last.be = bx + 1
|
|
422
|
+
else hunks.push({ bs: bx, be: bx + 1, os: oy, oe: oy })
|
|
423
|
+
} else {
|
|
424
|
+
if (last && last.be === bx && last.oe === oy) last.oe = oy + 1
|
|
425
|
+
else hunks.push({ bs: bx, be: bx, os: oy, oe: oy + 1 })
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return hunks
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// diff3 over two hunk lists: identical hunks dedupe; disjoint hunks both apply; overlap
|
|
432
|
+
// → conflict (null). Two INSERTIONS at the same base point are ambiguous (order
|
|
433
|
+
// unknowable) → conflict. `ak`/`bbk` are each side's atom arrays (token keys or lines),
|
|
434
|
+
// used to compare replacement content for the dedupe check. Returns [{h, side}] in base
|
|
435
|
+
// order, or null. Symmetric: ordering decisions are content/position-based, never
|
|
436
|
+
// side-identity-based, so merge(a,b) === merge(b,a) is preserved by construction.
|
|
437
|
+
function diff3Interleave(ha, hb, ak, bbk, opts = {}) {
|
|
438
|
+
const sameHunk = (x, y) => x.bs === y.bs && x.be === y.be && (x.oe - x.os) === (y.oe - y.os) &&
|
|
439
|
+
ak.slice(x.os, x.oe).every((k, i) => k === bbk[y.os + i])
|
|
440
|
+
const edits = []
|
|
441
|
+
let i = 0, j = 0
|
|
442
|
+
while (i < ha.length || j < hb.length) {
|
|
443
|
+
const x = ha[i], y = hb[j]
|
|
444
|
+
if (x && y) {
|
|
445
|
+
const overlap = x.bs < y.be && y.bs < x.be
|
|
446
|
+
const samePointInsert = x.bs === y.bs && x.be === x.bs && y.be === y.bs
|
|
447
|
+
if (overlap || samePointInsert) {
|
|
448
|
+
if (sameHunk(x, y)) { edits.push({ h: x, side: 'a' }); i++; j++; continue }
|
|
449
|
+
// UNION MODE (opt-in, for independent-intent merges — the multi-agent case where
|
|
450
|
+
// the medium KNOWS the two edits serve different tasks): two pure INSERTIONS at
|
|
451
|
+
// the same base point both survive, in content order (side-independent, so peers
|
|
452
|
+
// converge). Everything else (true overlaps, edit-vs-edit) still conflicts, and
|
|
453
|
+
// the parse gates downstream still refuse any union that breaks the code.
|
|
454
|
+
if (samePointInsert && opts.unionInserts) {
|
|
455
|
+
const ka = ak.slice(x.os, x.oe).join('\n'), kb = bbk.slice(y.os, y.oe).join('\n')
|
|
456
|
+
const [first, second] = ka <= kb ? [{ h: x, side: 'a' }, { h: y, side: 'b' }] : [{ h: y, side: 'b' }, { h: x, side: 'a' }]
|
|
457
|
+
edits.push(first, second); i++; j++; continue
|
|
458
|
+
}
|
|
459
|
+
return null
|
|
460
|
+
}
|
|
461
|
+
if (x.bs < y.bs || (x.bs === y.bs && x.be <= y.bs && x.be < y.be)) { edits.push({ h: x, side: 'a' }); i++ }
|
|
462
|
+
else { edits.push({ h: y, side: 'b' }); j++ }
|
|
463
|
+
} else if (x) { edits.push({ h: x, side: 'a' }); i++ }
|
|
464
|
+
else { edits.push({ h: y, side: 'b' }); j++ }
|
|
465
|
+
}
|
|
466
|
+
return edits
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const TOKEN3_MAX_TOKENS = 20000, TOKEN3_MAX_D = 4000
|
|
470
|
+
function tokenMerge3(baseText, aText, bText, B, A, Bb, mopts = {}) {
|
|
471
|
+
if (B.length > TOKEN3_MAX_TOKENS || A.length > TOKEN3_MAX_TOKENS || Bb.length > TOKEN3_MAX_TOKENS) return null
|
|
472
|
+
const bk = B.map((t) => t.k)
|
|
473
|
+
const ha = tokenHunks(bk, A.map((t) => t.k), TOKEN3_MAX_D)
|
|
474
|
+
const hb = tokenHunks(bk, Bb.map((t) => t.k), TOKEN3_MAX_D)
|
|
475
|
+
if (!ha || !hb) return null
|
|
476
|
+
const edits = diff3Interleave(ha, hb, A.map((t) => t.k), Bb.map((t) => t.k), mopts)
|
|
477
|
+
if (!edits) return null
|
|
478
|
+
// splice back-to-front, mapping token hunks to base char ranges; replacement bytes come
|
|
479
|
+
// from the editing side's exact text between its tokens. A guard space prevents two word
|
|
480
|
+
// tokens fusing at a splice boundary (parse validation downstream still has final say).
|
|
481
|
+
const wordish = (c) => c && /[A-Za-z0-9_$]/.test(c)
|
|
482
|
+
let out = baseText
|
|
483
|
+
for (let e = edits.length - 1; e >= 0; e--) {
|
|
484
|
+
const { h, side } = edits[e]
|
|
485
|
+
const [toks, text] = side === 'a' ? [A, aText] : [Bb, bText]
|
|
486
|
+
const cS = h.bs < B.length ? B[h.bs].start : baseText.length
|
|
487
|
+
const cE = h.be > h.bs ? B[h.be - 1].end : cS
|
|
488
|
+
let ins = h.oe > h.os ? text.slice(toks[h.os].start, toks[h.oe - 1].end) : ''
|
|
489
|
+
if (ins && wordish(out[cS - 1]) && wordish(ins[0])) ins = ' ' + ins
|
|
490
|
+
if (ins && wordish(out[cE]) && wordish(ins[ins.length - 1])) ins = ins + ' '
|
|
491
|
+
out = out.slice(0, cS) + ins + out.slice(cE)
|
|
492
|
+
}
|
|
493
|
+
return out
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// LINE-LEVEL 3-way merge of one unit: the same Myers + diff3 interleave with WHOLE LINES
|
|
497
|
+
// as atoms. Every replacement carries complete lines verbatim — indentation can never be
|
|
498
|
+
// stranded or doubled, which is what makes it the safe multi-hunk tier for indentation
|
|
499
|
+
// languages (the ConGra corpus showed token-splice arithmetic mangling Python indent when
|
|
500
|
+
// hunks straddle newline tokens: `conn = None` landing at column 0, a doubled 16-space
|
|
501
|
+
// indent). Token-level still runs after this for same-line micro-edits; this tier catches
|
|
502
|
+
// the far more common "both sides edited different LINES of the same unit" cleanly.
|
|
503
|
+
function splitLinesKeepEnds(text) {
|
|
504
|
+
const out = []
|
|
505
|
+
let i = 0
|
|
506
|
+
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 }
|
|
507
|
+
return out
|
|
508
|
+
}
|
|
509
|
+
function lineMerge3(baseText, aText, bText, mopts = {}) {
|
|
510
|
+
const L = splitLinesKeepEnds(baseText), La = splitLinesKeepEnds(aText), Lb = splitLinesKeepEnds(bText)
|
|
511
|
+
if (L.length > TOKEN3_MAX_TOKENS) return null
|
|
512
|
+
const ha = tokenHunks(L, La, TOKEN3_MAX_D)
|
|
513
|
+
const hb = tokenHunks(L, Lb, TOKEN3_MAX_D)
|
|
514
|
+
if (!ha || !hb) return null
|
|
515
|
+
const edits = diff3Interleave(ha, hb, La, Lb, mopts)
|
|
516
|
+
if (!edits) return null
|
|
517
|
+
const out = L.slice()
|
|
518
|
+
for (let e = edits.length - 1; e >= 0; e--) {
|
|
519
|
+
const { h, side } = edits[e]
|
|
520
|
+
const src = side === 'a' ? La : Lb
|
|
521
|
+
out.splice(h.bs, h.be - h.bs, ...src.slice(h.os, h.oe))
|
|
522
|
+
}
|
|
523
|
+
return out.join('')
|
|
524
|
+
}
|
|
525
|
+
|
|
155
526
|
// FORMAT-PRESERVING assembly: rebuild the merged file by splicing the merged units back
|
|
156
527
|
// into the BASE text at their original byte ranges. Unchanged units — and all the
|
|
157
528
|
// whitespace/comments BETWEEN units — survive verbatim; only changed units carry the
|
|
158
529
|
// editing side's bytes, and genuinely new units are appended. This is what stops ICR from
|
|
159
530
|
// reformatting code it merges. Deterministic and symmetric, so peers converge.
|
|
160
|
-
function spliceUnits(baseText, baseUnits, order, textByKey) {
|
|
531
|
+
function spliceUnits(baseText, baseUnits, order, textByKey, opts = {}) {
|
|
161
532
|
const baseKeySet = new Set(baseUnits.map((u) => u.key))
|
|
162
|
-
const
|
|
163
|
-
const
|
|
533
|
+
const baseByKey = new Map(baseUnits.map((u) => [u.key, u]))
|
|
534
|
+
const orderSet = new Set(order)
|
|
535
|
+
const deleted = baseUnits.filter((u) => !orderSet.has(u.key))
|
|
164
536
|
let out = '', pos = 0
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
537
|
+
|
|
538
|
+
function emitGap(from, to) {
|
|
539
|
+
let cursor = from
|
|
540
|
+
for (const d of deleted) {
|
|
541
|
+
if (d.start >= cursor && d.end <= to) {
|
|
542
|
+
out += baseText.slice(cursor, d.start)
|
|
543
|
+
cursor = d.end
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
out += baseText.slice(cursor, to)
|
|
169
547
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
548
|
+
|
|
549
|
+
const seenKeys = new Set()
|
|
550
|
+
for (const k of order) {
|
|
551
|
+
if (seenKeys.has(k)) continue
|
|
552
|
+
seenKeys.add(k)
|
|
553
|
+
if (baseKeySet.has(k)) {
|
|
554
|
+
const u = baseByKey.get(k)
|
|
555
|
+
if (u.start >= pos) {
|
|
556
|
+
emitGap(pos, u.start)
|
|
557
|
+
pos = u.end
|
|
558
|
+
}
|
|
559
|
+
const val = textByKey.get(k)
|
|
560
|
+
if (val != null) out += val
|
|
561
|
+
} else {
|
|
562
|
+
// New unit: insert it exactly where the merge order dictated
|
|
563
|
+
const val = textByKey.get(k)
|
|
564
|
+
if (val != null) {
|
|
565
|
+
out += (out.endsWith('\n') ? '' : '\n') + val + '\n'
|
|
566
|
+
}
|
|
567
|
+
}
|
|
176
568
|
}
|
|
177
|
-
|
|
569
|
+
if (pos < baseText.length) {
|
|
570
|
+
emitGap(pos, baseText.length)
|
|
571
|
+
}
|
|
572
|
+
// Top-level: drop orphan leading blank lines a deleted first decl leaves behind. Inner
|
|
573
|
+
// splice (trimLeadingBlank:false): keep the body's own leading `\n ` so indentation holds.
|
|
574
|
+
return opts.trimLeadingBlank === false ? out : out.replace(/^\n+/, '')
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// --- HyperAST Caching -----------------------------------------------------------
|
|
578
|
+
// A memory cache to bypass redundant AST parsing. This gives O(1) lookups for
|
|
579
|
+
// unchanged structures during interleaved diff3 merges, dropping engine execution
|
|
580
|
+
// time drastically on large files.
|
|
581
|
+
const providerCache = new WeakMap();
|
|
582
|
+
|
|
583
|
+
function withHyperAST(lang) {
|
|
584
|
+
if (providerCache.has(lang)) return providerCache.get(lang);
|
|
585
|
+
|
|
586
|
+
const cache = new Map();
|
|
587
|
+
const get = (op, text, fn) => {
|
|
588
|
+
if (typeof text !== 'string' || text.length > 500000) return fn(); // Bypass for massive strings
|
|
589
|
+
const key = op + ':' + text;
|
|
590
|
+
let res = cache.get(key);
|
|
591
|
+
if (res !== undefined) return res;
|
|
592
|
+
res = fn();
|
|
593
|
+
if (cache.size > 10000) cache.clear();
|
|
594
|
+
cache.set(key, res);
|
|
595
|
+
return res;
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
const wrapped = Object.create(lang);
|
|
599
|
+
if (lang.parses) wrapped.parses = (src) => get('parses', src, () => lang.parses(src));
|
|
600
|
+
if (lang.units) wrapped.units = (src) => get('units', src, () => lang.units(src));
|
|
601
|
+
if (lang.tokenize) wrapped.tokenize = (src) => get('tokenize', src, () => lang.tokenize(src));
|
|
602
|
+
if (lang.splitUnit) wrapped.splitUnit = (src) => get('splitUnit', src, () => lang.splitUnit(src));
|
|
603
|
+
if (lang.declaredNames) wrapped.declaredNames = (src) => get('declaredNames', src, () => lang.declaredNames(src));
|
|
604
|
+
|
|
605
|
+
providerCache.set(lang, wrapped);
|
|
606
|
+
return wrapped;
|
|
178
607
|
}
|
|
179
608
|
|
|
180
609
|
// --- public API -----------------------------------------------------------------
|
|
181
610
|
|
|
182
611
|
// Does this source parse under the given (or default JS) language?
|
|
183
|
-
export function parses(src, lang = javascript) { return lang.parses(src) }
|
|
612
|
+
export function parses(src, lang = javascript) { return withHyperAST(lang).parses(src) }
|
|
184
613
|
|
|
185
614
|
// 3-way STRUCTURAL + INTENT merge of `a` and `b` against common ancestor `base`.
|
|
186
615
|
// opts: { lang } a provider, or { filename } to pick one by extension (defaults to JS).
|
|
@@ -191,7 +620,8 @@ export function parses(src, lang = javascript) { return lang.parses(src) }
|
|
|
191
620
|
// 'fallback' — couldn't merge safely (unparseable input, or the merge wouldn't
|
|
192
621
|
// parse, or unsupported language); caller keeps both. Never broken.
|
|
193
622
|
export function structuralMerge(base, a, b, opts = {}) {
|
|
194
|
-
|
|
623
|
+
let lang = opts.lang || (opts.filename ? languageFor(opts.filename) : javascript) || javascript
|
|
624
|
+
lang = withHyperAST(lang)
|
|
195
625
|
|
|
196
626
|
if (!lang.parses(base) || !lang.parses(a) || !lang.parses(b))
|
|
197
627
|
return { status: 'fallback', text: null, conflicts: [], reason: 'unparseable input' }
|
|
@@ -210,10 +640,20 @@ export function structuralMerge(base, a, b, opts = {}) {
|
|
|
210
640
|
let text, provenance
|
|
211
641
|
if (a === base) { text = b; provenance = [] }
|
|
212
642
|
else if (b === base) { text = a; provenance = [] }
|
|
643
|
+
else if (lang.mergeWhole) {
|
|
644
|
+
// WHOLE-DOCUMENT MERGE: a provider that understands the entire document as DATA
|
|
645
|
+
// (e.g. JSON) merges it directly — real-parser accuracy, value-wise 3-way semantics,
|
|
646
|
+
// conflicts named by path. Returns { text } on success, { conflicts, resolvable? } on
|
|
647
|
+
// a same-value clash, or null when it can't (engine falls back safely).
|
|
648
|
+
const w = lang.mergeWhole(base, a, b)
|
|
649
|
+
if (!w) return { status: 'fallback', text: null, conflicts: [], reason: 'whole-document merge failed' }
|
|
650
|
+
if (w.conflicts && w.conflicts.length) return { status: 'semantic-conflict', text: null, conflicts: w.conflicts, resolvable: w.resolvable || [] }
|
|
651
|
+
text = w.text; provenance = w.provenance || []
|
|
652
|
+
}
|
|
213
653
|
else {
|
|
214
654
|
const baseUnits = lang.units(base)
|
|
215
|
-
const merge = mergeKeyed(lang, baseUnits, lang.units(a), lang.units(b))
|
|
216
|
-
if (merge.conflicts.length) return { status: 'semantic-conflict', text: null, conflicts: merge.conflicts }
|
|
655
|
+
const merge = mergeKeyed(lang, baseUnits, lang.units(a), lang.units(b), { unionInserts: !!opts.unionInserts })
|
|
656
|
+
if (merge.conflicts.length) return { status: 'semantic-conflict', text: null, conflicts: merge.conflicts, resolvable: merge.conflictUnits }
|
|
217
657
|
// Prefer the format-preserving splice (units carry byte ranges); fall back to a plain
|
|
218
658
|
// join for languages whose units don't expose ranges.
|
|
219
659
|
const canSplice = baseUnits.every((u) => typeof u.start === 'number')
|
|
@@ -222,7 +662,10 @@ export function structuralMerge(base, a, b, opts = {}) {
|
|
|
222
662
|
}
|
|
223
663
|
|
|
224
664
|
// THE GUARANTEE: the merged result must parse, or we refuse it.
|
|
225
|
-
|
|
665
|
+
// THE GUARANTEE stands: a merge that would not parse is REFUSED — text stays null so no
|
|
666
|
+
// caller can ever mistake broken output for a merge. (Debugging wants the bytes? That is
|
|
667
|
+
// what `debugText` is for; it is deliberately not `text`.)
|
|
668
|
+
if (!lang.parses(text)) return { status: 'fallback', text: null, debugText: text, conflicts: [], reason: 'merge would not parse' }
|
|
226
669
|
|
|
227
670
|
// RENAME DETECTION (intent): a declaration gone from base whose identical-bodied twin
|
|
228
671
|
// appears under a new name is a rename — rewrite stale call sites to the new name.
|
|
@@ -232,6 +675,7 @@ export function structuralMerge(base, a, b, opts = {}) {
|
|
|
232
675
|
let removed = [...baseNames].filter((n) => !mergedNames.has(n))
|
|
233
676
|
const renames = []
|
|
234
677
|
const rename = lang.renameFreeRefs || lang.renameRefs // prefer scope-aware rewriting
|
|
678
|
+
|
|
235
679
|
if (removed.length && lang.declBody && rename) {
|
|
236
680
|
const added = [...mergedNames].filter((n) => !baseNames.has(n))
|
|
237
681
|
const claimed = new Set()
|
|
@@ -249,6 +693,24 @@ export function structuralMerge(base, a, b, opts = {}) {
|
|
|
249
693
|
if (renames.length && lang.parses(rewritten)) { merged = rewritten; mergedNames = lang.declaredNames(merged) }
|
|
250
694
|
}
|
|
251
695
|
|
|
696
|
+
// ASYNC/AWAIT SYNCHRONIZATION (intent):
|
|
697
|
+
// When a declaration was synchronous in base and became ASYNC in the merged result,
|
|
698
|
+
// upgrade call sites inside async callers with `await` so the contract holds.
|
|
699
|
+
const asyncUpgrades = []
|
|
700
|
+
if (lang.isAsyncDecl && lang.upgradeCallSitesToAwait) {
|
|
701
|
+
for (const name of mergedNames) {
|
|
702
|
+
const wasAsyncInBase = baseNames.has(name) ? lang.isAsyncDecl(base, name) : false
|
|
703
|
+
const isAsyncInMerged = lang.isAsyncDecl(merged, name)
|
|
704
|
+
if (!wasAsyncInBase && isAsyncInMerged) {
|
|
705
|
+
const upgraded = lang.upgradeCallSitesToAwait(merged, name)
|
|
706
|
+
if (upgraded !== merged && lang.parses(upgraded)) {
|
|
707
|
+
merged = upgraded
|
|
708
|
+
asyncUpgrades.push(name)
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
252
714
|
// DANGLING REFERENCE (intent): a declaration removed (and NOT explained by a rename)
|
|
253
715
|
// but still referenced is a semantic conflict even though the code parses cleanly.
|
|
254
716
|
// Uses the SCOPE-AWARE reference set when the language provides one (so a local binding
|
|
@@ -257,6 +719,7 @@ export function structuralMerge(base, a, b, opts = {}) {
|
|
|
257
719
|
removed = [...baseNames].filter((n) => !mergedNames.has(n))
|
|
258
720
|
const refs = lang.referencedFreeNames ? lang.referencedFreeNames(merged)
|
|
259
721
|
: lang.usedIdentifiers ? lang.usedIdentifiers(merged) : null
|
|
722
|
+
if (process.env.ICR_DEBUG_MERGED) console.error('--- ICR merged intermediate ---\n' + merged + '\n--- refs: ' + [...(refs || [])].join(',') + ' ---')
|
|
260
723
|
if (removed.length && refs) {
|
|
261
724
|
const dangling = removed.filter((n) => refs.has(n))
|
|
262
725
|
if (dangling.length)
|
|
@@ -267,6 +730,10 @@ export function structuralMerge(base, a, b, opts = {}) {
|
|
|
267
730
|
return {
|
|
268
731
|
status: 'auto', text: merged, conflicts: [],
|
|
269
732
|
renames: renames.map(([o, n]) => o + '->' + n),
|
|
733
|
+
asyncUpgrades,
|
|
270
734
|
provenance: attribute(provenance),
|
|
271
735
|
}
|
|
272
736
|
}
|
|
737
|
+
|
|
738
|
+
// Internal handles for diagnostic harnesses (not part of the public API).
|
|
739
|
+
export const _internals = { tokenMerge, tokenMerge3, tokenHunks, tryInnerMerge, lineMerge3 }
|