JSON Deep Merge

Merge JSON by key path, not by line. Arrays get a strategy, not a coin flip.

Overview

JSON is the lingua franca of configuration — package.json, tsconfig.json, firebase.json, app.json, Kubernetes manifests, feature-flag files, i18n bundles. It is also the single biggest source of noise conflicts in a typical repo. Two developers add two dependencies to the same "dependencies" block and Git sees overlapping lines where there is no semantic disagreement.

Aura's JSON merger is a key-path-aware deep merger with configurable array strategies and lossless formatting. It understands that adding "axios": "^1.6.0" and adding "zod": "^3.22.0" to the same object are commutative operations. It understands that bumping "version" on both sides is a real conflict. And it preserves your file's original indent, key order, and trailing-newline discipline.

JSON conflicts are almost always false positives. The fix is not to merge harder — it is to merge at the right level.

How It Works

Parse into a canonical model

Each of the three versions (ancestor, ours, theirs) is parsed into an order-preserving JSON DOM. Objects keep insertion order. Arrays keep index order. Numbers retain their original lexical form (1.0, 1.00, 1e0 are distinct as text, identical as value — Aura treats them as equal for merge but writes back whatever style dominates).

Walk three trees in lockstep

The merger recurses into every key path present in any of the three trees. At each node it classifies the situation:

| Ancestor | Ours | Theirs | Action | |----------|------|--------|--------| | present | unchanged | modified | take theirs | | present | modified | unchanged | keep ours | | present | modified | modified, same value | dedupe, no conflict | | present | modified | modified, different | conflict at this path | | absent | inserted | absent | keep ours | | absent | absent | inserted | take theirs | | absent | inserted | inserted, same value | dedupe | | absent | inserted | inserted, different | conflict at this path | | present | deleted | modified | conflict | | present | deleted | deleted | delete |

The crucial bit: object-level merges recurse into each key independently. Adding two different keys to the same object is never a conflict.

Arrays use a strategy

Arrays are ambiguous. Is ["a", "b"] a set, an ordered list, or a keyed table? Aura does not guess. Every array merge picks a strategy, chosen globally, per file, or per key path:

| Strategy | Behavior | |------------|----------| | union | Union of elements by deep equality. Order: ancestor order first, then ours-new, then theirs-new. | | concat | Ancestor + new-from-ours + new-from-theirs. Duplicates kept. | | replace | If either side replaced the array wholesale, take that replacement. If both did with different values, conflict. | | keyed:id | Treat as a table keyed by the given field (e.g. id, name). Per-row deep merge. | | prepend / append | Non-destructive insertion on one side. |

Defaults ship with sensible per-file rules: package.json uses union for "keywords" and "files", keyed:name for nothing, and flags "dependencies" as an object (not an array). VS Code's settings.json uses union for most arrays. Kubernetes manifests use keyed:name for env, ports, volumes.

Write back losslessly

After merge, the DOM is serialized with the file's observed style: indent width, key quoting, trailing comma tolerance (JSONC), final newline. If ancestor used 2-space indent and sorted keys alphabetically, the merged file does too.

Examples

A: Two new dependencies

Ancestor package.json:

{
  "name": "checkout",
  "version": "1.4.0",
  "dependencies": {
    "react": "^18.2.0",
    "express": "^4.18.0"
  }
}

Ours adds axios:

{
  "name": "checkout",
  "version": "1.4.0",
  "dependencies": {
    "react": "^18.2.0",
    "express": "^4.18.0",
    "axios": "^1.6.0"
  }
}

Theirs adds zod:

{
  "name": "checkout",
  "version": "1.4.0",
  "dependencies": {
    "react": "^18.2.0",
    "express": "^4.18.0",
    "zod": "^3.22.0"
  }
}

Key-path diff:

ours    = [Insert("/dependencies/axios", "^1.6.0")]
theirs  = [Insert("/dependencies/zod",   "^3.22.0")]
disjoint paths -> merge cleanly

Merged:

{
  "name": "checkout",
  "version": "1.4.0",
  "dependencies": {
    "react": "^18.2.0",
    "express": "^4.18.0",
    "axios": "^1.6.0",
    "zod": "^3.22.0"
  }
}

B: Conflicting version bump

Ours bumps "version" to 1.5.0. Theirs bumps it to 2.0.0. Same path, different scalar values. Aura raises:

CONFLICT: package.json :: /version
  ancestor: "1.4.0"
  ours:     "1.5.0"
  theirs:   "2.0.0"

The conflict is localized to the path. Every other key in the file merges cleanly. A keep ours resolution touches only "version".

C: Array of tags, union strategy

Ancestor tsconfig.json:

