Skip to content

Commit c1d75cb

Browse files
authored
feat: implement deterministic ordering algorithm (#162)
1 parent 57bbb93 commit c1d75cb

13 files changed

Lines changed: 1606 additions & 165 deletions

File tree

.github/linters/.cspell.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,14 @@
2727
"marketingappextension",
2828
"mjyhjbm",
2929
"mocharc",
30+
"movetag",
3031
"mutingpermissionset",
3132
"nonoctal",
33+
"npmjs",
3234
"nycrc",
3335
"permissionset",
3436
"permissionsetgroup",
37+
"picklist",
3538
"scolladon",
3639
"sebastien",
3740
"sébastien",

DESIGN.md

Lines changed: 264 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,21 @@ The merge driver implements a three-way merge algorithm specifically designed fo
1212

1313
The merge logic uses the **Strategy Pattern** to handle different merge scenarios. Each scenario (based on which versions have content) has its own strategy implementation.
1414

15-
```
16-
┌─────────────────────┐
17-
│ ScenarioStrategy │ (interface)
18-
├─────────────────────┤
19-
│ + execute(context)
20-
└─────────────────────┘
21-
22-
│ implements
23-
24-
┌─────┴─────┬─────────────┬─────────────┬─────────────┐
25-
│ │ │ │ │
26-
┌───┴───┐ ┌─────┴─────┐ ┌─────┴─────┐ ┌─────┴─────┐ ┌─────┴─────┐
27-
│ None │ │ LocalOnly │ │ OtherOnly │ │ AllPresent│ │ ... │
28-
│Strategy│ │ Strategy │ │ Strategy │ │ Strategy │ │ │
29-
└───────┘ └───────────┘ └───────────┘ └───────────┘ └───────────┘
15+
```mermaid
16+
classDiagram
17+
class ScenarioStrategy {
18+
<<interface>>
19+
+execute(context) MergeResult
20+
}
21+
22+
ScenarioStrategy <|.. NoneStrategy
23+
ScenarioStrategy <|.. LocalOnlyStrategy
24+
ScenarioStrategy <|.. OtherOnlyStrategy
25+
ScenarioStrategy <|.. AncestorOnlyStrategy
26+
ScenarioStrategy <|.. LocalAndOtherStrategy
27+
ScenarioStrategy <|.. AncestorAndLocalStrategy
28+
ScenarioStrategy <|.. AncestorAndOtherStrategy
29+
ScenarioStrategy <|.. AllPresentStrategy
3030
```
3131

3232
**Strategies:**
@@ -43,43 +43,41 @@ The merge logic uses the **Strategy Pattern** to handle different merge scenario
4343

4444
The merge nodes implement the **Composite Pattern** to handle different data structures uniformly.
4545

46-
```
47-
┌─────────────────────┐
48-
│ MergeNode │ (interface)
49-
├─────────────────────┤
50-
│ + merge(config) │
51-
└─────────────────────┘
52-
53-
│ implements
54-
55-
┌─────┴─────┬─────────────────┬─────────────────┐
56-
│ │ │ │
57-
┌───┴───────┐ ┌─┴───────────────┐ ┌┴────────────────┐
58-
│ TextMerge │ │ KeyedArrayMerge │ │ TextArrayMerge │
59-
│ Node │ │ Node │ │ Node │
60-
└───────────┘ └─────────────────┘ └─────────────────┘
46+
```mermaid
47+
classDiagram
48+
class MergeNode {
49+
<<interface>>
50+
+merge(config) MergeResult
51+
}
52+
53+
MergeNode <|.. TextMergeNode
54+
MergeNode <|.. TextArrayMergeNode
55+
MergeNode <|.. KeyedArrayMergeNode
56+
MergeNode <|.. ObjectMergeNode
6157
```
6258

6359
**Node Types:**
6460
- `TextMergeNode` - Handles scalar/primitive values
65-
- `KeyedArrayMergeNode` - Handles arrays of objects with key fields (e.g., `fieldPermissions` with `field` key)
6661
- `TextArrayMergeNode` - Handles arrays of primitive values (e.g., `members` in package.xml)
67-
- `ObjectMergeNode` / `NestedObjectMergeNode` - Handles nested object structures
62+
- `KeyedArrayMergeNode` - Handles arrays of objects with key fields (e.g., `fieldPermissions` with `field` key)
63+
- `ObjectMergeNode` - Handles pure objects without key extractor (property-by-property merge)
6864

6965
### Factory Pattern
7066

7167
The `MergeNodeFactory` creates the appropriate node type based on the data structure:
7268

73-
```typescript
74-
interface MergeNodeFactory {
75-
createNode(ancestor, local, other, attribute): MergeNode
76-
}
69+
```mermaid
70+
flowchart TD
71+
Start["createNode()"] --> IsStringArray{{"Is string array?"}}
72+
IsStringArray -->|Yes| TextArray["TextArrayMergeNode"]
73+
IsStringArray -->|No| IsPureObject{{"Pure object without key extractor?"}}
74+
IsPureObject -->|Yes| Object["ObjectMergeNode"]
75+
IsPureObject -->|No| IsObject{{"Contains objects?"}}
76+
IsObject -->|Yes| KeyedArray["KeyedArrayMergeNode"]
77+
IsObject -->|No| Text["TextMergeNode"]
7778
```
7879

79-
Decision logic:
80-
1. If any value is a string array → `TextArrayMergeNode`
81-
2. If any value is an object → `KeyedArrayMergeNode`
82-
3. Otherwise → `TextMergeNode`
80+
Implementation: [MergeNodeFactory.ts](src/merger/nodes/MergeNodeFactory.ts)
8381

8482
## Core Components
8583

@@ -146,32 +144,34 @@ The `ConflictMarkerBuilder` constructs these markers, and `ConflictMarkerFormatt
146144

147145
## Data Flow
148146

149-
```
150-
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
151-
│ XML Input │────▶│ JSON Parse │────▶│ Merge │
152-
│ (3 files) │ │ (fast-xml) │ │ Orchestrator│
153-
└─────────────┘ └─────────────┘ └──────┬──────┘
154-
155-
┌──────────────────────────┘
156-
157-
┌─────────────────────┐
158-
│ Scenario Strategy │
159-
│ (based on content) │
160-
└──────────┬──────────┘
161-
162-
┌──────────┴──────────┐
163-
▼ ▼
164-
┌─────────────────┐ ┌─────────────────┐
165-
│ MergeNode │ │ ConflictMarker │
166-
│ (recursive) │ │ Builder │
167-
└────────┬────────┘ └────────┬────────┘
168-
│ │
169-
└──────────┬──────────┘
170-
171-
┌─────────────────────┐
172-
│ XML Output │
173-
│ (merged file) │
174-
└─────────────────────┘
147+
```mermaid
148+
flowchart TD
149+
subgraph Input
150+
XML["XML Input (3 files)"]
151+
end
152+
153+
subgraph Parse
154+
JSON["JSON Parse (fast-xml-parser)"]
155+
end
156+
157+
subgraph Merge
158+
Orchestrator["MergeOrchestrator"]
159+
Strategy["ScenarioStrategy"]
160+
Nodes["MergeNode (recursive)"]
161+
Conflict["ConflictMarkerBuilder"]
162+
end
163+
164+
subgraph Output
165+
Result["XML Output (merged file)"]
166+
end
167+
168+
XML --> JSON
169+
JSON --> Orchestrator
170+
Orchestrator --> Strategy
171+
Strategy --> Nodes
172+
Strategy --> Conflict
173+
Nodes --> Result
174+
Conflict --> Result
175175
```
176176

177177
## Key Design Decisions
@@ -198,3 +198,201 @@ The `MergeContext` is immutable, ensuring strategies cannot accidentally modify
198198
### 5. Configurable Conflict Markers
199199

200200
Conflict marker size and labels are configurable via Git's standard parameters (`-L`, `-S`, `-X`, `-Y` flags), allowing integration with existing Git workflows.
201+
202+
## Deterministic Ordering Algorithm
203+
204+
For ordered metadata types (e.g., `GlobalValueSet`, `StandardValueSet`), the driver implements a deterministic three-way merge algorithm that preserves element ordering while detecting and merging compatible changes.
205+
206+
### Core Principles
207+
208+
1. **User decides order** — never auto-resolve ambiguous ordering conflicts
209+
2. **Value-based comparison** — elements are compared by their key field, not position
210+
3. **Disjoint change detection** — non-overlapping reorderings can be merged automatically
211+
4. **Conflict on overlap** — when both sides move the same elements differently, conflict
212+
5. **Positional conflict detection** — concurrent additions at different positions trigger conflict
213+
214+
### Algorithm Overview
215+
216+
The `OrderedKeyedArrayMergeStrategy` handles ordered arrays through these steps:
217+
218+
```mermaid
219+
flowchart TD
220+
subgraph Build["Build Merge Context"]
221+
Extract["Extract keys: ancestorKeys, localKeys, otherKeys"]
222+
Maps["Build position maps: ancestorPos, localPos, otherPos"]
223+
ObjMaps["Build object maps: ancestorMap, localMap, otherMap"]
224+
Extract --> Maps --> ObjMaps
225+
end
226+
227+
subgraph Analyze["Analyze Orderings"]
228+
FastPath{{"Same order in local & other?"}}
229+
Moved["Detect moved elements vs ancestor"]
230+
Overlap{{"Overlapping moves? (C4)"}}
231+
Position{{"Positional conflict? (C6/C7)"}}
232+
end
233+
234+
Build --> FastPath
235+
FastPath -->|Yes| Spine["Use LCS spine algorithm"]
236+
FastPath -->|No| Moved
237+
Moved --> Overlap
238+
Overlap -->|Yes| Conflict["Full array conflict"]
239+
Overlap -->|No| Position
240+
Position -->|Yes| Conflict
241+
Position -->|No| Disjoint["Compute merged key order"]
242+
```
243+
244+
### Moved Element Detection
245+
246+
An element is considered "moved" if its relative order with any other element changed between ancestor and modified version. Uses upper-triangle optimization to avoid redundant pair comparisons:
247+
248+
```typescript
249+
// For each pair (a, b) where a comes before b in ancestor:
250+
// If a comes after b in modified → both a and b are "moved"
251+
for (i = 0; i < ancestorKeys.length; i++) {
252+
for (j = i + 1; j < ancestorKeys.length; j++) {
253+
if (modifiedPos[a] > modifiedPos[b]) {
254+
moved.add(a); moved.add(b)
255+
}
256+
}
257+
}
258+
```
259+
260+
### Positional Conflict Detection (C6)
261+
262+
When both sides add the same element but at different relative positions, a conflict is triggered. This is detected by comparing the relative order of added elements against all common elements:
263+
264+
```typescript
265+
// For element added by both sides:
266+
// Check if its position relative to any common element differs
267+
if (addedLocalPos < keyLocalPos !== addedOtherPos < keyOtherPos) {
268+
return true // Positional conflict
269+
}
270+
```
271+
272+
### Merge Scenarios
273+
274+
| ID | Scenario | Behavior |
275+
|----|----------|----------|
276+
| M1-M9 | Standard merges | Additions, deletions, modifications handled by spine algorithm |
277+
| M10 | Disjoint swaps | Local swaps {A,B}, other swaps {C,D} → merge both |
278+
| C4 | Divergent moves | Both sides move same element differently → conflict |
279+
| C6 | Positional conflict | Both sides add same element at different positions → conflict |
280+
| C7 | Concurrent addition with diverged orderings | Both sides add different elements while orderings diverge → conflict |
281+
282+
### Example: M10 Disjoint Swaps
283+
284+
```
285+
Ancestor: [A, B, C, D]
286+
Local: [B, A, C, D] ← swapped A↔B
287+
Other: [A, B, D, C] ← swapped C↔D
288+
289+
Analysis:
290+
- localMoved = {A, B} (A and B changed relative order)
291+
- otherMoved = {C, D} (C and D changed relative order)
292+
- Intersection = ∅ (disjoint changes)
293+
294+
Merge:
295+
- Apply local's order for {A,B}: [B, A]
296+
- Apply other's order for {C,D}: [D, C]
297+
- Result: [B, A, D, C]
298+
```
299+
300+
### Example: C6 Positional Conflict
301+
302+
```
303+
Ancestor: [A, B]
304+
Local: [A, X, B] ← added X between A and B
305+
Other: [X, A, B] ← added X before A
306+
307+
Analysis:
308+
- X added by both, but at different positions
309+
- In local: X is after A
310+
- In other: X is before A
311+
- Relative order conflict → full array conflict
312+
```
313+
314+
### Example: C7 Concurrent Addition with Diverged Orderings
315+
316+
```
317+
Ancestor: [A, B]
318+
Local: [B, A, X] ← swapped A↔B, added X
319+
Other: [A, B, Y] ← added Y
320+
321+
Analysis:
322+
- localMoved = {A, B} (swapped)
323+
- Both sides added different elements (X vs Y)
324+
- Ambiguous: should result be [B, A, X, Y] or [B, A, Y, X]?
325+
- Concurrent additions with diverged orderings → full array conflict
326+
```
327+
328+
### Implementation
329+
330+
Key methods in `OrderedKeyedArrayMergeStrategy` ([KeyedArrayMergeNode.ts](src/merger/nodes/KeyedArrayMergeNode.ts)):
331+
332+
| Method | Purpose |
333+
|--------|---------|
334+
| `buildMergeContext()` | Extracts keys and builds position/object maps for O(1) lookups |
335+
| `analyzeOrderings(ctx)` | Returns `{canMerge, localMoved, otherMoved}` |
336+
| `getMovedElements(ctx, modifiedPos)` | Finds elements that changed relative order |
337+
| `hasAddedElementPositionalConflict(ctx)` | Detects C6 scenario (same element added at different positions) |
338+
| `computeMergedKeyOrder(ctx, analysis)` | Builds merged key order; returns null for C7 conflict |
339+
| `processDivergedOrderings(config, ctx, analysis)` | Handles disjoint reorderings |
340+
| `processWithSpine(config, ctx)` | Uses LCS for spine-based merge |
341+
| `processSpine(config, spine, ctx)` | Iterates spine anchors, processes gaps between them |
342+
343+
### Spine-Based Merge Algorithm
344+
345+
The spine-based merge uses the [Longest Common Subsequence (LCS)](https://en.wikipedia.org/wiki/Longest_common_subsequence) algorithm to identify stable anchor points between versions.
346+
347+
#### Spine Computation
348+
349+
The **spine** is the stable backbone of elements present in all versions with preserved relative order:
350+
351+
```typescript
352+
spine = lcs(lcs(ancestor, local), lcs(ancestor, other))
353+
```
354+
355+
Implementation: [KeyedArrayMergeNode.ts:170](src/merger/nodes/KeyedArrayMergeNode.ts#L170)
356+
357+
#### Process Flow
358+
359+
```mermaid
360+
flowchart TD
361+
Start["processWithSpine()"] --> Compute["Compute spine via double-LCS"]
362+
Compute --> Process["processSpine()"]
363+
364+
Process --> ForEach["For each anchor in spine"]
365+
ForEach --> CollectGaps["Collect gaps before anchor"]
366+
CollectGaps --> MergeGap["mergeGap(): additions/deletions"]
367+
MergeGap --> MergeAnchor["mergeElement(): merge anchor"]
368+
MergeAnchor --> ForEach
369+
370+
ForEach --> Trailing["Process trailing elements"]
371+
Trailing --> Combine["combineResults()"]
372+
```
373+
374+
#### Example
375+
376+
```
377+
Ancestor: [A, B, C, D, E]
378+
Local: [A, X, B, D] ← deleted C, E; added X
379+
Other: [A, B, Y, D, E] ← added Y
380+
381+
spine = [A, B, D]
382+
383+
Gaps processed:
384+
before B: local adds X
385+
before D: other adds Y, local deletes C
386+
trailing: local deletes E
387+
388+
Result: [A, X, B, Y, D]
389+
```
390+
391+
### Applicable Metadata Types
392+
393+
Ordered merging applies to metadata with position-significant arrays:
394+
395+
- `GlobalValueSet``customValue` (key: `fullName`)
396+
- `StandardValueSet``standardValue` (key: `fullName`)
397+
- `CustomField``valueSet.customValue` (key: `fullName`)
398+
- `RecordType``picklistValues.values` (key: `fullName`)

0 commit comments

Comments
 (0)