hivecode-mcp 0.12.2 → 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/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
- return { conflicts: [], parts, provenance, order, textByKey }
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-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.
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 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]
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 + pa.open + body + pa.close
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 + pa.open + m.parts.join(pa.join) + pa.close
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
- const canSplice = baseUnits.every((u) => typeof u.start === 'number')
666
- text = canSplice ? spliceUnits(base, baseUnits, merge.order, merge.textByKey) : merge.parts.join('\n\n') + '\n'
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 {