trellis 1.0.8 → 2.0.5
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 -0
- package/README.md +533 -82
- package/bin/trellis.mjs +2 -0
- package/dist/cli/index.js +4718 -0
- package/dist/core/index.js +12 -0
- package/dist/decisions/index.js +19 -0
- package/dist/embeddings/index.js +43 -0
- package/dist/index-1j1anhmr.js +4038 -0
- package/dist/index-3s0eak0p.js +1556 -0
- package/dist/index-8pce39mh.js +272 -0
- package/dist/index-a76rekgs.js +67 -0
- package/dist/index-cy9k1g6v.js +684 -0
- package/dist/index-fd4e26s4.js +69 -0
- package/dist/{store/eav-store.js → index-gkvhzm9f.js} +4 -6
- package/dist/index-gnw8d7d6.js +51 -0
- package/dist/index-vkpkfwhq.js +817 -0
- package/dist/index.js +118 -2876
- package/dist/links/index.js +55 -0
- package/dist/transformers-m9je15kg.js +32491 -0
- package/dist/vcs/index.js +110 -0
- package/logo.png +0 -0
- package/logo.svg +9 -0
- package/package.json +79 -76
- package/src/cli/index.ts +2340 -0
- package/src/core/index.ts +35 -0
- package/src/core/kernel/middleware.ts +44 -0
- package/src/core/persist/backend.ts +64 -0
- package/src/core/store/eav-store.ts +467 -0
- package/src/decisions/auto-capture.ts +136 -0
- package/src/decisions/hooks.ts +163 -0
- package/src/decisions/index.ts +261 -0
- package/src/decisions/types.ts +103 -0
- package/src/embeddings/chunker.ts +327 -0
- package/src/embeddings/index.ts +41 -0
- package/src/embeddings/model.ts +95 -0
- package/src/embeddings/search.ts +305 -0
- package/src/embeddings/store.ts +313 -0
- package/src/embeddings/types.ts +85 -0
- package/src/engine.ts +1083 -0
- package/src/garden/cluster.ts +330 -0
- package/src/garden/garden.ts +306 -0
- package/src/garden/index.ts +29 -0
- package/src/git/git-exporter.ts +286 -0
- package/src/git/git-importer.ts +329 -0
- package/src/git/git-reader.ts +189 -0
- package/src/git/index.ts +22 -0
- package/src/identity/governance.ts +211 -0
- package/src/identity/identity.ts +224 -0
- package/src/identity/index.ts +30 -0
- package/src/identity/signing-middleware.ts +97 -0
- package/src/index.ts +20 -0
- package/src/links/index.ts +49 -0
- package/src/links/lifecycle.ts +400 -0
- package/src/links/parser.ts +484 -0
- package/src/links/ref-index.ts +186 -0
- package/src/links/resolver.ts +314 -0
- package/src/links/types.ts +108 -0
- package/src/mcp/index.ts +22 -0
- package/src/mcp/server.ts +1278 -0
- package/src/semantic/csharp-parser.ts +493 -0
- package/src/semantic/go-parser.ts +585 -0
- package/src/semantic/index.ts +34 -0
- package/src/semantic/java-parser.ts +456 -0
- package/src/semantic/python-parser.ts +659 -0
- package/src/semantic/ruby-parser.ts +446 -0
- package/src/semantic/rust-parser.ts +784 -0
- package/src/semantic/semantic-merge.ts +210 -0
- package/src/semantic/ts-parser.ts +681 -0
- package/src/semantic/types.ts +175 -0
- package/src/sync/index.ts +32 -0
- package/src/sync/memory-transport.ts +66 -0
- package/src/sync/reconciler.ts +237 -0
- package/src/sync/sync-engine.ts +258 -0
- package/src/sync/types.ts +104 -0
- package/src/vcs/blob-store.ts +124 -0
- package/src/vcs/branch.ts +150 -0
- package/src/vcs/checkpoint.ts +64 -0
- package/src/vcs/decompose.ts +469 -0
- package/src/vcs/diff.ts +409 -0
- package/src/vcs/engine-context.ts +26 -0
- package/src/vcs/index.ts +23 -0
- package/src/vcs/issue.ts +800 -0
- package/src/vcs/merge.ts +425 -0
- package/src/vcs/milestone.ts +124 -0
- package/src/vcs/ops.ts +59 -0
- package/src/vcs/types.ts +213 -0
- package/src/vcs/vcs-middleware.ts +81 -0
- package/src/watcher/fs-watcher.ts +217 -0
- package/src/watcher/index.ts +9 -0
- package/src/watcher/ingestion.ts +116 -0
- package/dist/ai/index.js +0 -688
- package/dist/cli/server.js +0 -3321
- package/dist/cli/tql.js +0 -5282
- package/dist/client/tql-client.js +0 -108
- package/dist/graph/index.js +0 -2248
- package/dist/kernel/logic-middleware.js +0 -179
- package/dist/kernel/middleware.js +0 -0
- package/dist/kernel/operations.js +0 -32
- package/dist/kernel/schema-middleware.js +0 -34
- package/dist/kernel/security-middleware.js +0 -53
- package/dist/kernel/trellis-kernel.js +0 -2239
- package/dist/kernel/workspace.js +0 -91
- package/dist/persist/backend.js +0 -0
- package/dist/persist/sqlite-backend.js +0 -123
- package/dist/query/index.js +0 -1643
- package/dist/server/index.js +0 -3309
- package/dist/workflows/index.js +0 -3160
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic Patching — Type Definitions
|
|
3
|
+
*
|
|
4
|
+
* DESIGN.md §4.1–4.4 — Pillar 2: Semantic Patching.
|
|
5
|
+
* Types for parser adapters, AST entities, parse results,
|
|
6
|
+
* semantic patches, and structured merge conflicts.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// AST Entities
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
export interface ASTEntity {
|
|
14
|
+
/** Stable ID derived from structural signature (name + kind + scope path). */
|
|
15
|
+
id: string;
|
|
16
|
+
/** Entity type. */
|
|
17
|
+
kind: ASTEntityKind;
|
|
18
|
+
/** Human-readable name. */
|
|
19
|
+
name: string;
|
|
20
|
+
/** Scope path for disambiguation (e.g. 'MyClass.myMethod'). */
|
|
21
|
+
scopePath: string;
|
|
22
|
+
/** Byte range in source for roundtrip (start, end). */
|
|
23
|
+
span: [number, number];
|
|
24
|
+
/** Raw source text of this declaration. */
|
|
25
|
+
rawText: string;
|
|
26
|
+
/** Structural signature for identity matching (excludes whitespace/comments). */
|
|
27
|
+
signature: string;
|
|
28
|
+
/** Child entities (nested functions, inner classes, etc.). */
|
|
29
|
+
children: ASTEntity[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type ASTEntityKind =
|
|
33
|
+
| 'FunctionDef'
|
|
34
|
+
| 'ClassDef'
|
|
35
|
+
| 'InterfaceDef'
|
|
36
|
+
| 'TypeAlias'
|
|
37
|
+
| 'EnumDef'
|
|
38
|
+
| 'VariableDecl'
|
|
39
|
+
| 'MethodDef'
|
|
40
|
+
| 'PropertyDef'
|
|
41
|
+
| 'Constructor'
|
|
42
|
+
| 'Unknown';
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Import / Export Relations
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
export interface ImportRelation {
|
|
49
|
+
source: string;
|
|
50
|
+
specifiers: string[];
|
|
51
|
+
isDefault: boolean;
|
|
52
|
+
isNamespace: boolean;
|
|
53
|
+
rawText: string;
|
|
54
|
+
span: [number, number];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ExportRelation {
|
|
58
|
+
name: string;
|
|
59
|
+
isDefault: boolean;
|
|
60
|
+
source?: string;
|
|
61
|
+
rawText: string;
|
|
62
|
+
span: [number, number];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Parse Result
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
export interface ParseResult {
|
|
70
|
+
/** The file entity this parse belongs to. */
|
|
71
|
+
fileEntityId: string;
|
|
72
|
+
/** File path. */
|
|
73
|
+
filePath: string;
|
|
74
|
+
/** Language identifier. */
|
|
75
|
+
language: string;
|
|
76
|
+
/** Top-level declarations found in the file. */
|
|
77
|
+
declarations: ASTEntity[];
|
|
78
|
+
/** Import relationships. */
|
|
79
|
+
imports: ImportRelation[];
|
|
80
|
+
/** Export relationships. */
|
|
81
|
+
exports: ExportRelation[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Parser Adapter Interface
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
export interface ParserAdapter {
|
|
89
|
+
/** Languages this adapter supports. */
|
|
90
|
+
languages: string[];
|
|
91
|
+
/** Parse a source file into AST-level entities. */
|
|
92
|
+
parse(content: string, filePath: string): ParseResult;
|
|
93
|
+
/** Compute semantic patches between two parse results. */
|
|
94
|
+
diff(oldResult: ParseResult, newResult: ParseResult): SemanticPatch[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Semantic Patch Types
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
export type SemanticPatch =
|
|
102
|
+
| { kind: 'symbolAdd'; entity: ASTEntity }
|
|
103
|
+
| { kind: 'symbolRemove'; entityId: string; entityName: string }
|
|
104
|
+
| {
|
|
105
|
+
kind: 'symbolModify';
|
|
106
|
+
entityId: string;
|
|
107
|
+
entityName: string;
|
|
108
|
+
oldSignature: string;
|
|
109
|
+
newSignature: string;
|
|
110
|
+
oldRawText: string;
|
|
111
|
+
newRawText: string;
|
|
112
|
+
}
|
|
113
|
+
| {
|
|
114
|
+
kind: 'symbolRename';
|
|
115
|
+
entityId: string;
|
|
116
|
+
oldName: string;
|
|
117
|
+
newName: string;
|
|
118
|
+
}
|
|
119
|
+
| {
|
|
120
|
+
kind: 'symbolMove';
|
|
121
|
+
entityId: string;
|
|
122
|
+
entityName: string;
|
|
123
|
+
oldFile: string;
|
|
124
|
+
newFile: string;
|
|
125
|
+
}
|
|
126
|
+
| {
|
|
127
|
+
kind: 'importAdd';
|
|
128
|
+
fileId: string;
|
|
129
|
+
source: string;
|
|
130
|
+
specifiers: string[];
|
|
131
|
+
rawText: string;
|
|
132
|
+
}
|
|
133
|
+
| {
|
|
134
|
+
kind: 'importRemove';
|
|
135
|
+
fileId: string;
|
|
136
|
+
source: string;
|
|
137
|
+
}
|
|
138
|
+
| {
|
|
139
|
+
kind: 'importModify';
|
|
140
|
+
fileId: string;
|
|
141
|
+
source: string;
|
|
142
|
+
oldSpecifiers: string[];
|
|
143
|
+
newSpecifiers: string[];
|
|
144
|
+
}
|
|
145
|
+
| {
|
|
146
|
+
kind: 'exportAdd';
|
|
147
|
+
fileId: string;
|
|
148
|
+
name: string;
|
|
149
|
+
rawText: string;
|
|
150
|
+
}
|
|
151
|
+
| {
|
|
152
|
+
kind: 'exportRemove';
|
|
153
|
+
fileId: string;
|
|
154
|
+
name: string;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// Semantic Merge Conflict
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
export interface SemanticMergeConflict {
|
|
162
|
+
entityId: string;
|
|
163
|
+
entityName: string;
|
|
164
|
+
entityKind: string;
|
|
165
|
+
filePath: string;
|
|
166
|
+
ours: SemanticPatch;
|
|
167
|
+
theirs: SemanticPatch;
|
|
168
|
+
suggestion?: 'accept-ours' | 'accept-theirs' | 'combine';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface SemanticMergeResult {
|
|
172
|
+
clean: boolean;
|
|
173
|
+
patches: SemanticPatch[];
|
|
174
|
+
conflicts: SemanticMergeConflict[];
|
|
175
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync Module — Public Surface
|
|
3
|
+
*
|
|
4
|
+
* @module sync
|
|
5
|
+
*
|
|
6
|
+
* Re-exports the CRDT {@link reconcile|reconciler}, {@link SyncEngine} for
|
|
7
|
+
* the have→want→ops→ack protocol, and {@link MemoryTransport} for testing.
|
|
8
|
+
* Branch policies control whether sync uses linear (fast-forward) or
|
|
9
|
+
* CRDT (concurrent append) mode.
|
|
10
|
+
*
|
|
11
|
+
* @see DESIGN.md §3.5 for the branch concurrency model.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type {
|
|
15
|
+
PeerId,
|
|
16
|
+
SyncMessage,
|
|
17
|
+
SyncHaveMessage,
|
|
18
|
+
SyncWantMessage,
|
|
19
|
+
SyncOpsMessage,
|
|
20
|
+
SyncAckMessage,
|
|
21
|
+
SyncState,
|
|
22
|
+
BranchPolicy,
|
|
23
|
+
SyncTransport,
|
|
24
|
+
} from './types.js';
|
|
25
|
+
|
|
26
|
+
export { reconcile, findForkPoint } from './reconciler.js';
|
|
27
|
+
|
|
28
|
+
export type { ReconcileResult, ReconcileConflict } from './reconciler.js';
|
|
29
|
+
|
|
30
|
+
export { SyncEngine } from './sync-engine.js';
|
|
31
|
+
|
|
32
|
+
export { MemoryTransport } from './memory-transport.js';
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-Memory Sync Transport
|
|
3
|
+
*
|
|
4
|
+
* A simple in-memory transport for testing and local multi-engine sync.
|
|
5
|
+
* Messages are delivered synchronously between connected peers.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { SyncTransport, SyncMessage, PeerId } from './types.js';
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// In-memory transport
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
export class MemoryTransport implements SyncTransport {
|
|
15
|
+
private peerId: string;
|
|
16
|
+
private peerName: string;
|
|
17
|
+
private handlers: ((message: SyncMessage) => void)[] = [];
|
|
18
|
+
private connectedPeers: Map<string, MemoryTransport> = new Map();
|
|
19
|
+
|
|
20
|
+
constructor(peerId: string, peerName: string = peerId) {
|
|
21
|
+
this.peerId = peerId;
|
|
22
|
+
this.peerName = peerName;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Connect two transports so they can exchange messages.
|
|
27
|
+
*/
|
|
28
|
+
static connect(a: MemoryTransport, b: MemoryTransport): void {
|
|
29
|
+
a.connectedPeers.set(b.peerId, b);
|
|
30
|
+
b.connectedPeers.set(a.peerId, a);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Disconnect two transports.
|
|
35
|
+
*/
|
|
36
|
+
static disconnect(a: MemoryTransport, b: MemoryTransport): void {
|
|
37
|
+
a.connectedPeers.delete(b.peerId);
|
|
38
|
+
b.connectedPeers.delete(a.peerId);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async send(peerId: string, message: SyncMessage): Promise<void> {
|
|
42
|
+
const peer = this.connectedPeers.get(peerId);
|
|
43
|
+
if (!peer) {
|
|
44
|
+
throw new Error(`Peer not connected: ${peerId}`);
|
|
45
|
+
}
|
|
46
|
+
// Deliver to peer's handlers
|
|
47
|
+
for (const handler of peer.handlers) {
|
|
48
|
+
handler(message);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
onMessage(handler: (message: SyncMessage) => void): void {
|
|
53
|
+
this.handlers.push(handler);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
peers(): PeerId[] {
|
|
57
|
+
return [...this.connectedPeers.entries()].map(([id, transport]) => ({
|
|
58
|
+
id,
|
|
59
|
+
name: transport.peerName,
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
getPeerId(): string {
|
|
64
|
+
return this.peerId;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CRDT Reconciler
|
|
3
|
+
*
|
|
4
|
+
* DESIGN.md §10.5 — Merges divergent op streams using causal ordering.
|
|
5
|
+
* Each device maintains its own causal chain. The reconciler merges
|
|
6
|
+
* divergent chains by:
|
|
7
|
+
* 1. Finding the common ancestor (fork point)
|
|
8
|
+
* 2. Collecting ops unique to each side
|
|
9
|
+
* 3. Topologically sorting the combined set by causal dependencies
|
|
10
|
+
* 4. Detecting conflicts using patch commutativity (§4.4)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { VcsOp } from '../vcs/types.js';
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Types
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export interface ReconcileResult {
|
|
20
|
+
/** The merged op stream in causal order. */
|
|
21
|
+
merged: VcsOp[];
|
|
22
|
+
/** Ops that were only on side A. */
|
|
23
|
+
uniqueToA: VcsOp[];
|
|
24
|
+
/** Ops that were only on side B. */
|
|
25
|
+
uniqueToB: VcsOp[];
|
|
26
|
+
/** Common ancestor op hash (fork point). */
|
|
27
|
+
forkPoint: string | null;
|
|
28
|
+
/** Whether the merge was clean (no causal conflicts). */
|
|
29
|
+
clean: boolean;
|
|
30
|
+
/** Conflicting op pairs (both modify same file without commutativity). */
|
|
31
|
+
conflicts: ReconcileConflict[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ReconcileConflict {
|
|
35
|
+
opA: VcsOp;
|
|
36
|
+
opB: VcsOp;
|
|
37
|
+
filePath: string;
|
|
38
|
+
reason: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Core reconciliation
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Find the common ancestor (fork point) of two op streams.
|
|
47
|
+
* Returns the hash of the last op that appears in both streams.
|
|
48
|
+
*/
|
|
49
|
+
export function findForkPoint(opsA: VcsOp[], opsB: VcsOp[]): string | null {
|
|
50
|
+
const hashesB = new Set(opsB.map((o) => o.hash));
|
|
51
|
+
let forkPoint: string | null = null;
|
|
52
|
+
|
|
53
|
+
for (const op of opsA) {
|
|
54
|
+
if (hashesB.has(op.hash)) {
|
|
55
|
+
forkPoint = op.hash;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return forkPoint;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Reconcile two divergent op streams into a single merged stream.
|
|
64
|
+
*
|
|
65
|
+
* Algorithm:
|
|
66
|
+
* 1. Find the fork point (last common op)
|
|
67
|
+
* 2. Split each stream into shared prefix + unique suffix
|
|
68
|
+
* 3. Check for conflicts in the unique portions
|
|
69
|
+
* 4. Interleave unique ops in causal (timestamp) order
|
|
70
|
+
*/
|
|
71
|
+
export function reconcile(opsA: VcsOp[], opsB: VcsOp[]): ReconcileResult {
|
|
72
|
+
const forkPoint = findForkPoint(opsA, opsB);
|
|
73
|
+
|
|
74
|
+
// Split into shared prefix and unique suffixes
|
|
75
|
+
const hashesA = new Set(opsA.map((o) => o.hash));
|
|
76
|
+
const hashesB = new Set(opsB.map((o) => o.hash));
|
|
77
|
+
|
|
78
|
+
const shared: VcsOp[] = [];
|
|
79
|
+
const uniqueToA: VcsOp[] = [];
|
|
80
|
+
const uniqueToB: VcsOp[] = [];
|
|
81
|
+
|
|
82
|
+
for (const op of opsA) {
|
|
83
|
+
if (hashesB.has(op.hash)) {
|
|
84
|
+
shared.push(op);
|
|
85
|
+
} else {
|
|
86
|
+
uniqueToA.push(op);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const op of opsB) {
|
|
91
|
+
if (!hashesA.has(op.hash)) {
|
|
92
|
+
uniqueToB.push(op);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// If one side has no unique ops, it's a fast-forward
|
|
97
|
+
if (uniqueToA.length === 0) {
|
|
98
|
+
return {
|
|
99
|
+
merged: [...shared, ...uniqueToB],
|
|
100
|
+
uniqueToA: [],
|
|
101
|
+
uniqueToB,
|
|
102
|
+
forkPoint,
|
|
103
|
+
clean: true,
|
|
104
|
+
conflicts: [],
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (uniqueToB.length === 0) {
|
|
109
|
+
return {
|
|
110
|
+
merged: [...shared, ...uniqueToA],
|
|
111
|
+
uniqueToA,
|
|
112
|
+
uniqueToB: [],
|
|
113
|
+
forkPoint,
|
|
114
|
+
clean: true,
|
|
115
|
+
conflicts: [],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Both sides diverged — detect conflicts
|
|
120
|
+
const conflicts = detectConflicts(uniqueToA, uniqueToB);
|
|
121
|
+
|
|
122
|
+
// Merge unique ops by timestamp (causal ordering)
|
|
123
|
+
const interleaved = interleaveByTimestamp(uniqueToA, uniqueToB);
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
merged: [...shared, ...interleaved],
|
|
127
|
+
uniqueToA,
|
|
128
|
+
uniqueToB,
|
|
129
|
+
forkPoint,
|
|
130
|
+
clean: conflicts.length === 0,
|
|
131
|
+
conflicts,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Conflict detection
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
/** VcsOp kinds that represent file-level mutations. */
|
|
140
|
+
const FILE_MUTATION_KINDS = new Set([
|
|
141
|
+
'vcs:fileAdd',
|
|
142
|
+
'vcs:fileModify',
|
|
143
|
+
'vcs:fileDelete',
|
|
144
|
+
'vcs:fileRename',
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Detect conflicts between two sets of unique ops.
|
|
149
|
+
* Two ops conflict when they both mutate the same file.
|
|
150
|
+
*/
|
|
151
|
+
function detectConflicts(
|
|
152
|
+
uniqueA: VcsOp[],
|
|
153
|
+
uniqueB: VcsOp[],
|
|
154
|
+
): ReconcileConflict[] {
|
|
155
|
+
const conflicts: ReconcileConflict[] = [];
|
|
156
|
+
|
|
157
|
+
// Index A's file mutations
|
|
158
|
+
const aMutations = new Map<string, VcsOp[]>();
|
|
159
|
+
for (const op of uniqueA) {
|
|
160
|
+
if (!FILE_MUTATION_KINDS.has(op.kind) || !op.vcs?.filePath) continue;
|
|
161
|
+
const path = op.vcs.filePath;
|
|
162
|
+
if (!aMutations.has(path)) aMutations.set(path, []);
|
|
163
|
+
aMutations.get(path)!.push(op);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Check B's file mutations against A's
|
|
167
|
+
for (const op of uniqueB) {
|
|
168
|
+
if (!FILE_MUTATION_KINDS.has(op.kind) || !op.vcs?.filePath) continue;
|
|
169
|
+
const path = op.vcs.filePath;
|
|
170
|
+
const aOps = aMutations.get(path);
|
|
171
|
+
if (!aOps) continue;
|
|
172
|
+
|
|
173
|
+
for (const aOp of aOps) {
|
|
174
|
+
// Same file modified by both sides
|
|
175
|
+
if (aOp.kind === 'vcs:fileModify' && op.kind === 'vcs:fileModify') {
|
|
176
|
+
conflicts.push({
|
|
177
|
+
opA: aOp,
|
|
178
|
+
opB: op,
|
|
179
|
+
filePath: path,
|
|
180
|
+
reason: `Both sides modified ${path}`,
|
|
181
|
+
});
|
|
182
|
+
} else if (
|
|
183
|
+
(aOp.kind === 'vcs:fileDelete' && op.kind === 'vcs:fileModify') ||
|
|
184
|
+
(aOp.kind === 'vcs:fileModify' && op.kind === 'vcs:fileDelete')
|
|
185
|
+
) {
|
|
186
|
+
conflicts.push({
|
|
187
|
+
opA: aOp,
|
|
188
|
+
opB: op,
|
|
189
|
+
filePath: path,
|
|
190
|
+
reason: `Delete/modify conflict on ${path}`,
|
|
191
|
+
});
|
|
192
|
+
} else if (aOp.kind === 'vcs:fileAdd' && op.kind === 'vcs:fileAdd') {
|
|
193
|
+
// Both added same file — conflict if different content
|
|
194
|
+
if (aOp.vcs?.contentHash !== op.vcs?.contentHash) {
|
|
195
|
+
conflicts.push({
|
|
196
|
+
opA: aOp,
|
|
197
|
+
opB: op,
|
|
198
|
+
filePath: path,
|
|
199
|
+
reason: `Both sides added ${path} with different content`,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return conflicts;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// Interleaving
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Interleave two op arrays by timestamp, preserving causal ordering
|
|
215
|
+
* within each array.
|
|
216
|
+
*/
|
|
217
|
+
function interleaveByTimestamp(a: VcsOp[], b: VcsOp[]): VcsOp[] {
|
|
218
|
+
const result: VcsOp[] = [];
|
|
219
|
+
let ai = 0;
|
|
220
|
+
let bi = 0;
|
|
221
|
+
|
|
222
|
+
while (ai < a.length && bi < b.length) {
|
|
223
|
+
const tA = new Date(a[ai].timestamp).getTime();
|
|
224
|
+
const tB = new Date(b[bi].timestamp).getTime();
|
|
225
|
+
|
|
226
|
+
if (tA <= tB) {
|
|
227
|
+
result.push(a[ai++]);
|
|
228
|
+
} else {
|
|
229
|
+
result.push(b[bi++]);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
while (ai < a.length) result.push(a[ai++]);
|
|
234
|
+
while (bi < b.length) result.push(b[bi++]);
|
|
235
|
+
|
|
236
|
+
return result;
|
|
237
|
+
}
|