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.
- package/HIVE_RULES.md +98 -98
- package/LICENSE +21 -21
- package/README.md +421 -421
- package/core.js +91 -91
- package/docs/ALWAYS_ON.md +310 -310
- package/docs/GITHUB_WORKFLOW.md +394 -394
- package/docs/HIVE_RULES.md +41 -41
- package/docs/MCP.md +157 -157
- package/docs/QUICKSTART.md +266 -266
- package/docs/SELF_HOSTING.md +256 -256
- package/docs/SLACK_SETUP.md +474 -474
- package/docs/TROUBLESHOOTING.md +105 -105
- package/hive-activity.js +336 -336
- package/hive-authors.js +46 -44
- package/hive-await.js +230 -230
- package/hive-branch.js +608 -607
- package/hive-busy.js +88 -88
- package/hive-coord.js +110 -110
- package/hive-gate.js +135 -135
- package/hive-github.js +3 -1
- package/hive-hook-install.js +179 -179
- package/hive-instructions.js +74 -74
- package/hive-land.js +695 -691
- package/hive-listen.js +81 -81
- package/hive-mailbox.js +520 -520
- package/hive-mcp-http.js +591 -591
- package/hive-mcp.js +33 -6
- package/hive-mux-proto.js +58 -58
- package/hive-mux.js +351 -351
- package/hive-notify.js +234 -233
- package/hive-obligations.js +247 -247
- package/hive-poll-proto.js +50 -50
- package/hive-pr-staleness.js +189 -189
- package/hive-pr.js +600 -512
- package/hive-privacy.js +327 -327
- package/hive-receipts.js +233 -233
- package/hive-relay-health.js +234 -234
- package/hive-repos.js +430 -430
- package/hive-room-key.js +210 -210
- package/hive-room-mode.js +1668 -1668
- package/hive-room-watcher.js +562 -561
- package/hive-secrets.js +89 -89
- package/hive-session.js +114 -114
- package/hive-setup.js +560 -560
- package/hive-slack-agent.js +408 -408
- package/hive-slack.js +2384 -2367
- package/hive-stdio.js +172 -172
- package/hive-stop-hook.js +293 -293
- package/hive-throttle.js +118 -118
- package/hive-time.js +71 -71
- package/hive-transport.js +578 -578
- package/hive-update.js +58 -58
- package/hive-version.js +110 -110
- package/hive-webhook.js +55 -54
- package/icr-merge.js +58 -58
- package/icr.js +851 -851
- package/lang-brace.js +305 -305
- package/lang-js.js +651 -651
- package/lang-json.js +211 -211
- package/lang-python.js +588 -588
- package/lang-ruby.js +230 -230
- package/lang-toml.js +136 -136
- package/lang-yaml.js +183 -183
- package/limits.js +413 -413
- package/package.json +1 -1
- package/reconcile.cjs +621 -621
- package/slack-app-manifest-agent.json +61 -61
- package/slack-app-manifest-inbound.json +36 -36
- package/slack-app-manifest.json +27 -27
- package/substrate.js +252 -252
- package/sync.js +169 -11
- package/token.js +201 -201
package/lang-json.js
CHANGED
|
@@ -1,211 +1,211 @@
|
|
|
1
|
-
// JSON language provider for ICR — the one provider where the guarantee is PERFECT,
|
|
2
|
-
// because JSON.parse IS the real parser (no heuristics anywhere).
|
|
3
|
-
//
|
|
4
|
-
// JSON is the most-merged file type in a repository (package.json, tsconfig, lockfile
|
|
5
|
-
// fragments, CI configs), and the classic line-merge failure is two sides editing
|
|
6
|
-
// nearby keys — git conflicts or, worse, fuses into invalid JSON. ICR merges JSON as
|
|
7
|
-
// DATA: value-wise 3-way merge, keyed by object path, so:
|
|
8
|
-
// • different keys edited on each side -> clean merge, both kept
|
|
9
|
-
// • the SAME path changed differently -> semantic conflict naming the path
|
|
10
|
-
// (surfaced as a machine-resolvable unit, so an agent judge can reconcile it)
|
|
11
|
-
// • one side deletes a key the other changed -> conflict (delete vs change)
|
|
12
|
-
// • arrays: one-sided changes merge; both-sided changes merge when the edited
|
|
13
|
-
// regions are DISJOINT (prefix/suffix diff — appends, prepends, middle inserts);
|
|
14
|
-
// overlapping array edits are an honest conflict
|
|
15
|
-
// Output is serialized with the file's own indentation style (detected from the base)
|
|
16
|
-
// and preserves the trailing-newline convention, so diffs stay minimal.
|
|
17
|
-
|
|
18
|
-
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
19
|
-
const eq = (x, y) => JSON.stringify(x) === JSON.stringify(y)
|
|
20
|
-
|
|
21
|
-
// PRECISION SAFETY GATE. The data merge re-serializes the whole document, which means
|
|
22
|
-
// every number goes through JS float64 — and that CORRUPTS values nobody touched:
|
|
23
|
-
// 9007199254740993 (beyond 2^53) silently loses a digit; 1e999 becomes Infinity and
|
|
24
|
-
// then `null`. Shipping a "clean" merge that corrupts untouched data is the cardinal
|
|
25
|
-
// sin, so: scan every number literal (string-aware); if ANY cannot survive the
|
|
26
|
-
// round-trip value-identically, refuse the data merge entirely — the engine falls back
|
|
27
|
-
// to the safe line tier, which never rewrites untouched bytes. (Found by the
|
|
28
|
-
// adversarial crucible, not by users — the way it should be.)
|
|
29
|
-
function numbersSafe(src) {
|
|
30
|
-
const n = src.length
|
|
31
|
-
let i = 0
|
|
32
|
-
while (i < n) {
|
|
33
|
-
const c = src[i]
|
|
34
|
-
if (c === '"') { i++; while (i < n) { if (src[i] === '\\') { i += 2; continue } if (src[i] === '"') { i++; break } i++ } continue }
|
|
35
|
-
if (c === '-' || (c >= '0' && c <= '9')) {
|
|
36
|
-
let j = i + 1
|
|
37
|
-
while (j < n && /[0-9eE+.-]/.test(src[j])) j++
|
|
38
|
-
const tok = src.slice(i, j)
|
|
39
|
-
const v = Number(tok)
|
|
40
|
-
if (!Number.isFinite(v)) return false // 1e999 → Infinity → null
|
|
41
|
-
if (Number.isInteger(v) && Math.abs(v) > Number.MAX_SAFE_INTEGER) return false // beyond 2^53
|
|
42
|
-
const digits = tok.replace(/[^0-9]/g, '').replace(/^0+/, '')
|
|
43
|
-
if (digits.length > 15 && !eq(Number(tok), Number(v.toPrecision(15)))) return false // >15 sig digits at risk
|
|
44
|
-
i = j; continue
|
|
45
|
-
}
|
|
46
|
-
i++
|
|
47
|
-
}
|
|
48
|
-
return true
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// Detect the base file's indent unit (' ', ' ', '\t', …). Defaults to two spaces.
|
|
52
|
-
function detectIndent(src) {
|
|
53
|
-
const m = /\n([ \t]+)\S/.exec(src)
|
|
54
|
-
return m ? m[1] : ' '
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// 3-way merge of two ARRAYS against base, by disjoint-region splice (the same
|
|
58
|
-
// prefix/suffix discipline the engine's token merge uses, on JSON-equal elements).
|
|
59
|
-
// Returns the merged array, or null for an overlapping (real) conflict.
|
|
60
|
-
function mergeArray(base, a, b) {
|
|
61
|
-
const K = (arr) => arr.map((x) => JSON.stringify(x))
|
|
62
|
-
const diffRange = (B, O) => {
|
|
63
|
-
if (B.length === O.length && B.every((x, i) => x === O[i])) return null
|
|
64
|
-
let p = 0
|
|
65
|
-
const maxp = Math.min(B.length, O.length)
|
|
66
|
-
while (p < maxp && B[p] === O[p]) p++
|
|
67
|
-
let s = 0
|
|
68
|
-
while (s < B.length - p && s < O.length - p && B[B.length - 1 - s] === O[O.length - 1 - s]) s++
|
|
69
|
-
return { bStart: p, bEnd: B.length - s, oStart: p, oEnd: O.length - s }
|
|
70
|
-
}
|
|
71
|
-
const KB = K(base), ra = diffRange(KB, K(a)), rb = diffRange(KB, K(b))
|
|
72
|
-
if (!ra) return b
|
|
73
|
-
if (!rb) return a
|
|
74
|
-
if (ra.bStart < rb.bEnd && rb.bStart < ra.bEnd) return null // overlapping regions
|
|
75
|
-
if (ra.bStart === rb.bStart) return null // ambiguous same-point edits
|
|
76
|
-
const edits = [
|
|
77
|
-
{ s: ra.bStart, e: ra.bEnd, ins: a.slice(ra.oStart, ra.oEnd) },
|
|
78
|
-
{ s: rb.bStart, e: rb.bEnd, ins: b.slice(rb.oStart, rb.oEnd) },
|
|
79
|
-
].sort((x, y) => y.s - x.s)
|
|
80
|
-
let out = base.slice()
|
|
81
|
-
for (const ed of edits) out.splice(ed.s, ed.e - ed.s, ...ed.ins)
|
|
82
|
-
return out
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// SYMMETRY. The merged object is re-serialized with JSON.stringify, which emits keys in
|
|
86
|
-
// INSERTION order — so the order we walk the key union IS the output byte order. The
|
|
87
|
-
// obvious union, `[...keys(base), ...keys(a), ...keys(b)]`, is side-ordered: ours's new
|
|
88
|
-
// keys always precede theirs's, so merge(base,a,b) and merge(base,b,a) produce clean
|
|
89
|
-
// 'auto' results that differ byte-for-byte. Measured at 43/167 auto merges (26%) by
|
|
90
|
-
// test/icr-data-torture-test.js. Semantically identical, but P2 SYMMETRY is asserted
|
|
91
|
-
// byte-wise for every other language (icr-torture-test.js), and byte-inequality is
|
|
92
|
-
// precisely what a CRDT peer and a git blob hash react to — the two consumers the README
|
|
93
|
-
// names. So the union order must not depend on which side is called `a`:
|
|
94
|
-
// • keys present in base keep BASE's order (unchanged behaviour, minimal diffs);
|
|
95
|
-
// • keys added by either side follow, ordered by where their own author put them
|
|
96
|
-
// (min index across the sides that have it), ties broken by key name.
|
|
97
|
-
// Both rules are symmetric in a/b, so swapping the sides cannot change the output.
|
|
98
|
-
function unionKeys(base, a, b) {
|
|
99
|
-
const rank = new Map()
|
|
100
|
-
for (const obj of [a, b]) {
|
|
101
|
-
const ks = Object.keys(obj)
|
|
102
|
-
for (let i = 0; i < ks.length; i++) {
|
|
103
|
-
const k = ks[i]
|
|
104
|
-
if (k in base) continue
|
|
105
|
-
const prev = rank.get(k)
|
|
106
|
-
if (prev === undefined || i < prev) rank.set(k, i)
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
const added = [...rank.keys()].sort((x, y) => (rank.get(x) - rank.get(y)) || (x < y ? -1 : x > y ? 1 : 0))
|
|
110
|
-
return [...Object.keys(base), ...added]
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// Value-wise 3-way merge. Fills `conflicts` (path strings) and `resolvable`
|
|
114
|
-
// ({key, base, ours, theirs} with pretty-printed values) on same-path clashes;
|
|
115
|
-
// the merged value keeps BASE at conflicted paths (the engine refuses the whole
|
|
116
|
-
// merge when conflicts exist, so nothing partial ever ships).
|
|
117
|
-
function mergeVal(base, a, b, path, conflicts, resolvable) {
|
|
118
|
-
if (eq(a, b)) return a
|
|
119
|
-
if (eq(a, base)) return b
|
|
120
|
-
if (eq(b, base)) return a
|
|
121
|
-
if (isObj(base) && isObj(a) && isObj(b)) {
|
|
122
|
-
const out = {}
|
|
123
|
-
for (const k of unionKeys(base, a, b)) {
|
|
124
|
-
const p = path ? path + '.' + k : k
|
|
125
|
-
const inB = k in base, inA = k in a, inBb = k in b
|
|
126
|
-
if (inB && !inA && !inBb) continue // deleted on both sides
|
|
127
|
-
if (inB && !inA) { // ours deleted it
|
|
128
|
-
if (eq(b[k], base[k])) continue // theirs untouched -> deletion wins
|
|
129
|
-
conflicts.push(p); resolvable.push(resUnit(p, base[k], undefined, b[k])); continue
|
|
130
|
-
}
|
|
131
|
-
if (inB && !inBb) { // theirs deleted it
|
|
132
|
-
if (eq(a[k], base[k])) continue
|
|
133
|
-
conflicts.push(p); resolvable.push(resUnit(p, base[k], a[k], undefined)); continue
|
|
134
|
-
}
|
|
135
|
-
if (!inB && inA && inBb) { // both added
|
|
136
|
-
if (eq(a[k], b[k])) { out[k] = a[k]; continue }
|
|
137
|
-
if (isObj(a[k]) && isObj(b[k])) { out[k] = mergeVal({}, a[k], b[k], p, conflicts, resolvable); continue }
|
|
138
|
-
conflicts.push(p); resolvable.push(resUnit(p, undefined, a[k], b[k])); continue
|
|
139
|
-
}
|
|
140
|
-
if (!inB) { out[k] = inA ? a[k] : b[k]; continue } // one side added
|
|
141
|
-
out[k] = mergeVal(base[k], a[k], b[k], p, conflicts, resolvable) // present everywhere
|
|
142
|
-
}
|
|
143
|
-
return out
|
|
144
|
-
}
|
|
145
|
-
if (Array.isArray(base) && Array.isArray(a) && Array.isArray(b)) {
|
|
146
|
-
const m = mergeArray(base, a, b)
|
|
147
|
-
if (m) return m
|
|
148
|
-
conflicts.push(path || '(root)'); resolvable.push(resUnit(path || '(root)', base, a, b))
|
|
149
|
-
return base
|
|
150
|
-
}
|
|
151
|
-
conflicts.push(path || '(root)') // scalar/shape clash
|
|
152
|
-
resolvable.push(resUnit(path || '(root)', base, a, b))
|
|
153
|
-
return base
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const show = (v) => (v === undefined ? '(deleted)' : JSON.stringify(v, null, 2))
|
|
157
|
-
const resUnit = (p, base, ours, theirs) => ({ key: 'json:' + p, base: show(base), ours: show(ours), theirs: show(theirs) })
|
|
158
|
-
|
|
159
|
-
export const json = {
|
|
160
|
-
id: 'json',
|
|
161
|
-
exts: ['.json'],
|
|
162
|
-
parses: (src) => { try { JSON.parse(String(src == null ? '' : src)); return true } catch { return false } },
|
|
163
|
-
// The engine's whole-document tier: merge the file as parsed data.
|
|
164
|
-
mergeWhole: (base, a, b) => {
|
|
165
|
-
if (!numbersSafe(base) || !numbersSafe(a) || !numbersSafe(b)) return null // precision hazard → safe fallback, never corrupt
|
|
166
|
-
let B, A, Bb
|
|
167
|
-
try { B = JSON.parse(base); A = JSON.parse(a); Bb = JSON.parse(b) } catch { return null }
|
|
168
|
-
const conflicts = [], resolvable = []
|
|
169
|
-
const merged = mergeVal(B, A, Bb, '', conflicts, resolvable)
|
|
170
|
-
if (conflicts.length) return { conflicts, resolvable }
|
|
171
|
-
const nl = /\n$/.test(base) || /\n$/.test(a) ? '\n' : ''
|
|
172
|
-
return { text: JSON.stringify(merged, null, detectIndent(base)) + nl }
|
|
173
|
-
},
|
|
174
|
-
// Minimal unit view (whole document) so generic engine paths stay well-defined;
|
|
175
|
-
// real merging happens in mergeWhole above.
|
|
176
|
-
units: (src) => [{ key: 'doc', text: String(src), start: 0, end: String(src).length }],
|
|
177
|
-
declaredNames: () => new Set(),
|
|
178
|
-
// INTENT RESOLUTION for data: a conflicted unit's "text" is a rendering of a parsed
|
|
179
|
-
// VALUE, so a judge's reconciliation is applied by setting the value at the unit's
|
|
180
|
-
// path (key 'json:<path>'), not by string replacement. The reconciled string must
|
|
181
|
-
// itself be valid JSON ('(deleted)' removes the key). Returns the re-serialized
|
|
182
|
-
// document, or null when it can't apply — resolveMerge then falls back to the safe
|
|
183
|
-
// conflict, so a malformed judgment never ships.
|
|
184
|
-
applyResolution: (text, unit, reconciled) => {
|
|
185
|
-
if (!unit || typeof unit.key !== 'string' || !unit.key.startsWith('json:')) return null
|
|
186
|
-
if (!numbersSafe(text)) return null // same precision gate: never re-serialize a doc we'd corrupt
|
|
187
|
-
let doc, value
|
|
188
|
-
try { doc = JSON.parse(text) } catch { return null }
|
|
189
|
-
const del = String(reconciled).trim() === '(deleted)'
|
|
190
|
-
if (!del) { try { value = JSON.parse(reconciled) } catch { return null } }
|
|
191
|
-
const path = unit.key.slice(5)
|
|
192
|
-
if (path === '(root)' || path === '') {
|
|
193
|
-
if (del) return null
|
|
194
|
-
doc = value
|
|
195
|
-
} else {
|
|
196
|
-
const parts = path.split('.')
|
|
197
|
-
let cur = doc
|
|
198
|
-
for (let i = 0; i < parts.length - 1; i++) {
|
|
199
|
-
if (cur == null || typeof cur !== 'object') return null
|
|
200
|
-
if (!(parts[i] in cur)) cur[parts[i]] = {}
|
|
201
|
-
cur = cur[parts[i]]
|
|
202
|
-
}
|
|
203
|
-
if (cur == null || typeof cur !== 'object') return null
|
|
204
|
-
const leaf = parts[parts.length - 1]
|
|
205
|
-
if (del) delete cur[leaf]
|
|
206
|
-
else cur[leaf] = value
|
|
207
|
-
}
|
|
208
|
-
const nl = /\n$/.test(text) ? '\n' : ''
|
|
209
|
-
return JSON.stringify(doc, null, detectIndent(text)) + nl
|
|
210
|
-
},
|
|
211
|
-
}
|
|
1
|
+
// JSON language provider for ICR — the one provider where the guarantee is PERFECT,
|
|
2
|
+
// because JSON.parse IS the real parser (no heuristics anywhere).
|
|
3
|
+
//
|
|
4
|
+
// JSON is the most-merged file type in a repository (package.json, tsconfig, lockfile
|
|
5
|
+
// fragments, CI configs), and the classic line-merge failure is two sides editing
|
|
6
|
+
// nearby keys — git conflicts or, worse, fuses into invalid JSON. ICR merges JSON as
|
|
7
|
+
// DATA: value-wise 3-way merge, keyed by object path, so:
|
|
8
|
+
// • different keys edited on each side -> clean merge, both kept
|
|
9
|
+
// • the SAME path changed differently -> semantic conflict naming the path
|
|
10
|
+
// (surfaced as a machine-resolvable unit, so an agent judge can reconcile it)
|
|
11
|
+
// • one side deletes a key the other changed -> conflict (delete vs change)
|
|
12
|
+
// • arrays: one-sided changes merge; both-sided changes merge when the edited
|
|
13
|
+
// regions are DISJOINT (prefix/suffix diff — appends, prepends, middle inserts);
|
|
14
|
+
// overlapping array edits are an honest conflict
|
|
15
|
+
// Output is serialized with the file's own indentation style (detected from the base)
|
|
16
|
+
// and preserves the trailing-newline convention, so diffs stay minimal.
|
|
17
|
+
|
|
18
|
+
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
19
|
+
const eq = (x, y) => JSON.stringify(x) === JSON.stringify(y)
|
|
20
|
+
|
|
21
|
+
// PRECISION SAFETY GATE. The data merge re-serializes the whole document, which means
|
|
22
|
+
// every number goes through JS float64 — and that CORRUPTS values nobody touched:
|
|
23
|
+
// 9007199254740993 (beyond 2^53) silently loses a digit; 1e999 becomes Infinity and
|
|
24
|
+
// then `null`. Shipping a "clean" merge that corrupts untouched data is the cardinal
|
|
25
|
+
// sin, so: scan every number literal (string-aware); if ANY cannot survive the
|
|
26
|
+
// round-trip value-identically, refuse the data merge entirely — the engine falls back
|
|
27
|
+
// to the safe line tier, which never rewrites untouched bytes. (Found by the
|
|
28
|
+
// adversarial crucible, not by users — the way it should be.)
|
|
29
|
+
function numbersSafe(src) {
|
|
30
|
+
const n = src.length
|
|
31
|
+
let i = 0
|
|
32
|
+
while (i < n) {
|
|
33
|
+
const c = src[i]
|
|
34
|
+
if (c === '"') { i++; while (i < n) { if (src[i] === '\\') { i += 2; continue } if (src[i] === '"') { i++; break } i++ } continue }
|
|
35
|
+
if (c === '-' || (c >= '0' && c <= '9')) {
|
|
36
|
+
let j = i + 1
|
|
37
|
+
while (j < n && /[0-9eE+.-]/.test(src[j])) j++
|
|
38
|
+
const tok = src.slice(i, j)
|
|
39
|
+
const v = Number(tok)
|
|
40
|
+
if (!Number.isFinite(v)) return false // 1e999 → Infinity → null
|
|
41
|
+
if (Number.isInteger(v) && Math.abs(v) > Number.MAX_SAFE_INTEGER) return false // beyond 2^53
|
|
42
|
+
const digits = tok.replace(/[^0-9]/g, '').replace(/^0+/, '')
|
|
43
|
+
if (digits.length > 15 && !eq(Number(tok), Number(v.toPrecision(15)))) return false // >15 sig digits at risk
|
|
44
|
+
i = j; continue
|
|
45
|
+
}
|
|
46
|
+
i++
|
|
47
|
+
}
|
|
48
|
+
return true
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Detect the base file's indent unit (' ', ' ', '\t', …). Defaults to two spaces.
|
|
52
|
+
function detectIndent(src) {
|
|
53
|
+
const m = /\n([ \t]+)\S/.exec(src)
|
|
54
|
+
return m ? m[1] : ' '
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 3-way merge of two ARRAYS against base, by disjoint-region splice (the same
|
|
58
|
+
// prefix/suffix discipline the engine's token merge uses, on JSON-equal elements).
|
|
59
|
+
// Returns the merged array, or null for an overlapping (real) conflict.
|
|
60
|
+
function mergeArray(base, a, b) {
|
|
61
|
+
const K = (arr) => arr.map((x) => JSON.stringify(x))
|
|
62
|
+
const diffRange = (B, O) => {
|
|
63
|
+
if (B.length === O.length && B.every((x, i) => x === O[i])) return null
|
|
64
|
+
let p = 0
|
|
65
|
+
const maxp = Math.min(B.length, O.length)
|
|
66
|
+
while (p < maxp && B[p] === O[p]) p++
|
|
67
|
+
let s = 0
|
|
68
|
+
while (s < B.length - p && s < O.length - p && B[B.length - 1 - s] === O[O.length - 1 - s]) s++
|
|
69
|
+
return { bStart: p, bEnd: B.length - s, oStart: p, oEnd: O.length - s }
|
|
70
|
+
}
|
|
71
|
+
const KB = K(base), ra = diffRange(KB, K(a)), rb = diffRange(KB, K(b))
|
|
72
|
+
if (!ra) return b
|
|
73
|
+
if (!rb) return a
|
|
74
|
+
if (ra.bStart < rb.bEnd && rb.bStart < ra.bEnd) return null // overlapping regions
|
|
75
|
+
if (ra.bStart === rb.bStart) return null // ambiguous same-point edits
|
|
76
|
+
const edits = [
|
|
77
|
+
{ s: ra.bStart, e: ra.bEnd, ins: a.slice(ra.oStart, ra.oEnd) },
|
|
78
|
+
{ s: rb.bStart, e: rb.bEnd, ins: b.slice(rb.oStart, rb.oEnd) },
|
|
79
|
+
].sort((x, y) => y.s - x.s)
|
|
80
|
+
let out = base.slice()
|
|
81
|
+
for (const ed of edits) out.splice(ed.s, ed.e - ed.s, ...ed.ins)
|
|
82
|
+
return out
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// SYMMETRY. The merged object is re-serialized with JSON.stringify, which emits keys in
|
|
86
|
+
// INSERTION order — so the order we walk the key union IS the output byte order. The
|
|
87
|
+
// obvious union, `[...keys(base), ...keys(a), ...keys(b)]`, is side-ordered: ours's new
|
|
88
|
+
// keys always precede theirs's, so merge(base,a,b) and merge(base,b,a) produce clean
|
|
89
|
+
// 'auto' results that differ byte-for-byte. Measured at 43/167 auto merges (26%) by
|
|
90
|
+
// test/icr-data-torture-test.js. Semantically identical, but P2 SYMMETRY is asserted
|
|
91
|
+
// byte-wise for every other language (icr-torture-test.js), and byte-inequality is
|
|
92
|
+
// precisely what a CRDT peer and a git blob hash react to — the two consumers the README
|
|
93
|
+
// names. So the union order must not depend on which side is called `a`:
|
|
94
|
+
// • keys present in base keep BASE's order (unchanged behaviour, minimal diffs);
|
|
95
|
+
// • keys added by either side follow, ordered by where their own author put them
|
|
96
|
+
// (min index across the sides that have it), ties broken by key name.
|
|
97
|
+
// Both rules are symmetric in a/b, so swapping the sides cannot change the output.
|
|
98
|
+
function unionKeys(base, a, b) {
|
|
99
|
+
const rank = new Map()
|
|
100
|
+
for (const obj of [a, b]) {
|
|
101
|
+
const ks = Object.keys(obj)
|
|
102
|
+
for (let i = 0; i < ks.length; i++) {
|
|
103
|
+
const k = ks[i]
|
|
104
|
+
if (k in base) continue
|
|
105
|
+
const prev = rank.get(k)
|
|
106
|
+
if (prev === undefined || i < prev) rank.set(k, i)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const added = [...rank.keys()].sort((x, y) => (rank.get(x) - rank.get(y)) || (x < y ? -1 : x > y ? 1 : 0))
|
|
110
|
+
return [...Object.keys(base), ...added]
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Value-wise 3-way merge. Fills `conflicts` (path strings) and `resolvable`
|
|
114
|
+
// ({key, base, ours, theirs} with pretty-printed values) on same-path clashes;
|
|
115
|
+
// the merged value keeps BASE at conflicted paths (the engine refuses the whole
|
|
116
|
+
// merge when conflicts exist, so nothing partial ever ships).
|
|
117
|
+
function mergeVal(base, a, b, path, conflicts, resolvable) {
|
|
118
|
+
if (eq(a, b)) return a
|
|
119
|
+
if (eq(a, base)) return b
|
|
120
|
+
if (eq(b, base)) return a
|
|
121
|
+
if (isObj(base) && isObj(a) && isObj(b)) {
|
|
122
|
+
const out = {}
|
|
123
|
+
for (const k of unionKeys(base, a, b)) {
|
|
124
|
+
const p = path ? path + '.' + k : k
|
|
125
|
+
const inB = k in base, inA = k in a, inBb = k in b
|
|
126
|
+
if (inB && !inA && !inBb) continue // deleted on both sides
|
|
127
|
+
if (inB && !inA) { // ours deleted it
|
|
128
|
+
if (eq(b[k], base[k])) continue // theirs untouched -> deletion wins
|
|
129
|
+
conflicts.push(p); resolvable.push(resUnit(p, base[k], undefined, b[k])); continue
|
|
130
|
+
}
|
|
131
|
+
if (inB && !inBb) { // theirs deleted it
|
|
132
|
+
if (eq(a[k], base[k])) continue
|
|
133
|
+
conflicts.push(p); resolvable.push(resUnit(p, base[k], a[k], undefined)); continue
|
|
134
|
+
}
|
|
135
|
+
if (!inB && inA && inBb) { // both added
|
|
136
|
+
if (eq(a[k], b[k])) { out[k] = a[k]; continue }
|
|
137
|
+
if (isObj(a[k]) && isObj(b[k])) { out[k] = mergeVal({}, a[k], b[k], p, conflicts, resolvable); continue }
|
|
138
|
+
conflicts.push(p); resolvable.push(resUnit(p, undefined, a[k], b[k])); continue
|
|
139
|
+
}
|
|
140
|
+
if (!inB) { out[k] = inA ? a[k] : b[k]; continue } // one side added
|
|
141
|
+
out[k] = mergeVal(base[k], a[k], b[k], p, conflicts, resolvable) // present everywhere
|
|
142
|
+
}
|
|
143
|
+
return out
|
|
144
|
+
}
|
|
145
|
+
if (Array.isArray(base) && Array.isArray(a) && Array.isArray(b)) {
|
|
146
|
+
const m = mergeArray(base, a, b)
|
|
147
|
+
if (m) return m
|
|
148
|
+
conflicts.push(path || '(root)'); resolvable.push(resUnit(path || '(root)', base, a, b))
|
|
149
|
+
return base
|
|
150
|
+
}
|
|
151
|
+
conflicts.push(path || '(root)') // scalar/shape clash
|
|
152
|
+
resolvable.push(resUnit(path || '(root)', base, a, b))
|
|
153
|
+
return base
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const show = (v) => (v === undefined ? '(deleted)' : JSON.stringify(v, null, 2))
|
|
157
|
+
const resUnit = (p, base, ours, theirs) => ({ key: 'json:' + p, base: show(base), ours: show(ours), theirs: show(theirs) })
|
|
158
|
+
|
|
159
|
+
export const json = {
|
|
160
|
+
id: 'json',
|
|
161
|
+
exts: ['.json'],
|
|
162
|
+
parses: (src) => { try { JSON.parse(String(src == null ? '' : src)); return true } catch { return false } },
|
|
163
|
+
// The engine's whole-document tier: merge the file as parsed data.
|
|
164
|
+
mergeWhole: (base, a, b) => {
|
|
165
|
+
if (!numbersSafe(base) || !numbersSafe(a) || !numbersSafe(b)) return null // precision hazard → safe fallback, never corrupt
|
|
166
|
+
let B, A, Bb
|
|
167
|
+
try { B = JSON.parse(base); A = JSON.parse(a); Bb = JSON.parse(b) } catch { return null }
|
|
168
|
+
const conflicts = [], resolvable = []
|
|
169
|
+
const merged = mergeVal(B, A, Bb, '', conflicts, resolvable)
|
|
170
|
+
if (conflicts.length) return { conflicts, resolvable }
|
|
171
|
+
const nl = /\n$/.test(base) || /\n$/.test(a) ? '\n' : ''
|
|
172
|
+
return { text: JSON.stringify(merged, null, detectIndent(base)) + nl }
|
|
173
|
+
},
|
|
174
|
+
// Minimal unit view (whole document) so generic engine paths stay well-defined;
|
|
175
|
+
// real merging happens in mergeWhole above.
|
|
176
|
+
units: (src) => [{ key: 'doc', text: String(src), start: 0, end: String(src).length }],
|
|
177
|
+
declaredNames: () => new Set(),
|
|
178
|
+
// INTENT RESOLUTION for data: a conflicted unit's "text" is a rendering of a parsed
|
|
179
|
+
// VALUE, so a judge's reconciliation is applied by setting the value at the unit's
|
|
180
|
+
// path (key 'json:<path>'), not by string replacement. The reconciled string must
|
|
181
|
+
// itself be valid JSON ('(deleted)' removes the key). Returns the re-serialized
|
|
182
|
+
// document, or null when it can't apply — resolveMerge then falls back to the safe
|
|
183
|
+
// conflict, so a malformed judgment never ships.
|
|
184
|
+
applyResolution: (text, unit, reconciled) => {
|
|
185
|
+
if (!unit || typeof unit.key !== 'string' || !unit.key.startsWith('json:')) return null
|
|
186
|
+
if (!numbersSafe(text)) return null // same precision gate: never re-serialize a doc we'd corrupt
|
|
187
|
+
let doc, value
|
|
188
|
+
try { doc = JSON.parse(text) } catch { return null }
|
|
189
|
+
const del = String(reconciled).trim() === '(deleted)'
|
|
190
|
+
if (!del) { try { value = JSON.parse(reconciled) } catch { return null } }
|
|
191
|
+
const path = unit.key.slice(5)
|
|
192
|
+
if (path === '(root)' || path === '') {
|
|
193
|
+
if (del) return null
|
|
194
|
+
doc = value
|
|
195
|
+
} else {
|
|
196
|
+
const parts = path.split('.')
|
|
197
|
+
let cur = doc
|
|
198
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
199
|
+
if (cur == null || typeof cur !== 'object') return null
|
|
200
|
+
if (!(parts[i] in cur)) cur[parts[i]] = {}
|
|
201
|
+
cur = cur[parts[i]]
|
|
202
|
+
}
|
|
203
|
+
if (cur == null || typeof cur !== 'object') return null
|
|
204
|
+
const leaf = parts[parts.length - 1]
|
|
205
|
+
if (del) delete cur[leaf]
|
|
206
|
+
else cur[leaf] = value
|
|
207
|
+
}
|
|
208
|
+
const nl = /\n$/.test(text) ? '\n' : ''
|
|
209
|
+
return JSON.stringify(doc, null, detectIndent(text)) + nl
|
|
210
|
+
},
|
|
211
|
+
}
|