hivecode-mcp 0.12.1 → 0.12.4
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/LICENSE +21 -21
- package/README.md +188 -8
- package/core.js +91 -91
- package/docs/HIVE_RULES.md +41 -41
- package/docs/MCP.md +5 -5
- package/docs/SELF_HOSTING.md +256 -256
- package/hive-activity.js +336 -310
- package/hive-await.js +187 -0
- package/hive-branch.js +656 -0
- package/hive-busy.js +88 -88
- package/hive-coord.js +110 -110
- package/hive-gate.js +135 -135
- package/hive-hook-install.js +179 -103
- package/hive-instructions.js +74 -66
- package/hive-listen.js +81 -0
- package/hive-mailbox.js +517 -386
- package/hive-mcp.js +483 -126
- package/hive-mux-proto.js +58 -58
- package/hive-mux.js +350 -350
- package/hive-obligations.js +247 -247
- package/hive-pr-staleness.js +189 -189
- package/hive-pr.js +337 -0
- package/hive-privacy.js +327 -288
- package/hive-receipts.js +233 -233
- package/hive-relay-health.js +7 -2
- package/hive-secrets.js +89 -0
- package/hive-setup.js +548 -445
- package/hive-stdio.js +172 -168
- package/hive-stop-hook.js +290 -241
- package/hive-throttle.js +118 -118
- package/hive-update.js +58 -58
- package/hive-version.js +110 -59
- package/icr-merge.js +58 -58
- package/icr.js +121 -15
- package/lang-json.js +211 -211
- package/lang-ruby.js +230 -230
- package/lang-toml.js +136 -136
- package/lang-yaml.js +183 -183
- package/limits.js +413 -394
- package/package.json +20 -11
- package/reconcile.cjs +614 -477
- package/substrate.js +252 -252
- package/sync.js +4691 -3005
- package/grm-link.cjs +0 -141
package/icr-merge.js
CHANGED
|
@@ -1,58 +1,58 @@
|
|
|
1
|
-
// icr-merge.js — the bridge that brings ICR into the live product, SAFELY.
|
|
2
|
-
//
|
|
3
|
-
// Hivecode's reconcile() merges concurrent edits with merge3() (a line-level 3-way
|
|
4
|
-
// merge). merge3 is convergent: across many peers it always settles on one canonical
|
|
5
|
-
// text. ICR's structural merge produces a DIFFERENT canonical form (it may reorder or
|
|
6
|
-
// reformat). Mixing two merge algorithms in a live multi-peer sync means the same logical
|
|
7
|
-
// content has two byte-forms, and peers ping-pong between them — which corrupts files
|
|
8
|
-
// (duplicated/*dropped* lines). So we do NOT change merge bytes with ICR here.
|
|
9
|
-
//
|
|
10
|
-
// Instead ICR runs in ADVISORY mode: merge3 still produces the bytes (full convergence
|
|
11
|
-
// preserved, identical to pre-ICR behavior), and ICR runs alongside purely to DETECT the
|
|
12
|
-
// meaning-level problems a line merge can't see — a dangling reference, a rename, a
|
|
13
|
-
// same-declaration clash — and surface them as a warning on the activity feed. This ships
|
|
14
|
-
// ICR's core value (catching semantic breaks) with zero divergence risk.
|
|
15
|
-
//
|
|
16
|
-
// ICR's full structural/rename AUTO-MERGE lives in the library (icr.js) and is proven by
|
|
17
|
-
// the test suite + fuzzer; turning it on in the product is a later step that needs a
|
|
18
|
-
// format-preserving, single-canonical-form merge so peers can't diverge.
|
|
19
|
-
|
|
20
|
-
import { structuralMerge, supports } from './icr.js'
|
|
21
|
-
import { merge3 } from './core.js'
|
|
22
|
-
|
|
23
|
-
// Turn ICR's machine conflict keys into a human sentence for the activity feed / chat.
|
|
24
|
-
function describeConflicts(conflicts) {
|
|
25
|
-
return (conflicts || []).map((c) => {
|
|
26
|
-
if (c.startsWith('ref:')) return `'${c.slice(4)}' was removed or renamed but is still used`
|
|
27
|
-
if (c.startsWith('fn:')) return `both sides changed function ${c.slice(3)}`
|
|
28
|
-
if (c.startsWith('class:')) return `both sides changed class ${c.slice(6)}`
|
|
29
|
-
if (c.startsWith('var:')) return `both sides changed ${c.slice(4)}`
|
|
30
|
-
return `both sides changed ${c}`
|
|
31
|
-
}).join('; ')
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Same shape as merge3 — { text, conflict } — plus an `icr` tag ('structural'|'rename')
|
|
35
|
-
// and an optional `icrWarning`. ICR's format-preserving merge is convergent (symmetric +
|
|
36
|
-
// fixed-point, verified by icr-converge-test.js), so its 'auto' result is used as the
|
|
37
|
-
// merged bytes for supported files; merge3 covers conflicts, fallbacks, and other languages.
|
|
38
|
-
export function icrMerge3(base, mine, theirs, relPath) {
|
|
39
|
-
const lm = merge3(base, mine, theirs)
|
|
40
|
-
if (!supports(relPath)) return lm
|
|
41
|
-
|
|
42
|
-
let r
|
|
43
|
-
try { r = structuralMerge(base, mine, theirs, { filename: relPath }) }
|
|
44
|
-
catch { return lm } // ICR must never break a sync
|
|
45
|
-
|
|
46
|
-
if (r.status === 'auto') {
|
|
47
|
-
const renamed = r.renames && r.renames.length
|
|
48
|
-
return {
|
|
49
|
-
text: r.text, conflict: false, icr: renamed ? 'rename' : 'structural',
|
|
50
|
-
icrWarning: renamed ? `auto-applied rename ${r.renames.join(', ')} — call sites updated` : undefined,
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
// A meaning-level conflict the line merge can't represent (dangling ref / clash): keep
|
|
54
|
-
// the safe line-merge bytes, but surface what ICR caught so it isn't shipped silently.
|
|
55
|
-
if (r.status === 'semantic-conflict')
|
|
56
|
-
return { ...lm, icrWarning: describeConflicts(r.conflicts) }
|
|
57
|
-
return lm // 'fallback'
|
|
58
|
-
}
|
|
1
|
+
// icr-merge.js — the bridge that brings ICR into the live product, SAFELY.
|
|
2
|
+
//
|
|
3
|
+
// Hivecode's reconcile() merges concurrent edits with merge3() (a line-level 3-way
|
|
4
|
+
// merge). merge3 is convergent: across many peers it always settles on one canonical
|
|
5
|
+
// text. ICR's structural merge produces a DIFFERENT canonical form (it may reorder or
|
|
6
|
+
// reformat). Mixing two merge algorithms in a live multi-peer sync means the same logical
|
|
7
|
+
// content has two byte-forms, and peers ping-pong between them — which corrupts files
|
|
8
|
+
// (duplicated/*dropped* lines). So we do NOT change merge bytes with ICR here.
|
|
9
|
+
//
|
|
10
|
+
// Instead ICR runs in ADVISORY mode: merge3 still produces the bytes (full convergence
|
|
11
|
+
// preserved, identical to pre-ICR behavior), and ICR runs alongside purely to DETECT the
|
|
12
|
+
// meaning-level problems a line merge can't see — a dangling reference, a rename, a
|
|
13
|
+
// same-declaration clash — and surface them as a warning on the activity feed. This ships
|
|
14
|
+
// ICR's core value (catching semantic breaks) with zero divergence risk.
|
|
15
|
+
//
|
|
16
|
+
// ICR's full structural/rename AUTO-MERGE lives in the library (icr.js) and is proven by
|
|
17
|
+
// the test suite + fuzzer; turning it on in the product is a later step that needs a
|
|
18
|
+
// format-preserving, single-canonical-form merge so peers can't diverge.
|
|
19
|
+
|
|
20
|
+
import { structuralMerge, supports } from './icr.js'
|
|
21
|
+
import { merge3 } from './core.js'
|
|
22
|
+
|
|
23
|
+
// Turn ICR's machine conflict keys into a human sentence for the activity feed / chat.
|
|
24
|
+
function describeConflicts(conflicts) {
|
|
25
|
+
return (conflicts || []).map((c) => {
|
|
26
|
+
if (c.startsWith('ref:')) return `'${c.slice(4)}' was removed or renamed but is still used`
|
|
27
|
+
if (c.startsWith('fn:')) return `both sides changed function ${c.slice(3)}`
|
|
28
|
+
if (c.startsWith('class:')) return `both sides changed class ${c.slice(6)}`
|
|
29
|
+
if (c.startsWith('var:')) return `both sides changed ${c.slice(4)}`
|
|
30
|
+
return `both sides changed ${c}`
|
|
31
|
+
}).join('; ')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Same shape as merge3 — { text, conflict } — plus an `icr` tag ('structural'|'rename')
|
|
35
|
+
// and an optional `icrWarning`. ICR's format-preserving merge is convergent (symmetric +
|
|
36
|
+
// fixed-point, verified by icr-converge-test.js), so its 'auto' result is used as the
|
|
37
|
+
// merged bytes for supported files; merge3 covers conflicts, fallbacks, and other languages.
|
|
38
|
+
export function icrMerge3(base, mine, theirs, relPath) {
|
|
39
|
+
const lm = merge3(base, mine, theirs)
|
|
40
|
+
if (!supports(relPath)) return lm
|
|
41
|
+
|
|
42
|
+
let r
|
|
43
|
+
try { r = structuralMerge(base, mine, theirs, { filename: relPath }) }
|
|
44
|
+
catch { return lm } // ICR must never break a sync
|
|
45
|
+
|
|
46
|
+
if (r.status === 'auto') {
|
|
47
|
+
const renamed = r.renames && r.renames.length
|
|
48
|
+
return {
|
|
49
|
+
text: r.text, conflict: false, icr: renamed ? 'rename' : 'structural',
|
|
50
|
+
icrWarning: renamed ? `auto-applied rename ${r.renames.join(', ')} — call sites updated` : undefined,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// A meaning-level conflict the line merge can't represent (dangling ref / clash): keep
|
|
54
|
+
// the safe line-merge bytes, but surface what ICR caught so it isn't shipped silently.
|
|
55
|
+
if (r.status === 'semantic-conflict')
|
|
56
|
+
return { ...lm, icrWarning: describeConflicts(r.conflicts) }
|
|
57
|
+
return lm // 'fallback'
|
|
58
|
+
}
|
package/icr.js
CHANGED
|
@@ -245,7 +245,9 @@ function mergeKeyed(lang, baseU, aU, bU, mopts = {}) {
|
|
|
245
245
|
}
|
|
246
246
|
if (text != null) { parts.push(text); provenance.push({ key: k, from }); order.push(k); textByKey.set(k, text) }
|
|
247
247
|
}
|
|
248
|
-
|
|
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 }
|
|
249
251
|
}
|
|
250
252
|
|
|
251
253
|
// FINER GRANULARITY: both sides edited the same function OR class. If the signature
|
|
@@ -287,6 +289,12 @@ function tryStructuralInner(lang, baseText, aText, bText, mopts = {}) {
|
|
|
287
289
|
const pb = lang.splitUnit(baseText), pa = lang.splitUnit(aText), pbb = lang.splitUnit(bText)
|
|
288
290
|
if (!pb || !pa || !pbb) return null
|
|
289
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
|
|
290
298
|
const m = mergeKeyed(lang, pb.units, pa.units, pbb.units, mopts)
|
|
291
299
|
if (m.conflicts.length) return null
|
|
292
300
|
// FORMAT-PRESERVING inner assembly: when the provider exposes the body text + byte-ranged
|
|
@@ -299,20 +307,28 @@ function tryStructuralInner(lang, baseText, aText, bText, mopts = {}) {
|
|
|
299
307
|
const spliceable = pb.spliceable && typeof pb.body === 'string' && pb.units.every((u) => typeof u.start === 'number' && typeof u.end === 'number')
|
|
300
308
|
if (spliceable) {
|
|
301
309
|
// COMMENT-EDIT PRESERVATION: gaps (comments/blank lines between statements) come from
|
|
302
|
-
// the splice BASIS. Splicing from base loses a side's comment
|
|
303
|
-
//
|
|
304
|
-
// side's
|
|
305
|
-
//
|
|
306
|
-
//
|
|
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).
|
|
307
318
|
const sameUnits = (u1, u2) => u1.length === u2.length && u1.every((u, i) => u.key === u2[i].key && u.text === u2[i].text)
|
|
308
|
-
const
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
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]
|
|
312
324
|
const body = spliceUnits(basisBody, basisUnits, m.order, m.textByKey, { trimLeadingBlank: false })
|
|
313
|
-
text = pa.sig +
|
|
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
|
|
314
330
|
} else {
|
|
315
|
-
text = pa.sig +
|
|
331
|
+
text = pa.sig + open + m.parts.join(pa.join) + close
|
|
316
332
|
}
|
|
317
333
|
const valid = lang.parsesUnit || lang.parses // accept class-member fragments when supported
|
|
318
334
|
return valid(text) ? text : null
|
|
@@ -523,6 +539,66 @@ function lineMerge3(baseText, aText, bText, mopts = {}) {
|
|
|
523
539
|
return out.join('')
|
|
524
540
|
}
|
|
525
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
|
+
|
|
526
602
|
// FORMAT-PRESERVING assembly: rebuild the merged file by splicing the merged units back
|
|
527
603
|
// into the BASE text at their original byte ranges. Unchanged units — and all the
|
|
528
604
|
// whitespace/comments BETWEEN units — survive verbatim; only changed units carry the
|
|
@@ -643,7 +719,7 @@ export function structuralMerge(base, a, b, opts = {}) {
|
|
|
643
719
|
// side verbatim (format perfectly preserved); otherwise do the keyed 3-way merge and
|
|
644
720
|
// splice it back into base. Either way the INTENT layer below still runs, so a deletion
|
|
645
721
|
// that leaves a dangling reference is caught even when only one side changed.
|
|
646
|
-
let text, provenance
|
|
722
|
+
let text, provenance, gapCheck = null
|
|
647
723
|
if (a === base) { text = b; provenance = [] }
|
|
648
724
|
else if (b === base) { text = a; provenance = [] }
|
|
649
725
|
else if (lang.mergeWhole) {
|
|
@@ -662,8 +738,26 @@ export function structuralMerge(base, a, b, opts = {}) {
|
|
|
662
738
|
if (merge.conflicts.length) return { status: 'semantic-conflict', text: null, conflicts: merge.conflicts, resolvable: merge.conflictUnits }
|
|
663
739
|
// Prefer the format-preserving splice (units carry byte ranges); fall back to a plain
|
|
664
740
|
// join for languages whose units don't expose ranges.
|
|
665
|
-
|
|
666
|
-
|
|
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'
|
|
667
761
|
provenance = merge.provenance
|
|
668
762
|
}
|
|
669
763
|
|
|
@@ -672,6 +766,12 @@ export function structuralMerge(base, a, b, opts = {}) {
|
|
|
672
766
|
// caller can ever mistake broken output for a merge. (Debugging wants the bytes? That is
|
|
673
767
|
// what `debugText` is for; it is deliberately not `text`.)
|
|
674
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
|
+
}
|
|
675
775
|
|
|
676
776
|
// RENAME DETECTION (intent): a declaration gone from base whose identical-bodied twin
|
|
677
777
|
// appears under a new name is a rename — rewrite stale call sites to the new name.
|
|
@@ -731,6 +831,12 @@ export function structuralMerge(base, a, b, opts = {}) {
|
|
|
731
831
|
if (dangling.length)
|
|
732
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' }
|
|
733
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' }
|
|
734
840
|
// PROVENANCE: attribute each surviving unit to the author whose version we kept. With
|
|
735
841
|
// opts.authors = { a, b, base } the labels are real names; otherwise they're 'a'/'b'/etc.
|
|
736
842
|
return {
|