hivecode-mcp 0.6.0 → 0.7.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 +36 -24
- package/hive-coord.js +72 -0
- package/hive-mcp.js +15 -0
- package/icr-merge.js +58 -0
- package/icr.js +267 -0
- package/lang-js.js +418 -0
- package/package.json +6 -1
- package/sync.js +287 -14
- package/token.js +20 -0
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
<h1>Hivecode</h1>
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**Oversight and control for your AI coding agents.**
|
|
4
4
|
|
|
5
|
-
Run
|
|
6
|
-
|
|
7
|
-
Open source.
|
|
5
|
+
Run AI agents on your codebase and actually stay in control: **watch** every edit
|
|
6
|
+
live, **fence** each agent to the folders it's allowed in, **approve** the risky
|
|
7
|
+
moves, and **undo** any agent instantly. No git push/pull. Open source.
|
|
8
8
|
|
|
9
9
|
[**Install for VS Code**](https://marketplace.visualstudio.com/items?itemName=hivecode.hivecode)
|
|
10
10
|
· [**Website**](https://livecode-xoss.onrender.com)
|
|
@@ -16,16 +16,21 @@ Open source.
|
|
|
16
16
|
|
|
17
17
|
## Why
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
You can't trust an AI agent blindly. The faster it edits, the more you have to
|
|
20
|
+
check — and the moment you run more than one, it gets worse: they touch files they
|
|
21
|
+
shouldn't, overwrite each other, and you lose track of who changed what. Today's
|
|
22
|
+
answer is to isolate each agent in a git worktree and pray at merge time — which
|
|
23
|
+
doesn't help you *trust* the output, it just defers the mess.
|
|
23
24
|
|
|
24
|
-
Hivecode
|
|
25
|
-
together — but with real access control. You decide what each agent can reach, and
|
|
26
|
-
the relay enforces it; an out-of-scope agent never even receives the bytes.
|
|
25
|
+
Hivecode adds the missing layer: **oversight and control.**
|
|
27
26
|
|
|
28
|
-
|
|
27
|
+
- **See everything** — a live Control Room (browser or phone) shows every agent and every file it touches, in real time.
|
|
28
|
+
- **Fence them in** — folder-scoped access the relay enforces; an out-of-scope agent never even receives the code.
|
|
29
|
+
- **Approve what matters** — gate risky work so nothing lands without your OK.
|
|
30
|
+
- **Undo anything** — instant rollback: restore any file, or revert *everything* one agent did.
|
|
31
|
+
|
|
32
|
+
It's **agent-neutral**: Claude Code, Cursor, Windsurf, or your own bot over MCP. No
|
|
33
|
+
git push/pull — edits sync in about a second.
|
|
29
34
|
|
|
30
35
|
---
|
|
31
36
|
|
|
@@ -47,24 +52,33 @@ Use **Manage access** to re-scope or revoke anyone, and **Leave Session** to sto
|
|
|
47
52
|
|
|
48
53
|
## Add an AI agent
|
|
49
54
|
|
|
50
|
-
Agents join through the Hivecode MCP server ([`hive-mcp.js`](hive-mcp.js)).
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
+
Agents join through the Hivecode MCP server ([`hive-mcp.js`](hive-mcp.js)). Set it
|
|
56
|
+
up once, then just hand the agent a join link; it appears in the room as an `ai`
|
|
57
|
+
member, scoped to exactly the folders you invited it to.
|
|
58
|
+
|
|
59
|
+
- **[JOIN_WITH_AGENT.md](JOIN_WITH_AGENT.md)** — step-by-step for a *human*: set up MCP, then bring your agent in.
|
|
60
|
+
- **[AGENT_MANUAL.md](AGENT_MANUAL.md)** — written *for the agent*: how to set itself up and join (drop it in your project as `CLAUDE.md` so the agent reads it automatically).
|
|
61
|
+
- **[MCP.md](MCP.md)** — the full tool reference.
|
|
55
62
|
|
|
56
63
|
---
|
|
57
64
|
|
|
58
65
|
## What you get
|
|
59
66
|
|
|
60
|
-
**
|
|
67
|
+
**Oversight & control** *(the point of the whole thing)*
|
|
68
|
+
- **Live Control Room** — watch every agent and every file it touches in real time,
|
|
69
|
+
from any browser or your phone. No more "what is it even doing right now?"
|
|
61
70
|
- **Folder-scoped access** — invite an agent to `frontend/` only; it never receives
|
|
62
|
-
anything else.
|
|
63
|
-
- **Approval gates** —
|
|
64
|
-
|
|
65
|
-
- **
|
|
71
|
+
anything else (relay-enforced, not trust-based).
|
|
72
|
+
- **Approval gates** — risky work waits for your OK; agent-to-agent coordination
|
|
73
|
+
flows automatically.
|
|
74
|
+
- **Instant rollback** — restore any file to an earlier point, or **revert
|
|
75
|
+
*everything* one agent did** in a click. Undo a rogue agent without touching the
|
|
76
|
+
rest of the team's work.
|
|
77
|
+
- **Mission control** — pause, resume, or reassign any agent; agents honor it
|
|
78
|
+
mid-task.
|
|
66
79
|
- **Instant revoke** — cut access mid-session, enforced server-side on every
|
|
67
80
|
reconnect (and it survives a relay restart).
|
|
81
|
+
- **Read-only roles** — reviewers see everything, change nothing.
|
|
68
82
|
|
|
69
83
|
**Coordination**
|
|
70
84
|
- **Built-in chat**, a **shared task board** (assign / complete), and **live
|
|
@@ -72,8 +86,6 @@ talk; and to wait for work. See [MCP.md](MCP.md) for setup.
|
|
|
72
86
|
- **Co-editing heads-up** before two people touch the same file.
|
|
73
87
|
- **Never lose work** — overlapping edits keep both versions with conflict markers;
|
|
74
88
|
a stale rewrite can't silently delete another's lines.
|
|
75
|
-
- **Mission control** — pause, resume, or reassign any agent; agents honor it
|
|
76
|
-
mid-task.
|
|
77
89
|
|
|
78
90
|
**Trust**
|
|
79
91
|
- **Server-enforced auth** — signed tokens, algorithm-pinned, fail-closed.
|
package/hive-coord.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// hive-coord.js — the Hive coordination layer (v0 prototype).
|
|
2
|
+
//
|
|
3
|
+
// The bet: AI coding agents work fast and BLIND — they collide because, unlike a human
|
|
4
|
+
// team, they have no way to coordinate BEFORE they edit. Merging (ICR) cleans up after a
|
|
5
|
+
// collision. This prevents the collision in the first place.
|
|
6
|
+
//
|
|
7
|
+
// The design is a real hive: NO central controller, no conductor, no extra agent. Order
|
|
8
|
+
// emerges from each agent following one local rule against a shared medium — exactly how
|
|
9
|
+
// bees coordinate via traces in the environment (stigmergy), and how millions of Ethernet
|
|
10
|
+
// devices share one wire with no master (carrier-sense / collision-detect / backoff).
|
|
11
|
+
//
|
|
12
|
+
// The shared medium is a CRDT map living IN the codebase doc (a Y.Map in Hivecode; a plain
|
|
13
|
+
// Map in tests). Each region of work (a file, or a unit) can hold one CLAIM:
|
|
14
|
+
// { by: agentId, intent: string, at: time, ttl: ms }
|
|
15
|
+
//
|
|
16
|
+
// An agent's entire protocol — no coordinator anywhere:
|
|
17
|
+
// 1. SENSE — read the shared map. Is my target region claimed and fresh?
|
|
18
|
+
// 2. FLOW — if taken, move to open space (pick another free region). This is the
|
|
19
|
+
// emergent load-balancing: agents spread across the codebase on their own.
|
|
20
|
+
// 3. CLAIM — write my claim to the free region.
|
|
21
|
+
// 4. VERIFY — re-read (collision-detect): if a concurrent claim beat mine, BACK OFF and
|
|
22
|
+
// retry later. (Like Ethernet: listen, send, detect collision, back off.)
|
|
23
|
+
// 5. RELEASE — when done, delete my claim and (optionally) leave a "done" trace.
|
|
24
|
+
//
|
|
25
|
+
// EVAPORATION: claims carry a TTL. A crashed or stalled agent's claim simply expires, and
|
|
26
|
+
// the region frees itself — no central garbage collector, no deadlock. (The pheromone fades.)
|
|
27
|
+
//
|
|
28
|
+
// `shared` is any object with get(key)/set(key,val)/delete(key)/entries() — so the same
|
|
29
|
+
// code runs on a JS Map (tests) and a Yjs Y.Map (production). `now` is injectable so tests
|
|
30
|
+
// can drive a deterministic virtual clock.
|
|
31
|
+
|
|
32
|
+
export function makeCoordinator(shared, self, opts = {}) {
|
|
33
|
+
const ttl = opts.ttl != null ? opts.ttl : 5000
|
|
34
|
+
const now = opts.now || (() => Date.now())
|
|
35
|
+
const expired = (c) => !c || (now() - c.at) >= c.ttl
|
|
36
|
+
|
|
37
|
+
// SENSE: the live, non-expired claim on a region (or null if open).
|
|
38
|
+
const sense = (region) => { const c = shared.get(region); return expired(c) ? null : c }
|
|
39
|
+
|
|
40
|
+
// Is this region open to ME? (free, expired, or already mine)
|
|
41
|
+
const free = (region) => { const c = shared.get(region); return expired(c) || c.by === self }
|
|
42
|
+
|
|
43
|
+
const claim = (region, intent) => { shared.set(region, { by: self, intent, at: now(), ttl }) }
|
|
44
|
+
const release = (region) => { const c = shared.get(region); if (c && c.by === self) shared.delete(region) }
|
|
45
|
+
|
|
46
|
+
// ACQUIRE one region: sense → claim → verify. Returns true iff I now hold it.
|
|
47
|
+
// The verify step is the collision-detector: if a concurrent writer won the region,
|
|
48
|
+
// I see their id on re-read and concede.
|
|
49
|
+
const acquire = (region, intent) => {
|
|
50
|
+
const c = shared.get(region)
|
|
51
|
+
if (c && !expired(c) && c.by !== self) return false // sensed an owner → defer
|
|
52
|
+
claim(region, intent)
|
|
53
|
+
const after = shared.get(region)
|
|
54
|
+
return !(after && after.by !== self) // verify: did my claim survive?
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// FLOW to open space: try my preferred regions in order, take the first I can acquire.
|
|
58
|
+
// This is what makes the swarm spread out with nobody assigning work.
|
|
59
|
+
const acquireAny = (candidates, intent) => {
|
|
60
|
+
for (const r of candidates) if (acquire(r, intent)) return r
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Everything currently claimed (live), as a snapshot — for awareness / a Control Room.
|
|
65
|
+
const board = () => {
|
|
66
|
+
const out = []
|
|
67
|
+
for (const [region, c] of shared.entries()) if (!expired(c)) out.push({ region, ...c })
|
|
68
|
+
return out
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { sense, free, claim, release, acquire, acquireAny, board }
|
|
72
|
+
}
|
package/hive-mcp.js
CHANGED
|
@@ -142,6 +142,12 @@ const TOOLS = [
|
|
|
142
142
|
inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'how many recent messages (default 30)' } } } },
|
|
143
143
|
{ name: 'hive_read_board', description: 'Read recent whole-file rewrites (who rewrote what, which symbols). Read this before editing a file — if it was just rewritten, re-read the file first.',
|
|
144
144
|
inputSchema: { type: 'object', properties: {} } },
|
|
145
|
+
{ name: 'hive_claim', description: 'CLAIM a file (or region) BEFORE you edit it, so other agents flow around you instead of colliding. Returns whether you got it — if false, someone else holds it: work on something else. Claims auto-expire, so call hive_release when done (or it frees itself).',
|
|
146
|
+
inputSchema: { type: 'object', properties: { region: { type: 'string', description: 'the file path (or region id) you want to edit' }, intent: { type: 'string', description: 'short note on what you intend to do' } }, required: ['region'] } },
|
|
147
|
+
{ name: 'hive_release', description: 'Release a file/region you claimed, so other agents can take it. Call this when you finish editing it.',
|
|
148
|
+
inputSchema: { type: 'object', properties: { region: { type: 'string', description: 'the file path (or region id) to release' } }, required: ['region'] } },
|
|
149
|
+
{ name: 'hive_claims', description: 'See what every agent is currently working on (the live claim board). Read this to pick open work and avoid taken regions.',
|
|
150
|
+
inputSchema: { type: 'object', properties: {} } },
|
|
145
151
|
{ name: 'hive_members', description: 'List who is currently in the room (humans and AI agents).', inputSchema: { type: 'object', properties: {} } },
|
|
146
152
|
{ name: 'hive_status', description: 'Show the current session (room, relay, folder, name).', inputSchema: { type: 'object', properties: {} } },
|
|
147
153
|
{ name: 'hive_leave', description: 'Leave the room and stop syncing.', inputSchema: { type: 'object', properties: {} } },
|
|
@@ -186,6 +192,15 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
186
192
|
case 'hive_approve': { const r = requireSession().hive.decide(String(args.id), true); return r.ok ? text(`approved ${args.id}`) : err(r.error) }
|
|
187
193
|
case 'hive_deny': { const r = requireSession().hive.decide(String(args.id), false); return r.ok ? text(`denied ${args.id}`) : err(r.error) }
|
|
188
194
|
case 'hive_complete': { const r = requireSession().hive.complete(String(args.id), String(args.note || '')); return r.ok ? text(`completed ${args.id}`) : err(r.error) }
|
|
195
|
+
case 'hive_claim': {
|
|
196
|
+
const region = String(args.region || ''); if (!region) return err('need a region (file path)')
|
|
197
|
+
const got = requireSession().hive.claim(region, String(args.intent || 'edit'))
|
|
198
|
+
if (got) return text(`claimed ${region} — it's yours. Edit it, then call hive_release.`)
|
|
199
|
+
const c = requireSession().hive.senseClaim(region)
|
|
200
|
+
return text(`could NOT claim ${region}${c ? ` — held by ${c.by}${c.intent ? ` (${c.intent})` : ''}` : ''}. Work on something else; check hive_claims for open files.`)
|
|
201
|
+
}
|
|
202
|
+
case 'hive_release': { const region = String(args.region || ''); if (!region) return err('need a region'); requireSession().hive.release(region); return text(`released ${region}`) }
|
|
203
|
+
case 'hive_claims': { const b = requireSession().hive.claimsBoard(); return text(b.length ? b.map((c) => `${c.region} — ${c.by}${c.intent ? ` (${c.intent})` : ''}`).join('\n') : '(nothing claimed right now — all open)') }
|
|
189
204
|
case 'hive_members': { const now = Date.now(); return text(requireSession().hive.members().map((m) => `${m.name} (${m.kind})${m.editing && now - m.editing.at < 15000 ? ` — editing ${m.editing.file}` : ''}`).join('\n') || '(none)') }
|
|
190
205
|
case 'hive_status': { const s = requireSession(); return text({ room: s.room, relay: s.relay, dir: s.dir, name: s.name }) }
|
|
191
206
|
case 'hive_leave': { requireSession().hive.stop(); session = null; return text('left the room') }
|
package/icr-merge.js
ADDED
|
@@ -0,0 +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
|
+
}
|
package/icr.js
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// ICR — Intent-aware Code Replication (v1 proof-of-concept).
|
|
2
|
+
//
|
|
3
|
+
// The first real step from Hivecode-the-tool toward a FOUNDATIONAL primitive:
|
|
4
|
+
// merge code by STRUCTURE and INTENT, not characters, with a hard guarantee — never
|
|
5
|
+
// emit code more broken than its inputs.
|
|
6
|
+
//
|
|
7
|
+
// Why this matters: CRDTs (Yjs) merge characters. They will happily fuse two edits
|
|
8
|
+
// into syntactically-valid garbage, because they have no idea what code MEANS. ICR
|
|
9
|
+
// parses the code, merges at the level of declarations (functions / classes / vars),
|
|
10
|
+
// descends INTO a declaration both sides touched (so disjoint edits inside one function
|
|
11
|
+
// still merge), detects when two authors changed the SAME thing (a real conflict), and
|
|
12
|
+
// validates that the merged result still parses — falling back safely if it wouldn't.
|
|
13
|
+
//
|
|
14
|
+
// On top of structure it adds an INTENT layer:
|
|
15
|
+
// • RENAME DETECTION — a declaration gone from base whose identical-bodied twin appears
|
|
16
|
+
// under a new name is recognized as a rename; stale call sites are rewritten so the
|
|
17
|
+
// other agent's fresh calls keep working. Nothing else does this.
|
|
18
|
+
// • DANGLING REFERENCE — a declaration removed/renamed but still referenced is flagged
|
|
19
|
+
// as a semantic conflict even though the code parses. CRDTs/git/plain merge miss it.
|
|
20
|
+
//
|
|
21
|
+
// LANGUAGE-AGNOSTIC BY DESIGN: this file is the engine. Everything that knows about a
|
|
22
|
+
// specific language (how to parse, what a declaration is, how to find references) lives
|
|
23
|
+
// behind a provider (see lang-js.js). Adding Python/Go/Rust = writing another provider
|
|
24
|
+
// and registerLanguage()-ing it; the merge logic below never changes. JavaScript via
|
|
25
|
+
// `acorn` is simply the first provider. The production path swaps in tree-sitter
|
|
26
|
+
// providers for every language behind this same interface.
|
|
27
|
+
|
|
28
|
+
import { javascript } from './lang-js.js'
|
|
29
|
+
|
|
30
|
+
// --- language registry ----------------------------------------------------------
|
|
31
|
+
const LANGUAGES = [javascript]
|
|
32
|
+
|
|
33
|
+
// Register an additional language provider. Must expose the provider contract:
|
|
34
|
+
// { id, exts:[...], parses, units, declaredNames, usedIdentifiers, declBody,
|
|
35
|
+
// renameRefs, fnParts }. Newest wins on extension clashes.
|
|
36
|
+
export function registerLanguage(provider) { LANGUAGES.unshift(provider) }
|
|
37
|
+
|
|
38
|
+
function extname(p) { const m = /\.[^.\/\\]+$/.exec(p || ''); return m ? m[0].toLowerCase() : '' }
|
|
39
|
+
|
|
40
|
+
// The provider for a filename, or null if no registered language claims its extension.
|
|
41
|
+
export function languageFor(filename) {
|
|
42
|
+
const e = extname(filename)
|
|
43
|
+
return LANGUAGES.find((l) => l.exts.includes(e)) || null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Can ICR merge this file structurally? (Callers gate on this before trying.)
|
|
47
|
+
export function supports(filename) { return languageFor(filename) != null }
|
|
48
|
+
|
|
49
|
+
// --- generic merge primitives (language-independent: operate on {key,text}) -----
|
|
50
|
+
const mapOf = (us) => new Map(us.map((u) => [u.key, u.text]))
|
|
51
|
+
function dedupeOrder(keys) { const seen = new Set(), out = []; for (const k of keys) if (!seen.has(k)) { seen.add(k); out.push(k) } return out }
|
|
52
|
+
|
|
53
|
+
// For a given unit, choose which version survives: whoever CHANGED it wins; if neither
|
|
54
|
+
// changed it, keep the base.
|
|
55
|
+
function pick(k, B, A, Bb) {
|
|
56
|
+
const base = B.has(k) ? B.get(k) : null
|
|
57
|
+
const a = A.has(k) ? A.get(k) : null
|
|
58
|
+
const b = Bb.has(k) ? Bb.get(k) : null
|
|
59
|
+
if (a != null && a !== base) return a
|
|
60
|
+
if (b != null && b !== base) return b
|
|
61
|
+
return base != null ? base : (a != null ? a : b)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Core 3-way merge over a KEYED list of units (works at file level AND, recursively,
|
|
65
|
+
// inside a single declaration). Returns { conflicts:[...], parts:[...text] }.
|
|
66
|
+
// When both sides change the SAME key into different things, it first tries to descend
|
|
67
|
+
// INTO that unit (finer granularity) — only a clash it can't resolve becomes a conflict.
|
|
68
|
+
function mergeKeyed(lang, baseU, aU, bU) {
|
|
69
|
+
const B = mapOf(baseU), A = mapOf(aU), Bb = mapOf(bU)
|
|
70
|
+
const keys = dedupeOrder([...baseU.map((u) => u.key), ...aU.map((u) => u.key), ...bU.map((u) => u.key)])
|
|
71
|
+
|
|
72
|
+
const conflicts = [], resolved = new Map()
|
|
73
|
+
for (const k of keys) {
|
|
74
|
+
const bs = B.has(k) ? B.get(k) : null
|
|
75
|
+
const as_ = A.has(k) ? A.get(k) : null
|
|
76
|
+
const bbs = Bb.has(k) ? Bb.get(k) : null
|
|
77
|
+
const changedA = as_ !== bs, changedB = bbs !== bs
|
|
78
|
+
if (changedA && changedB && as_ !== bbs) {
|
|
79
|
+
// Both touched the same unit, differently. Try finer-grained inner merge first.
|
|
80
|
+
if (bs != null && as_ != null && bbs != null) {
|
|
81
|
+
const inner = tryInnerMerge(lang, bs, as_, bbs)
|
|
82
|
+
if (inner != null) { resolved.set(k, inner); continue }
|
|
83
|
+
}
|
|
84
|
+
conflicts.push(k)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (conflicts.length) return { conflicts, parts: [] }
|
|
88
|
+
|
|
89
|
+
// No unresolved clashes → decide which keys survive, honoring one-sided deletions.
|
|
90
|
+
const survives = new Set()
|
|
91
|
+
for (const k of keys) {
|
|
92
|
+
const inBase = B.has(k), inA = A.has(k), inB2 = Bb.has(k)
|
|
93
|
+
if (inBase && (!inA || !inB2)) {
|
|
94
|
+
const deletedByA = !inA, deletedByB = !inB2
|
|
95
|
+
const otherUnchangedFromBase =
|
|
96
|
+
(deletedByA && (!inB2 || Bb.get(k) === B.get(k))) ||
|
|
97
|
+
(deletedByB && (!inA || A.get(k) === B.get(k)))
|
|
98
|
+
if (otherUnchangedFromBase) continue // accept the deletion
|
|
99
|
+
}
|
|
100
|
+
survives.add(k)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// CONVERGENCE: the output order must be IDENTICAL no matter which side is `a` vs `b`,
|
|
104
|
+
// or two peers compute different text and never settle (then re-merging the divergence
|
|
105
|
+
// corrupts the file). So: surviving base units keep their base order; units added by a
|
|
106
|
+
// side are appended in a canonical (sorted) order — deterministic and symmetric.
|
|
107
|
+
const baseOrder = dedupeOrder(baseU.map((u) => u.key))
|
|
108
|
+
const baseSet = new Set(baseOrder)
|
|
109
|
+
const final = []
|
|
110
|
+
for (const k of baseOrder) if (survives.has(k)) final.push(k)
|
|
111
|
+
for (const k of [...survives].filter((k) => !baseSet.has(k)).sort()) final.push(k)
|
|
112
|
+
|
|
113
|
+
// Assemble, recording PROVENANCE per surviving unit: who authored the version we kept
|
|
114
|
+
// ('a' / 'b' changed it, 'both' if we merged inside it, 'base' if unchanged).
|
|
115
|
+
const parts = [], provenance = [], order = [], textByKey = new Map()
|
|
116
|
+
for (const k of final) {
|
|
117
|
+
let text, from
|
|
118
|
+
if (resolved.has(k)) { text = resolved.get(k); from = 'both' }
|
|
119
|
+
else {
|
|
120
|
+
text = pick(k, B, A, Bb)
|
|
121
|
+
const bs = B.has(k) ? B.get(k) : null, as_ = A.has(k) ? A.get(k) : null, bbs = Bb.has(k) ? Bb.get(k) : null
|
|
122
|
+
from = (as_ != null && as_ !== bs) ? 'a' : (bbs != null && bbs !== bs) ? 'b' : 'base'
|
|
123
|
+
}
|
|
124
|
+
if (text != null) { parts.push(text); provenance.push({ key: k, from }); order.push(k); textByKey.set(k, text) }
|
|
125
|
+
}
|
|
126
|
+
return { conflicts: [], parts, provenance, order, textByKey }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// FINER GRANULARITY: both sides edited the same function OR class. If the signature
|
|
130
|
+
// matches and they edited DIFFERENT inner units (statements / class members), merge those
|
|
131
|
+
// recursively rather than declaring the whole declaration a conflict. Returns merged
|
|
132
|
+
// text, or null meaning "couldn't safely merge inside — treat as a real conflict."
|
|
133
|
+
function tryInnerMerge(lang, baseText, aText, bText) {
|
|
134
|
+
// Language-specific same-key merges first (e.g. unioning import specifiers).
|
|
135
|
+
if (lang.mergeUnit) {
|
|
136
|
+
const t = lang.mergeUnit(baseText, aText, bText)
|
|
137
|
+
if (t != null && lang.parses(t)) return t
|
|
138
|
+
}
|
|
139
|
+
if (!lang.splitUnit) return null
|
|
140
|
+
const pb = lang.splitUnit(baseText), pa = lang.splitUnit(aText), pbb = lang.splitUnit(bText)
|
|
141
|
+
if (!pb || !pa || !pbb) return null
|
|
142
|
+
if (pa.sig.trim() !== pbb.sig.trim()) return null // signature itself changed → real conflict
|
|
143
|
+
const m = mergeKeyed(lang, pb.units, pa.units, pbb.units)
|
|
144
|
+
if (m.conflicts.length) return null
|
|
145
|
+
const text = pa.sig + pa.open + m.parts.join(pa.join) + pa.close
|
|
146
|
+
const valid = lang.parsesUnit || lang.parses // accept class-member fragments when supported
|
|
147
|
+
return valid(text) ? text : null
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// FORMAT-PRESERVING assembly: rebuild the merged file by splicing the merged units back
|
|
151
|
+
// into the BASE text at their original byte ranges. Unchanged units — and all the
|
|
152
|
+
// whitespace/comments BETWEEN units — survive verbatim; only changed units carry the
|
|
153
|
+
// editing side's bytes, and genuinely new units are appended. This is what stops ICR from
|
|
154
|
+
// reformatting code it merges. Deterministic and symmetric, so peers converge.
|
|
155
|
+
function spliceUnits(baseText, baseUnits, order, textByKey) {
|
|
156
|
+
const baseKeySet = new Set(baseUnits.map((u) => u.key))
|
|
157
|
+
const surviving = new Set(order.filter((k) => baseKeySet.has(k)))
|
|
158
|
+
const emitted = new Set()
|
|
159
|
+
let out = '', pos = 0
|
|
160
|
+
for (const u of baseUnits) {
|
|
161
|
+
out += baseText.slice(pos, u.start) // gap before this unit (comments/blank lines), verbatim
|
|
162
|
+
if (surviving.has(u.key) && !emitted.has(u.key)) { out += textByKey.get(u.key); emitted.add(u.key) }
|
|
163
|
+
pos = u.end
|
|
164
|
+
}
|
|
165
|
+
const added = order.filter((k) => !baseKeySet.has(k))
|
|
166
|
+
if (added.length) {
|
|
167
|
+
const head = out.replace(/\s*$/, '')
|
|
168
|
+
out = (head ? head + '\n\n' : '') + added.map((k) => textByKey.get(k)).join('\n\n') + '\n'
|
|
169
|
+
} else {
|
|
170
|
+
out += baseText.slice(pos) // trailing whitespace (final newline) verbatim
|
|
171
|
+
}
|
|
172
|
+
return out.replace(/^\n+/, '') // drop orphan leading blank lines left by a deleted first unit
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// --- public API -----------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
// Does this source parse under the given (or default JS) language?
|
|
178
|
+
export function parses(src, lang = javascript) { return lang.parses(src) }
|
|
179
|
+
|
|
180
|
+
// 3-way STRUCTURAL + INTENT merge of `a` and `b` against common ancestor `base`.
|
|
181
|
+
// opts: { lang } a provider, or { filename } to pick one by extension (defaults to JS).
|
|
182
|
+
// Returns { status, text, conflicts, renames? }:
|
|
183
|
+
// 'auto' — clean merge; `text` is valid, parseable code.
|
|
184
|
+
// 'semantic-conflict' — same declaration changed both sides, or a dangling reference;
|
|
185
|
+
// `conflicts` names them (e.g. ['fn:login'] or ['ref:helper']).
|
|
186
|
+
// 'fallback' — couldn't merge safely (unparseable input, or the merge wouldn't
|
|
187
|
+
// parse, or unsupported language); caller keeps both. Never broken.
|
|
188
|
+
export function structuralMerge(base, a, b, opts = {}) {
|
|
189
|
+
const lang = opts.lang || (opts.filename ? languageFor(opts.filename) : javascript) || javascript
|
|
190
|
+
|
|
191
|
+
if (!lang.parses(base) || !lang.parses(a) || !lang.parses(b))
|
|
192
|
+
return { status: 'fallback', text: null, conflicts: [], reason: 'unparseable input' }
|
|
193
|
+
|
|
194
|
+
// FIXED-POINT fast path: once two peers agree on a text T, re-merging must return T
|
|
195
|
+
// unchanged. This is what makes the live sync settle. Both agree ⇒ nothing to check.
|
|
196
|
+
if (a === b) return { status: 'auto', text: a, conflicts: [], renames: [], provenance: [] }
|
|
197
|
+
|
|
198
|
+
const authors = opts.authors || {}
|
|
199
|
+
const attribute = (prov) => (prov || []).map((p) => ({ unit: p.key, author: authors[p.from] || p.from }))
|
|
200
|
+
|
|
201
|
+
// Determine the structurally-merged text + provenance. A one-sided change takes that
|
|
202
|
+
// side verbatim (format perfectly preserved); otherwise do the keyed 3-way merge and
|
|
203
|
+
// splice it back into base. Either way the INTENT layer below still runs, so a deletion
|
|
204
|
+
// that leaves a dangling reference is caught even when only one side changed.
|
|
205
|
+
let text, provenance
|
|
206
|
+
if (a === base) { text = b; provenance = [] }
|
|
207
|
+
else if (b === base) { text = a; provenance = [] }
|
|
208
|
+
else {
|
|
209
|
+
const baseUnits = lang.units(base)
|
|
210
|
+
const merge = mergeKeyed(lang, baseUnits, lang.units(a), lang.units(b))
|
|
211
|
+
if (merge.conflicts.length) return { status: 'semantic-conflict', text: null, conflicts: merge.conflicts }
|
|
212
|
+
// Prefer the format-preserving splice (units carry byte ranges); fall back to a plain
|
|
213
|
+
// join for languages whose units don't expose ranges.
|
|
214
|
+
const canSplice = baseUnits.every((u) => typeof u.start === 'number')
|
|
215
|
+
text = canSplice ? spliceUnits(base, baseUnits, merge.order, merge.textByKey) : merge.parts.join('\n\n') + '\n'
|
|
216
|
+
provenance = merge.provenance
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// THE GUARANTEE: the merged result must parse, or we refuse it.
|
|
220
|
+
if (!lang.parses(text)) return { status: 'fallback', text: null, conflicts: [], reason: 'merge would not parse' }
|
|
221
|
+
|
|
222
|
+
// RENAME DETECTION (intent): a declaration gone from base whose identical-bodied twin
|
|
223
|
+
// appears under a new name is a rename — rewrite stale call sites to the new name.
|
|
224
|
+
let merged = text
|
|
225
|
+
const baseNames = lang.declaredNames(base)
|
|
226
|
+
let mergedNames = lang.declaredNames(merged)
|
|
227
|
+
let removed = [...baseNames].filter((n) => !mergedNames.has(n))
|
|
228
|
+
const renames = []
|
|
229
|
+
const rename = lang.renameFreeRefs || lang.renameRefs // prefer scope-aware rewriting
|
|
230
|
+
if (removed.length && lang.declBody && rename) {
|
|
231
|
+
const added = [...mergedNames].filter((n) => !baseNames.has(n))
|
|
232
|
+
const claimed = new Set()
|
|
233
|
+
for (const oldName of removed) {
|
|
234
|
+
const oldBody = lang.declBody(base, oldName)
|
|
235
|
+
if (oldBody == null) continue
|
|
236
|
+
const match = added.find((n) => !claimed.has(n) && lang.declBody(merged, n) === oldBody)
|
|
237
|
+
if (match) { renames.push([oldName, match]); claimed.add(match) }
|
|
238
|
+
}
|
|
239
|
+
let rewritten = merged
|
|
240
|
+
// Rewrite only references that resolve to the renamed binding (scope-aware) — a local
|
|
241
|
+
// variable that merely shares the old name is left untouched.
|
|
242
|
+
for (const [oldName, newName] of renames) rewritten = rename(rewritten, oldName, newName)
|
|
243
|
+
// only accept the rewrite if it still parses — never trade a parse error for intent.
|
|
244
|
+
if (renames.length && lang.parses(rewritten)) { merged = rewritten; mergedNames = lang.declaredNames(merged) }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// DANGLING REFERENCE (intent): a declaration removed (and NOT explained by a rename)
|
|
248
|
+
// but still referenced is a semantic conflict even though the code parses cleanly.
|
|
249
|
+
// Uses the SCOPE-AWARE reference set when the language provides one (so a local binding
|
|
250
|
+
// that merely shares the deleted name is correctly NOT treated as a reference to it),
|
|
251
|
+
// falling back to the approximate identifier set otherwise.
|
|
252
|
+
removed = [...baseNames].filter((n) => !mergedNames.has(n))
|
|
253
|
+
const refs = lang.referencedFreeNames ? lang.referencedFreeNames(merged)
|
|
254
|
+
: lang.usedIdentifiers ? lang.usedIdentifiers(merged) : null
|
|
255
|
+
if (removed.length && refs) {
|
|
256
|
+
const dangling = removed.filter((n) => refs.has(n))
|
|
257
|
+
if (dangling.length)
|
|
258
|
+
return { status: 'semantic-conflict', text: null, conflicts: dangling.map((n) => 'ref:' + n), reason: 'dangling reference: a declaration was removed/renamed but is still used' }
|
|
259
|
+
}
|
|
260
|
+
// PROVENANCE: attribute each surviving unit to the author whose version we kept. With
|
|
261
|
+
// opts.authors = { a, b, base } the labels are real names; otherwise they're 'a'/'b'/etc.
|
|
262
|
+
return {
|
|
263
|
+
status: 'auto', text: merged, conflicts: [],
|
|
264
|
+
renames: renames.map(([o, n]) => o + '->' + n),
|
|
265
|
+
provenance: attribute(provenance),
|
|
266
|
+
}
|
|
267
|
+
}
|