{ "include": ["src/**/*"] }

Ours:

{ "include": ["src/**/*", "test/**/*"] }

Theirs:

{ "include": ["src/**/*", "scripts/**/*"] }

Union strategy produces:

{ "include": ["src/**/*", "test/**/*", "scripts/**/*"] }

D: Keyed table merge

Ancestor k8s/deploy.json:

{
  "env": [
    { "name": "PORT", "value": "3000" },
    { "name": "NODE_ENV", "value": "production" }
  ]
}

Ours edits PORT:

{
  "env": [
    { "name": "PORT", "value": "8080" },
    { "name": "NODE_ENV", "value": "production" }
  ]
}

Theirs adds LOG_LEVEL:

{
  "env": [
    { "name": "PORT", "value": "3000" },
    { "name": "NODE_ENV", "value": "production" },
    { "name": "LOG_LEVEL", "value": "info" }
  ]
}

With keyed:name, Aura treats each entry as a row. Merged:

{
  "env": [
    { "name": "PORT", "value": "8080" },
    { "name": "NODE_ENV", "value": "production" },
    { "name": "LOG_LEVEL", "value": "info" }
  ]
}

Text merge would have produced a conflict on the PORT block because the surrounding array syntax shifted.

E: Per-path strategy overrides

A project can pin strategies in .aura/merge.json:

{
  "json": {
    "package.json": {
      "/keywords":         "union",
      "/files":            "union",
      "/scripts":          "object-merge",
      "/peerDependencies": "object-merge"
    },
    "**/k8s/*.json": {
      "/spec/template/spec/containers/*/env": "keyed:name",
      "/spec/template/spec/containers/*/ports": "keyed:containerPort"
    }
  }
}

Glob patterns match files; JSON Pointer paths target nodes within them. Missing strategies fall back to language defaults.

Edge Cases

JSONC and trailing commas. tsconfig.json and VS Code settings allow comments and trailing commas. Aura's parser accepts both and round-trips them. Comments are attached to their nearest key and travel with it during merge.

Number precision. 1.0 vs 1 vs 1e0 parse to the same value. Aura treats them as equal for conflict detection and writes back the ours-side formatting by default. Numeric precision loss (e.g. 0.1 + 0.2) is never introduced — Aura does not evaluate, only transports.

Key reordering. If ours reorders keys and theirs edits a value, Aura keeps ours's order and applies theirs's value edit. If both sides reorder differently with no value changes, the merger picks ours's order and reports the reorder in the merge summary rather than as a conflict.

Duplicate keys. Strictly invalid JSON but occasionally seen. Aura preserves both and emits a warning; the merge treats the last occurrence as authoritative, matching parser behavior in most runtimes.

Very large files. Lockfiles routinely exceed 1MB. The JSON merger streams these and applies a fast-path: if one side is unchanged from the ancestor, take the other side wholesale without walking the tree.

Object vs array mismatch. If ancestor has an object at a path and one side replaces it with an array, that is treated as a wholesale replace and conflicts with any edit on the other side. Aura never silently coerces types.

Path-specific conflict messages. Every conflict names the exact JSON Pointer path — /dependencies/axios, not "somewhere in package.json." For nested conflicts, the path is absolute and human-readable. Tooling that consumes Aura's conflict stream can route different paths to different reviewers (e.g. a security team owns /scripts/**).

Nested arrays. A JSON document with arrays inside arrays (e.g. a 2D matrix) handles each level with the configured strategy. union at the outer level does not imply union at the inner level; strategies are per-path. For matrix-style data, replace is almost always the correct choice — unioning rows against each other rarely produces sensible output.

Schema-aware defaults. Aura ships with a library of schema-aware defaults for common files: package.json, tsconfig.json, composer.json, Cargo.toml (handled through its TOML equivalent), firebase.json, vercel.json, .vscode/settings.json, and Kubernetes manifests. These defaults encode community consensus on which arrays are sets, which are ordered lists, and which are keyed tables. Projects can override any default via .aura/merge.json.

Diff-friendly output. The serializer preserves the ancestor's key order where possible so that the post-merge diff against ours is minimal. This matters for code review: reviewers should see new keys as small additions, not as a reordered file that forces line-by-line re-reading.

Null handling. null is a real value in JSON, distinct from "key absent." Setting a key to null on one side while theirs modifies it to a non-null value is a normal modify/modify conflict. Aura does not treat null as a deletion.

Performance. Deep merges on files in the 10-50k-node range run in low milliseconds. Very large configuration bundles (e.g. generated i18n files with 200k keys) parse and merge in well under a second, enabled by the order-preserving DOM which uses open-addressing maps rather than balanced trees.

See Also